# 22 | 如何用仿射变换来移动和旋转3D物体? 你好,我是月影。 在前面的课程里,我们学习过使用仿射变换来移动和旋转二维图形。那在三维世界中,想要移动和旋转物体,我们也需要使用仿射变换。 但是,仿射变换该怎么从二维扩展到三维几何空间呢?今天,我们就来看一下三维仿射变换的基本方法,以及怎么对它进行优化。 三维仿射变换和二维仿射变换类似,也包括平移、旋转与缩放等等,而且具体的变换公式也相似。 比如,对于平移变换来说,如果向量$P(x\_{0},y\_{0},z\_{0})$沿着向量 $Q(x\_{1},y\_{1},z\_{1})$平移,我们只需要让$P$加上$Q$,就能得到变换后的坐标。 $$ \\left\\{\\begin{array}{l} x=x\_{0}+x\_{1} \\\\\\ y=y\_{0}+y\_{1} \\\\\\ z=z\_{0}+z\_{1} \\end{array}\\right. $$ 再比如,对于缩放变换来说,我们直接让三维向量乘上标量,就相当于乘上要缩放的倍数就可以了。最后我们得到的三维缩放变换矩阵如下: $$ M=\\left\[\\begin{array}{ccc} s\_{x} & 0 & 0 \\\\\\ 0 & s\_{y} & 0 \\\\\\ 0 & 0 & s\_{z} \\end{array}\\right\] $$ 而且,我们也可以使用齐次矩阵来表示三维仿射变换,通过引入一个新的维度,就可以把仿射变换转换为齐次矩阵的线性变换了。 $$ M’=\\left\[\\begin{array}{ccc} M & 0 \\\\\\ 0 & 1 \\end{array}\\right\] $$ 这个齐次矩阵,是一个4X4的矩阵,其实它就是我们在[第20节课](https://time.geekbang.org/column/article/269494)提到的模型矩阵(ModelMatrix)。 总之,对于三维的仿射变换来说,平移和缩放都只是增加一个$z$分量,这和二维放射变换没有什么不同。但对于物体的旋转变换,三维就要比二维稍微复杂一些了。因为二维旋转只有一个参考轴,就是$z$轴,所以二维图形旋转都是围绕着$z$轴的。但是,三维物体的旋转却可以围绕$x、y、z$,这三个轴其中任意一个轴来旋转。 因此,这节课,我们就把重点放在处理三维物体的旋转变换上。 ## 使用欧拉角来旋转几何体 我们先来看一下三维物体的旋转变换矩阵: $$绕y轴旋转:R\_{y}=\\left\[\\begin{array}{ccc}\\cos \\alpha & 0 & \\sin \\alpha \\\\\\ 0 & 1 & 0 \\\\\\ -\\sin \\alpha & 0 & \\cos \\alpha\\end{array}\\right\]$$ $$绕x轴旋转:R\_{x}=\\left\[\\begin{array}{ccc}1 & 0 & 0 \\\\\\ 0 & \\cos \\beta & -\\sin \\beta \\\\\\ 0 & \\sin \\beta & \\cos \\beta\\end{array}\\right\]$$ $$绕z轴旋转:R\_{z}=\\left\[\\begin{array}{ccc}\\cos \\gamma & -\\sin \\gamma & 0 \\\\\\ \\sin \\gamma & \\cos \\gamma & 0 \\\\\\ 0 & 0 & 1\\end{array}\\right\]$$ 你会看到,我们使用了三个旋转矩阵$Ry、Rx、Rz$来描述三维的旋转变换。这三个旋转矩阵分别表示几何体绕$y$轴、$x$轴、$z$轴转过$α、β、γ$角。而这三个角,就叫做**欧拉角。** ### 什么是欧拉角? 那什么是欧拉角呢?欧拉角是描述三维物体在空间中取向的标准数学模型,也是航空航天普遍采用的标准。对于在三维空间里的一个[参考系](https://baike.baidu.com/item/%E5%8F%82%E7%85%A7%E7%B3%BB/1531482?fromtitle=%E5%8F%82%E8%80%83%E7%B3%BB&fromid=823115&fr=aladdin),任何坐标系的取向,都可以用三个欧拉角来表示。 举个例子,下图中这个飞机的飞行姿态,可以由绕$x$轴的旋转角度(翻滚机身)、绕$y$轴的旋转角度(俯仰),以及绕$z$轴的旋转角度(偏航)来表示。 ![](https://static001.geekbang.org/resource/image/0e/7d/0e9540f7a6da478eb1a83d38e9d3d17d.jpeg) 也就是说,这个飞机的姿态可以由这三个欧拉角来确定。具体的表示公式就是$Rx、Ry、Rz$,这三个旋转矩阵相乘。 $$M=R\_{y} \\times R\_{x} \\times R\_{z}$$ 这里,我们是按照$Ry、Rx、Rz$的顺序相乘的。而$y-x-z$顺序有一个专属的名字叫做欧拉角的**顺规**,也就是说,我们现在采用的是$y-x-z$顺规。欧拉角有很多种不同的顺规表示方式,一共可以分两种:一种叫做**Proper Euler angles**,包含六种顺规,分别是$z-x-z、x-y-x、y-z-y、z-y-z、x-z-x、y-x-y$;另一种叫做**Tait–Bryan angles**,也包含六种顺规,分别是$x-y-z、y-z-x、z-x-y、x-z-y、z-y-x、 y-x-z$。 ![](https://static001.geekbang.org/resource/image/83/14/8328b6492bf69d900760fb8e9bfbe814.jpeg) 显然,我们采用的 $y-x-z$ 顺规,属于**Tait–Bryan angles。** 不同的欧拉角顺规虽然表示方法不同,但它们本质上还是欧拉角,都可以表示三维几何空间中的任意取向。所以,我们在绘制三维图形的时候,使用任何一种表示法都可以。今天,我就以$y-x-z$顺规为例来接着讲。 采用$y-x-z$顺规的欧拉角之后,我们能得到如下的旋转矩阵结果: ![](https://static001.geekbang.org/resource/image/fa/20/fa3c632502410545bf68672de376ee20.jpeg) ### 如何使用欧拉角来旋转几何体? 接下来,我们通过一个例子来实际体会,使用欧拉角旋转几何体的具体过程。 这里,我们还是用OGL框架。OGL的几何网格(Mesh)对象直接支持欧拉角,我们直接用对象的rotation属性就可以设置欧拉角,rotation属性是一个三维向量,它的$x、y、z$坐标就对应围绕$x、y、z$旋转的欧拉角。而且OGL框架默认的欧拉角顺规是$y-x-z$。 为了增加趣味性,我们不用立方体、圆柱体这些一般几何体,而是旋转一个飞机的几何模型。 在OGL中,我们可以加载JSON文件,来载入预先设计好的几何模型。 下面就是我先封装好的,一个加载几何模型的函数。这个函数会载入JSON文件的内容,然后根据其中的数据创建Geometry对象,并返回这个对象。 ``` async function loadModel(src) { const data = await (await fetch(src)).json(); const geometry = new Geometry(gl, { position: {size: 3, data: new Float32Array(data.position)}, uv: {size: 2, data: new Float32Array(data.uv)}, normal: {size: 3, data: new Float32Array(data.normal)}, }); return geometry; } ``` 这样,我们通过如下指令,就可以加载飞机几何体模型了。 ``` const geometry = await loadModel('../assets/airplane.json'); ``` 这里的assets/airplane.json是一份几何模型文件,内容类似于下面这样: ``` { "position": [0.752, 1.061, 0.0, 0.767...], "normal": [0.975, 0.224, 0.0, 0.975...], "uv": [0.745, 0.782, 0.705, 0.769...] } ``` 其中position、normal、uv是顶点数据,我们比较熟悉,分别是顶点坐标、法向量和纹理坐标。这样的数据一般是由设计工具直接生成的,不需要我们来计算。 接下来,我们加载飞机的纹理图片,同样要先封装一个加载图片纹理的函数。在函数里,我们用img元素加载图片,然后将图片赋给对应的纹理对象。函数代码如下: ``` function loadTexture(src) { const texture = new Texture(gl); return new Promise((resolve) => { const img = new Image(); img.onload = () => { texture.image = img; resolve(texture); }; img.src = src; }); } ``` 接着,我们就可以加载飞机的纹理图片了。具体操作如下: ``` const texture = await loadTexture('../assets/airplane.jpg'); ``` 然后,我们在片元着色器中,直接读取纹理图片中的颜色信息: ``` precision highp float; uniform sampler2D tMap; varying vec2 vUv; void main() { gl_FragColor = texture2D(tMap, vUv); } ``` 最后,我们就能将元素渲染出来了。渲染指令如下: ``` const program = new Program(gl, { vertex, fragment, uniforms: { tMap: {value: texture}, }, }); const mesh = new Mesh(gl, {geometry, program}); mesh.setParent(scene); renderer.render({scene, camera}); ``` 最终,我们就能得到可以随意调整欧拉角的飞机模型了,效果如下图所示: ![](https://static001.geekbang.org/resource/image/4b/c7/4b803c9b9b4114faeaaf68f177d952c7.gif) ## 如何理解万向节锁? 使用欧拉角来操作几何体的方向,虽然很简单,但是有一个小缺陷,这个缺陷叫做万向节锁(Gimbal Lock)。那万向节锁是什么呢,我们通过上面的例子来解释。 你会发现,当我们分别改变飞机的alpha、beta、theta值时,飞机会做出对应的姿态调整,包括偏航(改变alpha)、翻滚(改变beta)和俯仰(改变theta)。 但是如果我们将beta固定在正负90度,改变alpha和beta,我们会发现一个奇特的现象: ![](https://static001.geekbang.org/resource/image/f6/06/f60deb3ec9096dab9bef78438c660e06.gif) 如上图所示,我们将beta设为90度,不管改变alpha还是改变theta,飞机都绕着$y$轴旋转,始终处于一个平面上。也就是说,本来飞机姿态有$x、y、z$三个自由度,现在$y$轴被固定了,只剩下两个自由度了,这就是万向节锁。 万向节锁,并不是真的“锁”住。而是在特定的欧拉角情况下,姿态调整的自由度丢失了。而且,只要是欧拉角,不管我们使用哪一种顺规,万向节锁都会存在。这该怎么解决呢? 要避免万向节锁的产生,我们只能使用其他的数学模型,来代替欧拉角描述几何体的旋转。其中一个比较好的模型是**四元数**(Quaternion)。 ## 使用四元数来旋转几何体 四元数是一种高阶复数,一个四元数可以表示为:$q = w + xi + yj + zk$。其中,$i、j、k$是三个虚数单位,$w$是标量,它们满足$i^{2} = j^{2} = k^{2} = ijk = -1$。如果我们把 $xi + yj + zk$ 看成是一个向量,那么四元数$q$又可以表示为 $q=(v, w)$,其中$v$是一个三维向量。 我们可以用单位四元数来描述3D旋转。所谓单位四元数,就是其中的参数满足 $x^{2} + y^{2} + z^{2} + w^{2}= 1$。单位四元数对应的旋转矩阵如下: $$ R(q)=\\left\[\\begin{array}{ccc} 1-2 y^{2}-2 z^{2} & 2 x y-2 z w & 2 x z+2 y w \\\\\\ 2 x y+2 z w & 1-2 x^{2}-2 z^{2} & 2 y z-2 x w \\\\\\ 2 x z-2 y w & 2 y z+2 x w & 1-2 x^{2}-2 y^{2} \\end{array}\\right\] $$ 这个旋转矩阵的[数学推导过程](https://krasjet.github.io/quaternion/quaternion.pdf)比较复杂,我们只要记住这个公式就行了。 与欧拉角相比,四元数没有万向节死锁的问题。而且与旋转矩阵相比,四元数只需要四个分量就可以定义,模型上更加简洁。但是,四元数相对来说没有旋转矩阵和欧拉角那么直观。 ### 四元数与轴角 四元数有一个常见的用途是用来处理**轴角**。所谓轴角,就是在三维空间中,给定一个由单位向量表示的轴,以及一个旋转角度$⍺$,以此来表示几何体绕该轴旋转$⍺$角。 [![](https://static001.geekbang.org/resource/image/29/85/29861c395af520f3906c4b7fe20db385.jpeg "轴角")](https://zinghd.gitee.io/Att-err3/) 绕单位向量$u$旋转$⍺$角,对应的四元数可以表示为:$q = (usin(⍺/2), cos(⍺/2))$。接着,我们来看一个四元数处理轴角的例子。 还是以前面飞机为例,不过,这次我们将欧拉角换成轴角,实现一个updateAxis和updateQuaternion函数,分别更新轴和四元数。 ``` // 更新轴 function updateAxis() { const {x, y, z} = palette; const v = new Vec3(x, y, z).normalize().scale(10); points[1].copy(v); axis.updateGeometry(); renderer.render({scene, camera}); } // 更新四元数 function updateQuaternion(val) { const theta = 0.5 * val / 180 * Math.PI; const c = Math.cos(theta); const s = Math.sin(theta); const p = new Vec3().copy(points[1]).normalize(); const q = new Quat(p.x * s, p.y * s, p.z * s, c); mesh.quaternion = q; renderer.render({scene, camera}); } ``` 然后,我们定义轴, 再把它显示出来。在OGL里面,我们可以通过Polyline对象来绘制轴。代码如下: ``` const points = [ new Vec3(0, 0, 0), new Vec3(0, 10, 0), ]; const axis = new Polyline(gl, { points, uniforms: { uColor: {value: new Color('#f00')}, uThickness: {value: 3}, }, }); axis.mesh.setParent(scene); ``` 那么,随着我们修改轴或者修改旋转角,物体就会绕着轴旋转。效果如下图所示: ![](https://static001.geekbang.org/resource/image/5f/7b/5fa4f4629db4aa3e0ec974c974a9637b.gif) 这样,我们就实现了用四元数让飞机沿着某个轴旋转的效果了。这其中最重要的一步,是要你理解怎么根据旋转轴和轴角来计算对应的四元数,也就是updateQuaternion函数里面做的事情。然后我们将这个更新后的四元数赋给飞机的mesh对象,就可以更新飞机的位置,实现飞机绕轴的旋转。我只在课程中给出了关键部分的代码,你可以去GitHub仓库里找到对应例子的完整代码。 ## 要点总结 今天,我们学习了使用三维仿射变换,来移动和旋转3D物体。三维仿射变换在平移和缩放变换上的绘制方法,与二维仿射变换类似,只不过增加了一个z维度。但是对于旋转变换,三维放射变换就要复杂一些了,因为3D物体可以绕$x、y、z$轴中任意一个方向旋转。 那想要旋转三维几何体,我们可以使用欧拉角。欧拉角实际上就等于,绕$x、y、z$三个轴方向的旋转矩阵相乘,相乘的顺序就是欧拉角的顺规。 虽然顺规有很多种,但是选择不同的顺规,只是表达方式不一样,最终结果是等价的,都是欧拉角。那在这节课中,我们采用$y-x-z$顺规,它也是OGL库默认采用的。 但是欧拉角有一个万向节锁的问题,就是当$β$角旋转到正负90度的时候,我们无论怎么改变$α、γ$角,都只能让物体在一个水平面上运动。而且,只要我们使用欧拉角,就无法避免万向节锁的出现。 为了避免万向节锁,我们可以用四元数来旋转几何体。除此之外,四元数还有一个作用是可以用来构造轴角,让物体沿着某个具体的轴旋转。你可以回想一下我们刚刚实现的绕轴飞行的飞机。 ## 小试牛刀 你可以试着利用放射变换,来实现一个旋转的3D陀螺效果。陀螺的形状可以用一个简单的圆锥体来表示。旋转的过程中,你可以让陀螺绕自身的中间轴旋转,也可以让它绕着三维空间某个固定的轴旋转。快来动手试一试吧。效果如下: ![](https://static001.geekbang.org/resource/image/00/37/00bcb30bc90yy33a18640fdcf68d4a37.gif) 除了旋转的飞机和旋转的陀螺,你还能实现哪些旋转的物体呢?不如也把这篇文章分享给你的朋友们,一起来实现一下吧! * * * ## 源码 课程中详细示例代码[GitHub仓库](https://github.com/akira-cn/graphics/tree/master/3d-model) ## 推荐阅读 \[1\] [进一步理解欧拉角](https://en.wikipedia.org/wiki/Euler_angles) \[2\] [欧拉角的不同表示方法参考文档](https://en.wikipedia.org/wiki/Euler_angles) \[3\] [四元数与三维旋转](https://krasjet.github.io/quaternion/quaternion.pdf) \[4\] [三维旋转:欧拉角、四元数、旋转矩阵、轴角之间的转换](https://zhuanlan.zhihu.com/p/45404840)