Skip to content

在之前的坐标系基本变换章节中,我们学习了 3D 基本旋转的四个方法:绕 3 个坐标轴、绕任意轴的旋转,并讲述了推导过程。本节介绍另外两个旋转的表示方法:欧拉角四元数

任何一个概念的提出都有它自身的意义,新概念的诞生大多是为了解决一些问题,欧拉角与四元数也不例外。

欧拉角

我们看下前四种旋转的特点,前四种旋转可以归结为旋转矩阵。

  • 首先,旋转矩阵是一个 3 X 3 矩阵,需要 9 个数字来表示一个旋转。
  • 其次,旋转矩阵通过 3 个绕基本坐标轴的矩阵相乘得到,计算过程相对繁琐。
  • 最后,物体旋转用矩阵来描述的话不易理解。为什么不易理解,是因为我们习惯于用角度来描述旋转状态,比如向左旋转多少度,绕着什么什么旋转多少度,这种说法很容易在脑子里想象出来。但如果我们看到一种旋转用如下方式来表示:

$ \begin{aligned} R = \begin{pmatrix} 0.25 & 0.1 & 0.3 \
0.1 & 0.02 & 0.2 \
0.1 & 0.02 & 0.2 \end{pmatrix} \end{aligned} $

我想,这种反人类的旋转表示方法,人类是无法理解的,当然计算机是能读懂这种旋转的。

那么,如何表示才能让人很容易地理解旋转呢?于是欧拉角的表示方法诞生了。关于欧拉角的详细介绍,大家可以从这里了解,本节不做具体描述。

欧拉角是飞控系统中用于描述飞行器姿态的方式,使用三个角度来表示,分别是yaw偏航角、pitch俯仰角、roll滚转角

  • yaw:偏航角,是指飞行器偏离原来航线的角度。
  • pitch:俯仰角,是指飞行器机头抬起的角度。
  • roll:滚转角,是指飞行器绕着自身头尾轴线翻滚的角度。

对比到笛卡尔坐标系,偏航角是绕着 Y 轴旋转的角度 α,俯仰角是绕着 X 轴旋转的角度 β,滚转角是绕着 Z 轴旋转的角度 γ。

欧拉角旋转时绕的轴系,既可以参照世界坐标系,也可以参照自身坐标系。本节所讲的内容都是参照自身坐标系。

可以看出,欧拉角很容易就能表示出一个旋转运动,而且用角度来描述旋转,容易被人理解。

$R = ( \alpha, \beta, \gamma)$

欧拉角旋转顺序。

上面讲到,欧拉角是由三个角度构成,那么这三个角度的旋转顺序又是如何表示呢?

我们必须清楚,欧拉角的旋转顺序必须保证统一性。如果顺序不统一,同样的三个角度,旋转结果也会不一样。就好比我们平常走路,向左转α然后向右转β,向右转α然后向左转β,两种旋转最终表示的姿态也会不同。

我们常说的欧拉角严格意义上还可以细分为欧拉角Euler-angles和泰特布莱恩角Tait-Bryan-angles,这两种方法都利用了笛卡尔坐标系的三个坐标轴作为旋转轴,区别主要在于绕轴的旋转顺序。

欧拉角

欧拉角的选取顺序有以下6种:

  • XYX
  • XZX
  • YZY
  • YXY
  • ZXZ
  • ZYZ

以 XYX 欧拉角为例,最开始物体的坐标系和世界坐标系保持一致,首先物体绕 X 轴旋转 α角度,此时物体的坐标系发生了变化,产生了新的坐标系E1,然后绕新坐标系E1的 Y 轴旋转 β角度,这时又产生了新的坐标系 E2, 接着绕 E2 的 X 轴旋转 γ 角度,此时即物体的最终姿态。

可以看到,这种顺序有一个共同点:第一个旋转轴和最后一个旋转轴在物体这个参照系下相同,可以理解为对称型欧拉角。

泰特布莱恩角。

泰特布莱恩角的选取顺序有如下 6 种:

  • XYZ
  • XZY
  • ZXY
  • ZYX
  • YXZ
  • YZX

可以看出,此种旋转顺序是非对称型的,我们前面所说的 yaw-pitch-roll 旋转就是采用的泰特布莱恩角。

欧拉角的矩阵表示

欧拉角的定义有了,那么我们最终还是要将它推导成对应的旋转矩阵才能使用。

这里有这几种顺序的最终推导公式,但接下来我还是要讲解一下这个公式是如何推导出来的。

