网格变形动画MeshTransform
原文:Mesh Transforms
作者:Bartosz Ciechanowski
譯者:kmyhy
我是 transform 屬性的超級粉絲。讓 UIView 或者 CALayer 的形體發生改變的最簡單方法就是聯合使用旋轉、平移和縮放。在易于適用的同時,常規變換所能實現的效果也同時被限制住了——比如一個矩形只能變換成其它四邊形。這是毫無疑問的,但除此之外我們還可以做的更多。
本文介紹網格變形。網格變形的核心概念非常簡單:你可以將一個 layer 劃分成若干個頂點,通過移動頂點的方式讓幾何形狀發生改變:
本文的主要內容介紹了一個 Core Animateion 的私有 API,這個框架從 iOS 5.0 之后開始引入。不用擔心你的思想會被私有 API 所“污染”,因為本文的第二部分會介紹一個可替換方案:一個與之類似的開源框架。
CAMeshTransform
當我第一次看見 iOS 運行時庫的頭文件時,我不禁為之癡迷。有這么多的私有類、隱藏屬性,讓人大開眼界。其中最有趣的一個發現就是 CAMeshTransform 以及 CALayer 的 meshTransform 屬性。有一股強烈的求知欲望讓我非要把搞清楚,直到最近我終于吃透了它。它看起來非常復雜,但網格轉換的底層概念還是很容易搞懂的。CAMeshTransform 有一個構造方法是這個樣子的:
+ (instancetype)meshTransformWithVertexCount:(NSUInteger)vertexCountvertices:(CAMeshVertex *)verticesfaceCount:(NSUInteger)faceCountfaces:(CAMeshFace *)facesdepthNormalization:(NSString *)depthNormalization;- 1
- 2
- 3
- 4
- 5
這個方法清楚地描述了網格轉換的基本構成——頂點、面和一個字符串用于描述 depth normalization。我們接下來會逐一討論它們。
注:不幸的是,結構體內部的字段名被編譯后無法看到,因此我不得不用自己的理解進行描述。原始的字段名可能不一樣,但意思應該是一致的。
頂點 Vertex
一個頂點是一個擁有兩個字段屬性的結構:
typedef struct CAMeshVertex {CGPoint from;CAPoint3D to; } CAMeshVertex;- 1
- 2
- 3
- 4
CAPoint3D 和普通的 CGPoint 非常像——只是增加了一個 z 坐標:
typedef struct CAPoint3D {CGFloat x;CGFloat y;CGFloat z; } CAPoint3D;- 1
- 2
- 3
- 4
- 5
這樣,CAMeshVertex 的用途就不難猜出了:它描述了一個 layer 平面上的二維點到 3D 空間中的點的映射。CAMeshVertex 定義了這樣的行為:“獲取 layer 上的點,并將這個點移動到指定位置。”因為 CAPoint3D 由 x、y、z 字段構成,因此網格轉換注定不會是平面的:
平面 Face
CAMeshFace 也很簡單:
typedef struct CAMeshFace {unsigned int indices[4];float w[4]; } CAMeshFace;- 1
- 2
- 3
- 4
indecies 數組保存了一個平面上的 4 個頂點。因為 CAMeshTransform 中也包含了一個頂點數組,因此一個 CAMeshFace 可以通過頂點在這個數組中的索引來引用這些頂點。這中計算機圖形學中的標準范式有一個好處——多個 Face 有可能引用同一個頂點。這不僅解決了數據復制的問題,而且要修改所有相鄰平面的形狀更加方便了:
平面由它們的頂點所定義對于 CAMeshFace 的 w 字段,這將在后面進行討論。
坐標
看過頂點和平面之后,我們仍然不是很清楚我們應該在一個 CAMeshVertex 中放入什么。在 CALayer 中許多屬性是以點 Point 的形式定義的,有的使用了單元坐標系,比如 anchorPoint 就是最常見的一個。CAMeshVertex 也使用單元坐標系。點 {0.0, 0.0}對應于 layer 的左上角,而點 {1.0, 1.0} 對應于 layer 的右下角。下面的點 to 使用了同一坐標系:
頂點用單元坐標系進行定義使用單元坐標系的原因是在 Core Animation Programming Guide 中有敘述:
使用單元坐標系是為了不和屏幕坐標系進行綁定,因為每個值都是相對于其他值的。
單元坐標系的最大好處在于它們的大小不會改變。你可以在小視圖和大視圖上都使用相同的網格,效果并無二致。我認為這才是在 CAMeshTransform 使用單元坐標系的最大原因。
修改網格變形
創建一個普通 CAMeshTransform 的壞處是它是不可變的,所有頂點和平面必須在變形創建之前就定義。幸運的是,它有一個可變的子類,叫做 CAMutableMeshTransform,允許我們隨時添加、刪除、替換頂點和平面。
兩個網格變形的類都有一個相同的 subdivisionSteps 屬性,指定當 layer 繪制在屏幕上時需要切分成多少部分。這個值是一個指數,設置為 3,表示每邊被分成 8 片。默認值是 -1,這會讓網格接近平滑。我覺得它會自動調整網格數以保證最終結果不會太糟糕。
不太明確的一點是,當 subdivisionSteps 不為零時,所產生的網格不會完全通過它的所有頂點。
切分后的網格形狀和它的頂點事實上,頂點是一個平面的控制點,通過觀察它們是如何對幾何形狀產生影響,我發現 CAMeshTransform 實際上定義了一個三次 NURBS 曲面。這就不得不提到 CAMeshFace 的 w 字段了。將這個值設置為 w 數組中的 4 個索引中的一個時,似乎會影響到對應頂點的權重。這個系數并不像是 NURBS 公式中的 weight 系數。不幸的是,盡管我搜遍了幾百行浮點匯編代碼還是沒有什么收獲。
盡管 NURBS 曲面極其強大,它們也無法讓遍歷頂點的過程變快。在我定義自己的網格時,我需要完全的控制所產生的網格最終是什么樣子,因此我將 subdivisionSteps 屬性設置為 0。
應用網格變形
光光是創建一個 CAMeshTransform 是沒有意義的,我們需要將它賦給一個 CALayer 的私有屬性:
@property (copy) CAMeshTransform *meshTransform;- 1
下列代碼創建了一個波浪形的網格變形。代碼非常冗長,因為我們想完整演示整個流程。只需要定義幾個便利函數,就可以將代碼縮減到幾行代碼.
- (CAMeshTransform *)wavyTransform {const float Waves = 3.0;const float Amplitude = 0.15;const float DistanceShrink = 0.3;const int Columns = 40;CAMutableMeshTransform *transform = [CAMutableMeshTransform meshTransform];for (int i = 0; i <= Columns; i++) {float t = (float)i / Columns;float sine = sin(t * M_PI * Waves);CAMeshVertex topVertex = {.from = {t, 0.0},.to = {t, Amplitude * sine * sine + DistanceShrink * t, 0.0}};CAMeshVertex bottomVertex = {.from = {t, 1.0},.to = {t, 1.0 - Amplitude + Amplitude * sine * sine - DistanceShrink * t, 0.0}};[transform addVertex:topVertex];[transform addVertex:bottomVertex];}for (int i = 0; i < Columns; i++) {unsigned int topLeft = 2 * i + 0;unsigned int topRight = 2 * i + 2;unsigned int bottomRight = 2 * i + 3;unsigned int bottomLeft = 2 * i + 1;[transform addFace:(CAMeshFace){.indices = {topLeft, topRight, bottomRight, bottomLeft}}];}transform.subdivisionSteps = 0;return transform; }- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 27
- 28
- 29
- 30
- 31
- 32
- 33
- 34
- 35
- 36
- 37
- 38
- 39
- 40
- 41
這段代碼對一個 UILabel 使用了網格變形:
值得一提的是,在模擬器和設備上運行會得到不同的結果。因為 iOS 模擬器的 Core Animation 版本在繪制 3D 圖形時使用的是軟件模擬,軟件模擬的渲染器和 OpenGL ES 的渲染器是不同的。對于貼圖紋理來說尤為明顯。
抽象漏洞
當你在 retina 屏上仔細觀察經過網格變形的 UILabel 時,你會發現它的文字質量有一點模糊。這可以用下面一句代碼來解決:
label.layer.rasterizationScale = [UIScreen mainScreen].scale;- 1
這可能是底層機制上的一個疏漏。CALayer 和它的 sublayer 上的內容被柵格化為單一紋理然后應用到頂點網格上。理論上,柵格化能夠將所變形的 CALayer 的 sublayer 很好地放置在 superlayer 上,從而避免產生不正確的網格。而且在一般情況下,sublayer 的頂點會被放置在父 CALayer 的頂點之間,這回導致一個糟糕的 z-fighting 現象(Z 緩沖沖突)。柵格化是一種很好的解決方案。
我還發現另外一個問題來自于硬件。CAMeshTransform 提供了一個對平面的抽象,其實就是一個四邊形。但是現代 GPU 只認三角形。四邊形在發送給 GPU 之前必須被切分成兩個三角形。這種切分會有兩種不同的方式進行:
表面上這不會產生什么大問題,但在執行同一個變形時會導致結果大不相同:
注意網格變形的形狀是完全對稱的,但是它們最終形成的結果卻完全不是。左邊的網格只有一個三角形被變形。右邊的網格兩個三角形都變形了。不難猜到為什么 Core Animation 要使用四方形進行切分了。注意當你改變組成平面的頂點的索引順序時,也會導致同樣的效果。
盡管柵格化和三角形的抽象漏洞會帶來一些問題,而且這些問題確實不可忽略,但一些列解決這些問題的復雜性被掩蓋了。
添加深度
單元坐標系對于寬高來說適合的。但是我們無法定義第三維——CALayer 的 bounds 的 size 屬性中只有兩維。一個寬度單位剛好等于 bounds.size.width 個像素,而高度也是類似的。但深度為 1 表示幾個像素?Core Animation 的締造者們用一種非常簡單但極其有效的方式解決了這個問題。
CAMeshTransform 的 depthNoramlization 屬性是一個字符串,它可能取值為下述 6 個常量之一:
extern NSString * const kCADepthNormalizationNone; extern NSString * const kCADepthNormalizationWidth; extern NSString * const kCADepthNormalizationHeight; extern NSString * const kCADepthNormalizationMin; extern NSString * const kCADepthNormalizationMax; extern NSString * const kCADepthNormalizationAverage;- 1
- 2
- 3
- 4
- 5
- 6
分別說明如下: CAMeshTransform 將 depthNormalization 當成是其他兩維的一個函數。這些常量的含義和其字面意義相同,我們舉例進行說明。如果將 depthNoramlization 設為 kCADepthNormalizationAverage,而 CALayer 的 bounds 為 GRectMake(0.0, 0.0, 100.0, 200.0)。由于我們使用平均 normalization,深度的 1 個單位等于 150.0 像素。如果 CAMeshVertext 的坐標為 {1.0, 0.5, 1.5} 轉換成 3D 坐標系等于 {100.0, 100.0, 225.0}:
單位轉換為點為什么要進行單位坐標到像素點的轉換?因為 CALayer 的 transform 屬性的類型是 CATransform3D。CATranform3D 的屬性是以點為單位定義的。實際上你可以在 CALayer 上使用任意變形,它都會影響到它的頂點。注意 z 坐標移動和透視變換會得益于這個特性。
這里我們看另一個例子,讓 depthNormalization 不等于默認的 kCADepthNormalizationNone 就行。這會導致令人意外的結果——所有東西都是平面的。深度和頂點 z 坐標相加是非常令人難以置信的。我們先跳過這個步驟,討論一個新的組件,這個組件會增強網格的斜度和曲率——陰影。
遭遇普羅米修斯
既然我們已經打開了私有 Core Animation 類的潘多拉之盒,那么我們還可以使用另一個私有API。不出意外,也有一個類叫做 CALight,它非常有用,因為 CALayer 還有一個私有的數組類型的 lights 屬性。
CALight 用 + (id)lightWithType:(NSString *)lightType 創建,lightType 參數可能取值如下:
extern NSString * const kCALightTypeAmbient; extern NSString * const kCALightTypeDirectional; extern NSString * const kCALightTypePoint; extern NSString * const kCALightTypeSpot;- 1
- 2
- 3
- 4
我不會介紹 CALight 太多,我們直接就上例子。這次我們將使用兩個自定義的 CAMutableMeshTransform 便利方法。第一個
是 identityMeshTransformWithNumberOfRows:numberOfColumns:,會用均勻分布的頂點創建一個網格,不帶任何干擾紋。我們會用 mapVerticesUsingBlock: 方法修改頂點,這個方法將所有頂點轉換為另一個頂點。
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
這是對一個正方形 UILabel 應用這段代碼后的效果:
CALight, CAMeshTransform, 和 CATransform3D 一起使用燈光看起來有點粗劣,但重要的是只需要很輕松的方式就能創造十分復雜的效果。
可以修改 CALight 的環境光、漫反射和鏡面反射強度——標準的 Phong 反射模型中的參數。同時,CALayer 還有一堆表面反射屬性。我對它們進行了短時間的嘗試,結果一無所獲。但我瀏覽了這些私有頭文件,因此要測試 Core Animation 的燈光并不難。
為什么這些 API 是私有的
將一個 API 定義為私有的一個最主要的原因是它并不可靠,CAMeshTransform 當然也是這樣。有幾個證據足以說明。
首先,將 subdivisionSteps 設置為 20 就能輕易讓設備重啟。控制臺中輸出一堆內存警告清晰地表明這是為什么。這確實很糟心,但也很容易避免——不要碰這個屬性,或者將它設置為 0。
如果你定義的某個平面發生退化,比如它的索引指向同一個頂點,你會讓設備掛起。一切都會停止響應,包括硬件按鈕(!),唯一能做的只有硬啟動(長按 home 鍵+電源按鈕)。這個框架似乎無法處理輸入錯誤的情況。
為什么會這樣?這是因為 backboard —— 另外一個進程,充當 Core Animation 的渲染服務器。嚴格來說,是系統而不是 app 讓系統崩潰的,這是 iOS 核心組件因為錯誤使用而導致問題。
缺失的特性
一個能夠進行網格變形的 CALayer 的通常的目的非常復雜,以至于 Core Animation 團隊無法考慮到方方面面并忽略了一些潛在特性。
Core Animation 允許網格變形的 CALayer 擁有 alpha 通道。要正確繪制半透明對象根本不成問題。這是畫筆程序常見的功能。z-排序步驟不難實現,實際上代碼類似于進行基數排序,排序非常快,因為基數排序可以對浮點數進行排序。但是,卻無法對三角形進行排序,因為三角形有可能相交或重疊。
對于這個問題通常的辦法是切分三角形,以便所有邊都會被刪除。這部分算法似乎未實現。幸好,一個正確的、格式良好的網格基本上不會出現交疊,但偶爾會出現,這時網格變形的 CALayer 看起來會出現一些顯示問題。
另一個被完全忽略掉的問題是 hit testing——CALayer 好像是從來沒有被網格變形過一樣。因為無論是 CALayer 還是 UIView 的 hitTest: 方法都不知道網格,所以整個控件的 hit test 區域都無法和它們的可視化外觀進行匹配:
UISwitch 的 hit tes 區域不受網格變形的影響這個問題的解決方式是發出一根射線,計算所擊中的三角形是哪些,將點擊點從 3D 空間投射到 2D 空間,然后進行普通的 hit testing。這是可行的,但不容易。
替換私有 API
考慮到 CAMeshTransform 的這些缺陷,大家可能會統一它是一個失敗的產品。當然不是。它仍然有它的魅力。它打開了一個全新的 iOS 動畫和交互的窗口。和過去痛苦的老舊的轉換、漸入和模糊相比,這是一股清新空氣。我想在每一樣東西上使用網格轉換,但我無法容忍要調用那么多的私有 API。因此我寫了一個開源的、與之相近的替代物。
受 CAMeshTransform 的激發,我創建了一個 BCMeshTransform,它幾乎重現了原類的所有功能。我的意圖非常簡單:如果 CAMeshTransform 中已經有的方法,你可以在任何 CALayer 上使用基本相同的網格變換函數并達到非常類似的效果,當然 CAMeshTransform 中沒有方法另當別論。這樣,你只需要將所有的 BC 類前綴替換為 CA 即可。
Transform 類已經有了,剩下的事情就是進行網格變形。為此我創建了 BCMeshTransformView,它是一個 UIView 的子類,擁有一個 meshTransform 屬性。
沒有直接公開地訪問 Core Animation 渲染服務器,實現時我強制使用了 OpenGL。這不是最佳方案,因為它會帶來一些原類所沒有的缺陷,但這是目前唯一可行的解決方案。
幾點提示
當我創建這個類時,我遇到了幾個坑,這不妨礙我談談它們的解決辦法。
UIView 的 Animation 塊
編寫一個類的可動畫的自定義屬性并不難。David R?nnqvist在presentation on UIView animations中提到,CALayer 在任何其可動畫屬性被賦值時會詢問它的委托對象(一個 UIView 擁有這個 CALayer)。
如果我們在動畫塊中,當 actionForKey: 方法調用時,UIView 會返回一個 animation 對象。通過這個 CAAnimation,我們可以訪問它的屬性以獲得動畫參數并基于這些參數進行動畫。
我最初的實現是這樣的:
- (void)setMeshTransform:(BCMeshTransform *)meshTransform {CABasicAnimation *animation = [self actionForLayer:self.layer forKey:@"opacity"];if ([animation isKindOfClass:[CABasicAnimation class]]) {// we're inside an animation blockNSTimeInterval duration = animation.duration;...}... }- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
很快我就知道這樣是不行的——這個完成塊永遠不會被觸發。當一個基于動畫的塊被創建時,UIKit 會創建一個 UIViewAnimationState 實例并賦給塊中創建的 CAAnimation 的委托。我的猜測也被驗證了,UIViewAnimationState 等它所有的 animation 完成或取消后才會調用這個完成塊。為了讀取 animation 的屬性,我引用了這個 animation,但是它沒有被添加到任何 CALayer 中,因此它永遠不會完成。
解決辦法比我想象的更簡單。我為 BCMeshTransformView 創建了一個 subview 作為它的“替代品”。這是我目前的實現:
- (void)setMeshTransform:(BCMeshTransform *)meshTransform {[self.dummyAnimationView.layer removeAllAnimations];self.dummyAnimationView.layer.opacity = 1.0;self.dummyAnimationView.layer.opacity = 0.0;CAAnimation *animation = [self.dummyAnimationView.layer animationForKey:@"opacity"];if ([animation isKindOfClass:[CABasicAnimation class]]) {// we're inside UIView animation block}... }- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
對 opacity 屬性進行兩次賦值是必要的,這樣才能保證這個屬性的值發生了變化。animation 不會被加到 CALayer,如果它已經是目標狀態的話。同時,CALayer 必須存在于 UIView 或 UIWindow 的視圖樹中才行,否則它的屬性不會動畫。
因為要對網格進行動畫,需要讓 Core Animation 插入浮點數,這就需要將這些數轉換成 NSNumber,放進數組,實現 needsDisplayForKey: 類方法并在 setValue:forKey: 方法中負責顯示 CALayer 的改變。在獲得便利的同時,這些方法有嚴重的性能問題。一個 25x25 個平面的網格就無法在 60 FPS 下進行動畫,哪怕是用 iPad Air。封包和解包的代價太大了。
替代 Core Animation 的方法,我使用一個非常簡單的基于 CADisplayLink 的動畫引擎。這獲得了更好的性能,100x100 的平面仍然能夠在 60 FPS 下流暢地動畫。這不是最佳解決方案,我們因此失去了許多 CAAnimation 帶來的便利,但 16 倍速度的好處完全可以忽略這些。
渲染內容
BCMeshTransformView 的目的是呈現它的網格變形的 subview。在提交給 OpenGL 之前 UIView 層次必須被渲染成紋理。然后這個紋理化的頂點網格用 GLKView 顯示出來,而后者是 BCMeshTransformView 的核心。從高度抽象的層次看非常簡單,但不等不提到一個將 subview 層次進行截圖問題。
我們不想對 GLKView 進行截取,因為這會導致一個鏡面隧道效應。另外,我們不想直接顯示其他 subview ——它們的作用主要是在 OpenGL 中可見但在 UIKit 視圖層次中不可見。它們不能放在 GLKView 下,因為它們必須是不透明的。要解決這個問題我使用了 contentView 的概念,就像 UITableViewCell 管理它的 subview 一樣。這個視圖層次類似于下圖所示:
BCMesthTransformView 的視圖樹contentView 嵌到一個 containerView 中。containerView 的 frame 為 CGRectZero,clipsToBounds 為 YES,這樣它就隱藏了,但它仍然存在于 BCMeshTransformView 中。每個需要進行網格變形的 subview 都必須添加到 contentView 中。
contentView 的內容通過 drawViewHierarchyInRect:afterScreenUpdates: 方法渲染到紋理中。截取并上傳紋理的整個過程是非常快的,但不幸的是對于較大的視圖會花去 16 毫秒以上的時間。對于每幀需要繪制一次視圖樹來說這就太長了。盡管 BCMeshTransformView 會自動觀察 contentView 的 subview 的改變并重新繪制其紋理,它也不支持對網格化的 subview 的動畫。
結束
毫無疑問,網格變形是一個神奇的概念,然而仍然有太多秘密未被人們所知。它也給一成不變的屏幕帶來了更多的樂趣。事實上,你可以立即體驗一把網格變形的威力。在你的 iOS 設備上點開 Game Center,觀察氣泡的細微變化。這正是 CAMeshTransform 的力量。
我建議你下載 BCMeshTranformView 的示例 app。它包含了幾個將網格變換用于豐富界面交互的例子,比如簡單但強大的 Dribble。要想體驗更多關于網格的精彩創意,Experiments by Marcus Eckert 是一個不錯的地方。
我真心希望 BCMeshTransformView 在 WWDC 2014 的第一天被廢棄。Core Animation 能夠實現網格變形的更多功能并緊密集成到系統中。盡管目前它還不能正確地處理某些邊緣情況,還有一些需要完善的地方。希望 6 月 2 號能有好消息傳來。
技術交流、商務合作請直接聯系博主或者掃碼或搜索:猿說python
總結
以上是生活随笔為你收集整理的网格变形动画MeshTransform的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: jq之callback
- 下一篇: C语言 函数指针和指针函数区别 - C语