四元数的学习理解

网上四元数相关的文章真的很抽象,包括 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 更贵,多了一些运算。仅此而已。

相关推荐
遇印记11 小时前
传输层,网络层
运维·服务器·网络·windows·学习
clorinda13 小时前
PyQt5学习:从界面搭建到与机器学习实战
学习
欣欣之王来了14 小时前
Vue3项目脚手架:Vite vs Vue CLI怎么选?
前端·学习·架构·项目·vue3教程
kaixin_啊啊14 小时前
【零基础学AI】第 7 章——让 AI 辅助学习和办公
人工智能·学习
信奥卷王14 小时前
[GESP202506 六级] 学习小组(含视频)
学习
宛 禾16 小时前
Qt 控件入门:QLabel 从拖拽到对象树,一篇讲透
c++·qt·学习
无敌贵点大王16 小时前
嵌入式软件面试经(自用)
c语言·stm32·单片机·学习·mongodb·面试·职场和发展
Xudde.16 小时前
PwnLab: init 靶机渗透测试(Vulnhub Writeup)
笔记·学习·安全·web安全
我命由我1234517 小时前
Function Calling 模型
人工智能·python·学习·ai·学习方法·ai编程·ai写作