ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

从复数到四元数:三维旋转原理、常见误区与工程选型

从复数到四元数:三维旋转原理、常见误区与工程选型

简介:一份围绕四元数与三维旋转关系展开的技术文档,面向计算机图形学、机器人学与计算机视觉方向的学习者和开发者,也适合希望补齐姿态表示基础的读者。压缩包内仅含1个PDF文件,大小约478KB,篇幅集中,便于按主题检索阅读。文档从复数与二维旋转的类比切入,逐步梳理四元数的定义、乘法、模长与共轭等性质,并借助几何和线性代数方法解释四元数如何表示三维旋转,以及其与旋转矩阵之间的对应转换;文中还提供了MATLAB/Octave演示代码与GIF动画的线索,方便读者结合可视化结果理解抽象概念。已有182人学习,适合作为理解姿态表示、排查旋转计算问题的参考材料。

1. 一句话说清四元数:为什么三个角度不够用

拧瓶盖的时候,你只需要知道“转了多少度”;可要让一个模型在三维空间里摆成某个特定朝向,光靠三个角度去描述,转着转着就会撞上一个叫“万向节死锁”的坑——两个轴突然重合,白白丢掉一个自由度。四元数就是为绕开这类麻烦而存在的四个数,写成a + bi + cj + dk,把三维旋转这件几何上的事,压缩成一次干净的代数运算。

不用被 i、j、k 吓退,它和“用一个复数表示平面上的转动”是同一套思路,只是从二维升到了三维。搞懂它,你就能明白游戏里角色的朝向、机械臂的姿态、动画里两个动作之间的平滑过渡,底层到底在算什么。

2. 从复数说起:旋转为什么能“算”出来

2.1 复数就是平面上的一个箭头

任意一个复数都能写成z = a + bi,其中 a 叫实部、b 叫虚部,而 i 满足i² = -1。这个式子看起来抽象,但只要把它画出来就一目了然:横轴是实部、纵轴是虚部,a + bi就对应平面上坐标为(a, b)的那个点。换句话说,一个复数同时是“一个数”和“平面上的一个箭头”。

有了箭头,就有了长度和方向。长度叫模长,记作‖z‖ = √(a² + b²),就是勾股定理;方向则是箭头与实轴正方向的夹角。还有一个常用操作叫共轭,把虚部的符号翻过来,z̄ = a - bi,它和原复数相乘会得到一个纯实数z·z̄ = a² + b² = ‖z‖²。这条性质后面会反复用到,因为它说明“共轭”是拿来消掉方向、只留长度的工具。

为什么讲四元数要先绕到复数?因为这两者的关系不是“有点像”,而是同一套结构的升维版本。复数在二维平面上能做的事——表示旋转、复合旋转、求逆——四元数在三维空间里几乎逐条对应。把复数的这套直觉打牢,四元数里那些看着玄学的公式,大部分就能一眼看出它在干嘛。

2.2 复数相乘,等于旋转加缩放

复数最漂亮的地方在乘法。设z₁ = a + bi、z₂ = c + di,按分配律展开再用i² = -1化简:

z₁z₂ = (ac - bd) + (ad + bc)i

结果还是复数,没什么稀奇。稀奇的是换个角度看这个式子。把z₂看成向量(c, d),那么上面这个结果恰好等于一个矩阵乘向量的形式:

[ a -b ] [ c ] [ ac - bd ] [ b a ] [ d ] = [ ad + bc ]

也就是说,“乘以 z₁”这个动作,和一个 2×2 矩阵作用在向量上,效果完全等价。那这个矩阵到底代表什么变换?把它拆一下就清楚了:把模长‖z₁‖ = √(a²+b²)提到矩阵外面,剩下的部分每一格都除以了模长,而a/‖z₁‖正好是 cosθ、b/‖z₁‖正好是 sinθ,θ 就是这个复数在复平面上的角度:

[ a -b ] [ cosθ -sinθ ] [ b a ] = ‖z₁‖ [ sinθ cosθ ]

