网上四元数相关的文章真的很抽象,包括 3Blue1Brown 大佬的四元数的可视化,我都没法理解四元数。但是看着四元数在计算机图形学以及动画、控制旋转等方面这么普及,不了解明白感觉很多 API 看到一头雾水,而且,不突破一下属实说不过去的。
本篇文章不会上来给一大堆公式,让人看到一头雾水。首先我们先问一个问题:
1. 欧拉角
我没有四元数之前,我都在用什么表示旋转?
Bingo!欧拉角。欧拉角的意思就是:绕 X / Y / Z 转多少度。
text
Pitch = 30°
Yaw = 60°
Roll = 10°
很好理解,但它有一个著名问题:万向锁 Gimbal Lock。所以游戏引擎内部经常不用欧拉角真正做旋转,而是用四元数。
我们先不看万向锁是什么,我们就假装它是一个问题。
2. 四元数
四元数长这样:Quaternion = (x, y, z, w)。
PS:你可能第一反应:四个数字跟旋转有毛关系?
核心来了,你可以把它理解成:旋转轴 + 旋转角度。
假设我要:绕 Y 轴,旋转 60°。我们拆一下信息:
text
旋转轴:Axis = (0, 1, 0)
角度:θ = 60°
那么对应的四元数实际上存的是:
text
x = Axis.x * sin(θ/2)
y = Axis.y * sin(θ/2)
z = Axis.z * sin(θ/2)
w = cos(θ/2)
等价于:
text
q = (0 * sin30°, 1 * sin30°, 0 * sin30°, cos30°)
= (0, 0.5, 0, 0.866)
对,这就是一个四元数。
2.1 但他和欧拉角的区别呢?目前看着差不多感觉。
欧拉角是:X 轴转多少,Y 轴转多少,Z 轴转多少。
四元数更接近:我要围绕这根轴,整体旋转 θ 度。
------也就是:任意旋转 = 一根旋转轴 + 一个旋转角度。
四元数只是用一种很巧妙的数学方式,把 Axis + Angle 编码成 x、y、z、w。
2.2 为什么角度要 /2?(why?!)
四元数描述 3D 旋转的时候,数学结构天然使用"半角"。
就像透视投影,一开始也可以先接受:tan(FOV / 2)。这可不是设计者闲着蛋疼没事除 2,而是数学推导最终就长这样。(这块比较复杂,后面会详细讲!)
2.3 四元数的优势在哪里?!(凭什么代替我的欧拉角)
(1)万向死锁!!!!【最重要的原因】
欧拉角非常直观,大家一眼就能看出来每个轴转了多少。
四元数非常不直观。(0, 0.5, 0, 0.866) 放到代码里,我一个核桃脑袋能看懂这个是旋转多少么?显然是不可能的。但是四元数有个牛逼的地方,就是特别适合旋转插值。
2.4 那它到底怎么适合了?
假设一个人物现在朝前:

我做两个旋转:
旋转 A:向右转 90°
旋转 B:再抬头 30°
text
初始姿态
↓
旋转 A
↓
旋转 B
↓
最终姿态
如果用旋转矩阵,你其实已经知道它可以写成:Mfinal = MB * MA。
四元数形式和它完全一样:qfinal = qB * qA。
所以你第一层理解就这么简单:
四元数乘法,可以先把它当成旋转矩阵乘法:把两个旋转组合成一个新的旋转。
例如:(1)QuatA = 向右转 90 度;(2)QuatB = 抬头 30 度;(3)QuatResult = B * A。
这个 Result 本身又是一个四元数。于是原来两个操作:先转 A,再转 B,被压成了 Result 一个旋转。
2.4.1 可能会有同学问为什么不能直接把 xyz 加起来?
A = 绕 Y 轴转 90°,B = 绕 X 轴转 90°。
可能直觉觉得:旋转结果 = Y 90° + X 90°。
但 3D 旋转有一个很麻烦的特点:旋转顺序会影响结果。
例如:先向右转 90° 再向前翻 90°,和先向前翻 90° 再向右转 90°,明显结果是不一样的。
所以:qB * qA ≠ qA * qB。(也就是说四元数乘法不满足交换律)这里和矩阵是一样的。
2.4.2 那四元数乘法到底干了什么?
假设:qA = 旋转 A,qB = 旋转 B。每一个四元数背后其实都表示:旋转轴 + 旋转角度。
数学会重新计算出:qC → 一根新的旋转轴 + 一个新的旋转角度。
所以关键不是:
四元数把两个角度加起来。
而是:
四元数把两个连续旋转,重新合成为"一次等价旋转"。
2.4.3 Axis-Angle
我们想想为什么"一堆旋转"最后能变成"一根轴 + 一个角"?
因为在 3D 空间里,不管你先绕 X 转、再绕 Y 转、再绕 Z 转、再绕奇怪的轴转。
最终姿态,总能找到:某一根轴 Axis + 某一个角度 θ,一次旋转直接转过去。这就是所谓的 Axis-Angle。
2.5 那四元数怎么真正旋转一个 Vector?
我要让 Forward 被 q 旋转。数学上写:v′ = q * v * q⁻¹。(看到这个先别害怕。)
翻译成人话:原始向量 v → 套进去旋转 q → 得到旋转后的 v′。
这里的 q⁻¹ 是什么鬼?可以暂时理解成:
反向旋转。
例如:q 绕 Y +90°。那么:q⁻¹ 绕 Y -90°。
对于标准化四元数,它特别简单。
假设:q = (x, y, z, w),它的共轭:q* = (-x, -y, -z, w)。
如果这个 Quaternion 长度为 1:q⁻¹ = q*。
所以比如:q = (0, 0.707, 0, 0.707) 代表绕 Y:+90°。
那么:q⁻¹ = (0, -0.707, 0, 0.707) 就是:-90°。
四元数反旋转 ≈ xyz 取负,w 不变。
为什么我要写 q * v * q⁻¹,而不是 q * v?
因为 Quaternion 和 Vector 本质不是一个东西。我们会偷偷把 Vector:v = (x, y, z),变成一种特殊四元数:V = (x, y, z, 0)。【升维】
然后:V′ = q * V * q⁻¹,最终算出来:V′ = (x′, y′, z′, 0)。取前三个 (x′, y′, z′) 就是旋转后的向量。你暂时不用背乘法展开,只要知道:q → 夹住 Vector → q * V * q⁻¹,就完成旋转。
用过 UE 的同学都知道,我们平时在 UE 使用的旋转接口是:
cpp
FQuat Rotation;
FVector Forward = FVector::ForwardVector;
FVector NewForward = Rotation.RotateVector(Forward);
虽然表面看到:Rotation.RotateVector(Forward);底层思想就是:q * V * q⁻¹。当然实际实现会做数学展开优化,不会傻乎乎真的创建三个 Quaternion 一顿乘。
2.6 万向死锁 Gimbal Lock
它本质特别简单:
欧拉角是"一根轴转完,再绕另一根轴转"。某些姿态下,两根旋转轴会重合,于是你少了一个自由度。
因为欧拉角的几个旋转轴是逐层旋转的。
注意:不是"旋转坏掉了!!!!!!!!"
万向锁不是说:
3D 世界突然不能旋转了。
而是:
你选择的 Euler Angle 这种表示方式,在这个姿态附近失去了一个独立旋转轴。
空间本身当然还是正常的。问题出在:Yaw → Pitch → Roll 这种"连续绕三个轴旋转"的表示法。
2.6.1 Quaternion 为什么没这个问题?
因为 Quaternion 根本不是这样描述旋转的。
Euler 是:
绕 X 转一点
再绕 Y 转一点
再绕 Z 转一点
它依赖:三个旋转轴,所以轴可能互相撞到一起。
但是 Quaternion 的想法是:不要给我三个连续旋转。直接告诉我:绕哪一根轴 Axis,转多少度 Angle。
还有就是很多同学认为**:Quaternion 有四个维度,所以比 Euler 多一个维度,因此没有 Gimbal Lock。**这句话是不对的!!!!!!!!!!!
Quaternion 虽然有 x、y、z、w 四个数,但表示 3D Rotation 时它通常是单位四元数:x² + y² + z² + w² = 1。所以这四个数字并不是四个随便变化的自由度。
3D Rotation 本质仍然只有:3 个自由度。
Quaternion 避免 Gimbal Lock 的原因不是"多一个数",而是:
它没有使用 Euler Angle 那种三个连续旋转轴的参数化方式。
2.6.2 编辑器还是给我显示 Pitch / Yaw / Roll
还是因为 Quaternion 太反人类。
这个我们上面说过:(0.183, 0.423, -0.081, 0.883),你能看出来角色朝哪吗?完全不能。所以编辑器上层显示还是欧拉角,但是实际运算还是会把欧拉角转换成四元数来处理。
3. Slerp(Spherical Linear Interpolation)球面线性插值
这是Quaternion 牛逼的地方。
假设人物:当前朝向 ↑,目标朝向 →。现在希望他慢慢转:↑ ↗ →。
最简单的想法可能是:CurrentRotation = Lerp(A, B, t);但是对于 Rotation 来说,普通 Lerp 并不总是理想。Quaternion 有一个专门的东西:Slerp(A, B, t),球面线性插值。
3.1不是哥们 ,Quaternion 和"球"有关系?
刚才我们说了,旋转 Quaternion 一般要求:x² + y² + z² + w² = 1。你有没有觉得很熟悉?
二维:x² + y² = 1,这不就是圆的公式么?!
三维:x² + y² + z² = 1,这不就是单位球么?!
x² + y² + z² + w²,那当然是四维球?!这就是很多人说四元数是 4 维的原因。
大家想看四维啥样子,可以去看四元数可视化。
如果不想看的同学也没事,你只要知道:所有合法单位 Quaternion,都在一个"四维单位球面"上。
所以现在有两个 Quaternion A、Quaternion B。可以想象成球面上的两个点:

Slerp 做的事情就是:
沿着球面,从 A 平滑地走向 B。
而不是直接穿球内部。常用的代码样子就是:
cpp
FQuat Result = FQuat::Slerp(A, B, t);
3.1.1 为啥这样特别适合旋转?
因为我们真正关心的是:旋转角度均匀变化。
例如角色转头:0°、20°、40°、60°、80°、90°。视觉上应该是匀速旋转。
Slerp 就正好保证:
沿旋转空间的最短圆弧进行插值,并且角速度保持比较自然的均匀变化。
所以动画、骨骼、IK、Camera 等场景都会用到它。
3.1.2 q 和 -q 为什么表示同一个旋转?
假设四元数:q = (x, y, z, w)。
其实就是:q = (Axis · sin(θ/2), cos(θ/2))。
比如:
绕 Y 轴旋转 60°:q = (0, 0.5, 0, 0.866)。
现在整体取反:-q = (0, -0.5, 0, -0.866)。
看起来完全不一样。但神奇的是:它俩表示同一个最终 3D 旋转。不是咋可能呢?!
莫慌!先用最简单的方法理解:
你可以想象一个圆。一个方向可以顺时针转 60°,也可以逆时针转 300°。
你会发现最后朝向一样。四元数里也有类似的"两个表示对应同一个旋转"的现象。
更加准确地说:
text
Quaternion 空间
q ●
│
│
O
│
│
-q ●
q 和 -q 在四元数球面上是完全相反的两个点。但映射回现实中的 3D Rotation 时:

这叫:Double Cover(双重覆盖)。
为什么从公式上也成立?
还记得旋转 Vector:v′ = q * v * q⁻¹。现在把 q 全换成 -q:
text
(-q) * v * (-q)⁻¹
= (-q) * v * (-q⁻¹)
= q * v * q⁻¹
诶,又回来了:q * v * q⁻¹。
所以结果完全一样。
也就是说:q 旋转 Vector 和 -q 旋转 Vector,结果完全相同。
3.1.3 话说回来!那这个和 Slerp 有什么关系?
假设:A = 当前旋转,B = 目标旋转。
Quaternion 在球面上:

注意:B 和 -B 表示现实世界中完全一样的旋转。但是在 Quaternion 球面里,它们却位于相反的位置,于是问题出现了。假设 A 离 -B 很近:
text
A ●──● -B
● B
但你偏偏让 Slerp 去找 B。
那么它可能会:A → 绕球面走非常非常远 → B。
现实里:明明只需要转 20°,结果给你绕 340°。
所以要选最近的那个表示。
既然 B 和 -B 表示现实世界中完全一样的旋转,那我们当然应该选择:
离 A 更近的那个。
怎么知道谁更近?用 Dot(A, B) 四元数点积。不要拍,它和 Vector 点积思想完全一样。
点积的几何意义:说明两者在 Quaternion 球面上的夹角关系。
Dot < 0(夹角 > 90°)
A 和 B 离得比较远。
代码上:
cpp
if (Dot(A, B) < 0)
{
B = -B;
}
变成:
原本:
text
A ● ---------------- ● B
实际上改成:
text
A ● ---- ● -B
然后从 A 到 -B 插值。因为:B == -B在最终旋转意义上完全一样。于是我们就能走最短路径。
3.1.4 Slerp 的核心
cpp
Quat Slerp(Quat A, Quat B, float t)
第一件事经常就是:
cpp
float Dot = dot(A, B);
if (Dot < 0)
{
B = -B;
Dot = -Dot;
}
这段以前如果你直接看源码,可能感觉莫名其妙:
为啥好端端把 Quaternion 乘 -1?
现在就知道了:q 和 -q 表示同一个 Rotation,选离 A 最近的那个表示,避免 Slerp 绕远路。
第二件事:真正的 Slerp 才开始。
假设四元数球面上,
A ●
/ .
/ .
/ .
O ------- ● B
A 和 B 之间夹角叫 Ω。
Slerp 会让结果:
text
t = 0
A
t = 0.25
↓
t = 0.5
↓
t = 0.75
↓
t = 1
B
沿圆弧均匀运动。所以它叫:球面线性插值。
这里又有个特别容易混淆的地方:Slerp 和 Lerp 区别,可以直接想成:
Lerp
直接两点连直线,它会穿过球内部。
结果长度可能不再是 1:|q| != 1。
所以通常还要:Normalize()。这就得到:Nlerp= Normalized Lerp。
Slerp
则是:沿着球面圆弧走,所以更符合 Rotation 空间。

那是不是 Slerp 永远比 Nlerp 好?
其实很简单:Slerp 比 NSlerp 更贵,多了一些运算。仅此而已。