今次我们来弄一个好玩的效果,纯粹是好玩,我觉得这个效果应该很少在实际项目中用得到吧(当然不排除一些变态项目)。
当然这个效果运用的核心技术就是CoreAnimation的CATransform3D(还有我的DHVector2D!),那么CATransform3D是个啥玩意呢?不要着急,我们先来了解一点关于计算机图形学的,非常非常基础的东西:矩阵变换和齐次坐标。
计算机图形学是干啥的呢,简单来说的话,因为咱们的电脑(手机)屏幕是二维的,或者说像素点是二维的,但是我们往往要去显示三维的内容(就像上图那样的非常自然的3D变换),那么我应该如何处理这些像素点让它们显示出来的东西看起来是三维的呢?或者说,如何把3维的内容投射到二维平面上去?这就是计算机图形学研究的大致方向,通常一家游戏公司招聘的时候,最首要的要求就是掌握计算机图形学。而计算机图形学的基础是线性代数,怎么样,后悔大学没好好学线性代数了吧?
那么这篇博客我会以尽量简短的内容帮大家理解iOS中的CATransform3D是如何帮助系统让平面的图像(比如一个普通的CALayer)能够进行看起来是三维的变形的,如果你有线性代数的基础,那么在讲解具体代码之前的那部分关于计算机图形学的内容你读起来应该会比较轻松,如果你没有学过线性代数,那么希望你至少能明白一个二维向量是什么东西,那样也能尽量看明白前面的内容,否则的话,只能直接去代码实现的部分了。当然,您也可以参考我技巧篇的这篇博客来对二维向量有一个大致的理解。
在讲CAShapeLayer那一章的时候提到过位图和矢量图,而我们的显示器就是一张大大的位图。显示器通过密集地排布像素点来显示任意的图像。那么既然硬件上是这么玩的,我们的UIView和CALayer在显示的时候肯定也是通过排布像素点来绘制图像的。
绘制信息会通过CPU进行计算,在垂直同步信号(iOS默认是开启垂直同步的,这时屏幕刷新信号就等同于垂直同步信号)到来时再提交给GPU进行渲染,然后GPU渲染好了以后提交给帧缓冲区,最后由显示器从帧缓冲区中获得一帧图像显示到显示器上。所以GPU和屏幕都有各自的刷新率,比如你牛逼的1080,可能GPU每秒能渲染144帧图像,但是你的显示器有点辣鸡,每秒只能绘制60帧图像,那么这之间存在的差异就会导致画面撕裂,这时候就需要垂直同步技术来解决画面撕裂。具体的大家可以去网上搜索学习,这里就不做过多解释了。
总之我们要知道一个视图或者CALayer也是通过像素点来显示的,这些像素点的信息由CPU先计算好然后在屏幕刷新信号到来时提交给GPU,然后按照上面的流程显示到屏幕上。
学习过线性变换的朋友知道,对一个向量进行线性变换(比如旋转、拉伸、斜切等)只需要乘以一个对应的变换矩阵即可。那么为什么乘以这个矩阵就可以得到变换后的向量?以及这个矩阵是怎么得来的?矩阵变换的这部分内容(基向量、线性变换、线性变换的复合、平移变换)主要解决这两个问题,如果感兴趣的话可以读一读,只需要你了解二维向量的姿势即可。如果不感兴趣当然可以直接跳到CATransform3D那里去。
既然我们看到的这些二维图像是通过像素点的排列显示出来的,那么计算机如何处理图像的变换(比如旋转、拉伸等)的呢?
图像的显示涉及到像素点,每个像素点有两个属性:坐标和颜色。在图像进行变形的时候,每个像素点对应的颜色是不会改变的,但是大家应该很轻易的能想到,它们的坐标在变化。那么坐标是如何变化的呢?
作为开发者,我们当然希望存在这样一个神奇函数:它接收一个点为输入参数,然后返回一个新的点,这个返回的点就是输入点变换后所在的点。这样我们把图像上所有的点都拿去调这个函数,就可以得到变换后的图像的所有点的坐标了,那么变换后的图像自然就能画出来了。
这个函数存在吗?实际上在线性代数中有一个和它极其相似的函数,其实在线性代数中它不叫函数,叫变换:线性变换。当然你完全可以把它当做函数来看,因为它也是接收参数,返回结果。线性变换处理的对象是向量,它接收一个向量作为输入参数,然后返回变换后的向量,向量和点之间又存在着那么一丝丝微妙的关系,我们当然可以用线性变换作为入口来考虑上面提到的神奇函数。
所以我们先来看点线代和图形学的基础姿势。
在平面系统中,我们可以定义一对基向量,用它们来表示平面中任意的向量。如何表示呢?通常在平面坐标系中我们取两个坐标轴上的单位向量为基向量(方向沿坐标轴正方向、长度为1):x轴上的基向量,记作 i⃗=(1,0) i → = ( 1 , 0 ) ,y轴上的基向量,记作 j⃗=(0,1) j → = ( 0 , 1 ) 。
定义了基向量以后,该系统中任意的向量都能用它们的线性组合(加法和数量积)来表示。也就是考虑任意一个向量 v⃗=(x,y) v → = ( x , y ) ,那么根据向量加法,我们可以构造两个新向量
所以任意的向量都可以表示为基向量的线性组合,这是非常重要的思想,请牢牢记在脑海中。
实际上基向量我们可以任意去取,你可以在坐标系中任意找两个向量作为基向量,只要这一对基向量线性无关(不共线),那么它们就可以用来表示这个坐标系下所有存在的向量。
我们来简单证明一下:比如我们自己随意取的基向量分别是 i⃗=(a,b),j⃗=(c,d) i → = ( a , b ) , j → = ( c , d ) ,那么任意向量 v⃗=(x,y) v → = ( x , y ) 如何表示呢?同样因为要用基向量的线性组合来表示,我们只考虑加法和数乘。则对于任意的向量 v⃗=(x,y) v → = ( x , y ) ,一定存在两个实数 n,m n , m ,使得
那么就有方程组:
因为 i⃗j⃗ i → j → 线性无关,即 ab≠cd a b ≠ c d ,也就是 ad≠bc a d ≠ b c ,那么方程就存在唯一解:
那么对于任意的向量 v⃗=(x,y) v → = ( x , y ) ,和一对基向量 i⃗=(a,b),j⃗=(c,d) i → = ( a , b ) , j → = ( c , d ) 我们都能找到两个常数 n,m n , m 来表示这个向量
顺便一提,如果这对基向量线性相关(共线),那么它们就只能表示它们所在的那条直线上的所有向量;而如果它们都是零向量,则它们只能表示零向量。这应该比较好理解。
关于基向量,要记住两点,非常重要:
你可以取平面上任意一对向量作为基向量
如果这对基向量线性无关,则它们可以表示平面上所有向量;如果线性相关,则它们只能表示它们所在的直线上的所有向量;如果它们都为零向量,则它们只能表示零向量。
这对基向量能表示的所有的向量的集合,叫做这对基向量张成的空间。在平面中,如果这对基向量线性无关,则它们张成的空间就是整个平面;如果它们线性相关,则张成的空间就是它们所在的直线;如果它们是零向量,则它们张成的空间就是原点。
上面的结论同样适用于三维空间甚至更高维空间,大家可以尝试自己去推广一下。
在线性代数中有一种变换叫做线性变换,CATransform3D中直接提供的三种变换,旋转(rotate)、拉伸(scale)都是线性变换,而平移(translation)则不是线性变换!后面会专门针对平移变换进行讨论。
线性变换是什么呢,当然线性代数书上给出了明确的定义,而我们作为IT人员,我觉得用一个programmer的思维方式来描述它更为合适。
所以现在我们来以programmer的思维来理解线性变换,考虑有一个函数 f f ,这个函数接收一个向量作为输入参数,然后产生一个新的向量作为返回值,我们先来写一段f的伪代码:
vector f(vector v) {
vector x = v进行某些计算后的结果
return x;
}
就相当于,我们有一个向量 v⃗ v → ,然后把 v⃗ v → 作为参数传入 f f ,然后通过其返回值得到一个新的向量 x⃗ x → :
那么函数 f f 就是一个变换。所以变换就是把一个向量映射成另一个向量的过程(比如旋转,一个向量旋转后就变成另一个向量了)。因为这个映射的过程可以是任意的,如果这个映射的过程满足某些条件的话,嘿嘿,那么这个变换就可以叫做线性变换了。
ok,这“某些条件”是哪些条件啊?按照书上对线性的定义,函数需要满足:
则该函数所代表的变换就是线性的,即可加性和等比例(一阶齐次)。
注意上面两个式子中的向量指的任意向量,c指的任意常数。
而具体地,线性变换如何操作?举个例子,我要实现一个旋转的线性变换,应该对输入向量进行怎样的算法?
还记得我们的基向量吗,这里我不知道数学家们当时的脑回路是怎样的,我只能说这波操作极其风骚。
接下来会出现很多向量相关的等式,但是都是非常基础简单的,不要被吓到,勇敢的去读!(如果比较难理解,大家可以先把下面提到的所有“线性变换”暂时理解成“把一个向量沿原点进行旋转”)
我们知道,平面上任意一个向量都能用基向量的线性组合来表示。我们首先定义一对基向量:
如果有任意线性变换 L L ,将它作用于任意向量 v⃗=(x,y) v → = ( x , y ) ,即 L(v⃗) L ( v → ) ,等同于 L(xi⃗+yj⃗) L ( x i → + y j → )
由线性变换的性质:
我们来关注一下结论:
大家看着这个等式,能想到什么呢?我再放一个等式在这大家再对比看一下:
还看不出来?再进一步,我们定义两个新的基向量及输出向量:
则有:
这样就非常清晰了:对平面上任意一个向量进行线性变换,相当于把平面上的基向量进行该线性变换,然后用新的基向量来表示变换后的向量。也就是对于任意向量 v⃗=(x,y) v → = ( x , y ) ,对它进行线性变换 L L ,就相当于对基向量先进行线性变换 L L ,得到新的基向量 k⃗,l⃗ k → , l → ,再用这对新的基向量表示向量 xk⃗+yl⃗ x k → + y l → 就是向量 v⃗ v → 进行变换后的向量了。
接下来我们对(1)式进行变形:
其中 k⃗=L(i⃗),l⃗=L(j⃗) k → = L ( i → ) , l → = L ( j → ) 。
若经过线性变换后得到新的基向量为 k⃗=(a,b),l⃗=(c,d) k → = ( a , b ) , l → = ( c , d ) ,那么我们可以构造一个矩阵 At=(k⃗l⃗)=(acbd) A t = ( k → l → ) = ( a b c d )
对(3)式进一步计算:
只关注一下结果:
看看我们得到了什么结论:
对向量进行线性变换实际上就是让向量乘以一个矩阵 At A t ,所以最关键的过程就是找到这个矩阵,然后让我们的输入向量乘以该矩阵,就完事了。
这样我们就解决了“为什么线性变换就是向量乘以一个矩阵”的问题,接下来我们来看如何构造这个矩阵。
我们先来把伪代码写一写:
vector linear_transformation(vector v) {
// 找到该变换对应的矩阵
matrix At = (a,b,c,d);
// 输入向量左乘矩阵A
vector u = vAt;
return u;
}
这里我们把矩阵 At A t 叫做变换矩阵。
具体地,变换矩阵 At A t 如何来找呢?举个例子,如果我们的线性变换要让所有输入向量都逆时针旋转 θ° θ ° ,按照我们上面基向量的思想,对于任意的输入向量 v⃗=(x,y) v → = ( x , y ) 我们只需让两个基向量 i⃗=(1,0)和j⃗=(0,1) i → = ( 1 , 0 ) 和 j → = ( 0 , 1 ) 先逆时针旋转 θ° θ ° ,然后得到两个新的基向量 k⃗=(a,b)和l⃗=(c,d) k → = ( a , b ) 和 l → = ( c , d ) ,然后得到变换矩阵 A=(acbd) A = ( a b c d ) ,接下来就是如何求 a,b,c,d a , b , c , d 了。先画个图,如图:
在平面直角坐标系中,红色的两个向量为我们的基向量 i⃗j⃗ i → j → ,蓝色的一对向量为基向量逆时针旋转 θ° θ ° 后得到的新的基向量 k⃗j⃗ k → j → ,因为其长度均为1,那么可以得到
所以我们得到变换矩阵
注意顺时针旋转的情况有所不同,大家可以自己去求一下顺时针旋转的变换矩阵。
那么对于任意输入向量 v⃗=(x,y) v → = ( x , y ) ,其进行线性变换(这里是逆时针旋转 θ° θ ° )后的输出向量就是
大家可以自己尝试求一下顺时针旋转和缩放的变换矩阵。
如果我一个向量要作多次变换呢?比如我先旋转,再拉伸要如何操作呢?
那不管怎样,我们先构造一个旋转矩阵 Arotate A r o t a t e ,一个拉伸矩阵 Ascale A s c a l e 。对于任意的输入向量 v⃗ v → ,先旋转,那么得到旋转后的输出向量 u⃗=v⃗Arotate u → = v → A r o t a t e ,然后把 u⃗ u → 作为拉伸的输入向量,进行拉伸,得到拉伸后的输出向量 w⃗=u⃗Ascale w → = u → A s c a l e 。也就是说,最先旋转再拉伸的输出向量为:
而线性运算乘法满足结合律,所以有
其中
也就是说,我们可以将多个变换矩阵复合成一个矩阵,然后输入向量乘以(右乘)这个复合矩阵就相当于进行了多个变换。注意先后顺序,先变换的矩阵要放在乘法的最左边,然后依次右乘接下来的变换矩阵。
为什么平移变换不是线性变换呢?在几何上,向量的线性变换的一个必要条件是:线性变换作用于坐标系上所有的点,变换后原点的位置不会发生改变。显然,平移变换后原点的位置会随着平移而发生改变,从几何的角度平移变换也不是线性变换。
顺便一提,另一个必要条件是:变换前处于同一直线上的点,变换后仍要处于同一直线,且变换后它们之间的距离之比与变换前相同(比如变换之前处于同一直线上的三个点A,B,C,变换后分别对应A’,B’,C’,那么A’,B’,C’必须也处于同一直线,且AB/BC = A’B’/B’C’)。你也可以这样理解:变换前所有处于同一直线且等距的点,在变换后也处于同一直线且等距。
我们在齐次坐标中再来具体讨论平移变换如何实现。
如果大家按住cmd点进CATransform3D的定义里面去就可以看到,CATransform3D实际上是一个四阶方阵(4x4矩阵)。
备注写着Homogeneous three-dimensional transforms,意思就是齐次3D变换。
为什么要用四维的矩阵表示三维的内容呢,这是使用了齐次坐标,1是数学家们发明出来用来解决欧式几何无法解决的透视问题,在欧式几何(笛卡尔坐标系)中,两条平行线永远不会相交,但是在透视空间中,两条平行线是可以相交于无穷远,如图,火车轨道的两边相汇于无穷远处。2是用来区分点和向量之间的区别。
我们先从坐标点和向量坐标表示的区别开始。我们知道平面上一个点可以用一个二元元组来表示: P(x,y) P ( x , y ) ,而一个向量也可以如此表示: v⃗=(x,y) v → = ( x , y ) ,这有啥区别呢?
点的坐标是相对于原点的,而向量的坐标是向量终点相对于向量起点的,如果一个向量的起点就是坐标原点,那么此时向量和点在线性变换时没有任何区别。
既然点坐标是相对于原点的,我们可以把点 P(x,y) P ( x , y ) 看做是原点 O(0,0) O ( 0 , 0 ) 沿着向量 v⃗=(x,y) v → = ( x , y ) 平移后的结果。
那么对于平面上的一对基向量 i⃗=(1,0),j⃗=(0,1) i → = ( 1 , 0 ) , j → = ( 0 , 1 ) ,我们可以这样表示向量:
而点则看做原点沿着向量的平移,有:
则可以表示点 P P :
把(1)式和(2)式写成向量相乘的形式:
我们看到,一旦我们把原点 O O 作为新的基向量(零向量)来考虑,点和向量有了不同的表示形式。多出的这一维的数字表示和坐标原点的关系,向量是0,表示向量和坐标系原点没有关系,无论你原点在哪都不影响我向量的大小和方向;点是1,表示点坐标是和坐标系原点密切相关的,不同坐标系原点下的 P(x,y) P ( x , y ) 可能会画出不同的点来。
像这样用N+1维元组来表示N维的点和向量就是齐次坐标表示。
在此基础上我们再来看平移变换。在线性变换中,点和起点在原点的向量之间是可以直接互相转换的,比如你要让一个点绕着坐标原点旋转,那就可以让一个表达式和点相同的向量绕坐标轴原点旋转(乘以旋转矩阵),然后转换成点即可,其实就是让点 (x,y) ( x , y ) 乘以旋转矩阵就完事了。但是你没法通过乘以某个矩阵让点进行平移:
比如对于平面上任意一点 P(x,y) P ( x , y ) ,要让它沿着向量 v⃗=(a,b) v → = ( a , b ) 进行平移,那么平移后的点就是 P′(x+a,y+b) P ′ ( x + a , y + b ) 。现在我们来尝试找一个变换矩阵 At A t ,使得:
你会发现永远也找不到这样一个矩阵。现在我们引入齐次坐标,也就是把点用 P(x,y,1) P ( x , y , 1 ) 来表示,平移后的点就是 P′(x+a,y+b,1) P ′ ( x + a , y + b , 1 ) ,现在再来尝试找一个变换矩阵 At A t ,使得:
我们就可以找到一个3x3矩阵 At=⎛⎝⎜⎜10a01b001⎞⎠⎟⎟ A t = ( 1 0 0 0 1 0 a b 1 ) 。显然,为了适配平移变换的问题,我们的旋转和缩放等线性变换也应该放到齐次坐标下来。在二维平面上,对应的旋转矩阵(逆时针旋转)和缩放矩阵的齐次坐标表示分别为:
这样在进行变换的复合时,就可以加上平移矩阵了。
这里顺便提一下,图像变换的顺序一定要是先缩放、再旋转、最后平移。这个原因涉及到物体坐标系到世界坐标系的转换,缩放既不改变坐标原点,也不改变坐标轴方向;旋转不改变坐标原点,但会影响坐标轴方向;平移则干脆连坐标原点都变了,所以变换的复合必须按着这个顺序来,不然就会出现奇怪的效果。
这样对于平面上任意的点 P(x,y) P ( x , y ) ,其在齐次坐标下的表示为 P(x,y,1) P ( x , y , 1 ) ,然后对它实施各种变换,进行变换后的新点为 P′(x′,y′,1) P ′ ( x ′ , y ′ , 1 ) ,然后转换回笛卡尔坐标: P′(x′,y′) P ′ ( x ′ , y ′ ) ,这样齐次坐标产生的新的维度就不影响我们在笛卡尔坐标系下的点的表示。
你可能会发现,如果让向量来平移,就算是齐次坐标也无法实现,因为我们找不到这样一个变换矩阵 At A t ,使得
这也印证了我们之前的结论,向量只有方向和大小,没有位置的概念,所以平移变换对一个向量而言是没有意义的。
同时我们可以验证平移变换的可加性,比如考虑一个沿着 v⃗=(a,b) v → = ( a , b ) 平移的平移变换:
我们验证可加性,对于任意两个点 U(ux,uy)和V(vx,vy) U ( u x , u y ) 和 V ( v x , v y ) ,有
所以
所以平移变换不满足可加性,从线性变换的定义出发也验证了平移变换不是线性变换。
以上就是齐次坐标的第一个作用:解决向量和点之间的区别。
向量没有位置的概念,而点有。而旋转和缩放对于位置是没有关系的,如图,无论你把向量放在哪里,只要向量的表达式不变,那么它旋转后的结果也不变,缩放同理。
那么对于旋转和缩放,点和向量之间的差别(位置)就不起作用了,那么对于这两个变换,点和向量是可以完美互相转换的(相当于对于旋转和缩放而言,它们认为向量和点是同一个东西)。但是我们的变换需求还有平移变换,而平移变换对于没有位置概念的向量而言是没有意义的(N维向量找不到N维平移变换矩阵,也就是用坐标值乘以矩阵来实现变换对于平移变换而言就不存在了),为了适配缩放和旋转(使用矩阵乘法来表示一个变换,这样才能实现变换的复合,将多个变换表示为一个矩阵),我们把N维的点和向量用N+1维的形式来表示,这样对于新的表示下的点 P(x,y,1) P ( x , y , 1 ) ,就找到了能够用矩阵乘法来表示的平移矩阵,解决了适配的问题。
新的这一维的值如果是1,则表示一个点(因为它和坐标原点相关),如果是0,则表示一个向量(和坐标原点无关),或者表示一个无穷远处的点(无穷远处的点无论你坐标原点在哪,它还是无穷远处的点,所以也和坐标原点无关)。
最后我们来看,齐次坐标解决透视空间下的两条平行线可以相交的问题。
我们回到齐次坐标对点的表达式 P(x,y,1) P ( x , y , 1 ) ,不知道有没有朋友在考虑这样一个问题:多的那一位表示和坐标原点“有”关系或者“无”关系,那不就是“非空”与“空”么,也就是“非零”与“零”的关系,那既然有关系用1来表示,为什么不可以用2、3、4…来表示呢?
当然数学家们也想到了这个不严谨的地方,所以他们添加了一条定义:
即当 k k 非零时,所有形如 (kx,ky,k) ( k x , k y , k ) 的三元组都表示同一个点,比如 (x,y,1) ( x , y , 1 ) 和 (2x,2y,2) ( 2 x , 2 y , 2 ) 就表示同一个点。由此我们就可以引出齐次坐标的定义,即给定一个二维点 (x,y) ( x , y ) ,那么形如 (kx,ky,k) ( k x , k y , k ) 的所有三元组就都是等价的,它们就是这个点的齐次坐标。对每一个齐次坐标,我们只要把它除以三元组中的第三个数,即可得到原始的二维点坐标。这也就是为什么这玩意叫做“齐次”坐标。
而当 k=0 k = 0 时,因为除数不能为0,也就是点 (x,y,0) ( x , y , 0 ) 是没有意义的,毕竟无论坐标原点在哪,你也无法表示它,这样的点当然就在无穷远的地方了。
好,现在我们开始来解决平行线的相交问题,考虑两条直线:
这两条直线是线性相关的(斜率一样,所以是两条平行线)。
在笛卡尔坐标中,如果 C≠D C ≠ D ,那么方程组无解;如果 C=D C = D 那它们就是同一条直线了。
现在我们令 kx=x′,ky=y′ k x = x ′ , k y = y ′ ,放到透视空间下来求解:
整理一下得到
现在我们在 C≠D C ≠ D 的情况下得到一组解 (x′,y′,0) ( x ′ , y ′ , 0 ) ,也就是说这两条直线相交于无穷远处的一点 (x′,y′,0) ( x ′ , y ′ , 0 ) 。所以3D图像投射到平面上时,就需要使用齐次坐标来表示点在透视空间下的表示(比如上面的火车轨道那张图,就是平面图形显示3D内容时,如何表示两条平行线的),我们在接下来的CATransform3D就可以看到。
在开始CATransform3D之前,我们先来回顾一下上面得到的一些结论:
有一点要说明一下,以免有同学钻进了牛角尖出不来。并不是因为旋转和缩放是线性变换所以它们才用矩阵乘法来表示。我们描述一个变换,实际上是对向量进行操作,只要输出满足我们的效果就可以了。而之所以要用矩阵乘法来表示旋转和缩放,是因为我们恰好能很方便的通过基向量来找到这样的变换矩阵,并且矩阵乘法满足结合律,多个变换就可以用一个矩阵来表示。平移变换不是线性变换,但是它仍是一个变换,只要是变换,就是考虑输入向量通过某些操作得到输出向量。既然旋转和缩放是用的矩阵乘法,那么为了让平移也能复合进去,我们应该优先考虑平移变换也用矩阵乘法来实现。很明显平移变换最简单的实现就是 f(x,y)=(x+a,y+b) f ( x , y ) = ( x + a , y + b ) ,这样表示一个点 (x,y) ( x , y ) 向x轴方向平移a个单位,向y轴方向平移b个单位。输入为 P(x,y) P ( x , y ) ,输出为 P′(x+a,y+b) P ′ ( x + a , y + b ) ,这样f就是一个完美的平移变换函数。但是如果这样来表示平移变换,就没办法通过矩阵乘法与缩放和旋转进行复合了。所以我们在找这个平移矩阵时,发现只有在齐次坐标下,这个矩阵才存在,于是为了让平移变换用矩阵乘法表示,点和变换矩阵都应该在齐次坐标下,这时旋转和缩放也就应该用齐次坐标来表示了。而线性变换和非线性变换的变换矩阵的区别在于,线性变换的变换矩阵是通过基向量变换后的结果来合成的,因此很好找,而非线性变换,比如平移变换,是“硬算”出来的,它和“用变换后的基向量来表示变换后的向量”毫无关系。再仔细看看基向量那一部分,我们是通过线性变换才有的性质(可加性和一阶齐次)才得出了“用变换后的基向量来表示变换后的向量”的结论,非线性变换可不能这么做。
从矩阵变换到齐次坐标,为了方便大家理解,我是以二维的情况进行的各种推导,这些结论都可以推广到高维上去。
比如我们接下来就要看的三维变换CATransform3D。在齐次坐标开始的时候我就截了个图,这玩意是一个矩阵,没错,它是一个施加于CALayer的变换矩阵。也就是CALayer上所有的像素点,最终都会乘以这个变换矩阵来实现各种奇奇怪怪的变换效果,比如平移缩放旋转。可能大家都知道,如果要让一个layer进行缩放的变形,一般是这样写的:
// 让一个layer在x轴方向拉伸2倍,在y轴方向拉伸3倍,在z轴方向拉伸1倍。
// 当然一般的layer是没有厚度的概念的,所以z轴的拉伸对layer而言就是没有意义的
layer.transform = CATransform3DMakeScale(2, 3, 1);
这就是改变layer的transform,也就是改变layer的变换矩阵,让layer在绘制的时候,所有的像素点乘以该矩阵来实现变形。同样我们点进CATransform3DMakeScale的注释里面去看
/* Returns a transform that scales by `(sx, sy, sz)':
* t' = [sx 0 0 0; 0 sy 0 0; 0 0 sz 0; 0 0 0 1]. */
CA_EXTERN CATransform3D CATransform3DMakeScale (CGFloat sx, CGFloat sy,
CGFloat sz)
CA_AVAILABLE_STARTING (10.5, 2.0, 9.0, 2.0);
这个就是苹果为我们实现好的一个3维齐次坐标下的缩放矩阵:
这就是三个基向量缩放后的结果组成一个矩阵再转换成齐次坐标:
x轴的基向量i⃗=(1,0,0),y轴的基向量j⃗=(0,1,0),z轴的基向量k⃗=(0,0,1) x 轴 的 基 向 量 i → = ( 1 , 0 , 0 ) , y 轴 的 基 向 量 j → = ( 0 , 1 , 0 ) , z 轴 的 基 向 量 k → = ( 0 , 0 , 1 )
缩放后的基向量分别是:
再组合成非齐次坐标下的变换矩阵:
最后转换成齐次坐标就是我们的CATransform3DMakeScale的结果,同理大家可以看看旋转矩阵和平移矩阵:
/* Returns a transform that translates by '(tx, ty, tz)':
* t' = [1 0 0 0; 0 1 0 0; 0 0 1 0; tx ty tz 1]. */
CA_EXTERN CATransform3D CATransform3DMakeTranslation (CGFloat tx,
CGFloat ty, CGFloat tz)
CA_AVAILABLE_STARTING (10.5, 2.0, 9.0, 2.0);
/* Returns a transform that scales by `(sx, sy, sz)':
* t' = [sx 0 0 0; 0 sy 0 0; 0 0 sz 0; 0 0 0 1]. */
CA_EXTERN CATransform3D CATransform3DMakeScale (CGFloat sx, CGFloat sy,
CGFloat sz)
CA_AVAILABLE_STARTING (10.5, 2.0, 9.0, 2.0);
/* Returns a transform that rotates by 'angle' radians about the vector
* '(x, y, z)'. If the vector has length zero the identity transform is
* returned. */
CA_EXTERN CATransform3D CATransform3DMakeRotation (CGFloat angle, CGFloat x,
CGFloat y, CGFloat z)
CA_AVAILABLE_STARTING (10.5, 2.0, 9.0, 2.0);
由于赋值会覆盖之前的值,所以使用这三个矩阵赋值的话无论你之前经历了怎样的变换,都会被替换成这一个变换,而不是在之前的变换的基础上进行复合。当然苹果肯定为我们提供了复合的方法:
/* Translate 't' by '(tx, ty, tz)' and return the result:
* t' = translate(tx, ty, tz) * t. */
CA_EXTERN CATransform3D CATransform3DTranslate (CATransform3D t, CGFloat tx,
CGFloat ty, CGFloat tz)
CA_AVAILABLE_STARTING (10.5, 2.0, 9.0, 2.0);
/* Scale 't' by '(sx, sy, sz)' and return the result:
* t' = scale(sx, sy, sz) * t. */
CA_EXTERN CATransform3D CATransform3DScale (CATransform3D t, CGFloat sx,
CGFloat sy, CGFloat sz)
CA_AVAILABLE_STARTING (10.5, 2.0, 9.0, 2.0);
/* Rotate 't' by 'angle' radians about the vector '(x, y, z)' and return
* the result. If the vector has zero length the behavior is undefined:
* t' = rotation(angle, x, y, z) * t. */
CA_EXTERN CATransform3D CATransform3DRotate (CATransform3D t, CGFloat angle,
CGFloat x, CGFloat y, CGFloat z)
CA_AVAILABLE_STARTING (10.5, 2.0, 9.0, 2.0);
/* Concatenate 'b' to 'a' and return the result: t' = a * b. */
CA_EXTERN CATransform3D CATransform3DConcat (CATransform3D a, CATransform3D b)
CA_AVAILABLE_STARTING (10.5, 2.0, 9.0, 2.0);
前三个就是在某个变换(参数t提供的变换)之上进行平移、缩放、旋转(注释就写清楚了,用t乘以新生成了平移、缩放、旋转矩阵来得到复合后的矩阵并返回)。最后一个函数就是把两个参数a和b进行乘法,也就是变换的复合。如果你要自己搞些奇怪的变换,那可以使用这个函数来帮你计算矩阵乘法。
你可以理解成:
// 这是一个数学公式而不是赋值语句
CATransform3DScale(t,sx,sy,sz) = CATransform3DConcat(t,CATransform3DMakeScale(sx,sy,sz))
顺便一提,默认的transform是
/* The identity transform: [1 0 0 0; 0 1 0 0; 0 0 1 0; 0 0 0 1]. */
CA_EXTERN const CATransform3D CATransform3DIdentity
也就是一个单位矩阵,任何点乘以单位矩阵得到点本身,意思就是没有任何变换效果。如果你想从变形后的状态还原,那就用这个矩阵给transform属性赋值就好了。
然后我们来看我们这个效果主要用到的,旋转变换。
/* Returns a transform that rotates by 'angle' radians about the vector
* '(x, y, z)'. If the vector has length zero the identity transform is
* returned. */
CA_EXTERN CATransform3D CATransform3DMakeRotation (CGFloat angle, CGFloat x,
CGFloat y, CGFloat z)
CA_AVAILABLE_STARTING (10.5, 2.0, 9.0, 2.0);
返回一个表示绕向量(x,y,z)旋转angle度数的transform。所以这四个参数分别表示:angle = 旋转角度(弧度)。x,y,z表示一个向量 v⃗=(x,y,z) v → = ( x , y , z ) ,这个向量就是旋转轴。
所以我们先来写个3D旋转的效果看看。大家应该能脑补出来,如果我们绕着z轴(垂直于手机屏幕)旋转,那么画面就是在屏幕上旋转而已,不会出现3D的效果。所以我们先来写一个绕着x轴旋转的效果试试:
- (void)touchesEnded:(NSSet *)touches withEvent:(UIEvent *)event
{
CALayer * layer = [CALayer layer];
layer.frame = CGRectMake(0, 0, 320, 240);
layer.position = self.view.center;
layer.contents = (__bridge id)[UIImage imageNamed:@"1.jpg"].CGImage;
[self.view.layer addSublayer:layer];
// 为了让效果更直观,我们写个动画出来看,让layer绕着x轴旋转的动画
CABasicAnimation * animation = [CABasicAnimation animation];
animation.keyPath = @"transform";
animation.duration = 5;
animation.removedOnCompletion = NO;
animation.fillMode = kCAFillModeForwards;
// 绕x轴旋转π/4
CATransform3D transform = CATransform3DMakeRotation(M_PI/4, 1, 0, 0);
animation.toValue = [NSValue valueWithCATransform3D:transform];
[layer addAnimation:animation forKey:@""];
}
效果如图。
空间想象能力足够的同学应该能想出来,这是绕着x轴进行的旋转,不过为啥看着反而像是沿着y轴压缩了。。。不过这里我们还是通过实验得到了一个我称为左手定则的玩意,也就是旋转方向的问题:
将左手的大拇指朝向你指定给CATransform3DMakeRotation的向量的方向,另外四指自然弯曲,弯曲的方向就是旋转的方向。
上面的效果确实是绕着x轴在旋转,之所以看起来这么奇怪,是因为我们没有添加透视效果。所谓透视,就是在平面上展现空间感的一种技术。大家在自己画一个正方体的时候都知道,如果正方体的一面不是正对着画面的话,就要画成平行四边形,这样把空间中的正方形变形成了平面上的平行四边形,给人一种立体感,这就是一种透视技术。
那么CATransform3D是如何实现透视效果的呢?CATransform3D这个矩阵是一个齐次坐标表示,我们讲齐次坐标的时候就说了,齐次坐标可以用来解决透视空间下的两条平行线相交的问题,这里就不再从计算机图形学的角度通过数学和矩阵来说明了。CATransform3D的m34(第3行第4列)的元素可以用来控制透视效果,我们只需要把它设置为 −1500到−11000 − 1 500 到 − 1 1000 即可:
- (void)touchesEnded:(NSSet *)touches withEvent:(UIEvent *)event
{
CALayer * layer = [CALayer layer];
layer.frame = CGRectMake(0, 0, 320, 240);
layer.position = self.view.center;
layer.contents = (__bridge id)[UIImage imageNamed:@"1.jpg"].CGImage;
[self.view.layer addSublayer:layer];
CABasicAnimation * animation = [CABasicAnimation animation];
animation.keyPath = @"transform";
animation.duration = 5;
animation.removedOnCompletion = NO;
animation.fillMode = kCAFillModeForwards;
// 设置透视变换矩阵
CATransform3D perspectiveTransform = CATransform3DIdentity;
perspectiveTransform.m34 = -1.f/700;
// 将透视变换复合到旋转变换中
// 绕x轴旋转π/4
CATransform3D transform = CATransform3DRotate(perspectiveTransform, M_PI/4, 1, 0, 0);
animation.toValue = [NSValue valueWithCATransform3D:transform];
[layer addAnimation:animation forKey:@""];
}
效果如图:
这样就有明显的3D变换效果了。
旋转的问题解决了,接下来我们来看如何用pan手势来控制旋转。
按照我们实践篇的思路,我们仍然先分解动画效果,分解的过程是从具体到抽象,所以我们可以这样来分解:
这样我们一个一个来解决。第一点,3D旋转效果我们已经知道了,用CATransform3D
来实现,注意用m34来控制透视效果即可。第二点,用pan手势控制旋转效果,到这里,我们所要使用的系统API就确定了:CATransform3D
和UIPanGestureRecognizer
。第三点,关于旋转方向,3D旋转函数
CATransform3D CATransform3DRotate (CATransform3D t, CGFloat angle,
CGFloat x, CGFloat y, CGFloat z)
中,最后三个参数x,y,z表示一个向量 v⃗=(x,y,z) v → = ( x , y , z ) ,旋转会以该向量为轴进行旋转。所以我们在脑海里想一想,模拟一下,如果我的手指往上滑,这个视图就是“朝上旋转”的,也就是上面我们3D旋转的那个例子的示意图的效果。而此时大家想想,这个效果的“旋转轴”是什么?我们在写代码的时候传的是(1,0,0),所以很明显这个旋转轴就是沿着x轴正方向的一个向量。也就是说我们最终要传给CATransform3D
的是这个轴,而不是“旋转方向”。而旋转方向和旋转轴是有关系的:左手定则,即打开左手,大拇指指向旋转轴的方向,那么另外四指弯曲的方向就是“旋转方向”。或者我们用向量来描述的话,如果我们用 D⃗ D → 的方向来表示旋转的方向,用 C⃗ C → 来表示旋转轴的方向,那么 C⃗ C → 就是 D⃗ D → 顺时针旋转90°的结果。
旋转轴和旋转方向的问题解决了,最后一个就是手指移动的方向,这个肯定是由UIPanGestureRecognizer
来提供,这样就能得到旋转方向(就是手指移动的方向),再根据旋转方向来得到旋转轴的方向,然后传给CATransform3D
,就完成了一次旋转。
总结一下我们的思路:3D旋转效果用CATrasnform3D
来实现;拖动手势由UIPanGestureRecognizer
来实现;3D旋转方向就是手指移动的方向;手指移动的方向由UIPanGestureRecognizer
提供,所以可以在回调方法里面获取到手指移动的方向;最终要传给CATransform3D
的参数是旋转轴;旋转轴与手指移动方向(旋转方向)的关系是:将手指移动方向顺时针旋转90°就是旋转轴方向。
以上,我们可以通过UIPanGestureRecognizer
得到手指移动方向,然后用这个方向生成一个向量,将它顺时针旋转90°得到的新向量就是我们的旋转轴向量,作为参数传给CATransform3D
就行了。
接下来是最后一个难题,熟悉UIPanGestureRecognizer
的同学应该知道,它的回调方法传出来的向量,即它的translationInView
方法,返回的是“手指移动轨迹的起点到当前手指所在的点连成的向量”,那么我们如何来确定手指在移动的时候任意时刻手指的移动方向?我们画个图出来分析,如图
假如黑色的线条就是我们手指移动的轨迹,我们在轨迹上任取一点,比如蓝色的点,当我们手指按这个轨迹移动到蓝色的点的时候,很明显,此时手指的移动方向就是蓝点的上一个点到蓝点的连线形成的方向,上一个点我用橙色来表示(为了看的更清楚橙色和蓝色我画的离得有点分开,不影响我们分析),那么橙色的箭头就是我们要找的手指的方向。然而translationInView
方法返回的是当手指移动到蓝点和橙点时与起点的连线的方向,即蓝色和红色的箭头是我们已知的,我们要求出橙色箭头所代表的向量,怎么做呢?如果熟悉向量加减法的同学在这里应该能一眼就看出来了,用向量减法:
这样我们在任意时刻手指的移动方向就是:当前的translationInView
减去上一次回调时传来的translationInView
,我们把手势回调的代码写出来:
- (void)onPanGesture:(UIPanGestureRecognizer *)sender
{
if (sender.state == UIGestureRecognizerStateBegan) {
} else if (sender.state == UIGestureRecognizerStateChanged) {
// 获取当前手指的位移(translation)
CGPoint panTranslation = [sender translationInView:sender.view];
// 我们要的向量是手指上次所在的点到这次所在的点连成的一个向量,这是你这次手指滑动的方向,传给transform3DRotate函数的向量是垂直于这个向量的向量。而我们已知的只有这个transition,也就是手指最开始的点到手指当前点连成的一个向量(也就是手指的位移,只考虑起始点和结束点)。
// 画出图来就发现,我们要的向量就是当前向量-上一次手指的位移向量(向量减法)
// 通过这个位移生成一个向量,这就是我们当前的位移向量。
DHVector * vector = [[DHVector alloc] initWithCoordinateExpression:panTranslation];
// 用当前的位移向量-上次的位移向量得到我们手指的位移偏移量
DHVector * translateVector = [DHVector aVector:vector substractedByOtherVector:[self lastTranslation]];
// 把这个向量保存起来,下次调用这个方法的时候需要拿到这次的向量,用来做减法
// 下次再调用这个方法的时候的lastTranslation就是这次的位移向量,所以用这次的位移向量覆盖掉lastTranslation(用这次的位移向量给lastTranslation赋值)
[self setLastTranslation:vector];
// 随便计算一下单位旋转角度,也就是每次调用这个方法的时候应该旋转多少度(线性插值)
CGFloat radian = 1.5f / maxTranslate_ * maxRotateRadian_;
// 生成旋转向量,也就是要传给CATransform3DRotate函数的向量,它通过translateVector顺时针旋转90度(PI/2)得到
DHVector * rotateVector = [DHVector vectorWithVector:translateVector];
[rotateVector rotateClockwiselyWithRadian:M_PI/2];
// 把旋转向量传给函数
self.layer.transform = CATransform3DRotate(self.layer.transform, radian, rotateVector.coordinateExpression.x, rotateVector.coordinateExpression.y, 0);
} else if (sender.state == UIGestureRecognizerStateCancelled || sender.state == UIGestureRecognizerStateEnded) {
}
}
那么剩下的代码就比较简单了,我们把整个获取旋转轴的过程提出来写到一个方法里面去,就可以把剩下的。注意这里涉及到了手势交互,而CALayer
是不支持交互的,所以这里要用UIView
来做动画。
// 手指的最大位移量和当手指达到最大位移量时对应的旋转角度,用来插值计算每次手指移动应该旋转多少度
static const CGFloat maxTranslate_ = 400.f;
static const CGFloat maxRotateRadian_ = M_PI * 2;
@interface ViewController ()
@property (nonatomic, strong) UIView * transformView;
@property (nonatomic, strong) DHVector * lastTranslation;
@property (nonatomic, assign) CGFloat transformUnit;
@end
@implementation ViewController
- (void)viewDidLoad {
[super viewDidLoad];
self.view.backgroundColor = [UIColor groupTableViewBackgroundColor];
self.transformUnit = 1.5;
[self.view addSubview:self.transformView];
[self.view addGestureRecognizer:[[UIPanGestureRecognizer alloc] initWithTarget:self action:@selector(onPanGesture:)]];
}
- (void)setTransform3DWithPanTranslation:(CGPoint)translation
{
// 我们要的向量是手指上次所在的点到这次所在的点连成的一个向量,这是你这次手指滑动的方向,传给transform3DRotate函数的向量是垂直于这个向量的向量。而我们已知的只有这个transition,也就是手指最开始的点到手指当前点连成的一个向量(也就是手指的位移,只考虑起始点和结束点)。
// 画出图来就发现,我们要的向量就是当前向量-上一次手指的位移向量(向量减法)
// 通过这个位移生成一个向量。
DHVector * vector = [[DHVector alloc] initWithCoordinateExpression:translation];
// 用当前的位移向量-上次的位移向量得到我们手指的位移偏移量
DHVector * translateVector = [DHVector aVector:vector substractedByOtherVector:[self lastTranslation]];
// 把这个向量保存起来,下次调用这个方法的时候需要拿到这次的向量,用来做减法
[self setLastTranslation:vector];
// 随便计算一下单位旋转角度,也就是每次调用这个方法的时候应该旋转多少度
CGFloat radian = self.transformUnit / maxTranslate_ * maxRotateRadian_;
// 生成旋转向量,也就是要传给CATransform3DRotate函数的向量,它通过translateVector顺时针旋转90度(PI/2)得到
DHVector * rotateVector = [DHVector vectorWithVector:translateVector];
[rotateVector rotateClockwiselyWithRadian:M_PI/2];
// 把旋转向量传给函数
self.transformView.layer.transform = CATransform3DRotate(self.transformView.layer.transform, radian, rotateVector.coordinateExpression.x, rotateVector.coordinateExpression.y, 0);
}
#pragma mark - callback
- (void)onPanGesture:(UIPanGestureRecognizer *)sender
{
if (sender.state == UIGestureRecognizerStateBegan) {
} else if (sender.state == UIGestureRecognizerStateChanged) {
[self setTransform3DWithPanTranslation:[sender translationInView:sender.view]];
} else if (sender.state == UIGestureRecognizerStateCancelled || sender.state == UIGestureRecognizerStateEnded) {
}
}
#pragma mark - getter
- (UIView *)transformView
{
if (!_transformView) {
_transformView = ({
UIView * view = [[UIView alloc] initWithFrame:CGRectMake(0, 0, 320, 240)];
view.transformUnit = 1.5;
view.center = self.view.center;
view.backgroundColor = [UIColor blueColor];
view.layer.contents = (__bridge id)[UIImage imageNamed:@"1.jpg"].CGImage;
[view prepareForTransform3D];
view;
});
}
return _transformView;
}
@end
这个效果我经过简单的封装,通过category让任意视图都能通过一个方法调用来实现这一个效果,我放到了这个git仓库
CoreAnimation专题的最后一篇终于结束了,整个实践篇的目的在于让大家通过我们原理篇和技巧篇的内容来解决需求中可能遇到的各种各样的动画难题,所以我在实践篇的写作中大量提及思考的过程,阅读起来可能会比较难啃,比较干涩(毕竟都是干货呢),但是我的想法是让大家读完实践篇后不仅能实现实践篇里面那么几个效果,还能拥有动画实现的基本思路(套路,即各种分解动画的思维方式),结合我们的技巧篇的各种工具,能够见招拆招,遇到什么都不怕,这样才是内力的修炼,而不是只会几个固定的招数。内力修炼的过程是比较漫长而痛苦的,我也是一步一步一个坑一个坑走过来的,希望大家都能有所收获吧!