土法炼钢兴趣小组的算法知识备份

游戏中的数学 (3) - 四元数

目录

引言

在 Unity 的 Inspector 中,你看到的是 \((x, y, z)\) 的欧拉角。但在代码底层,旋转通常是存储为四元数 (Quaternion) \((x, y, z, w)\)

为什么?为什么不直接用三个角度?

💡 快速上手:如果你只想学会使用四元数,可以直接跳到第 3 节:四元数的运算第 5 节:实战技巧。理论部分可以之后再回来细读。

1. 欧拉角 (Euler Angles) 的问题

欧拉角非常直观:绕 X 轴转多少度,绕 Y 轴转多少度,绕 Z 轴转多少度。

但它有一个致命缺陷:万向节死锁 (Gimbal Lock)

万向锁示意图

当中间的旋转轴(通常是 Y 或 X,取决于旋转顺序)旋转 90 度时,第三个旋转轴会与第一个旋转轴重合。此时,你失去了一个自由度。无论你怎么转第三个轴,效果都和转第一个轴一样。

真实案例:在飞行模拟器中,当飞机垂直向上(俯仰角 90°)时,偏航和滚转变得无法区分。这就是为什么航天器和现代游戏引擎都使用四元数。

此外,欧拉角很难进行平滑插值。直接对角度进行 Lerp 会导致奇怪的旋转路径(比如从 350° 转到 10°,会绕一大圈经过 180°,而不是直接跨过 0°)。

欧拉角插值问题

2. 什么是四元数?

2.1 从复数到四元数:旋转的代数表示

在理解四元数之前,先回顾复数如何表示 2D 旋转。

复数与 2D 旋转:复数 \(z = a + bi\) 可以写成极坐标形式 \(z = r(\cos\theta + i\sin\theta) = re^{i\theta}\)。当 \(r=1\) 时,乘以复数 \(e^{i\theta}\) 就是绕原点旋转 θ 角

