<audio id="audio" title="22 | 如何用仿射变换来移动和旋转3D物体？" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/73/a7/73d1bbb48920fb81221f3d4512def1a7.mp3"></audio>

你好，我是月影。

在前面的课程里，我们学习过使用仿射变换来移动和旋转二维图形。那在三维世界中，想要移动和旋转物体，我们也需要使用仿射变换。

但是，仿射变换该怎么从二维扩展到三维几何空间呢？今天，我们就来看一下三维仿射变换的基本方法，以及怎么对它进行优化。

三维仿射变换和二维仿射变换类似，也包括平移、旋转与缩放等等，而且具体的变换公式也相似。

比如，对于平移变换来说，如果向量$P(x_{0},y_{0},z_{0})$沿着向量 $Q(x_{1},y_{1},z_{1})$平移，我们只需要让$P$加上$Q$，就能得到变换后的坐标。

$$<br>
\left\{\begin{array}{l}<br>
x=x_{0}+x_{1} \\\<br>
y=y_{0}+y_{1} \\\<br>
z=z_{0}+z_{1}<br>
\end{array}\right.<br>
$$

再比如，对于缩放变换来说，我们直接让三维向量乘上标量，就相当于乘上要缩放的倍数就可以了。最后我们得到的三维缩放变换矩阵如下：

$$<br>
M=\left[\begin{array}{ccc}<br>
s_{x} &amp; 0 &amp; 0 \\\<br>
0 &amp; s_{y} &amp; 0 \\\<br>
0 &amp; 0 &amp; s_{z}<br>
\end{array}\right]<br>
$$

而且，我们也可以使用齐次矩阵来表示三维仿射变换，通过引入一个新的维度，就可以把仿射变换转换为齐次矩阵的线性变换了。

$$<br>
M’=\left[\begin{array}{ccc}<br>
M &amp; 0  \\\<br>
0 &amp;  1<br>
\end{array}\right]<br>
$$

这个齐次矩阵，是一个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 &amp; 0 &amp; \sin \alpha \\\ 0 &amp; 1 &amp; 0 \\\ -\sin \alpha &amp; 0 &amp; \cos \alpha\end{array}\right]$$

$$绕x轴旋转：R_{x}=\left[\begin{array}{ccc}1 &amp; 0 &amp; 0 \\\ 0 &amp; \cos \beta &amp; -\sin \beta \\\ 0 &amp; \sin \beta &amp; \cos \beta\end{array}\right]$$

$$绕z轴旋转：R_{z}=\left[\begin{array}{ccc}\cos \gamma &amp; -\sin \gamma &amp; 0 \\\ \sin \gamma &amp; \cos \gamma &amp; 0 \\\ 0 &amp; 0 &amp; 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&amp;fromid=823115&amp;fr=aladdin)，任何坐标系的取向，都可以用三个欧拉角来表示。

举个例子，下图中这个飞机的飞行姿态，可以由绕$x$轴的旋转角度（翻滚机身）、绕$y$轴的旋转角度（俯仰），以及绕$z$轴的旋转角度（偏航）来表示。

<img src="https://static001.geekbang.org/resource/image/0e/7d/0e9540f7a6da478eb1a83d38e9d3d17d.jpeg" alt="">

也就是说，这个飞机的姿态可以由这三个欧拉角来确定。具体的表示公式就是$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$。

<img src="https://static001.geekbang.org/resource/image/83/14/8328b6492bf69d900760fb8e9bfbe814.jpeg" alt="">

显然，我们采用的 $y-x-z$ 顺规，属于**Tait–Bryan angles。**

不同的欧拉角顺规虽然表示方法不同，但它们本质上还是欧拉角，都可以表示三维几何空间中的任意取向。所以，我们在绘制三维图形的时候，使用任何一种表示法都可以。今天，我就以$y-x-z$顺规为例来接着讲。

采用$y-x-z$顺规的欧拉角之后，我们能得到如下的旋转矩阵结果：

<img src="https://static001.geekbang.org/resource/image/fa/20/fa3c632502410545bf68672de376ee20.jpeg" alt="">

### 如何使用欧拉角来旋转几何体？

接下来，我们通过一个例子来实际体会，使用欧拉角旋转几何体的具体过程。

这里，我们还是用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是一份几何模型文件，内容类似于下面这样：

```
{
  &quot;position&quot;: [0.752, 1.061, 0.0, 0.767...],
  &quot;normal&quot;: [0.975, 0.224, 0.0, 0.975...],
  &quot;uv&quot;: [0.745, 0.782, 0.705, 0.769...]
}

```

其中position、normal、uv是顶点数据，我们比较熟悉，分别是顶点坐标、法向量和纹理坐标。这样的数据一般是由设计工具直接生成的，不需要我们来计算。

接下来，我们加载飞机的纹理图片，同样要先封装一个加载图片纹理的函数。在函数里，我们用img元素加载图片，然后将图片赋给对应的纹理对象。函数代码如下：

```
function loadTexture(src) {
  const texture = new Texture(gl);
  return new Promise((resolve) =&gt; {
    const img = new Image();
    img.onload = () =&gt; {
      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});

```

最终，我们就能得到可以随意调整欧拉角的飞机模型了，效果如下图所示：

<img src="https://static001.geekbang.org/resource/image/4b/c7/4b803c9b9b4114faeaaf68f177d952c7.gif" alt="">