右边那个矩阵,是标准的二维旋转矩阵,作用是把向量逆时针转 θ 度。左边那个系数‖z₁‖,则是纯粹的缩放。两件事乘在一起,结论就出来了:一个复数乘以另一个复数,等价于把后者旋转 θ 度、再缩放 ‖z₁‖ 倍。角度 θ 用atan2(b, a)就能算出来,模长用勾股定理。

这也解释了一个日常里不太注意的现象:复数乘法是可交换的,z₁z₂ = z₂z₁。对应到几何上,就是“先转 30 度再转 50 度”和“先转 50 度再转 30 度”结果一样。二维旋转只有一个自由度,谁先谁后无所谓。

2.3 单位复数:把缩放摘掉,只剩下转

如果模长等于 1,也就是a² + b² = 1,那个缩放系数就变成了 1,矩阵只剩下旋转部分。此时这个复数可以写成cosθ + i·sinθ,按欧拉公式进一步写成e^{iθ}。这个写法很省事:旋转 θ 度就是乘e^{iθ},转两次就是乘两次、指数相加,角度自然叠加。

v' = e^{iθ} · v

一句话总结二维的情况:一个单位复数,就是一个旋转。旋转的复合就是复数相乘,旋转的逆就是取共轭,旋转的角度就是复数的辐角。整套操作只需要一个数、一次乘法。

但三维不行。三维旋转有三个自由度:绕 x、绕 y、绕 z 各一个,或者等价地说,一根轴的方向占两个自由度、绕轴转的角度占一个。一个复数只有两个实参数,装不下三个自由度。想要“装下”,就得再加虚部。四元数之所以有三个虚单位 i、j、k,根源就在这里——不是数学家故意把简单问题搞复杂,而是三维旋转本身就比二维多一个自由度,一个虚数不够用。

3. 三维旋转的真相:任何转动都是绕着一根轴

3.1 欧拉角的麻烦,和轴角为什么更本质

描述三维旋转,最直觉的办法是欧拉角:绕 x 转多少、绕 y 转多少、绕 z 转多少,三个数字,人也能读。但它有两个硬伤。第一是顺序敏感,先绕 x 再绕 y,和先绕 y 再绕 x,结果完全不同,所以“三个角度”必须附带一句“按什么顺序转”才有意义。第二是万向节死锁:当中间那个角度转到 90 度时,首尾两个旋转轴会重合,本来三个独立的转动方向塌缩成两个,某些朝向就调不出来了——这不是数值误差,是表示方法本身的缺陷。

轴角表示换了个思路:不去管三个坐标轴,而是直接问“绕哪根轴、转多少度”。这里有个几何上的定心丸,叫欧拉旋转定理:三维空间里任何一次旋转,都可以看成绕某根固定轴的转动。于是表示一次旋转只需要四个量——轴的 x、y、z 分量,加上角度 θ。

四个量会不会太多?其实不会,因为“绕 u 转”和“绕 3u 转”是同一件事,轴的长度根本不影响结果,重要的只是它指的方向。就像说“朝正东走”和“朝正东走三公里”指的是同一个方向。方向只需要两个自由度就能定(想想地球上的经纬度),所以给轴加一个“长度必须为 1”的约束,四个量减去一个约束,正好剩三个自由度,和三维旋转的真实自由度对上了。

提示:代码里如果让用户随便输入一个轴向量,动手算之前必须先归一化,否则叉乘和投影的结果全是错的。这是新手最常翻车的地方之一。

3.2 把向量拆成两半:平行的那半不动

有了单位轴 u,接下来的思路非常朴素:把要被旋转的向量 v 拆成两块,一块平行于 u,一块垂直于 u:

v = v∥ + v⊥

平行的这块怎么算?用正交投影公式,v 在 u 上的投影是(u·v)u(前提是 u 为单位向量,否则分母上还要除以u·u)。垂直的那块就是减法:v⊥ = v - (u·v)u。这两块加起来还是原来的 v,没有丢任何信息。

