简介:quaternion.pdf 是一份围绕四元数与三维旋转关系的专题讲义,面向计算机图形学、机器人学、计算机视觉方向的学习者,以及需要理解姿态表示与旋转矩阵相互转换的开发者。正文从复数与二维旋转的类比切入,逐步引出四元数的定义、虚单位运算规则、模长与共轭性质,并推导四元数与旋转矩阵之间的对应公式,侧重用基础几何与线性代数做直观解释,而非纯代数抽象。压缩包内仅含 1 个 PDF 文件,大小约 478KB,体积轻量,便于本地留存与逐页批注阅读;文中配有图示说明,并提及配套的 MATLAB/Octave 演示代码与动画资源,方便读者动手验证旋转过程。目前已有 182 人学习,适合作为入门四元数、补齐姿态表示知识短板的参考材料。
1. 四元数到底是个什么东西:让三维旋转不打结的四个数
如果你玩过三维建模软件,大概遇到过一个很玄学的瞬间:明明只想把模型转到某个朝向,拖到某个角度附近,它却突然像被卡住,怎么拖都不对劲,只能退回去换个顺序重新转。这个卡住的瞬间,背后是一整套关于「怎么描述旋转」的数学,四元数就是其中最常被提起的方案。
一句话说,四元数是一个实部加三个虚部组成的数,写成q = a + bi + cj + dk,i、j、k 满足 i² = j² = k² = ijk = -1;把模长限制为 1,它就能干净地表示三维空间里的旋转。只要涉及「物体朝哪、怎么转过去」——动画骨骼、机器人关节、飞行器姿态、相机控制——底层的姿态表示多半绕不开它。
2. 为什么三维旋转要靠四个数:轴角表示里的多余自由度
2.1 三个角度听上去够用,直到它卡住
描述三维旋转最符合直觉的方式,是欧拉角:绕 x 轴转一个角,再绕 y 轴转一个角,再绕 z 轴转一个角。三句话就能说清一个朝向,人读得懂、写下来也短,很多建模软件的操作面板上摆的就是三个数字。
问题出在中间那个角上。当第二个角转到 ±90 度时,第一个旋转轴和第三个旋转轴会重合,原本三个独立的转动方向塌缩成两个,你明明还剩一个角可以调,但它在几何上已经和另一个角干着同一件事。操作者感受到的就是「怎么转都不对劲」,数学上把这个位置叫万向节死锁。它不是数值误差,也不是软件写得烂,而是三个角度这套描述方式天生的死穴。
还有一层麻烦:欧拉角和坐标轴的选定强绑定。同一个朝向,换一套轴顺序、换一套参考系,三个数字全变。做动画时把两个不同工具导出的欧拉角直接拼接,结果模型扭成麻花,这种翻车现场并不少见。
所以三维旋转需要一种不依赖轴顺序、没有卡死位置的描述方式。轴角表示就是这样一种替代方案。
2.2 换一种描述:一根轴加一个角
轴角表示的想法很朴素:任何一次三维旋转,都可以理解为「绕着某根过原点的轴,转某个角度」。轴用一个三维向量u = (x, y, z)表示,角用θ表示,四个变量,齐了。
这里有个容易被忽略的细节:四个变量,但旋转本身只有三个自由度。多出来的那个去哪了?答案在轴的长度上。我们关心的是轴指的方向,不是它有多长——绕(0, 0, 1)转和绕(0, 0, 3)转,是同一个旋转。一根轴的方向只需要两个量就能确定,就像地球上任意一个方位只需要经度和纬度。所以只要规定轴是单位向量,多出来的自由度就被消掉了,四个变量重新对齐三个自由度。
规定|u| = 1不只是为了好看。后面的推导里会反复出现u · u = 1,很多项会因此直接消掉,公式能短一大截。实际写代码时,用户输入的轴几乎不可能是单位向量,所以在做任何计算之前,必须先把轴归一化:
import math def normalize(v): # 求向量长度,注意长度为 0 时不能做除法 n = math.sqrt(sum(c * c for c in v)) if n == 0: raise ValueError("旋转轴不能是零向量") # 每个分量除以长度,得到方向不变、长度为 1 的向量 return tuple(c / n for c in v)这个函数很短,但它是所有轴角与四元数代码的第一道关卡。参数说明只有一条:传入的v是任意长度的三维向量,返回的是同方向的单位向量;如果传进来的是零向量,直接报错,因为零向量没有方向,无法定义「绕它转」这件事。
2.3 把向量分成两半,旋转就变成一道平面几何题
有了单位轴 u,接下来处理待旋转的向量 v。直接算 v 转到哪很难,但把它拆开就简单了:v 可以分解成平行于 u 的分量v∥和垂直于 u 的分量v⊥,两者相加还是原来的 v。
平行的这一半最好办。它本身就躺在旋转轴上,绕着轴转一圈,它还是它自己,位置完全不动。所以v∥旋转之后等于v∥,一个符号都不用改。
垂直的这一半才是真正要干的活。v⊥垂直于轴,旋转时它始终待在一个与轴垂直的平面里,绕圈走,长度不变,轨迹是个圆。这就把一个三维问题降到了二维:在平面里,一个向量转了 θ 度。但平面里只有一个向量不够用,得再找一个方向当参照系——拿 u 和v⊥做叉乘,得到w = u × v⊥。这个 w 同时垂直于 u 和v⊥,长度又恰好等于v⊥,所以它正好也落在那个圆上,天然可以当第二个坐标轴。
于是v⊥旋转后的结果,可以直接写成两个方向上的投影相加:
v'⊥ = cos(θ) · v⊥ + sin(θ) · (u × v⊥)把平行和垂直两半拼回去,再代入v∥ = (u · v)u和v⊥ = v - (u · v)u,化简之后得到一个相当对称的式子:
v' = cos(θ)·v + (1 - cos(θ))·(u · v)·u + sin(θ)·(u × v)这就是轴角旋转的一般公式。看着有点长,但三项各有分工:第一项是原向量按角度缩放,第二项补上平行分量没被转的部分,第三项负责垂直分量在平面内的“横向移动”。三项凑齐,任意向量绕任意单位轴的旋转就都能算出来。
换成代码,它几乎是一行行照抄公式:
def rotate_axis_angle(v, axis, theta): # v: 待旋转向量;axis: 旋转轴,任意长度;theta: 弧度制角度 u = normalize(axis) # 先归一化,否则公式不成立 cos_t, sin_t = math.cos(theta), math.sin(theta) dot = sum(u[i] * v[i] for i in range(3)) # u · v,标量 cross = (u[1]*v[2] - u[2]*v[1], # u × v u[2]*v[0] - u[0]*v[2], u[0]*v[1] - u[1]*v[0]) # 三项分别对应:缩放项、平行补正项、垂直横移项 return tuple( cos_t * v[i] + (1 - cos_t) * dot * u[i] + sin_t * cross[i] for i in range(3) ) print(rotate_axis_angle((1, 0, 0), (0, 0, 1), math.pi / 2)) # 约 (0, 1, 0)最后那行验证值得多看两眼:把 x 轴方向的向量绕 z 轴逆时针转 90 度,应该落到 y 轴方向上。这是右手坐标系的约定——右手拇指指向轴的正方向,四指弯曲的方向就是旋转正方向。如果跑出来是(0, -1, 0),八成是把坐标系约定或者叉乘顺序搞反了,这在图形学里是最常见的一类踩坑。
参数上有两个地方需要注意。theta用的是弧度不是角度,直接传 90 会得到完全错误的结果;axis必须先归一化,如果跳过这一步,公式里(1 - cos θ)(u·v)u这一项会因为|u| ≠ 1而被放大,转出来的向量长度都会变。
2.4 四个数字的真正好处:没有卡死的位置
轴角表示解决了欧拉角的核心痛点。角度从 0 连续变到 2π,整个过程没有任何一个位置会让自由度塌缩,也不依赖先绕哪个轴的顺序——轴和角是两个独立的对象,说清就是一次旋转。
但它也有自己的短板:两次旋转叠在一起,没法直接算。给你两个轴角对,问「先转 A 再转 B 等效于绕哪根轴转多少度」,用轴角形式硬推会非常痛苦。实际工程里的做法是把它转成矩阵或者四元数,乘完再转回来。这也解释了四元数为什么会出现——它要继承轴角的无奇异特性,同时又要像复数那样可以直接相乘来复合旋转。
3. 复数是怎么转起来的:四元数的预告片
3.1 复数乘法藏在了一个 2×2 矩阵里
在跳进四元数之前,先看一个更小、更好懂的例子。复数z = a + bi,其中 i² = -1。两个复数相乘,按分配律展开再合并同类项,结果是:
(a + bi)(c + di) = (ac - bd) + (bc + ad)i把这个结果的实部和虚部拆成两行写,会发现它其实是一个矩阵乘一个向量的形状:
[ ac - bd ] [ a -b ] [ c ] [ bc + ad ] = [ b a ] [ d ]右边那个 2×2 矩阵,就是复数a + bi的矩阵形态。这一步很关键:复数乘法不再只是一个代数运算,它等价于用这个矩阵去做一次线性变换。复数和矩阵,在这里是同一件事的两种写法。
顺带还能看出,虚数单位 i 对应的矩阵是[[0, -1], [1, 0]],把它自己乘自己,得到的是负的单位矩阵,也就是 -1。四个数字组成的 i² = -1,在矩阵形态下同样成立。
3.2 拆开这个矩阵,里面是缩放套着旋转
接下来对这个矩阵做一次「解剖」。把矩阵的每个元素都除以复数的模长|z| = √(a² + b²),再把这个模长提到矩阵外面:
[ a -b ] [ a/|z| -b/|z| ] [ b a ] = |z| ·[ b/|z| a/|z| ]此时矩阵里的四个元素,正好可以对应到三角函数:a/|z| = cos θ,b/|z| = sin θ,其中 θ 就是复数在复平面上与实轴正方向的夹角。于是整个矩阵变成了:
[ cos θ -sin θ ] [ sin θ cos θ ] 乘以标量 |z|右边这个 2×2 矩阵是标准的二维旋转矩阵。所以结论很清楚:一个复数乘以另一个复数,做的事情就是「把对方旋转 θ 度,再把长度缩放 |z| 倍」。当复数的模长为 1 时,缩放因子变成 1,剩下的就纯粹是旋转。
这就是四元数故事的原型。复数用一个虚部描述了二维平面上的旋转,那么增加虚部的个数,是不是就能描述三维空间里的旋转?答案是能,但要多加两个虚部,而且代价是乘法不再满足交换律。
3.3 极坐标形式:把旋转写成一个指数
旋转角度和模长这两件事,可以用更紧凑的方式写出来。按照欧拉公式,cos θ + i sin θ等于e^(iθ)。于是任意复数都能写成:
z = r · e^(iθ)其中 r 是模长,θ 是角度。这个形式的好处是,旋转这件事被压缩进了一个指数里,乘法的意义变得一目了然:两个复数相乘,模长相乘、角度相加。想让一个二维向量转 θ 度,把它当成复数,乘上e^(iθ)就行:
def rotate_2d(v, theta): # 把 (x, y) 视为复数 x + yi,乘上单位复数 e^(iθ) c, s = math.cos(theta), math.sin(theta) x, y = v # 展开 (c + si)(x + yi),实部与虚部分别对应新坐标 return (c * x - s * y, s * x + c * y) print(rotate_2d((1, 0), math.pi / 2)) # 约 (0, 1)这段代码和上一章的轴角旋转放在一起看会很有意思:一个是三维里绕轴转,一个是二维里绕原点转,实现上却都是三角函数加乘减。二维之所以简单,是因为平面里只有「一个旋转方向」,不需要指定轴。
参数说明同样简单:v是二维向量,theta是弧度制角度。函数内部没有任何归一化步骤,因为cos θ和sin θ天然满足平方和为 1,单位复数是自动保证的——这一点在四元数那里就不成立了,得手动归一化。
3.4 两次旋转叠在一起:角度相加,顺序无所谓
二维旋转还有一个非常好的性质。假设先转 θ 度,再转 φ 度,两次旋转对应的复数分别是z₁和z₂。复合的结果就是两个复数相乘:
z_net = z₂ · z₁ = (cos φ + i sin φ)(cos θ + i sin θ) = cos(φ + θ) + i sin(φ + θ)用三角恒等式展开后,结果仍然是「一个角度」的形式,角度就是两个角之和。这说明两件事:二维旋转复合之后还是旋转;复合的顺序不影响结果,先转 θ 再转 φ,和先转 φ 再转 θ,落到同一个位置。
第二条性质来自复数乘法满足交换律。z₁z₂和z₂z₁结果完全一样,所以在二维平面上,「先转哪个」这个问题根本不存在。
到了三维,这件事就不成立了。绕着不同的轴旋转,先后顺序换一下,最终的朝向会完全不同——拿起手边的杯子,先绕竖直轴转 90 度再绕水平轴转 90 度,和反过来的顺序,杯子最终朝向截然不同。这就是四元数乘法不满足交换律的几何根源,也是它和复数最大的分野。
4. 四元数怎么表示旋转:三个虚部和那个半角
4.1 从一到三:多出来的两个虚部改变了什么
四元数的写法和复数长得像,只是虚部从一个变成了三个:
q = a + bi + cj + dk, a, b, c, d ∈ ℝ约束条件是i² = j² = k² = ijk = -1。注意最后那一项,它不是三个独立等式,而是把 i、j、k 绑在了一起。由这一条能推出整个乘法表:ij = k、jk = i、ki = j,反过来ji = -k、kj = -i、ik = -j。也就是说,任意两个不同虚部单位相乘,交换顺序会多出一个负号。
| × | 1 | i | j | k |
|---|---|---|---|---|
| 1 | 1 | i | j | k |
| i | i | -1 | k | -j |
| j | j | -k | -1 | i |
| k | k | j | -i | -1 |
这张表就是四元数的全部性格来源。它带来的直接后果是:一般情况下q₁q₂ ≠ q₂q₁。乘法不再交换,左乘和右乘是两件不同的事。这一点在写代码时格外要命,顺序写反了不会报错,只会得到一个看起来很正常、但完全不对的朝向。
4.2 标量加向量:把四个数读成「一个数配一根轴」
四个分量直接摆着读不出几何意义,所以四元数常被写成「标量 + 三维向量」的有序对:
q = [s, v], s ∈ ℝ, v = (x, y, z)这样写之后,四元数乘法可以用比较紧凑的形式表达,也更容易看出它跟轴角的关系:s 管角度,v 管轴的方向。
模长的定义照抄复数的思路,把四个分量的平方加起来开根号:
|q| = √(a² + b² + c² + d²) = √(s² + |v|²)这里要提醒一句:四元数的模长没有直观的几何解释,它度量的是一个四维向量的长度。不必强行在三维里找对应物,把它当成一个定义就行。真正有用的是模长为 1 的那一类,也就是单位四元数。
4.3 单位四元数:为什么角度要写一半
把一个单位四元数写成轴角的形式,会得到一个看起来有点奇怪的式子:
q = [cos(θ/2), sin(θ/2)·u]θ 是旋转角,u 是单位旋转轴。角度上多出来的那个 1/2,是四元数最让人困惑的地方之一。
原因在于四元数作用在向量上的方式不是普通乘法。要让一个三维向量 v 绕轴转 θ 度,得用「夹心」形式:v' = q · [0, v] · q⁻¹。q 从左边乘一次,q 的逆从右边乘一次,两次各贡献一半的角度,合起来才是 θ。所以参数里只写 θ/2。
这个「夹心」还带来一个反直觉的后果:q 和 -q 表示的是同一个旋转。因为 (-q) 乘 (-q)⁻¹ 等于 q 乘 q⁻¹,负号被抵消掉了。换句话说,四元数到旋转的映射是「双倍覆盖」的:转一圈(360 度),q 会走到 -q,而不是回到 q;得再转一圈,累计 720 度,才回到原来的 q。
这一点在实际工程中会造成实打实的麻烦。两段动画之间做插值,如果一段的四元数是 q,另一段存成了 -q,直接插值会走一条绕远路的长弧,模型在半空中翻个跟头。所以几乎所有做四元数插值的库,都会在插值前先检查两个四元数的点积,如果是负的就把其中一个取反。这是血泪经验,不是数学洁癖。
4.4 用四元数转一个向量:把公式翻成代码
把上面这些拼起来,四元数旋转一个向量的完整流程是:由轴和角构造 q,把向量塞进实部为 0 的四元数,做夹心乘法,取结果的虚部。核心是四元数乘法本身:
def quat_mul(q1, q2): # 四元数按 (w, x, y, z) 存储,w 是实部 w1, x1, y1, z1 = q1 w2, x2, y2, z2 = q2 # 直接由乘法表展开,注意 i、j、k 交叉项的正负号 return ( w1*w2 - x1*x2 - y1*y2 - z1*z2, w1*x2 + x1*w2 + y1*z2 - z1*y2, w1*y2 - x1*z2 + y1*w2 + z1*x2, w1*z2 + x1*y2 - y1*x2 + z1*w2, ) def quat_from_axis_angle(axis, theta): # 单位四元数:实部 cos(θ/2),虚部 sin(θ/2) 乘单位轴 u = normalize(axis) half = theta / 2 c, s = math.cos(half), math.sin(half) return (c, s * u[0], s * u[1], s * u[2]) def quat_rotate(v, q): # 夹心乘法:q * [0, v] * q⁻¹ # 单位四元数的逆等于共轭,即虚部取反 q_inv = (q[0], -q[1], -q[2], -q[3]) pure = (0.0, v[0], v[1], v[2]) result = quat_mul(quat_mul(q, pure), q_inv) return result[1:] # 取虚部,就是旋转后的向量三个函数的分工很清楚:quat_mul处理乘法,quat_from_axis_angle把轴角翻译成四元数,quat_rotate完成整个夹心流程。参数上要注意quat_rotate里的q必须是单位四元数,否则q⁻¹不等于共轭,得老老实实除以模长平方。这是另一个常见翻车点:从外部读入的四元数如果带累积误差导致模长偏离 1,旋转结果就会带上一点莫名其妙的缩放,而缩放又会被误认为是模型本身的问题。
这里额外说明一个细节:quat_rotate里做了两次四元数乘法,看起来比轴角公式的开销大。但实际工程中,两个旋转的复合只需要一次四元数乘法,也就是四个数的乘加,代价远小于把两个旋转矩阵相乘的 27 次乘法。这正是四元数在实时图形学里受欢迎的经济学原因。
5. 常见误区:四元数、欧拉角、旋转矩阵别混着说
5.1 误区一:以为四元数就是「四个角度」
最容易混的一点,是把四元数的四个分量读成「四个角度」或者「三个轴加一个角」。如果真是这样,它和轴角表示就没区别了。
为什么容易混:轴角表示确实是三个轴分量加一个角,而四元数在单位化之后恰好也能反推出一个轴和一个角,两者形式对得上,于是很容易顺手画等号。
怎么区分:四元数的四个分量是四个实数,通过cos(θ/2)和sin(θ/2)·u的对应关系才能换算成轴角。它们的量纲根本不同——轴的分量是无量纲的方向分量,角是弧度;而四元数的四个分量都是无量纲的数值,加起来的平方和必须等于 1。更直接的区分方法是看复合:轴角没法直接相乘,四元数可以。
5.2 误区二:以为四元数乘法满足交换律
学复数时被「乘法可交换」惯坏了,看到四元数长得像复数,就默认q₁q₂ = q₂q₁。
为什么容易混:二维旋转确实可以先转谁都行,复数乘法也确实可交换,这个直觉在平面里是成立的,搬到三维就失效了。
怎么区分:记住一句口诀——「三维旋转天然不交换」。物理上,先绕竖直轴转 90 度再绕水平轴转 90 度,和反过来,结果就是不一样。四元数只是忠实地把这个几何事实编码进了乘法规则里。写代码时,q2 * q1表示「先施加 q1,再施加 q2」,顺序从右往左读,这个约定必须统一,否则多人协作时一定会出问题。
5.3 误区三:把任意四元数都当成旋转
单位四元数表示旋转,但四元数本身不限于单位长度。长度为 2 的四元数、长度为 0 的四元数,都是合法的四元数,但它们不是旋转。
为什么容易混:四元数的定义里没有任何「必须归一化」的约束,a + bi + cj + dk中四个实数随便取,都能构成一个四元数。只有额外规定模长为 1,才进入「旋转」这个子集。
怎么区分:看模长。|q| = 1才是旋转;|q| ≠ 1的普通四元数更像是「带缩放因子的旋转」,作用在向量上会把长度一起改掉。所以在任何接收外部四元数的接口里,归一化都应该是第一步,而不是可选的优化。
5.4 误区四:以为四元数比欧拉角「更高级」
四元数没有死锁、复合方便,于是很多人得出结论说欧拉角是落后的东西,应该全面淘汰。
为什么容易混:性能和维护性上四元数确实占优,这个优势被反复强调,容易盖过另一面。
怎么区分:两者的面向对象不同。欧拉角是给人看的,美术在面板上调三个数字就能摆姿势,直觉性无可替代;四元数是给机器算的,四个分量对人不友好,你没法看着(0.707, 0, 0.707, 0)说出物体朝哪。合理的工程做法是界面层用欧拉角供人操作,内部计算层用四元数,两者之间做转换,而不是二选一。
5.5 误区五:以为四元数和旋转矩阵可以无损随意互转
从四元数算旋转矩阵是确定性的,四个数进去九个数出来,没有歧义。反过来就没那么干净。
为什么容易混:既然两个东西表示同一个旋转,那互相转换应该是对称的,这个直觉看起来无懈可击。
怎么区分:矩阵转四元数时会出现符号二义性——q和-q对应同一个矩阵,反解时无法区分,只能按 convention 选一个(通常是让实部非负)。另外,矩阵里如果混入了数值误差,反解出来的四元数模长可能不为 1,还需要再归一化一次。所以这条转换链路上不能假设「无损往返」,每次都要做校正。
6. 什么时候该用四元数,什么时候别用
把三种姿态表示摆在一起,边界就清楚了:
| 表示方式 | 参数个数 | 有无死锁 | 复合方便度 | 人类可读性 |
|---|---|---|---|---|
| 欧拉角 | 3 | 有 | 差 | 高 |
| 旋转矩阵 | 9 | 无 | 中(矩阵乘) | 低 |
| 轴角 | 4(轴 + 角) | 无 | 差 | 中 |
| 四元数 | 4 | 无 | 高(一次乘法) | 低 |
从表里能读出一条很实用的判断线:需要连续复合、需要避免死锁、需要频繁插值的场景,四元数是最优解。典型例子是动画骨骼系统——上百个关节串成一条链,每个关节的朝向都要和父关节叠加,如果用欧拉角,链条一长误差和死锁都会找上门;用四元数,一次乘法叠加完,还能顺手归一化压住漂移。
反过来,不必用四元数的场景也很明确。如果只是「让相机绕物体转一圈」这种单一轴的固定旋转,一个角度变量就够了,引入四元数纯属给代码加戏。如果整个系统只需要把模型摆成一个静态朝向,且朝向由美术在编辑器里拖出来,那就存欧拉角,到渲染前再转一次,省心省力。
还有一个常被忽略的边界:涉及大量旋转平均或者旋转优化的场合,四元数的四个分量必须满足模长为 1 的约束,直接做最小二乘会对不上。这时候要么转到李代数那套语言里,要么接受近似的代价。这不是四元数的缺陷,而是「四个数表达三个自由度」必然带来的冗余,任何冗余表示都会在优化问题里冒出类似的麻烦。
我自己在这上面栽过一次。做一个相机环绕控制,为了图省事,把鼠标的横向和纵向位移分别构造两个四元数,然后直接相乘,结果相机在接近正上方时开始出现诡异的抖动。查了很久才想明白:横纵两个旋转需要复合的次序和参考系必须一致,我先乘谁后乘谁当时是随手写的,恰好在一个角度区间里对了,换到另一个区间就翻了。从那以后,凡是涉及多个旋转复合的地方,我都强制在注释里写清「先施加哪个、后施加哪个」,并且用一个特例值先手动验证一遍再继续往上写。
绕了一圈,回到那个最朴素的判断:如果你需要的是「算得准、叠得稳、不卡死」,四元数值得花时间搞懂;如果你需要的是「人能看懂、能直接调」,别为难自己,欧拉角挺好。真正把这两件事理清楚的人,比会背公式的人少得多。
如果你想把这条推导链从复数一路走到四元数,中间不跳步、每步都有几何解释,那份完整的资料里有更细的展开和配套的演示代码,值得存下来慢慢翻。希望帮到你。
本文还有配套的精品资源,点击获取