前面说过了,顺序不同,所对应的旋转矩阵不同,旋转结果也不同。那么我们根据不同的顺序推导对应的旋转矩阵:

XYZ 顺序

在坐标系基本变换章节我们讲解了矩阵的基本旋转,那么,本节的欧拉旋转其实相当于矩阵绕基本坐标轴的复合旋转。 以 XYZ 顺序为例,XYZ 顺序的欧拉旋转可以表示如下:

$ R_{xyz} = R_x R_y R_z $

看到这个表达式,我们首先要思考一个问题,上面这个表达式表示的是什么样的旋转呢?

请谨记,上面表示的旋转可以用以下两种方式理解:

  • 参照自身坐标系,先绕X轴旋转,再绕 Y 轴旋转,最后绕 Z 轴旋转。
  • 参照世界坐标系,先绕 Z 轴旋转,再绕 Y 轴旋转,最后绕 X 轴旋转。

这两种旋转顺序相反。下面的两个方法可以验证,点击这里查看源码

如何验证参照世界坐标系的旋转顺序?

  • 首先改变 Z 轴旋转角度,直到旋转 90 度。
  • 其次改变 Y 轴旋转角度,直到旋转 90 度。
  • 最后改变 X 轴旋转角度,直到旋转 90 度。

我们发现,以世界坐标系为参照,旋转按照先 Z 、再 Y 、最后 X 轴的顺序依次进行。

如何验证参照自身坐标系的旋转顺序?

  • 首先改变 X 轴旋转角度,直到旋转 90 度。
  • 其次改变 Y 轴旋转角度,直到旋转 90 度。
  • 最后改变 Z 轴旋转角度,直到旋转 90 度。

我们发现,以自身坐标系为参照,旋转按照先 X 、再 Y 、最后 Z 轴的顺序依次进行。

所以我们得出以下结论:一个复合变换矩阵,既可以理解为世界坐标系下的依次变换,也可以理解为模型坐标系下的依次变换,变换顺序相反。

根据欧拉角推导旋转矩阵

接下来,我们按照 XYZ 的顺序推导旋转矩阵,如下所示:

MommyTalk1677065558787.jpg

有了推导公式,我们就可以很容易编写JavaScript 算法了:

javascript
function makeRotationFromEuler(euler, target){
    target = target || new Float32Array(16);
    
    var x = euler.x, y = euler.y, z = euler.z;
    var cx = Math.cos(x), sx = Math.sin(x),
        cy = Math.cos(y), sy = Math.sin(y),
        cz = Math.cos(z), sz = Math.sin(z);
    var sxsz = sx * sz;
    var cxcz = cx * cz;
    var cxsz = cx * sz;
    var sxcz = sx * cz;
    target[0] = cy * cz;
    target[1] = sxcz * sy + cxsz;
    target[2] = sxsz - cxcz * sy;
    target[3] = 0;
    
    target[4] = -cy * sz;
    target[5] = cxcz - sxsz * sy;
    target[6] = sxcz + cxsz * sy
    target[7] = 0;
    
    target[8] = sy;
    target[9] = -sx * cy;
    target[10] = cx * cy;
    target[11] = 0;
    
    target[12] = 0;
    target[13] = 0;
    target[14] = 0;
    target[15] = 1;
    
    return target;
}

其它顺序推导

其它顺序的推导公式和 XYZ 类似,大家只需要按照矩阵相乘顺序推导即可,比如:

  • XZY 顺序的推导公式:

$ R_{xzy} = R_x R_z R_y $

  • YXZ 顺序的推导公式:

$ R_{yxz} = R_y R_x R_z $

  • YZX 顺序的推导公式:

$ R_{yzx} = R_y R_z R_x $

  • ZXY 顺序的推导公式:

$ R_{zxy} = R_z R_x R_y $

  • ZYX 顺序的推导公式:

$ R_{zyx} = R_z R_y R_x $

点击这里可以查看不同顺序的欧拉角算法实现。

有了欧拉角生成旋转矩阵的算法之后,我们就可以按照任意顺序进行旋转了。但请注意,在一个应用中尽量要统一旋转顺序,否则物体的旋转姿态将不是我们期望的。

实战演练

上面推导出的算法使用起来相当简单,只需传入一个能够表示欧拉角的对象即可:

一个欧拉角对象包含x、y、z 三个属性,分别表示绕 X 轴、Y 轴、Z 轴旋转的角度,以及一个表示欧拉旋转的顺序 order。

javascript

