原文: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)vertexCount
vertices:(CAMeshVertex *)vertices
faceCount:(NSUInteger)faceCount
faces:(CAMeshFace *)faces
depthNormalization:(NSString *)depthNormalization;
这个方法清楚地描述了网格转换的基本构成——顶点、面和一个字符串用于描述 depth normalization。我们接下来会逐一讨论它们。
注:不幸的是,结构体内部的字段名被编译后无法看到,因此我不得不用自己的理解进行描述。原始的字段名可能不一样,但意思应该是一致的。
顶点 Vertex
一个顶点是一个拥有两个字段属性的结构:
typedef struct CAMeshVertex {
CGPoint from;
CAPoint3D to;
} CAMeshVertex;
CAPoint3D 和普通的 CGPoint 非常像——只是增加了一个 z 坐标:
typedef struct CAPoint3D {
CGFloat x;
CGFloat y;
CGFloat z;
} CAPoint3D;
这样,CAMeshVertex 的用途就不难猜出了:它描述了一个 layer 平面上的二维点到 3D 空间中的点的映射。CAMeshVertex 定义了这样的行为:“获取 layer 上的点,并将这个点移动到指定位置。”因为 CAPoint3D 由 x、y、z 字段构成,因此网格转换注定不会是平面的:
平面 Face
CAMeshFace 也很简单:
typedef struct CAMeshFace {
unsigned int indices[4];
float w[4];
} CAMeshFace;
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;
下列代码创建了一个波浪形的网格变形。代码非常冗长,因为我们想完整演示整个流程。只需要定义几个便利函数,就可以将代码缩减到几行代码.
- (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;
}
这段代码对一个 UILabel 使用了网格变形:
值得一提的是,在模拟器和设备上运行会得到不同的结果。因为 iOS 模拟器的 Core Animation 版本在绘制 3D 图形时使用的是软件模拟,软件模拟的渲染器和 OpenGL ES 的渲染器是不同的。对于贴图纹理来说尤为明显。
抽象漏洞
当你在 retina 屏上仔细观察经过网格变形的 UILabel 时,你会发现它的文字质量有一点模糊。这可以用下面一句代码来解决:
label.layer.rasterizationScale = [UIScreen mainScreen].scale;
这可能是底层机制上的一个疏漏。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;
分别说明如下: 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;
我不会介绍 CALight 太多,我们直接就上例子。这次我们将使用两个自定义的 CAMutableMeshTransform 便利方法。第一个
是 identityMeshTransformWithNumberOfRows:numberOfColumns:,会用均匀分布的顶点创建一个网格,不带任何干扰纹。我们会用 mapVerticesUsingBlock: 方法修改顶点,这个方法将所有顶点转换为另一个顶点。
CALight *light = [CALight new]; // directional light by default
[label.superview.layer setLights:@[light]]; // has to be applied to superlayer
CAMutableMeshTransform *meshTransform = [CAMutableMeshTransform identityMeshTransformWithNumberOfRows:50 numberOfColumns:50];
[meshTransform mapVerticesUsingBlock:^CAMeshVertex(CAMeshVertex vertex, NSUInteger vertexIndex) {
float x = vertex.from.x - 0.5f;
float y = vertex.from.y - 0.5f;
float r = sqrtf(x * x + y * y);
vertex.to.z = 0.05 * sinf(r * 2.0 * M_PI * 4.0);
return vertex;
}];
label.layer.meshTransform = meshTransform;
CATransform3D transform = CATransform3DMakeRotation(-M_PI_4, 1, 0, 0);
transform.m34 = -1.0/800.0; // some perspective
label.layer.transform = transform;
这是对一个正方形 UILabel 应用这段代码后的效果:
灯光看起来有点粗劣,但重要的是只需要很轻松的方式就能创造十分复杂的效果。
可以修改 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 区域都无法和它们的可视化外观进行匹配:
这个问题的解决方式是发出一根射线,计算所击中的三角形是哪些,将点击点从 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 block
NSTimeInterval duration = animation.duration;
...
}
...
}
很快我就知道这样是不行的——这个完成块永远不会被触发。当一个基于动画的块被创建时,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
}
...
}
对 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 一样。这个视图层次类似于下图所示:
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 号能有好消息传来。