## 如何理解万向节锁？

使用欧拉角来操作几何体的方向，虽然很简单，但是有一个小缺陷，这个缺陷叫做万向节锁(Gimbal Lock)。那万向节锁是什么呢，我们通过上面的例子来解释。

你会发现，当我们分别改变飞机的alpha、beta、theta值时，飞机会做出对应的姿态调整，包括偏航（改变alpha）、翻滚（改变beta）和俯仰（改变theta）。

但是如果我们将beta固定在正负90度，改变alpha和beta，我们会发现一个奇特的现象：

<img src="https://static001.geekbang.org/resource/image/f6/06/f60deb3ec9096dab9bef78438c660e06.gif" alt="">

如上图所示，我们将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$。单位四元数对应的旋转矩阵如下：

$$<br>
R(q)=\left[\begin{array}{ccc}<br>
1-2 y^{2}-2 z^{2} &amp; 2 x y-2 z w &amp; 2 x z+2 y w \\\<br>
2 x y+2 z w &amp; 1-2 x^{2}-2 z^{2} &amp; 2 y z-2 x w \\\<br>
2 x z-2 y w &amp; 2 y z+2 x w &amp; 1-2 x^{2}-2 y^{2}<br>
\end{array}\right]<br>
$$

这个旋转矩阵的[数学推导过程](https://krasjet.github.io/quaternion/quaternion.pdf)比较复杂，我们只要记住这个公式就行了。

与欧拉角相比，四元数没有万向节死锁的问题。而且与旋转矩阵相比，四元数只需要四个分量就可以定义，模型上更加简洁。但是，四元数相对来说没有旋转矩阵和欧拉角那么直观。

### 四元数与轴角

四元数有一个常见的用途是用来处理**轴角**。所谓轴角，就是在三维空间中，给定一个由单位向量表示的轴，以及一个旋转角度$⍺$，以此来表示几何体绕该轴旋转$⍺$角。

[<img src="https://static001.geekbang.org/resource/image/29/85/29861c395af520f3906c4b7fe20db385.jpeg" alt="" title="轴角">](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);

```

那么，随着我们修改轴或者修改旋转角，物体就会绕着轴旋转。效果如下图所示：

<img src="https://static001.geekbang.org/resource/image/5f/7b/5fa4f4629db4aa3e0ec974c974a9637b.gif" alt="">

这样，我们就实现了用四元数让飞机沿着某个轴旋转的效果了。这其中最重要的一步，是要你理解怎么根据旋转轴和轴角来计算对应的四元数，也就是updateQuaternion函数里面做的事情。然后我们将这个更新后的四元数赋给飞机的mesh对象，就可以更新飞机的位置，实现飞机绕轴的旋转。我只在课程中给出了关键部分的代码，你可以去GitHub仓库里找到对应例子的完整代码。

## 要点总结

今天，我们学习了使用三维仿射变换，来移动和旋转3D物体。三维仿射变换在平移和缩放变换上的绘制方法，与二维仿射变换类似，只不过增加了一个z维度。但是对于旋转变换，三维放射变换就要复杂一些了，因为3D物体可以绕$x、y、z$轴中任意一个方向旋转。

那想要旋转三维几何体，我们可以使用欧拉角。欧拉角实际上就等于，绕$x、y、z$三个轴方向的旋转矩阵相乘，相乘的顺序就是欧拉角的顺规。

虽然顺规有很多种，但是选择不同的顺规，只是表达方式不一样，最终结果是等价的，都是欧拉角。那在这节课中，我们采用$y-x-z$顺规，它也是OGL库默认采用的。

但是欧拉角有一个万向节锁的问题，就是当$β$角旋转到正负90度的时候，我们无论怎么改变$α、γ$角，都只能让物体在一个水平面上运动。而且，只要我们使用欧拉角，就无法避免万向节锁的出现。

为了避免万向节锁，我们可以用四元数来旋转几何体。除此之外，四元数还有一个作用是可以用来构造轴角，让物体沿着某个具体的轴旋转。你可以回想一下我们刚刚实现的绕轴飞行的飞机。

## 小试牛刀

你可以试着利用放射变换，来实现一个旋转的3D陀螺效果。陀螺的形状可以用一个简单的圆锥体来表示。旋转的过程中，你可以让陀螺绕自身的中间轴旋转，也可以让它绕着三维空间某个固定的轴旋转。快来动手试一试吧。效果如下：

<img src="https://static001.geekbang.org/resource/image/00/37/00bcb30bc90yy33a18640fdcf68d4a37.gif" alt="">

除了旋转的飞机和旋转的陀螺，你还能实现哪些旋转的物体呢？不如也把这篇文章分享给你的朋友们，一起来实现一下吧！

## 源码

课程中详细示例代码[GitHub仓库](https://github.com/akira-cn/graphics/tree/master/3d-model)

## 推荐阅读

[1] [进一步理解欧拉角](https://en.wikipedia.org/wiki/Euler_angles)<br>
[2] [欧拉角的不同表示方法参考文档](https://en.wikipedia.org/wiki/Euler_angles)<br>
[3] [四元数与三维旋转](https://krasjet.github.io/quaternion/quaternion.pdf)<br>
[4] [三维旋转：欧拉角、四元数、旋转矩阵、轴角之间的转换](https://zhuanlan.zhihu.com/p/45404840)