拆开的好处是,两块可以分开转,转完再加起来。而平行的那块特别省事:它就躺在旋转轴上,绕轴转的时候它自己跟自己重合,转多少度都不变。所以v'∥ = v∥,一步到位。

真正需要动脑子的只剩垂直分量。而它在一张垂直于 u 的平面里,问题从三维降到了二维——这正是第 2 章那套复数直觉能派上用场的地方。

3.3 垂直分量在圆上转,需要一个帮手

v⊥在这个平面里绕原点转 θ 度,轨迹是一个圆,长度不变。但要在平面里描述一个旋转,光有一个向量不够,还得有第二个方向当参照,就像平面直角坐标系需要 x 轴和 y 轴两根。

这个帮手用叉乘造出来:w = u × v⊥。它有两个恰到好处的性质。方向上任,它同时垂直于 u 和 v⊥,所以落在同一张平面里,并且按右手定则,它指向v⊥逆时针转 90 度后的方向。长度上,因为 u 是单位向量、u 与 v⊥ 的夹角是 90 度,所以‖w‖ = ‖u‖·‖v⊥‖·sin90° = ‖v⊥‖,和v⊥一样长。

于是这张平面里就有了两个等长、互相垂直的向量,构成一组天然的正交基。旋转后的v'⊥可以按这组基分解,用一点三角函数就能写出来:

v'⊥ = cosθ · v⊥ + sinθ · (u × v⊥)

这个式子的读法是:原来那根轴上的分量按 cosθ 缩,新造出来的那根轴上的分量按 sinθ 长,两者合成,正好是转了 θ 度的结果。

3.4 罗德里格斯公式:三项各管一件事

把平行和垂直两部分的结果加起来,再代入投影公式,就得到三维旋转的一般形式:

v' = cosθ·v + (1 - cosθ)(u·v)u + sinθ·(u × v)

这个式子叫罗德里格斯旋转公式。它看着比前面的复杂,但每一项的分工很清楚。第一项cosθ·v是主体,相当于把原向量整体缩一下;第二项(1-cosθ)(u·v)u是补偿项,因为第一项把平行于轴的那部分也一起缩了,而平行分量本来不该变,所以要把它加回来;第三项sinθ·(u × v)是侧向的推力,把向量往旋转方向推出去。三项合起来,就是“绕单位轴 u 转 θ 度”的完整答案。

这里要记住一件事:这个公式里没有任何三角函数嵌套,也没有矩阵,只有点乘、叉乘和标量乘法。它的计算成本很低,而且形式统一。四元数的旋转公式展开之后,逐项对应的就是这个式子——所以四元数并不神秘,它只是把这个几何结果换了一套更好用的代数外壳。

4. 四元数登场:四个数字怎么装下三个自由度

4.1 定义:一个标量加一个三维向量

四元数的写法和复数几乎一字不差,唯一区别是虚单位从 1 个变成了 3 个:

q = a + bi + cj + dk, a, b, c, d ∈ ℝ i² = j² = k² = ijk = -1

就这一行约束,决定了四元数所有的运算规则。工程上更常写成“标量 + 向量”的有序对形式:q = [s, v],其中s = a是标量部分,v = (b, c, d)是向量部分。这种写法在做旋转时特别顺手,因为标量部分往往对应角度、向量部分对应转轴。

模长的定义照抄复数:‖q‖ = √(a² + b² + c² + d²),也就是√(s² + v·v)。加法和减法分量各自相加,标量乘法每个分量都乘,这些都没什么意外。

真正有内容的是乘法。因为 i、j、k 之间互相乘会串门(比如ij = k、ji = -k),两个四元数相乘不能简单地对位相乘,而要按一张乘法表逐项展开,最终得到四个分量各自的结果。手算一遍要写十几行,但它的形式是固定的,写成代码就是一个常数矩阵乘向量。

4.2 乘法不满足交换律:这不是缺陷,是必需