\[z' = e^{i\theta} \cdot z\]

复数与2D旋转

哈密顿的问题是:能否找到一种”3D 复数”来表示 3D 旋转?

四元数的诞生:1843 年,爱尔兰数学家哈密顿 (William Rowan Hamilton) 发现,需要三个虚数单位 \(i, j, k\) 才能推广到 3D:

\[q = w + xi + yj + zk\]

其中虚数单位满足著名的哈密顿关系\[i^2 = j^2 = k^2 = ijk = -1\]

这意味着:\(ij = k\), \(jk = i\), \(ki = j\)(循环),而 \(ji = -k\), \(kj = -i\), \(ik = -j\)(反向为负)。

哈密顿乘法规则

💡 哈密顿在都柏林的布鲁厄姆桥上顿悟了这个公式,激动地用刀刻在桥上。这座桥至今仍有纪念牌。

为什么需要三个虚数单位?

用两个虚数单位(如 \(i\)\(j\))无法闭合——它们的乘积 \(ij\) 必须是第三个独立的元素 \(k\)。这就是为什么 3D 旋转需要 4 个数来表示,而不是 3 个。

2.2 四元数的结构

四元数可以分解为标量部分向量部分

\[q = \underbrace{w}_{\text{标量部分}} + \underbrace{(xi + yj + zk)}_{\text{向量部分 } \vec{v}}\]

或者写成:\(q = (w, \vec{v})\),其中 \(\vec{v} = (x, y, z)\)

类型 定义 意义
纯四元数 \(w = 0\),即 \((0, \vec{v})\) 表示 3D 向量
单位四元数 \(\|q\| = 1\) 表示 3D 旋转
恒等四元数 \((1, 0, 0, 0)\) 表示无旋转

2.3 几何意义:四元数如何编码旋转

四元数表示旋转的核心思想是轴-角 (Axis-Angle) 表示法:

四元数轴角表示

任何 3D 旋转都可以描述为:绕某个轴 \(\vec{n}\) 旋转角度 \(\theta\)。这是欧拉旋转定理的核心结论——任意刚体的定点旋转都可以用一个轴和一个角度来描述。

对应的单位四元数为:

\[q = \cos\frac{\theta}{2} + \sin\frac{\theta}{2}(n_x i + n_y j + n_z k)\]

或者写成:

\[q = \left(\cos\frac{\theta}{2}, \; \sin\frac{\theta}{2} \cdot \vec{n}\right)\]

四元数分量的几何意义

各分量的几何意义: - \(w = \cos(\theta/2)\):编码旋转了多少(半角的余弦) - \((x, y, z) = \sin(\theta/2) \cdot \vec{n}\):编码绕什么轴旋转(轴方向按半角正弦缩放)

⚠️ 易错点:四元数的 \((x, y, z)\) 不是空间坐标!它们是旋转轴的方向向量乘以 \(\sin(\theta/2)\) 的结果。

举例:绕 Y 轴(向上)旋转 90°: - 轴:\(\vec{n} = (0, 1, 0)\) - 角度:\(\theta = 90° = \pi/2\) - 计算:\(w = \cos(45°) = 0.707\)\(y = \sin(45°) \times 1 = 0.707\) - 四元数:\(q = (w, x, y, z) = (0.707, 0, 0.707, 0)\)

常见四元数值速查

四元数常见值速查
旋转 四元数 (w, x, y, z)
无旋转 (1, 0, 0, 0)
绕X轴90° (0.707, 0.707, 0, 0)
绕Y轴90° (0.707, 0, 0.707, 0)
绕Z轴90° (0.707, 0, 0, 0.707)
绕X轴180° (0, 1, 0, 0)
绕Y轴180° (0, 0, 1, 0)
绕Z轴180° (0, 0, 0, 1)

2.4 为什么是半角 θ/2?

这是四元数最精妙也最反直觉的设计,有深刻的数学原因。

原因 1:旋转公式的要求——“三明治”乘法

用四元数 \(q\) 旋转向量 \(\vec{v}\) 的公式是:

\[\vec{v}' = q \cdot \vec{v} \cdot q^{-1}\]

这个”三明治”乘法(左右各乘一次)会让旋转角翻倍。因此,如果我们想旋转 \(\theta\),四元数必须编码 \(\theta/2\)

三明治乘法图解

直观理解: - 左乘 \(q\):旋转 \(\theta/2\) - 右乘 \(q^{-1}\):再旋转 \(\theta/2\)(在另一个意义上) - 合起来:正好旋转 \(\theta\)

💡 为什么需要三明治? 如果只用 \(q \cdot v\),结果不仅会旋转向量,还会引入额外的”扭曲”。三明治乘法 \(q \cdot v \cdot q^{-1}\) 巧妙地抵消了这个扭曲,只保留纯净的旋转。

2D vs 3D 的区别

半角的几何原理

原因 2:双覆盖性质

四元数双覆盖性质

这在数学上称为 SO(3) 的双覆盖:单位四元数构成的空间(3-球面 \(S^3\))是 3D 旋转群 SO(3) 的双重覆盖。

💡 直观理解:想象一条绳子,一端固定,你抓着另一端转了一圈(360°)。绳子扭了一圈,你需要再转一圈(共 720°)才能回到初始状态。这就是四元数的双覆盖性在物理世界中的体现(也叫做 Dirac 腰带技巧)。

原因 3:插值连续性

正是因为双覆盖,四元数在 360° 范围内是单值连续的,这保证了 Slerp 插值不会发生”跳变”或”抖动”。

2.5 单位四元数与 4D 超球面

单位四元数满足: \[|q| = \sqrt{w^2 + x^2 + y^2 + z^2} = 1\]

这意味着所有单位四元数都位于4D 空间中的单位球面 \(S^3\) 上。

单位四元数球面

几何直觉:虽然我们无法直接”看到”4D 空间,但可以用类比来理解:

维度 球面 点的表示 旋转类比
2D 圆周 \(S^1\) \((\cos\theta, \sin\theta)\) 2D 旋转角
3D 球面 \(S^2\) 经纬度 方向向量
4D 超球面 \(S^3\) \((w, x, y, z)\) 3D 旋转

3D 旋转与 \(S^3\) 上的点一一对应(除了 \(q\)\(-q\) 表示同一旋转)。这个几何视角解释了: - Slerp 是球面上的大圆弧插值——沿着球面的”最短路径”走 - 旋转的组合对应球面上的”乘法”——两个点”相乘”得到另一个点 - 归一化就是投影回球面——把偏离球面的点拉回到球面上

💡 为什么这很重要? 理解四元数是 4D 球面上的点,就能理解为什么 Slerp 能保证匀速旋转——它就是在走球面上的测地线(最短路径)。

由于浮点误差累积,长时间运算后需要重新归一化:

q = q.normalized;

3. 四元数的运算

3.1 乘法 = 组合旋转

就像矩阵一样,两个四元数相乘表示旋转的叠加。

四元数乘法

\[ Q_{final} = Q_2 \times Q_1 \]

表示先应用 \(Q_1\),再应用 \(Q_2\)(从右向左读,与矩阵乘法顺序相同)。

几何意义:如果 \(Q_1\) 表示”绕 X 轴转 30°“,\(Q_2\) 表示”绕 Y 轴转 45°“,那么 \(Q_2 \times Q_1\) 表示”先绕 X 轴转 30°,再绕 Y 轴转 45°“的组合旋转

四元数乘法的计算公式(设 \(q_1 = (w_1, x_1, y_1, z_1)\), \(q_2 = (w_2, x_2, y_2, z_2)\)):

\[ \begin{align*} w &= w_1 w_2 - x_1 x_2 - y_1 y_2 - z_1 z_2 \\ x &= w_1 x_2 + x_1 w_2 + y_1 z_2 - z_1 y_2 \\ y &= w_1 y_2 - x_1 z_2 + y_1 w_2 + z_1 x_2 \\ z &= w_1 z_2 + x_1 y_2 - y_1 x_2 + z_1 w_2 \end{align*} \]

向量形式(更容易记忆):

\[q_1 \cdot q_2 = (w_1 w_2 - \vec{v}_1 \cdot \vec{v}_2, \; w_1 \vec{v}_2 + w_2 \vec{v}_1 + \vec{v}_1 \times \vec{v}_2)\]

其中 \(\vec{v}_1 = (x_1, y_1, z_1)\)\(\vec{v}_2 = (x_2, y_2, z_2)\)

⚠️ 注意:四元数乘法不满足交换律\(Q_1 \times Q_2 \neq Q_2 \times Q_1\)

这与 3D 旋转的性质一致:先绕 X 轴转再绕 Y 轴转,和先绕 Y 轴转再绕 X 轴转,结果是不同的。

3.2 共轭与逆

四元数共轭与逆

对于单位四元数,逆等于共轭\(q^{-1} = q^*\)(因为 \(|q|^2 = 1\)

几何意义\(q^{-1}\) 表示相反方向的旋转。如果 \(q\) 是”绕 Y 轴顺时针转 30°“,那么 \(q^{-1}\) 就是”绕 Y 轴逆时针转 30°“。

\(q^{-1}\) 表示相反的旋转。如果你想”撤销”一个旋转:

Quaternion undoRotation = Quaternion.Inverse(rotation);

3.3 旋转向量

用四元数 \(q\) 旋转向量 \(\vec{v}\)

用四元数旋转向量

\[\vec{v}' = q \cdot \vec{v} \cdot q^{-1}\]

其中 \(\vec{v}\) 被扩展为纯四元数 \((0, v_x, v_y, v_z)\)

旋转的几何分解

理解这个公式的一种方法是把向量分解为平行于旋转轴和垂直于旋转轴的两部分:

四元数旋转分解
  1. 分解向量\(\vec{v} = \vec{v}_\parallel + \vec{v}_\perp\)
  2. 平行分量不变\(\vec{v}'_\parallel = \vec{v}_\parallel\)(与旋转轴平行的部分不受旋转影响)
  3. 垂直分量旋转\(\vec{v}'_\perp\) 在垂直于轴的平面内旋转角度 \(\theta\)
  4. 合并结果\(\vec{v}' = \vec{v}'_\parallel + \vec{v}'_\perp\)

展开的旋转公式(用于手动计算或理解):

\[\vec{v}' = \vec{v} + 2w(\vec{q} \times \vec{v}) + 2(\vec{q} \times (\vec{q} \times \vec{v}))\]

其中 \(\vec{q} = (x, y, z)\) 是四元数的向量部分,\(w\) 是标量部分。

// Unity 中可以直接用乘法
Vector3 rotatedVector = rotation * originalVector;

// 手动实现(用于理解原理)
Vector3 RotateVector(Quaternion q, Vector3 v) {
    Vector3 qv = new Vector3(q.x, q.y, q.z);
    Vector3 t = 2.0f * Vector3.Cross(qv, v);
    return v + q.w * t + Vector3.Cross(qv, t);
}

3.4 常用 API (Unity)

函数 说明
Quaternion.identity 无旋转 \((0, 0, 0, 1)\)
Quaternion.Euler(x, y, z) 欧拉角 → 四元数
q.eulerAngles 四元数 → 欧拉角
Quaternion.AngleAxis(angle, axis) 轴角 → 四元数
Quaternion.LookRotation(forward, up) 朝向 → 四元数
Quaternion.FromToRotation(from, to) 计算从一个方向到另一个方向的旋转
Quaternion.Inverse(q) 求逆
Quaternion.Dot(a, b) 点积(用于判断相似度)

4. 球面线性插值 (Slerp)

这是四元数最强大的功能之一。

4.1 Lerp vs Slerp 的几何差异

Lerp (线性插值) 是沿着直线走的。对于旋转来说,这意味着它会切过球体内部,导致旋转速度不均匀。

Slerp (Spherical Linear Interpolation) 是沿着球体的表面(大圆弧)走的。

Slerp Visual
Slerp 几何意义

为什么 Slerp 能保证匀速?

想象地球表面上从北京到纽约的两条路径: - Lerp:像是挖隧道穿过地球——距离确实最短,但你在隧道里走的速度和在地表的角速度不一致 - Slerp:沿着地球大圆弧飞行——这才是真正的”球面最短路径”,而且角速度恒定

4.2 Slerp 公式

\[\text{slerp}(q_1, q_2, t) = \frac{\sin((1-t)\Omega)}{\sin\Omega} q_1 + \frac{\sin(t\Omega)}{\sin\Omega} q_2\]

其中 \(\Omega = \arccos(q_1 \cdot q_2)\) 是两个四元数的夹角(在 4D 空间中的角度)。

公式解读: - \(t = 0\) 时:权重全在 \(q_1\),结果是 \(q_1\) - \(t = 1\) 时:权重全在 \(q_2\),结果是 \(q_2\) - \(t = 0.5\) 时:权重平分,结果是球面上的中点

它保证了旋转是最短路径匀速的。

4.3 双覆盖陷阱

⚠️ 双覆盖陷阱:由于 \(q\)\(-q\) 代表同一旋转,Slerp 前需要检查点积。如果 \(q_1 \cdot q_2 < 0\),应取反其中一个,否则会走”远路”:

if (Quaternion.Dot(q1, q2) < 0) {
    q2 = new Quaternion(-q2.x, -q2.y, -q2.z, -q2.w);
}

为什么? 当点积为负时,意味着两个四元数在 4D 球面上相距超过 90°。虽然它们可能表示相近的旋转(因为 \(-q\)\(q\) 是同一旋转),但 Slerp 会选择”穿过半个球面”的长路径。取反其中一个就能走短路。

4.4 代码示例

// 平滑旋转向目标
transform.rotation = Quaternion.Slerp(
    transform.rotation, 
    targetRotation, 
    Time.deltaTime * speed
);

// 或者按固定角速度旋转
transform.rotation = Quaternion.RotateTowards(
    transform.rotation,
    targetRotation,
    maxDegreesPerSecond * Time.deltaTime
);

Lerp vs Slerp

特性 Lerp Slerp
路径 直线(穿过球内) 大圆弧(沿球面)
速度 不均匀 均匀
性能 更快 稍慢
适用场景 角度差很小时 角度差较大时

💡 性能提示:当两个四元数非常接近时(如逐帧插值),Lerp + Normalize 的效果与 Slerp 几乎相同,但更快:

q = Quaternion.Lerp(q1, q2, t).normalized;

5. 实战技巧

5.1 避免直接操作分量

// ❌ 错误:直接修改分量会破坏单位长度约束
q.x = 0.5f;

// ✅ 正确:使用 API
q = Quaternion.AngleAxis(30, Vector3.up);

5.2 判断两个旋转是否接近

// 使用点积,接近 1 或 -1 表示相同旋转
float similarity = Mathf.Abs(Quaternion.Dot(q1, q2));
if (similarity > 0.999f) {
    // 几乎相同
}

5.3 获取旋转差

// q1 旋转到 q2 需要的旋转
Quaternion diff = q2 * Quaternion.Inverse(q1);

// 获取旋转角度
float angle;
Vector3 axis;
diff.ToAngleAxis(out angle, out axis);

5.4 组合多个旋转

// 依次应用多个旋转:先 q1,再 q2,最后 q3
Quaternion combined = q3 * q2 * q1;

// 常见应用:在父物体旋转的基础上应用局部旋转
Quaternion worldRotation = parentRotation * localRotation;

5.5 常见错误与陷阱

错误 后果 正确做法
直接修改 x/y/z/w 分量 破坏单位长度,旋转失效 使用 API 创建新四元数
对四元数做 Lerp 不归一化 旋转逐渐”缩小” SlerpLerp().normalized
忘记 q 和 -q 是同一旋转 Slerp 走远路、动画抖动 检查点积,必要时取反
混用不同引擎的分量顺序 旋转完全错误 确认 (w,x,y,z) 还是 (x,y,z,w)
欧拉角和四元数混用 万向锁、插值异常 内部全用四元数,仅 UI 显示欧拉角
累积旋转不归一化 浮点误差导致旋转漂移 定期调用 normalized
// ❌ 常见错误:累积旋转后不归一化
for (int i = 0; i < 10000; i++) {
    rotation = deltaRotation * rotation;
}
// rotation 可能已经不是单位四元数了!

// ✅ 正确做法:定期归一化
for (int i = 0; i < 10000; i++) {
    rotation = deltaRotation * rotation;
    if (i % 100 == 0) rotation = rotation.normalized;
}

6. 四元数与矩阵的转换

在实际开发中,四元数常需要转换为旋转矩阵(如传给着色器)。

四元数 → 旋转矩阵

给定单位四元数 \(q = (w, x, y, z)\),对应的 3x3 旋转矩阵为:

\[ R = \begin{bmatrix} 1 - 2(y^2 + z^2) & 2(xy - wz) & 2(xz + wy) \\ 2(xy + wz) & 1 - 2(x^2 + z^2) & 2(yz - wx) \\ 2(xz - wy) & 2(yz + wx) & 1 - 2(x^2 + y^2) \end{bmatrix} \]

// Unity 中的转换
Matrix4x4 rotationMatrix = Matrix4x4.Rotate(quaternion);

为什么用四元数而不直接用矩阵?

四元数与矩阵对比

7. 各引擎/库对比

不同引擎对四元数的存储顺序和 API 有所差异,迁移代码时需要注意:

引擎/库 四元数顺序 创建方式 注意事项
Unity (x, y, z, w) Quaternion.Euler() 左手坐标系,Y 轴向上
Unreal (x, y, z, w) FQuat::MakeFromEuler() 左手坐标系,Z 轴向上
glm (w, x, y, z) glm::quat(eulerAngles) 构造函数顺序与存储顺序不同!
DirectX (x, y, z, w) XMQuaternionRotationRollPitchYaw() 左手坐标系
Godot (x, y, z, w) Quat.from_euler() 右手坐标系,Y 轴向上

⚠️ glm 陷阱:glm 的构造函数是 glm::quat(w, x, y, z),但内部存储和打印输出是 (x, y, z, w)。这经常导致混淆!

// glm 的正确用法
glm::quat q = glm::angleAxis(glm::radians(90.0f), glm::vec3(0, 1, 0));
// 或者
glm::quat q(glm::vec3(pitch, yaw, roll)); // 欧拉角构造

总结

三种旋转表示法对比

表示法 存储 万向锁 插值 组合 适用场景
欧拉角 3个数 ❌ 有 ❌ 困难 ❌ 复杂 UI 显示、配置文件
旋转矩阵 9个数 ✅ 无 ❌ 困难 ✅ 简单 着色器、批量变换
四元数 4个数 ✅ 无 ✅ Slerp ✅ 简单 内部存储、动画插值

核心要点速查

四元数 q = (w, x, y, z) = (cos(θ/2), sin(θ/2)·n)

旋转向量:  v' = q · v · q⁻¹
组合旋转:  q_final = q2 × q1  (先 q1 后 q2)
逆旋转:    q⁻¹ = q* = (w, -x, -y, -z)  (单位四元数)
插值:      Slerp(q1, q2, t) 沿球面最短路径

最佳实践

// 内部使用四元数
private Quaternion _rotation;

// 编辑器显示欧拉角
public Vector3 EulerAngles {
    get => _rotation.eulerAngles;
    set => _rotation = Quaternion.Euler(value);
}

< 上一篇: 矩阵与变换 | 回到目录 | 下一篇: 距离与检测 >


By .