有很多种方式可以描述旋转,但是使用欧拉角来描述是最容易让人理解的。这篇文章将会介绍欧拉角的基础知识、欧拉角的问题和如何去解决这些问题,当然还有欧拉角无法解决的万向节死锁问题,在最后还会介绍如何将欧拉角转换成矩阵,便于程序计算。
坐标系
在介绍欧拉角之前,我们先来简单了解下坐标系。
我们知道在canvas 2d 中的画布坐标系是下图这个样子的。坐标原点在画布的左上角,X 轴正值向右,Y 轴正值向下。
WebGL 的坐标系和 canvas 2d 的不太一样,而且 WebGL 会比 canvas 2d 多一个 Z 轴。它的坐标系如下所示。
这个坐标系和数学中一样原点在中间,X 轴正值向右,Y 轴正值向上。而我们从正面看不见 Z 轴,得旋转下坐标系。
这里的 Z 轴可能有两种形式,一种是正值朝外也就是指向屏幕面前的你,另一种是正值朝内也就是指向屏幕里面。
当 Z 轴正值朝外,称为坐标系为右手坐标系,当 Z 轴正值朝内称为左手坐标系。可以伸出双手像下图一样比划下,就知道为什么称为左手坐标系和右手坐标系了。
左手坐标系和右手坐标系还有一个区别,是它们的旋转正方向。当绕 Z 轴旋转 90° 时,是顺时针还是逆时针旋转呢?
还是伸出双手,握拳,大拇指伸出,大拇指指向旋转轴的正方向,其他手指的弯曲方向就是旋转正方向。
欧拉角
我们在现实生活中向左转向右转,向上看向下看这些都是旋转,用欧拉角(Euler angles)来描述这些旋转最符合我们的常识,称作欧拉角是因为它是数学大神欧拉证明的,他证明任何一个 3D 空间的旋转,都可以拆分为沿着自身三个坐标轴的旋转,也就是任何 3D 空间旋转都是由三个基本旋转矩阵复合而成的。
我们一般称为这三个旋转为偏航-俯仰-翻滚(Yaw-Pitch-Roll 或 Heading-Pitch-Bank),也就是左右摇头-上下点头-左右歪头。这 3 个旋转的顺序是分别绕 Y 轴、X 轴和 Z 轴旋转,当然旋转的顺序也不一定非要是 YXZ,也可以 XYZ 等其他旋转顺序,比如 ThreeJS 的默认顺序就是 XYZ。
上图中使用的是上一小节介绍的右手坐标系,从轴的正值看向负值,逆时针旋转是旋转正方向。
欧拉角的三次旋转是沿着体轴旋转,而不是固定轴旋转。体轴会随着每一次旋转而旋转,固定轴则是固定不动不会跟随旋转。
我们可以先左右摇头 0 度,然后向下低头 90 度看向地面,最后按照 Z 轴旋转 90 度,此时我们还是面向地面,但是如果我们是按照固定轴旋转则此时是耳朵朝向地面。
image.png
上面图中,左边立方体是按照体轴旋转,右边立方体是安装固定轴旋转。点击该链接查看旋转动画 https://codesandbox.io/s/great-haze-grjke?file=/src/index.js 。
一个比较有意思点是,只要按照相反顺序旋转,固定轴旋转和体轴旋转一样的,比如体轴按照 YXZ 旋转,那么固定轴按照 ZXY 旋转相同角度,旋转结果是相同的!大家可以自己做下实验体会一下。
欧拉角的优点很明显,易于人类使用,我们可以轻松理解,而且欧拉角只用 3 个数字的存储定向可以节省内存。
规范欧拉角
欧拉角也有一些缺点,其中一个是定向不是唯一的,比如旋转 10
度和旋转 360 10
度是相同的。要解决这个问题,我们需要使用规范欧拉角,规范欧拉角将偏航和翻滚角限制在
,俯仰角限制在
,现在任何定向规范欧拉角都只有一个欧拉角三元组表示。
但是这样会有一个奇点,我们还需要规定如果俯仰角为正负 90 度时,翻滚角为 0,这在万向节死锁小节中解释。所以规范欧拉角需要满足如下规定。
代码语言:javascript复制-180 < Yaw <= 180
-90 <= Pitch <= 90
-180 < Roll <= 180
if (Pitch == -90 || Pitch == 90) Roll = 0
插值
欧拉角的另一个缺点是插值问题。在两个定向之间插值,给定参数 t
它的大小是 0
到 1
。如果它为 0.5
我们就可以获得两个定向中间的一个定向。但是欧拉角有两个方向可以插值。
如上图所示,这两个定向之间相差 20 度,如果我们使用简单的线性插值,那么会绕一大圈旋转 340 度,而不是 20 度。要解决这个问题,我们需要将插值角度限制在
之间。
代码语言:javascript复制function wrapPi(rad) {
if (Math.abs(rad) <= Math.PI) {
const PI2 = Math.PI * 2
rad -= (Math.floor((rad Math.PI) / PI2) * PI2)
}
return rad
}
function lerp(rad1, rad2, t) {
return rad1 t * wrapPi(rad2 - rad1)
}
有了上面工具,我们可以找到两个角度之间插值的最短弧。但是这并不能完全解决问题,它还会受到万向节死锁影响。
万向节死锁
万向节死锁(Gimbal lock)是欧拉角根本性的问题,我们并不能和上面一样通过一些方法来解决这个问题。无论用什么顺序(XYZ,YZX 等)去旋转,只要第二个轴旋转角度是正负 90 度就会发生万向节死锁问题。
当第二轴旋转正负 90 度时,第一个轴和第三个轴将会重叠在一起,也就是说这时候丢失了一个自由度,只有两个旋转自由度。我们很难自己去想象这种情形,建议观看这个演示动画。
假设现在有 ZYX 顺序的旋转,其中 Y 轴旋转为 90 度。我们可以看到下图中 X 轴的旋转和 Z 轴的旋转是对相同轴的旋转!
因为欧拉角是按照体轴旋转,旋转顺序是父子关系,父轴旋转会带动子轴旋转,上图中 Y 轴旋转 90 度,带动它的子轴 X 轴旋转 90 度,使 X 轴与 Z 轴重合。
我们也可以从公式来验证这一点。
通过上面公式我们可以发现,绕三个轴旋转,其实最终是绕两个轴旋转(X 轴和 Y 轴),我们丢失了 Z 轴的自由度。
需要注意的是万向节死锁问题,并不是说有欧拉角无法描述的定向。而是两个定向之间的插值问题,如果看了上方视频,可以发现当第二个轴旋转 90 度时,让它再旋转到另一个定向,会发生不自然的旋转,这可能就会照成物体突然晃动等问题。如下图所示,我们期望的是第二个旋转,而不是第一个不自然的旋转。
要避免万向节死锁问题,我们可以用四元数来描述定向,这将在下一篇文章介绍。
欧拉角转矩阵
欧拉角对于人来说很容易理解,但是对于电脑来说并不是。一般由用户输入欧拉角的值,程序在内部将欧拉角转换为矩阵,然后用矩阵去使物体发生旋转并呈现给用户。
因为欧拉角是围绕三个基本坐标轴的旋转,我们可以根据三个轴的旋转矩阵去计算最终的旋转矩阵。(这里不过多介绍如果计算出 3 个轴的旋转矩阵,可以点击连接进行查看)
矩阵的一个优势就是可以将不同的变换通过矩阵乘法相乘,就可以得到一个表示最终变换的矩阵。所以这里将欧拉角转换成矩阵就是将这三个旋转矩阵结合起来。
因为我们只关注旋转所以用 3x3
的旋转矩阵就行了,旋转顺序是偏航-俯仰-翻滚,将公式转换为代码如下所示。
class Mat3 {
static fromHPB(h, p, b, out = []) {
const ch = Math.cos(h), cb = Math.cos(b), cp = Math.cos(p);
const sh = Math.sin(h), sb = Math.sin(b), sp = Math.sin(p);
out[0] = ch * cb sh * sp * sb
out[1] = sb * cp
out[2] = ch * sp * sb - sh * cb
out[3] = sh * sp * cb - sb * ch
out[4] = cp * cb
out[5] = sb * sh ch * sp * cb
out[6] = sh * cp
out[7] = -sp
out[8] = ch * cp
return out
}
}
总结
用欧拉角来描述旋转是非常容易让人理解的,但是欧拉角也会有一些问题,一些问题我们可以用一些方法去解决,但是万向节死锁问题是欧拉角最根本的问题,没有方法可以解决这个问题。万向节死锁是欧拉角的第二个轴旋转角度是正负 90 度时,将会失去一个轴的自由度,它会让两个定向之间的插值变得不自然,要解决万向节死锁问题需要用到四元数。欧拉角容易让人理解,但是对于电脑并不是,所以在程序内部一般会将欧拉角转换成矩阵。