复数乘法可交换,四元数乘法一般不可交换,q₁q₂ ≠ q₂q₁。很多人第一次看到这里会觉得别扭,但这个“别扭”恰恰是四元数能正确描述三维旋转的原因。

回想第 3 章:三维旋转的复合本来就不可交换。先绕 x 轴转 90 度再绕 y 轴转 90 度,和反过来做,最终朝向完全不同,拿个方块在手里比划一下就清楚了。既然被描述的对象本身不交换,描述它的代数结构当然也不该交换。二维旋转可交换,所以复数可交换;三维旋转不可交换,所以四元数不可交换。这不是巧合,是结构上的必然。

因为不交换,就得分左乘和右乘。q₁q₂读作“q₂ 左乘 q₁”,q₂q₁读作“q₂ 右乘 q₁”,两者对应不同的复合顺序。另外,四元数乘法满足结合律和分配律,所以连续复合多个旋转时可以放心地先两两合并,不必按原顺序一个个算。

写成矩阵形式会更清楚:左乘一个四元数 q₁,等价于用一个 4×4 的矩阵去乘(e, f, g, h)这个列向量。因为乘法不交换,右乘 q₁ 对应的是另一个不同的矩阵。这种“左乘一个矩阵”的视角在实现里非常有用,比如批量旋转一堆点时,把四元数转成矩阵再和点集相乘,比逐点调函数快得多。

4.3 单位四元数:转轴和角度怎么塞进四个数

关键的一步来了。取一个单位四元数,把它的标量部分写成半角的余弦、向量部分写成半角的正弦乘以转轴:

q = [cos(θ/2), sin(θ/2)·u]

这个 q 就代表“绕单位轴 u 转 θ 度”。要对向量 v 施加这个旋转,先把 v 提升成纯四元数p = [0, v](标量部分为 0),然后做一次“三明治”乘法:

p' = q · p · q*

其中q*是 q 的共轭,把向量部分的符号翻过来。因为 q 是单位四元数,共轭同时也等于它的逆。算完之后p'的标量部分会回到 0,向量部分就是旋转后的结果。整个过程是四次四元数乘法,比 3×3 矩阵乘法(9 次乘加)还便宜,而且没有三角函数反复求值。

把这个三明治展开,得到的正是第 3 章那个罗德里格斯公式。也就是说,两条路殊途同归:一条是从几何出发,拆平行垂直、构造正交基、拼出公式;另一条是从代数出发,定义四个数、规定乘法、套上三明治。前者告诉你旋转“是什么样”,后者给你一个能高效计算的工具。

代码里更常见的写法是把三明治化简成向量运算,省掉两次四元数乘法:

import numpy as np def quat_rotate(q, v): # q: 单位四元数,格式 [w, x, y, z],w 为标量部分 # v: 待旋转的三维向量 w, qv = q[0], np.array(q[1:]) # t = 2 * (q_vec × v),对应公式里的侧向分量 t = 2.0 * np.cross(qv, v) # v' = v + w*t + q_vec × t return v + w * t + np.cross(qv, t) # 绕 z 轴转 90 度:w = cos(45°), z 分量 = sin(45°) q = np.array([np.cos(np.pi/4), 0.0, 0.0, np.sin(np.pi/4)]) print(quat_rotate(q, np.array([1.0, 0.0, 0.0]))) # 约等于 [0, 1, 0]

参数上要盯住两点。q必须是单位四元数,也就是w² + x² + y² + z² = 1;如果传入的是没归一化的数据,结果会被额外缩放,看起来就像“模型莫名其妙变大了一点”。格式上[w, x, y, z]和[x, y, z, w]两种写法在不同库里都有人用,混用是极常见的踩坑来源,写接口时最好在命名里标清楚。

4.4 半角从哪来,以及为什么 q 和 -q 是同一个朝向

θ/2这个半角是四元数最反直觉的地方。原因藏在三明治公式里:q在q·p·q*中出现了两次,一次在左、一次在右。每次作用贡献一半的角度,两次合起来才是完整的 θ。所以构造 q 时用的必须是半角,写成了整角,转出来的角度就翻倍了。