var rotateMatrix = matrix.getMatrixFromEuler({
    x: deg2radians(uniforms.xRotation),
    y: deg2radians(uniforms.yRotation),
    z: deg2radians(uniforms.zRotation),
    order:'XYZ'
});

欧拉角的缺点

尽管欧拉角易于理解,但它还是有一些缺点的:

  • 计算过程涉及到大量三角函数计算,运算量大,这点在推导公式的过程中显而易见。
  • 给定方位的欧拉角不唯一,有多个,这会对旋转动画的插值造成困难。同样一个姿态可以由好多个欧拉角来表示,即多对一的关系,那么在插值过程中就可能会引起姿态突变,产生抖动效果。
  • 万向节死锁,这个现象会在第二个旋转轴旋转了90 度时产生,当第二个旋转轴旋转 90 度时,会导致第三个旋转轴和第一个旋转轴重合,此时如果继续绕第三个旋转轴,相当于在第一个旋转轴上旋转。所谓死锁并不是旋转不了了,而是少了一个自由度。

万向节死锁

我们看一下万向节死锁的表现:

首先绕 X 轴旋转30度。

接着绕 Y 轴旋转90 度。

绕 Y 轴旋转 90 度后,此时自身坐标系的 Z 轴和最开始的 X 轴重合,触发了万向节死锁,那么它会产生什么后果呢?

我们绕 Z 轴做的旋转,等价于在最开始的 X 轴上旋转。那还要 Z 轴有什么用呢?是的,Z 轴的旋转已经没用了,此时我们无论怎么绕物体自身的 Z 轴旋转,都只能在原先 X 轴和 Y 轴上进行旋转,失去了原先 Z 轴方向上的的自由度。

上面的例子最终的旋转角度是(x: 30, y: 90, z: 50)。

接下来,我们把 Z 轴的旋转角度放到 X 轴上,不再绕 Z 轴旋转了,此时的欧拉角(x:80, y:90,z:0)。

可以看出,欧拉角(x: 80, y: 90, z: 0)和(x: 30, y: 90, z: 50) 表示的旋转一模一样。也就是说,多个欧拉角会对应一个旋转。这在做旋转动画时会导致旋转动画不准确的问题。

欧拉角缺陷演示

有句话说得好,当你没有碰到过使用欧拉角进行旋转所产生的缺陷时,你永远无法理解它的缺点,接下来我通过两个例子来演示一下:

大圆弧与小圆弧

我们知道 (0, 0, 330)和(0, 0, -30)所表示的方位一样,如果把物体从(0, 0, 0)旋转到(0,0,330)所代表的方位,我们期望的旋转动画应该是这样的:

但是实际上,欧拉角旋转路径却是这样的:

欧拉角的这个特点会导致插值动画产生抖动、跳跃的副作用。

动画路径怪异

除了上述大小圆弧产生的路径不正确以外,欧拉角的旋转路径有时很怪异,比如下面这个动画过程。

准备一个球体,球体初始状态处于万向节死锁状态,如下:

javascript
    xRotation: 0,
    yRotation: -90,
    zRotation: 0,

接下来我们让球体转动到如下状态:

    xRotation: 0,
    yRotation: -90,
    zRotation: 0,

我们看一下球体的旋转路径是怎样的:

  • 白色轨迹是采用欧拉角旋转时的运动路线。

  • 红色轨迹是我们正常的旋转路线。

可见,欧拉角有时会让我们的旋转绕个弯,产生比较怪异的动画效果,万向节死锁还是那么讨厌。

连续旋转

万向节死锁除了会产生上面的问题以外,还会导致在做连续增量旋转时姿态不准确的问题,这个问题在一些跟踪系统中导致的后果是跟丢目标。

举个例子,假设现在我们的飞行器先绕自身 X 轴(此时 X 轴和世界坐标系的 X 轴重合)旋转47 度,接着绕 Y 轴旋转 41 度,最后绕 Z 轴旋转 55 度。

javascript
var rotateMatrix = matrix.getMatrixFromEuler({
    x: deg2radians(47),
    y: deg2radians(41),
    z: deg2radians(55),
    order:'XYZ'
});

接着我们再绕飞行器自身坐标系的 X 轴旋转 8 度。

javascript
var rotateMoreMatrix = matrix.getMatrixFromEuler({
    x: deg2radians(8),
    y: deg2radians(0),
    z: deg2radians(0),
    order:'XYZ'
});

经过两次连续旋转之后,物体姿态如下图:

那么,如果我们不分为两次旋转,而是采用一次旋转,那么物体的旋转姿态有什么不同呢?看一下一次旋转的效果:

javascript
var rotateMatrix = matrix.getMatrixFromEuler({
    x: deg2radians(55),
    y: deg2radians(41),
    z: deg2radians(55),
    order:'XYZ'
});

可以看出,虽然有一些差异,但是大体上是一致的。

接下来我们逐渐改变 Y 轴的旋转角度,当 Y 轴旋转角度为 90 度时,我们再用上面的方法比较一下插值和不插值旋转的区别:

插值旋转:

javascript
var rotateMatrix = matrix.getMatrixFromEuler({
    x: deg2radians(47),
    y: deg2radians(90),
    z: deg2radians(55),
    order:'XYZ'
});

var rotateMoreMatrix = matrix.getMatrixFromEuler({
    x: deg2radians(8),
    y: deg2radians(0),
    z: deg2radians(0),
    order:'XYZ'
});

那么我们看下一次性旋转后的方位:

javascript
var rotateMatrix = matrix.getMatrixFromEuler({
    x: deg2radians(55),
    y: deg2radians(90),
    z: deg2radians(55),
    order:'XYZ'
});

这次能够很明显的感觉出插值前和插值后的区别了,结论是当第二个旋转轴越靠近 90 度,经过插值后的旋转姿态与一次旋转后的姿态产生的偏差越大。

结论

实际上欧拉角足以应对大部分场景,虽然它有一些缺点。我们可以做出一些限制来避免它们,比如我们可以将第二个旋转轴的旋转角度限制在 -90 到 +90 之间。但尽管如此,我们仍然无法规避死锁的产生,所以我们急需一种能够避免死锁的旋转方法,也就是接下来要出场的四元数。

四元数

还记得我们在基本变换里推导出的绕任意轴进行旋转的算法吗?但是通过轴角方式的旋转插值不是很直观,四元数的引入是对轴角旋转的升级,它能够完美地避免欧拉角的缺陷,并且能够很容易地对旋转进行插值,使物体旋转更自然,更平滑。

四元数基础

四元数,顾名思义,是由四个数字组成,包含一个实数和三个复数,可以表示为:

$ q = (w, x, y, z) $

或者

$ q = w + xi + yj + zk $

并且有以下特点:

$ i^2= j^2 = k^2 = -1 $

四元数还可以理解为一个实数 w 和一个向量 $\vec u(x,y,z)$

$ q = (w, \vec u) $

基本运算

加法/减法运算

四元数的加减是将对应位置的元素相加或者相减,得到新的四元数。

$ \begin{aligned} q0 + q1 &= (w_0, x_0i, y_0j, z_0k) + (w_1, x_1i, y_1j,z_1k) \
&=(w_0 + w_1, (x_0 + x_1)i, (y_0 + y_1)j,(z_0+z_1)k) \end{aligned} $

$ \begin{aligned} q0 - q1 &= (w_0 + x_0i+ y_0j+ z_0k) - (w_1+ x_1i+ y_1j+z_1k) \
&=w_0 - w_1 + (x_0 - x_1)i+(y_0 - y_1)j+(z_0-z_1)k \end{aligned} $

乘法运算

MommyTalk1677066507263.jpg

四元数的模

$ \begin{aligned} |q| = \sqrt{w^2+x^2+y^2+z^2} \end{aligned} $

四元数的共轭

$ \begin{aligned} q^* &= (w+xi+yj+zk)^* \
&=(w-xi-yj-zk) \end{aligned} $

四元数的倒数

q^{-1} . q &= q . q^{-1} = 1

$ q^{-1} = \cfrac{q^*}{w^2+x^2+y^2+z^2} $

四元数的性质

共轭与倒数的性质:

$ (q_0q_1)^{-1} = q_1^{-1}q_0^{-1} $

$ (q_0q_1)^{} = q_1^{}q_0^{*} $

加法乘法满足结合律和分配律

$ q_0+q_1+q_2 = q_0 + (q_1 + q_2) $ $ q_0q_1q_2 = q_0(q_1q_2) $

$ q_0(q_1+q_2) = q_0q_1+ q_0q_2 $

以上是四元数的运算法则和运算性质,我们对它们进行基本封装。

如何用四元数表示旋转?

四元数的旋转原理如下: 先将原向量表示为四元数$ q_0=(0,\vec{v})$ ,将旋转角度和旋转轴的信息用单位四元数 q 表示,下面是一个代表旋转的四元数:

$ q = cos\theta + \vec u sin\theta $

其中旋转轴 $\vec{u}$ 必须是单位向量。

该四元数表示绕轴 $\vec u$ 旋转 2 * θ 角度,注意是 θ 角的2倍哦。

旋转后得到的向量坐标利用公式 $r = q\cdot p\cdot q^*$ 或$ r = q \cdot p\cdot q^{-1}$ 计算得出。

多个四元数旋转

一个四元数代表一个旋转过程,那么多个四元数代表多个旋转过程。

假设有一个旋转 M 用四元数表示为 Q1,另一个旋转 N 用四元数表示为 Q2。

那么如果我们按顺序实现这两个旋转,先进行 M 旋转,再执行 N 旋转,我们有两种方式:

  • 将 Q2 和 Q1 相乘,然后将乘积转化为旋转矩阵。
    • 注意顺序:Q2 * Q1。
  • 将 Q2 和 Q1 分别转换成旋转矩阵,再将旋转矩阵相乘。
    • 注意顺序:N * M

注意:在计算四元数乘积或者旋转矩阵乘积时,一定要注意顺序,先进行的旋转矩阵或者旋转四元数要放在乘号右侧。

这两种方式所表达的旋转是一致的,但是显然,第一种方式计算量更小一些。

利用四元数实现旋转。

我们至少需要以下三个方法才能对物体进行旋转:

  • 通过如下三种方式构造出四元数。
    • setFromEuler,将一组欧拉角转化成四元数。
    • setFromAxis,将轴角转化成四元数。
    • setFromRotationMatrix,将旋转矩阵转化成四元数。
  • 已知初始状态四元数和结束状态四元数,构造某一阶段的四元数。
    • slerp
  • 根据四元数计算出该四元数所代表的旋转矩阵。
    • makeRotationFromQuaternion

公式的推导比较复杂,这里就不讲述推导过程了,感兴趣的同学可以点击这里,自己动动手试着推导一下。同时,THREEJS 已经为我们封装了关于四元数的函数,在这里我们掌握它提供的一些方法就能覆盖大部分应用场景。

除了上面的一些方法,THREEJS 还做了一些转换方法:

  • 将四元数转换成对应欧拉角。
  • 将四元数转换成对应轴向量。
  • 将四元数转换成绕轴向量旋转的角度。
  • 从当前四元数旋转到另一个四元数所经过的角度。

利用这些方法,很容易地将易于理解的欧拉旋转或者轴角旋转,转换成易于线性插值的四元数。

四元数的用法

看一下如何使用四元数进行插值,我们将物体从欧拉角(30,40,50)代表的方向旋转到(70,90,120)。

首先,我们将起始时刻和结束时刻的欧拉角转化为对应的四元数:

javascript
var startQuaternion = matrix.setFromEuler({
    _x: deg2radians(30),
    _y: deg2radians(40),
    _z: deg2radians(50)
});

var endQuaternion = matrix.setFromEuler({
    _x: deg2radians(70),
    _y: deg2radians(90),
    _z: deg2radians(120)
});

有了起始四元数和结束四元数,我们就可以利用球面插值算法slerp来求旋转矩阵了。假设我们本次旋转过程设置为 30 帧,那么由初始角度到当前帧所代表角度的旋转用四元数表示如下:

javascript
var currentQuaternion = matrix.slerp(startQuaternion, endQuaternion, progress / 30);

那么当前方位的旋转矩阵通过以下方法求得:

javascript
var currentMatrix = matrix.makeRotationFromQuaternion(unitQuaternion);

有了初始角度到每一帧角度的旋转矩阵 U ,那么左乘该旋转矩阵 U 可以实现平滑均匀的旋转动画了,如下:

如果让欧拉角来做 30 次连续插值旋转,最终的动画路径和旋转方向可能会不准确。

四元数在平滑插值上表现出了极大的优势,如果我们想做插值动画,那么四元数无疑是最佳选择。

总结

四元数相比欧拉角的优势还是很大的:

  • 计算量相对小一些。
  • 能够更平滑地插值。

但是四元数也有一定缺点:

  • 概念复杂,不易理解。

回顾

本节介绍了表示旋转的两种很重要的方法:欧拉角与四元数,并分析了它们的优缺点。在实际编程中,四元数的使用场景比较多,动画中的旋转往往需要平滑线性,这种情况我们采用四元数是最佳选择。

下一节,我们结合学过的算法,学习利用鼠标控制模型旋转的原理。