四元数与旋转
本篇位置:模块一 · 第 5 篇 | 前置:投影与深度精度 | 预计阅读:25 分钟
学习目标
- 说清欧拉角(Euler angles)的两大问题:万向锁(gimbal lock)、组合与插值时的顺序依赖。
- 会构造轴角四元数(axis-angle quaternion)并用它旋转向量。
- 区分 slerp 与 nlerp 的取舍,知道两者分别便宜在哪、精确在哪。
- 知道四元数与矩阵互转的存在与各自的使用时机。
旋转的三种表示
上一篇聚焦在"位置"怎么被投影矩阵压缩进裁剪空间;这一篇要处理图形管线里另一个同样基础、却更容易被写错的问题——朝向(orientation)怎么表示、怎么组合、怎么插值。表示旋转的方法不止一种,工程上常见的三种是欧拉角、轴角(axis-angle)、四元数(quaternion),它们不是互相替代的关系,而是各自在存储、组合、插值、直觉性这四个维度上做出了不同取舍:
| 表示 | 存储 | 组合 | 插值 | 直觉性 |
|---|---|---|---|---|
| 欧拉角 | 3 个浮点数,最紧凑 | 三次矩阵连乘,且结果依赖旋转顺序(XYZ ≠ ZYX) | 逐分量线性插值会穿过万向锁附近的奇异区域,路径不可控 | 最强——"绕 X 转多少度"是人脑最容易读出的描述 |
| 轴角 | 4 个数(轴 3 个 + 角 1 个),或压缩成 3 个数的旋转向量 | 没有闭式公式,通常要先转成四元数或矩阵再组合 | 同样没有直接的插值公式,需要借道四元数 | 较强——一根轴加一个角度依然符合直觉 |
| 四元数 | 4 个浮点数,带 1 个单位范数约束(有效自由度仍是 3) | 四元数乘法,结合律成立,处处良定义、没有奇异点 | slerp 沿单位球面走最短弧,能保持匀角速度 | 最弱——四个分量本身没有直接的几何读法,通常要靠工具可视化才看得懂 |
三者里,欧拉角是美术、策划在编辑器面板里最常打交道的形式,因为它符合人的直觉;四元数几乎不会被人直接手填,而是活在动画系统、物理引擎、传感器数据这些"机器对机器"的接口里;轴角则更多是一个中间态,常作为旋转轴+角度这种直觉输入,转换成四元数之后再参与真正的运算。理解这张表,接下来两节要回答的问题就很自然了:为什么工程上宁可放弃欧拉角的直觉性,也要绕道去用一个"看不懂"的四元数。
欧拉角的坑
欧拉角的第一个坑是万向锁(gimbal lock),它的物理原型是一个由三个同心圆环组成的机械陀螺仪(gimbal):外环固定在支架上绕一根轴转,中环挂在外环内侧绕另一根轴转,内环再挂在中环内侧绕第三根轴转——你握着最内层的物体,它的最终朝向由三个环各自转过的角度共同决定,这正是"绕 X 转多少度、再绕 Y 转多少度、再绕 Z 转多少度"这套欧拉角描述的物理来源。麻烦出现在某个特定姿态下:如果中间那个环恰好转了 90°,外环的转轴和内环的转轴会被"拧"到同一根轴线上——两个本来独立的旋转自由度,此刻变成了同一个自由度的两份拷贝,不管单独去转外环还是内环,得到的效果都完全一样,你丢失了一个自由度,这就是万向锁。三维空间里的旋转本该有 3 个自由度,万向锁发生的瞬间只剩下 2 个能独立控制,想要到达的某些朝向,无论怎么调三个角度值都到不了,除非先转出这个奇异姿态再绕过去。
欧拉角的第二个坑藏在组合运算里:三次绕轴旋转依次相乘得到最终矩阵,而矩阵乘法不满足交换律——先绕 X 轴转 90°、再绕 Y 轴转 90°,和先绕 Y 轴转 90°、再绕 X 轴转 90°,得到的最终朝向并不相同,这就是"顺序依赖",工程上常用 XYZ ≠ ZYX 这样的记号提醒自己。它的实际后果是:同一组角度数字,换一套旋转顺序约定去解读(Unity 用的是 ZXY,数学教材里常见的是 ZYX,不同引擎、不同工具链各有偏好)就会得到完全不同的朝向——美术资产在不同引擎间互导时,"角度对不上、模型转向诡异"这类 bug,十有八九都出在旋转顺序被静默换掉了。万向锁与顺序依赖是同一个根源的两种表现:欧拉角把一个连续、无奇异点的旋转群,硬塞进了三个"先后绕轴转"的离散步骤里,这套步骤本身就带着方向性和奇异区域。
四元数最小必要知识
四元数(quaternion)是威廉·哈密顿(William Hamilton)在复数基础上扩展出的四维数系,图形学只需要它的一个极小子集:单位四元数如何表示旋转。构造方法是轴角式的——给定旋转轴 (单位向量)和绕它转过的角度 ,对应的四元数写成
实部 ,虚部三个分量合起来正是 ——注意角度被减半了,这不是笔误,是四元数旋转公式自身的代数结构决定的,下面很快会看到原因。只有当 (即 )时,这个四元数才对应一个合法的刚体旋转;这也是 lab_05_quat.py 里 quat_axis_angle 一开始就把轴向量归一化的原因——保证 恒成立。
拿这个四元数去旋转一个向量 ,公式是
这里 要先临时升格成一个实部为 0 的"纯四元数" ,套两次四元数乘法(先左乘 、再右乘 ),结果的虚部就是转过 角之后的新向量 。这条公式不必推导——直觉上可以把它想成一把钳子,把 夹在 和它的逆之间,"夹出"的效果恰好是绕 转 度(也正因为要"夹两次",构造 时角度才会先减半,两次作用后角度才补回完整的 )。
四元数解决欧拉角那两个坑的方式很干脆:组合旋转变成了四元数乘法——先应用 、再应用 ,等价于算 (乘法顺序仍然重要,这是旋转本身的物理事实,四元数并没有让它消失,但乘法本身处处良定义,不会像欧拉角那样在特定姿态下退化掉一个自由度)。撤销一个旋转则对应共轭:单位四元数的共轭 恰好等于它的逆 ,物理意义是绕同一根轴反转 度——转过去再转回来, 化简得到 ,也就是"不转"这个恒等旋转。
插值:slerp vs nlerp
有了"四元数=旋转"这一层,两帧姿态之间怎么插值,就变成了两个单位四元数之间该怎么走的问题——这正是插帧、动画混合都要面对的运算。最标准的做法是球面线性插值(spherical linear interpolation,简称 slerp):
它沿着单位四元数所在的四维球面走 到 之间那段大圆弧, 每增加一个固定量,转过的角度也增加一个固定量——匀角速度。更便宜的替代方案是归一化线性插值(normalized linear interpolation,简称 nlerp):
先在四维空间里直接做普通线性插值(没有三角函数,只有加法和数乘),再把结果归一化拉回单位球面。
图: 到 转过 170° 的极端情形下,slerp(橙色)严格贴合虚线代表的匀速直线,nlerp(蓝色)在两端偏慢、中段偏快——因为它插值的是四维空间里连接两点的一条弦,弦的中点比端点更靠近球心, 在弦上均匀步进,投影到球面弧长上就不再均匀。真正影响工程选择的是角度大小:小角度时弦和弧几乎重合,两条曲线的差异小到可以忽略,nlerp 用一次归一化换掉了 slerp 里一次 arccos 和三次 sin 的求值,是大多数引擎的默认选择;只有旋转跨度较大、且对角速度均匀有明确要求(比如本图模拟的大幅甩镜头)时,才值得为 slerp 多付这份计算代价。
图里还有一个更容易被忽略的坑:单位四元数对旋转的覆盖是双倍的(double cover)—— 与 代表完全相同的旋转,因为 里的负号在两次乘法中被消去了两次,。这意味着同一个朝向在四维球面上对应两个互为对跖点(antipodal point)的位置。如果两帧姿态对应的四元数点积 恰好是负的,直接代入公式插值会绕远路走大圆的"长弧"而不是"短弧"——旋转看起来会先反方向甩一下再转回来。lab_05_quat.py 的 slerp/nlerp 实现里那一句 if d < 0: q1, d = -q1, -d 就是在处理这件事:把 换成等价的 ,强制点积非负,插值路径才会贴着最短弧走。
工程接口
四元数解决了存储和插值的问题,但 GPU 顶点管线从头到尾认的是矩阵——一个网格的顶点要变换到世界空间或裁剪空间,最终执行的永远是矩阵乘法,不存在"用四元数直接乘顶点"这种硬件路径。所以工程上四元数和矩阵是配对使用的:动画系统里存四元数、对四元数做 slerp,插值算出当前帧姿态后,再转换成 3×3(或嵌入 4×4)矩阵送进 uniform、送进蒙皮(skinning)计算,交给 GPU。转换公式是标准形式,这里直接给出,不推导:
(其中 是单位四元数。)这条公式抄错一个符号就会得到一个镜像或转错方向的旋转,属于那种"编译能过、跑起来姿态诡异"的隐蔽 bug——落笔前我用绕 Y 轴转 90° 的四元数代入这个矩阵做了数值验证,确认它把 转到了 ,并且与直接用 夹逼算出的结果逐分量对上、行列式为 1、矩阵正交,这些都是判断"四元数转矩阵"实现是否抄写正确的低成本自检手段,值得记住。
日常 C++ 工程里几乎不会手写这条公式——GLM(OpenGL Mathematics)库把它封装成了 glm::quat 类型和一整套配套函数:glm::slerp(q0, q1, t) 做插值,glm::mat3_cast(q) / glm::mat4_cast(q) 把插值结果转换成矩阵。选用哪种表示的分工也就清楚了:矩阵是"送进 GPU 之前的最后一站",四元数是"存储、插值、组合旋转时的工作表示"——四元数只有 4 个数、乘法结合律成立,多次组合后即便有浮点误差累积,也只需重新归一化(除以模长)就能修正;而矩阵要保持正交,每次组合后都可能慢慢漂离正交阵,修正起来要做代价高得多的 Gram-Schmidt 正交化。这正是动画系统普遍选择"四元数存储 + 矩阵消费"这套组合拳的原因。
🎯 岗位关联
插帧算法要在两帧真实渲染结果之间"造"出中间帧,如果场景里有摄像机转动(甩镜头、载具急转弯这类场景很常见),中间帧的相机朝向必须由两帧的相机姿态插值得到——这正是本篇要落地的场景。一个常见的错误实现是直接对 4×4 视图矩阵逐元素做线性插值:矩阵是 16 个数,看起来"lerp 一下"顺理成章,但线性插值出来的矩阵几乎必然不再正交,也就不再是一个合法的刚体变换(rigid transform)——它会带上错误的缩放和错切(shear)分量。这种非刚性形变在插帧的中间帧里表现为一种特有的鬼影(ghosting):画面看起来像是被轻微拉伸、扭曲了一下,尤其在相机快速转动的镜头里格外明显;正确的做法是只对旋转部分的四元数做 slerp、平移部分正常做线性插值,再重新组合成矩阵——这是本篇公式在插帧管线里最直接的落点。姿态数据的另一个来源是传感器:手机的陀螺仪与头瞄(head-tracking)类 feature(比如 VR/AR 场景、Android 的 TYPE_ROTATION_VECTOR 传感器)直接输出的就是四元数而不是欧拉角,这不是巧合——传感器融合算法(陀螺仪 + 加速度计 + 磁力计)内部本身就用四元数做姿态积分,原因和这一篇讲的完全一致:避免万向锁、避免插值路径失真。
小结
| 表示 | 存储 | 组合 | 插值 | 直觉性 |
|---|---|---|---|---|
| 欧拉角 | 3 个浮点数 | 矩阵连乘,顺序依赖(XYZ ≠ ZYX) | 逐分量插值会穿过万向锁奇异区 | 最强 |
| 轴角 | 4 个数(或压缩为旋转向量) | 无闭式公式,需借道四元数/矩阵 | 无直接公式 | 较强 |
| 四元数 | 4 个数,单位范数约束 | 四元数乘法,无奇异点 | slerp 匀速沿最短弧 | 最弱,需工具辅助 |
slerp / nlerp 选择规则
- 角度小、性能敏感、对匀速无硬性要求 → nlerp(一次归一化,省去一次 arccos 和三次 sin 的求值)。
- 角度大、或对角速度均匀有明确要求(比如本篇的相机大幅甩动场景)→ slerp。
- 无论选哪个,插值前先检查 的符号,必要时取反其中一个以保证走最短弧——这是双倍覆盖带来的必需步骤,忘记这一步是这套 API 最容易踩的坑。
延伸阅读
- 3Blue1Brown & Ben Eater,四元数交互可视化课程:https://eater.net/quaternions
- Ben Kenwright,Understanding Slerp, Then Not Using It(GDC 2013):https://www.essentialmath.com/GDC2013/GDC13_quaternions_final.pdf