半角还带来一个直接后果:q 和 -q 表示同一个旋转。把四个分量全部取反,等价于把角度从 θ 变成 θ + 2π,绕同一根轴多转一整圈,朝向当然没变。这在数学上叫“双覆盖”:单位四元数住在四维空间的一个球面上,这个球面是三维的,而三维旋转的空间也是三维的,两者之间是二对一的映射关系。

这个性质在实际工程里必须处理。比如判断两个姿态是否接近,不能直接比较 q₁ 和 q₂,得比较|q₁·q₂|;做插值时,如果两个四元数的点积为负,说明它们走了“远路”,要先给其中一个取反,否则物体会转出一个莫名其妙的大回环。这些都是不写进公式、只写在血泪经验里的东西。

5. 几个最容易搞混的地方:四元数、欧拉角与旋转矩阵的区别

5.1 误区一:四元数就是“四个角的旋转”

易混点:看到四个数字,下意识以为它和欧拉角一样,是“绕四个轴的角度”。

为什么混:欧拉角确实是几个角度,四元数也确实是几个数,形式上都是“若干个数字表示一个姿态”,很容易划等号。

怎么区分:看每个数字的含义。欧拉角的每个分量都是角度,单位是度或弧度,单独拿出来有意义(“绕 x 转 30 度”)。四元数的四个分量里,只有一个是标量,另外三个是一个向量的坐标,它们单独拿出来没有任何几何意义,必须四个一起看。更直接的判别方法:把四元数四个分量同时取反,朝向不变;把欧拉角三个角度同时取反,朝向一定变了。另外,欧拉角是三个参数,四元数是四个参数加一个单位约束,自由度都是三,但参数个数不同。

5.2 误区二:q 和 -q 是两个不同的姿态

易混点:数值不一样,就以为朝向不一样,于是比较两个四元数时直接算分量差的平方和。

为什么混:在其他表示法里,“数值不同 = 状态不同”几乎总是成立,这个习惯被顺带带了过来。

怎么区分:记住双覆盖。q 和 -q 对应同一次旋转,两者在四维球面上是 antipodal 的两个点,但映射到三维旋转空间是同一个。判断两个四元数是否表示同一朝向,用|q₁·q₂| ≈ 1;判断是否接近,用|q₁·q₂|接近 1 的程度。做插值前如果发现点积为负,先把其中一个取反,否则插值路径会绕远路,动画上表现为物体抽搐一下再转过去。

5.3 误区三:用了四元数就不会有万向节死锁

易混点:听说四元数是为了解决死锁而生,就认为只要存的是四元数,从此天下太平。

为什么混:这个说法只对了一半。四元数内部确实没有“三个轴依次转”的结构,也就没有轴重合的问题,所以用它做旋转复合和插值是安全的。

怎么区分:看你的数据有没有“转成角度再转回去”。如果界面上要给用户三个角度输入框,或者输出时要写进只认欧拉角的文件格式,那在转换的那一步,死锁就会原地复活——某些朝向提取出来的欧拉角会跳变,两个差一点点朝向可能对应完全不同的三个角度。正确的做法是:内部计算全程用四元数,只在最外层的人机交互或文件导入导出时做一次转换,并且转换后立刻转回四元数验证一致性。把四元数当成“唯一的内部表示”,而不是“万能的对外接口”。

5.4 误区四:两个姿态之间直接线性插值

易混点:q = (1-t)·q₁ + t·q₂,写起来和普通数值插值一样,看起来很合理。

为什么混:位置、颜色、透明度都是这么插的,效果也不错,于是顺手就套到了旋转上。

怎么区分:线性插值的结果不再是单位四元数,模长会缩水,中间时刻的旋转会“瘪”进去,表现为物体在转动过程中轻微缩小或角速度忽快忽慢。正确做法是球面线性插值(slerp),让插值路径落在四维单位球面上,角速度保持均匀:

def slerp(q1, q2, t): d = float(np.dot(q1, q2)) if d < 0.0: # 点积为负说明走了远路,翻转一个端点 q2, d = -q2, -d if d > 0.9995: # 夹角极小时退化为线性插值再归一化,避免除零 q = q1 + t * (q2 - q1) return q / np.linalg.norm(q) theta = np.arccos(d) # 两个四元数之间的夹角 return (np.sin((1-t)*theta)*q1 + np.sin(t*theta)*q2) / np.sin(theta)

t从 0 到 1 时,结果始终在单位球面上,角速度均匀。那两个判断不是可有可无的装饰:d < 0处理最短路径,d > 0.9995防止sin(theta)接近 0 时数值爆炸。少了任何一行,特定角度下就会出问题。

5.5 误区五:归一化可有可无

易混点:构造出来的四元数本来就是单位的,后面只是不停地乘来乘去,应该不会变吧。

为什么混:单次乘法确实没问题,误差小到看不出来。问题出在累积上。

怎么区分:浮点数有精度上限,几千次复合之后,模长会慢慢偏离 1,偏差虽小但会持续放大,最终表现为模型缓慢“发胖”或姿态漂移。所以工程上的惯例是:任何一次连续复合之后、任何一次从外部读入之后、任何一次插值之后,都做一次归一化。开销只有一次开方和四次除法,比起事后排查姿态漂移的时间成本,这个代价可以忽略。

6. 什么时候该用四元数,什么时候别碰它

把边界说清楚,比多背几个公式有用。下面这张表是选型时最先要看的东西:

表示方式参数个数自由度能否平滑插值复合一次的成本主要问题
欧拉角33勉强,需按轴处理低顺序敏感、万向节死锁
轴角43(含单位约束)不便,角度需特殊处理中不便复合、不便插值
旋转矩阵93(含正交约束)不能直接插值高(9 次乘加)存储大、易失去正交性
四元数43(含单位约束)可以,slerp低不直观、半角易错

该用的时候,特征很明显:需要频繁复合旋转,比如骨骼动画里一节节往下传的变换;需要在两个朝向之间做平滑过渡,比如镜头转向、角色转身;需要紧凑存储和网络传输,四个浮点比九个浮点省一半还多;需要长期累积而不漂移,比如惯性测量单元的姿态积分。这些场景里,四元数的四个参数、低成本复合、可直接插值这三条优势会同时兑现,用它几乎没有犹豫的余地。

不该用的时候,同样明显。只需要绕一根固定的轴转,那一个角度就够了,套四元数是自找麻烦。需要让人直接调参数,比如做个可视化调节面板,那三个角度输入框比四个看不懂的数字友好一百倍,界面上就该用欧拉角,内部再转成四元数。需要和外部系统交换数据,而对方只认某种矩阵或角度格式,那就老老实实按对方的规矩来,在边界处做一次转换。还有些变换压根不是旋转,比如缩放、错切、镜像,四元数一个都管不了,这时候该上完整的变换矩阵。

还有个容易被忽略的边界:如果你的系统里旋转只在二维平面内发生(比如俯视视角的策略游戏、某些 UI 动效),那复数或者单个角度就是最优解,四元数是纯粹的过度设计。工具的价值取决于问题,不是取决于它有多先进。

我自己的习惯是,只要一个模块里出现了“四元数”三个字,就在入口处强制做一次归一化,并且在所有跨模块传参的地方把分量顺序写进变量名里。这个习惯来自一次排查了整整两天的姿态漂移——最后发现是某个环节传进来的四元数模长是 1.0003,单独看毫不知情,累积几百帧之后模型就开始缓慢地歪。从那以后我每次接入新的姿态数据源,都强制先跑一遍模长检查和点积符号检查,确认没问题再接进主流程。希望这几点能帮到你,少走一段弯路。

这份资料从复数讲到四元数、从几何推导讲到代码实现,还配了可以直接跑起来的演示脚本和动画,适合边读边对照着看。想要完整版的话,按文末方式获取就行。

本文还有配套的精品资源,点击获取

返回列表