上周有同学拿着一段姿态解算代码来问:他在编辑器面板里把某一个角度改成 30,模型转出来的方向跟他在代码里算的完全对不上,一个往左一个往右。我扫了两眼就笑了,这不是算错了,是三个约定里至少有两个没对齐——旋转顺序、内旋外旋、左手右手。3D 里的方位与角位移这件事,说穿了只有三种表示法:旋转矩阵、欧拉角、四元数。每一个单拎出来都不难,难的是它们在同一个工程里共存的时候,你要清楚每一层用的是哪个、为什么用这个、以及两个值在什么条件下才允许互相转换。这三种表示法本质上描述的是同一件事——一个刚体相对于某个参考坐标系的朝向——但它们的冗余度、数值行为、可读性、插值友好度差得极远。选错表示法,代码能跑,但会在某个你不曾预料的角度上突然炸掉。这篇内容适合三类人:刚接触三维姿态、被万向锁卡住的入门者;写过旋转代码但说不清乘法顺序的中级开发者;以及需要把姿态数据在多个模块间传递、被精度和奇异点折磨过的工程实践者。下面我按"为什么需要三种表示"、"每种表示的真实边界"、"互相转换的工程细节"、"出问题时怎么定位"这条线,把踩过的坑一个个摊开。
1. 先分清"方位"和"角位移"是不是一件事
1.1 三个层次:朝向、旋转动作、坐标变换
很多人把这三个词混着用,结果代码里到处是语义错位。朝向(orientation)是一个静态描述:物体此刻的三根轴指向哪里,它是一个状态量,跟时间无关。角位移(angular displacement)是一个动作描述:从姿态 A 到姿态 B 需要绕某根轴转多少度,它带方向性,是一个算子。坐标变换则是同一批数据在两个坐标系下的换算,它既可以由朝向导出,也可以由角位移导出。
这三者在数学上是同一类对象的三种用法,但工程含义完全不同。举个最典型的场景:你要让一个相机跟随角色。角色当前朝向是状态量,你希望它平滑过渡到目标朝向,这个"过渡量"是角位移,而每帧渲染时把世界坐标转成相机坐标,用的是坐标变换。如果一开始就把它们当成一个东西处理,通常会在两个地方出问题:一是插值的时候拿状态量去做线性混合,结果朝向被拉扁;二是复合旋转的时候把"先转哪个后转哪个"搞混,最终朝向偏了个固定角度。
我的建议是给每个变量命名时就把语义写清楚。body_orientation存状态,delta_rotation存角位移,world_from_local存变换矩阵。名字里带delta、to、from的,一律是算子或者变换;不带的是状态。这个习惯看起来啰嗦,但它能在长达几千行的姿态代码里救你无数次。
1.2 自由度是 3,但三种表示的"容器"大小完全不同
一个三维空间里的旋转只有 3 个自由度,这是铁律。但三种表示的存储量分别是 9、3、4 个数,没有一个恰好等于 3。这不是设计缺陷,而是数学上的必然——你没法用 3 个全局连续、无奇点的参数去覆盖整个旋转群,这是拓扑层面的限制,跟实现水平无关。
| 表示法 | 存储量 | 冗余度 | 全局连续性 | 插值友好度 | 数值稳定性 |
|---|---|---|---|---|---|
| 旋转矩阵 | 9 | 6 个约束 | 连续无奇点 | 差(直接线性插值会缩放) | 会累积漂移,需正交化 |
| 欧拉角 | 3 | 0(最小) | 存在万向锁奇点 | 差(角度环绕、奇点跳变) | 在奇点附近极其敏感 |
| 四元数 | 4 | 1 个约束 | 双覆盖连续 | 好(球面插值) | 较好,需定期归一化 |
看清楚这张表,后面所有的选择逻辑都从它推出来。矩阵适合存和算,因为矩阵乘就是旋转复合,不需要记任何额外规则;欧拉角适合给人看和给人输入,因为"绕竖直轴转 30 度"是人的思维;四元数适合做插值和跨模块传输,因为它冗余度低且没有奇点。工程上的常规做法是内部用四元数或矩阵,只在 UI 层和配置层暴露欧拉角,中间做双向转换。
至于为什么"最小参数化必然有奇点",直觉解释是这样的:你可以把整个旋转空间想象成一个球面,三个欧拉角相当于用经纬度去贴这个球面。经纬度在极点附近是退化的——所有的经度值指向同一个点。万向锁就是三维旋转的"极点"。而四元数相当于把这个球面嵌到一个四维空间里,用单位球面上的点来表示旋转,代价是正反两个球面对称(q 和 -q 是同一个旋转),好处是没有退化点。
注意:冗余不是浪费。冗余意味着可以互相校验。矩阵的正交性可以检验,四元数的模长可以检验,欧拉角什么都检验不了。这也是为什么跨模块传输姿态时我几乎不用欧拉角。
1.3 三种表示共存的真实原因
有人会问:既然四元数这么好,为什么不全用四元数?因为人的脑子不是这么长的。策划要配一段动画,他跟你说的是"向外转 45 度,然后抬起来 20 度";美术在 DCC 工具里调骨骼,看到的是三条曲线;做 CAD 的工程师输入的是绕轴角度。这些场景用四元数表达,等于让人去做四维空间的算术。
反过来,如果你全用欧拉角存储,那在超过两种旋转复合、或者需要长时间累积旋转时,你会被角度环绕(359° 到 1° 的跳变)和万向锁折磨到怀疑人生。一个真实的例子:某次做角色转身的累积旋转,用欧拉角存,转身超过 180° 后角色开始抽搐,原因是 atan2 提取角度时在 ±180° 处发生了符号翻转,插值补间走了反方向的近路。换成四元数累积,问题当场消失。
所以正确的姿态是:在系统边界上做转换,在系统内部保持一致。具体来说,物理和动画更新用四元数,渲染提交用矩阵,编辑器和配置文件用欧拉角,三者在明确的接口处转换,绝不让欧拉角在半路悄悄流进计算链路。
2. 旋转矩阵:最笨但最可靠的底座
2.1 矩阵的几何含义:列就是转完之后的坐标轴
3x3 旋转矩阵最直观的读法是:它的每一列,是旋转之后坐标系的三根轴,在原坐标系下的坐标。这句话背下来,后面所有的事情都好推。比如绕 Z 轴转 90 度,原来 X 轴指向 (1,0,0),转完之后指向 (0,1,0),所以新矩阵的第一列就是 (0,1,0);原来 Y 轴指向 (0,1,0),转完指向 (-1,0,0),第二列是 (-1,0,0);Z 轴不动,第三列是 (0,0,1)。拼起来就是:
[0 -1 0] [1 0 0] [0 0 1]这个读法还顺带解释了两个容易搞混的点。第一,矩阵转置等于逆矩阵,因为"把新轴用旧轴表示"和"把旧轴用新轴表示"互为逆变换,而对于正交阵,逆就是转置。第二,行列式必须是 +1,如果算出来是 -1,说明中间混进了镜像(反射),那不是一个刚体旋转能产生的姿态。
还有一个高频混淆:主动旋转和被动旋转。同一个矩阵,既可以理解为"物体绕轴转了 θ",也可以理解为"坐标系绕轴转了 -θ",这两种解释相差一个转置。你在写代码时必须选一个并写进注释,否则接了别人的模块就会遇到"方向反了 180 度"或者"绕轴反了"。我的习惯是统一采用主动旋转 + 右手定则,也就是右手拇指指向轴的正方向,四指弯曲方向为正转角,并且全局只允许用这一个约定。
2.2 用罗德里格斯公式从"轴 + 角"直接构矩阵
从零手搓旋转矩阵,最省事的路径是罗德里格斯公式。给定单位轴 k 和转角 θ,先把 k 写成反对称矩阵 K:
K = [ 0 -kz ky ] [ kz 0 -kx ] [-ky kx 0 ]然后:
R = I + sinθ·K + (1 - cosθ)·K²这个公式的推导思路值得说一句,因为它解释了"为什么是这两个系数"。旋转矩阵的指数形式是 R = exp(θK),把指数按泰勒级数展开,利用反对称矩阵的性质 K³ = -K(当 k 是单位向量时成立),级数就自动分成两组:一组是 K 的奇次幂,收敛成 sinθ·K;另一组是 K² 的偶次幂,收敛成 (1 - cosθ)·K²。理解了这一层,你就知道这个公式不是背出来的,而是推出来的,也就不会在写代码时把 sin 和 (1-cos) 的位置搞反。
用 Python 写出来大概是这样:
import math def skew(k): x, y, z = k return [[0, -z, y], [z, 0, -x], [-y, x, 0]] def mat_mul(A, B): return [[sum(A[i][t] * B[t][j] for t in range(3)) for j in range(3)] for i in range(3)] def mat_add(A, B, sa=1.0, sb=1.0): return [[sa * A[i][j] + sb * B[i][j] for j in range(3)] for i in range(3)] def axis_angle_to_matrix(k, theta): n = math.sqrt(sum(c * c for c in k)) k = [c / n for c in k] # 先强制单位化 K = skew(k) K2 = mat_mul(K, K) I = [[1 if i == j else 0 for j in range(3)] for i in range(3)] s, c = math.sin(theta), math.cos(theta) return mat_add(mat_add(I, K, 1.0, s), K2, 1.0, 1.0 - c)这里有个几乎所有手写实现都踩过一次的坑:轴向量没有归一化。如果 k 的长度是 2,K² 会变成 4 倍,公式立刻失效,算出来的矩阵既不正交也不保长度。而且这种错误特别隐蔽——当 θ 很小的时候,sinθ ≈ θ,K 那项主导,(1-cosθ) 那项几乎为零,误差被压住了,你测不出来;等到转角大到 90 度以上,模型突然被拉伸,才发现问题。所以在函数入口无条件归一化,别相信调用方。
2.3 验证与正交化:一个必须写进工具函数的小动作
每次构造完矩阵,我都会跑一遍这个检查:
def ortho_error(R): Rt = [[R[j][i] for j in range(3)] for i in range(3)] P = mat_mul(Rt, R) I = [[1 if i == j else 0 for j in range(3)] for i in range(3)] return max(abs(P[i][j] - I[i][j]) for i in range(3) for j in range(3)) def det3(R): return (R[0][0] * (R[1][1] * R[2][2] - R[1][2] * R[2][1]) - R[0][1] * (R[1][0] * R[2][2] - R[1][2] * R[2][0]) + R[0][2] * (R[1][0] * R[2][1] - R[1][1] * R[2][0]))正常单精度下,构造出来的矩阵ortho_error应该在 1e-7 量级,det3在 0.999999 到 1.000001 之间。如果误差超过 1e-4,先别急着往下写,回去查构造过程。
长期累积旋转的场景,比如每帧把增量矩阵乘到当前矩阵上,误差会缓慢增长。我做过一个简单的测试:随机小角度旋转矩阵连乘 10 万次(单精度),最大正交性误差大约在 1e-3 到 1e-2 之间浮动,具体数值受旋转分布影响。这个量级在视觉上还看不出来,但如果你还要在这个矩阵上做逆变换、求特征值、或者喂给需要严格正交的算法,就得出问题了。
修复手段有两个。简单粗暴的是 Gram-Schmidt:把第一列归一化,第二列减去在第一列上的投影再归一化,第三列用前两列的叉乘补出来。它的问题是破坏了原有三轴之间的"对称性",会让最小旋转轴略微偏移。另一种是极分解近似,迭代式R ← R·(3I - RᵀR)/2,两三步就能把误差压下去,而且收敛到的矩阵是原来那个矩阵在正交群上的最近点,几何上更干净。我一般用后者,代码只有三行:
def reortho(R, iters=2): I = [[1 if i == j else 0 for j in range(3)] for i in range(3)] for _ in range(iters): Rt = [[R[j][i] for j in range(3)] for i in range(3)] P = mat_mul(Rt, R) # 3I - P Q = [[(3 if i == j else 0) - P[i][j] for j in range(3)] for i in range(3)] R = mat_mul(R, Q) return R提示:只在"每帧累积"这种场景加正交化,别在每个函数里都加。一次 3x3 矩阵乘是 27 次乘法,看着便宜,但如果你一帧要处理上万个骨骼节点,它就不便宜了。
2.4 矩阵的真实代价:内存和插值
矩阵最大的问题不在计算,在存储和插值。9 个数存 3 个自由度,冗余 3 倍。一个场景里有几万、几十万个实例需要携带姿态时,这个差异会变成实实在在的带宽压力。我做过粗略估算:10 万个实例,矩阵方案每帧要传 3.6 MB 的姿态数据,四元数方案只要 1.6 MB,压缩率超过一半。如果是每帧都要和 GPU 交换数据的场景,这个差距会直接体现在帧时间上。
插值问题是更致命的。你没法对两个旋转矩阵直接做逐元素线性插值——插值结果既不正交也不保长,几何上的表现就是模型在过渡过程中被"吸扁"。有人会用加权平均后再正交化来凑,效果勉强能用,但过渡路径不是最短路径,会出现奇怪的旋转方向。正确做法是先转成四元数做球面插值,再转回矩阵。绕这一圈的代价,比你在矩阵空间里强行凑要低得多,也正确得多。
3. 欧拉角:人最爱用,程序最容易翻车
3.1 十二种顺序,其实只有六类
欧拉角的核心问题不是"三个角度是多少",而是"这三个角度按什么顺序转"。三个轴的所有排列是 3! = 6 种,每种又分内旋(绕自身轴转)和外旋(绕固定参考轴转),所以名义上是 12 种约定。但因为内旋的 XYZ 恰好等价于外旋的 ZYX(顺序反转),实际独立的有 6 类。
| 顺序写法 | 含义 | 常见叫法 | 等价写法 |
|---|---|---|---|
| XYZ(外旋) | 先绕固定 X,再绕固定 Y,再绕固定 Z | 固定角 | ZYX(内旋) |
| ZYX(内旋) | 先绕自身 Z,再绕自身 Y,再绕自身 X | 偏航-俯仰-滚转 | XYZ(外旋) |
| ZXZ(内旋) | 先绕 Z,再绕 X,再绕 Z | 经典欧拉角 | ZXZ(外旋变体) |
| XYX、YZX、ZYZ 等 | 其余排列 | — | 各自对应 |
这里最容易出错的地方是"中间那个轴"。六个轴排列里,XYZ、YZX、ZXY 这种三个轴各出现一次的叫泰特-布莱恩角,实际应用最多;ZXZ、XYX 这种第一个和第三个轴相同的叫经典欧拉角,在轨道力学和某些机械臂里常见。两者的万向锁位置不一样:泰特-布莱恩角在中间角为 ±90° 时锁死,经典欧拉角在中间角为 0° 或 180° 时锁死。你要是不清楚自己用的是哪一类,排查方向就会完全跑偏。
我的做法是:项目里只允许存在一种顺序约定,把它写成常量,所有转换函数都读这个常量。如果确实需要支持多种(比如导入外部模型时),那就在导入层做一次转换,转换成内部统一约定,之后再也不提。多套约定共存是姿态 bug 的头号来源,没有之一。
3.2 万向锁到底锁住了什么
万向锁这个名字起得很吓人,其实它的现象很朴素:当中间那个轴转到 ±90° 时,第一个轴和第三个轴重合了,你失去了一个自由度。
想象一个三环陀螺仪。最外环固定,中间环绕外环的轴转,最内环绕中间环的轴转。当中间环绕了 90°,最内环的旋转轴就被转到了和外环的轴同一条线上。这时候你转最内环,效果和转最外环完全一样,两个控制量在做同一件事,另一个方向你无论如何都转不过去。
数学上验证一下。设姿态矩阵 R = Rz(γ)·Ry(β)·Rx(α),也就是内旋 Z-Y-X 顺序。把 β = 90° 代进去:
R = Rz(γ)·Ry(90°)·Rx(α)展开后你会发现,α 和 γ 只以 (α - γ) 或者 (α + γ) 的组合形式出现,也就是说,只有它们的差(或和)影响最终姿态,你没法分别确定两个角。给定任意一个目标姿态,解出来的 (α, γ) 有无穷多组。
用代码实测一下这个现象,比看公式直观得多:
def deg(d): return d * math.pi / 180.0 def rx(a): c, s = math.cos(a), math.sin(a) return [[1,0,0],[0,c,-s],[0,s,c]] def ry(a): c, s = math.cos(a), math.sin(a) return [[c,0,s],[0,1,0],[-s,0,c]] def rz(a): c, s = math.cos(a), math.sin(a) return [[c,-s,0],[s,c,0],[0,0,1]] def euler_zyx_to_matrix(alpha, beta, gamma): return mat_mul(mat_mul(rz(gamma), ry(beta)), rx(alpha)) # beta = 90 度,固定 (alpha - gamma) = 30 度,扫一遍 alpha for a in [0, 30, 60, 90, 150]: R = euler_zyx_to_matrix(deg(a), deg(90), deg(a - 30)) print(a, [round(R[0][j], 6) for j in range(3)])跑出来的第一行数值在所有 alpha 取值下都完全一样。这就是万向锁的实测形态:你把 alpha 从 0 扫到 150,gamma 跟着补偿,最终矩阵纹丝不动。反过来,如果你想在这个姿态下绕世界 X 轴再转一点,你会发现没有任何一组 (alpha, beta, gamma) 能做到——自由度真的少了一个。
注意:万向锁不是数值精度问题,是参数化本身的奇点,换 double 精度也没用。任何人跟你说"用双精度就能解决万向锁",你可以直接跳过他的方案。
3.3 判断自己是否靠近奇点
工程上不需要精确判断是否"正好"在奇点,只需要知道"离奇点还有多远"。对泰特-布莱恩角,看中间角的余弦值就够了:
def gimbal_risk(beta): c = abs(math.cos(beta)) if c > 1e-2: return "normal" elif c > 1e-5: return "near-singular" else: return "singular"cos(beta)接近 0,说明 beta 接近 ±90°。这个判据的好处是它不依赖具体的角度范围,也不依赖你是内旋还是外旋,只要中间角是那个"退化轴",就适用。
在靠近奇点的时候,提取出来的两个角度会剧烈抖动。你可能会看到 alpha 突然从 170° 跳到 -170°,gamma 跟着反向跳,但组合出来的姿态是连续的。这种现象在动画曲线里特别明显——曲线画出来像被刀切过一样。处理办法有两种:一是把欧拉角曲线转到四元数空间去做插值和采样,只在最终输出时转回来;二是在靠近奇点时锁定其中一个角为 0,把全部旋转量分给另一个角,这样曲线会有一个拐点,但不跳变。
3.4 欧拉角真正该用的地方
我不建议在任何运行时计算里用欧拉角存姿态,但有几个场景非它不可。
第一是编辑器面板和调试输出。三个带单位的数字,人能秒懂,四元数的四个分量没人看得懂。做工具的时候给个欧拉角显示,是刚需。
第二是动画曲线和程序化生成。比如你想做一段"角色从站立到躺下"的动画,用曲线控制俯仰角从 0 到 90,这个用欧拉角最自然。但注意,一旦涉及多轴同时变化,就要转到四元数做插值,别在两个欧拉角之间直接 lerp。
第三是机械结构的物理输入。很多关节就是单轴旋转,参数就是角度,这种场景用欧拉角没有歧义,反而更贴近物理。
第四是外部数据导入。美术资产、扫描数据、配置文件,到处都是欧拉角。这时候必须做一件事:确认顺序、确认内外旋、确认角度单位(度还是弧度)、确认坐标系手性。这四个确认缺一个,导入的姿态就是错的。
4. 四元数:工程默认选择,但要用对
4.1 四个数在表达什么
四元数最容易让人劝退的是那套 i、j、k 的代数。但你完全可以先不碰代数,用几何直觉理解它。
一个旋转可以由"绕某根单位轴 n 转 θ 角"唯一确定。四元数做的就是把这个信息打包成四个数:
q = (cos(θ/2), n.x·sin(θ/2), n.y·sin(θ/2), n.z·sin(θ/2))按 (w, x, y, z) 的顺序存。注意那个θ/2,这是四元数最关键的设计。为什么要取半角?因为四元数乘法在复合旋转时,角度是相加的——你转 θ₁ 再转 θ₂,等价于转 θ₁+θ₂。如果不用半角,相乘的时候就会多出 2 倍的因子,公式就不好看了。半角的设计让"旋转复合"直接对应"四元数相乘",代价是转一整圈 360° 之后,四元数变成 -q,而不是 q。这正好解释了为什么 q 和 -q 表示同一个旋转:转 360° 和没转在姿态上是同一个结果,但四元数走了半圈,到了对径点。
这也带来一个必须处理的问题:四元数的双覆盖。在插值、比较、求距离的时候,你要先判断两个四元数的点积是不是负数,如果是,就把其中一个取反,让它们走"短弧"。忘了这一步,会看到模型在插值中途突然转了一大圈。
def q_dot(a, b): return a[0]*b[0] + a[1]*b[1] + a[2]*b[2] + a[3]*b[3] def q_neg(q): return (-q[0], -q[1], -q[2], -q[3]) def q_normalize(q): n = math.sqrt(q_dot(q, q)) if n < 1e-12: return (1.0, 0.0, 0.0, 0.0) return (q[0]/n, q[1]/n, q[2]/n, q[3]/n)那个n < 1e-12的兜底不要省。我见过因为一次除零产生 NaN 四元数,然后整个场景的变换矩阵全变成 NaN,屏幕上什么都没有,日志里一行报错都不给的情况。整整排查了半天,最后发现源头是一个未初始化的姿态变量。
4.2 从轴角构造,以及一个必做的单位化
def axis_angle_to_quat(axis, theta): n = math.sqrt(sum(c*c for c in axis)) if n < 1e-12: return (1.0, 0.0, 0.0, 0.0) # 退化情况返回单位四元数 ax = [c/n for c in axis] h = theta * 0.5 s = math.sin(h) return (math.cos(h), ax[0]*s, ax[1]*s, ax[2]*s) def quat_to_axis_angle(q): q = q_normalize(q) if q[0] < 0: q = q_neg(q) # 统一到 w >= 0,保证 θ 在 [0, π] w = max(-1.0, min(1.0, q[0])) theta = 2.0 * math.acos(w) s = math.sqrt(max(0.0, 1.0 - w*w)) if s < 1e-8: return ((0.0, 1.0, 0.0), 0.0) # 转角接近 0,轴无意义 return ((q[1]/s, q[2]/s, q[3]/s), theta)acos那个 clamp 是必须的。浮点误差会让q[0]变成 1.0000000002,acos直接抛域错误,Python 里报 math domain error,C++ 里返回 NaN。这种 bug 在测试里几乎碰不到,因为只有精确的 0° 或者 180° 旋转才会触发,而这两个角度恰恰是很多默认姿态的取值。
还有那个q[0] < 0的翻转。不翻转的话,同一个旋转可能返回 θ = 350°,也可能返回 θ = -10°,取决于你从哪条路径进来的。做角度比较、做阈值判断的时候会乱七八糟。
4.3 乘法顺序:错一次,错一辈子
四元数乘法(Hamilton 积):
def q_mul(a, b): aw, ax, ay, az = a bw, bx, by, bz = b return ( aw*bw - ax*bx - ay*by - az*bz, aw*bx + ax*bw + ay*bz - az*by, aw*by - ax*bz + ay*bw + az*bx, aw*bz + ax*by - ay*bx + az*bw, )公式不难背,难的是理解q_mul(a, b)到底表示"先 a 后 b"还是"先 b 后 a"。答案是:先 b 后 a,在 a 所在的坐标系里。或者说,q_total = q_parent * q_local表示把 local 的旋转叠加到 parent 上,最终姿态是先在局部转 local,再整体转 parent。
用矩阵类比会清楚很多:M_total = M_parent · M_local,矩阵乘法从左往右读是"后作用",从右往左读是"先作用"。四元数乘法跟它完全一致。所以如果你的代码里写q = q * q_delta,含义是"在当前姿态的基础上,再绕当前姿态的自身轴转 q_delta";如果写q = q_delta * q,含义是"绕世界轴转"。
这两个的差别在实际项目里是灾难级的。举个具体的:你要让角色绕自身前方轴做一个翻滚。用q = q * q_roll,正确。用q = q_roll * q,角色会绕世界的某个固定轴转,看起来像在公转。而如果你没意识到这一点,可能会去调 roll 的方向正负号来"修",修到最后所有旋转都乱了。
提示:我的习惯是所有变量名带上坐标系信息,
q_world_from_body、q_delta_body,然后在乘法那一行强行写注释 "body delta, right multiply"。三个月后的自己会感谢现在的自己。
4.4 球面线性插值:为什么不能直接 lerp
四元数是四维单位球面上的点,两点之间最短的路径是球面上的大圆弧,不是直线。如果你直接对四个分量做线性插值然后归一化,得到的路径会在球面上"切直线",角速度不是匀速的,起步和结束的时候慢,中间快。视觉上表现为旋转"黏糊糊"的,不干脆。
正确的做法是球面线性插值:
def q_slerp(q0, q1, t): q0 = q_normalize(q0) q1 = q_normalize(q1) d = q_dot(q0, q1) if d < 0.0: q1 = q_neg(q1) d = -d d = min(1.0, d) if d > 0.9995: # 夹角极小,线性插值 + 归一化,避免除零 r = (q0[0] + t*(q1[0]-q0[0]), q0[1] + t*(q1[1]-q0[1]), q0[2] + t*(q1[2]-q0[2]), q0[3] + t*(q1[3]-q0[3])) return q_normalize(r) theta0 = math.acos(d) theta = theta0 * t s0 = math.sin(theta0 - theta) / math.sin(theta0) s1 = math.sin(theta) / math.sin(theta0) return q_normalize((s0*q0[0] + s1*q1[0], s0*q0[1] + s1*q1[1], s0*q0[2] + s1*q1[2], s0*q0[3] + s1*q1[3]))d > 0.9995那个分支很关键。当两个四元数几乎重合时,sin(theta0)接近 0,除下去会得到巨大的系数,然后相减,精度全丢。这个阈值 0.9995 大约是 1.8 度,低于这个角度用线性插值归一化,误差完全可以忽略。
另外,slerp 的参数 t 是线性的,但如果你需要匀速的角速度,那就直接用它——这正是 slerp 的定义。如果你需要缓动,就在 t 上先做缓动曲线,再喂给 slerp,别去改插值本身。
4.5 四元数的代价:不能直接读、不方便调试
四元数唯一的问题就是人看不懂。(0.707, 0.707, 0, 0)是多少度?绕哪个轴?没有任何人能一眼读出来。所以你在日志里打印四元数的时候,最好同步打印一份欧拉角或者轴角,不然出了问题只能靠猜。
我习惯的做法是写一个q_debug(q),输出"绕 (x, y, z) 轴转 N 度",这样一眼就能对上场景里的视觉表现。这个函数平时不用,但当你需要比对两个模块输出的姿态是否一致时,它比看四个小数有用一百倍。
5. 三者互转:工程细节全在这里
5.1 四元数与矩阵
四元数转矩阵是最稳的一步,没有分支、没有奇点:
def quat_to_matrix(q): q = q_normalize(q) w, x, y, z = q xx, yy, zz = x*x, y*y, z*z xy, xz, yz = x*y, x*z, y*z wx, wy, wz = w*x, w*y, w*z return [ [1-2*(yy+zz), 2*(xy-wz), 2*(xz+wy)], [ 2*(xy+wz), 1-2*(xx+zz), 2*(yz-wx)], [ 2*(xz-wy), 2*(yz+wx), 1-2*(xx+yy)], ]反过来的矩阵转四元数就要小心了。常见写法是先用 trace 判断,再从对角元素里挑最大的那个做分母:
def matrix_to_quat(R): m00, m01, m02 = R[0] m10, m11, m12 = R[1] m20, m21, m22 = R[2] tr = m00 + m11 + m22 if tr > 0.0: s = math.sqrt(tr + 1.0) * 2.0 w = 0.25 * s x = (m21 - m12) / s y = (m02 - m20) / s z = (m10 - m01) / s elif m00 > m11 and m00 > m22: s = math.sqrt(1.0 + m00 - m11 - m22) * 2.0 w = (m21 - m12) / s x = 0.25 * s y = (m01 + m10) / s z = (m02 + m20) / s elif m11 > m22: s = math.sqrt(1.0 + m11 - m00 - m22) * 2.0 w = (m02 - m20) / s x = (m01 + m10) / s y = 0.25 * s z = (m12 + m21) / s else: s = math.sqrt(1.0 + m22 - m00 - m11) * 2.0 w = (m10 - m01) / s x = (m02 + m20) / s y = (m12 + m21) / s z = 0.25 * s return q_normalize((w, x, y, z))为什么要分四种情况?因为如果只用 trace 那一个分支,当旋转接近 180° 时tr + 1接近 0,s接近 0,后面的除法会爆掉。分支的逻辑是"选四个分量里绝对值最大的那个当主元",保证分母不会太小。这个是数值稳定性的基本功,不是可选项。
还有一个容易忽略的处理:先用q_normalize收尾。即使输入矩阵本身是正交的,中间算出来的四元数也会有 1e-7 级别的模长误差,归一化之后后面的乘法才干净。
5.2 欧拉角与矩阵
以内旋 Z-Y-X(等价于外旋 X-Y-Z)为例,组合矩阵是R = Rz(γ)·Ry(β)·Rx(α),乘出来是:
R = [ cγcβ, cγsβsα - sγcα, cγsβcα + sγsα ] [ sγcβ, sγsβsα + cγcα, sγsβcα - cγsα ] [ -sβ, cβsα, cβcα ]从矩阵反解:
def matrix_to_euler_zyx(R): m00, m01, m02 = R[0] m10, m11, m12 = R[1] m20, m21, m22 = R[2] # beta = asin(-m20),先夹紧防止浮点越界 v = max(-1.0, min(1.0, -m20)) beta = math.asin(v) if abs(v) < 0.99999: alpha = math.atan2(m21, m22) gamma = math.atan2(m10, m00) else: # 万向锁:令 alpha = 0,把全部角度给 gamma alpha = 0.0 gamma = math.atan2(-m01, m11) return (alpha, beta, gamma)abs(v) < 0.99999这个阈值对应 beta 距离 ±90° 大约 0.25 度。在这个范围内,cos(beta)大到足以让atan2稳定;超过这个范围,m21和m22都趋近于 0,atan2(0, 0)的行为就是不确定的,得走锁死分支。
反方向的欧拉角转矩阵,注意顺序是反的:R = Rz·Ry·Rx,代码里写成mat_mul(mat_mul(rz(γ), ry(β)), rx(α))。我见过有人写成rz(rx(ry(α)))这种嵌套顺序错乱的形式,结果是错的但又不完全错——在某些单轴旋转的情况下居然是对的,所以测试很容易漏过去。
5.3 四元数与欧拉角
最省事的做法是走"四元数 → 矩阵 → 欧拉角"这条路径,虽然多算了一步,但代码量少,出错概率低。只有在性能极度敏感的地方,才值得写直接转换。直接转换的公式其实就是把quat_to_matrix的结果代入上面的反解式:
def quat_to_euler_zyx(q): w, x, y, z = q_normalize(q) # 对应矩阵的 m20 = 2(xz - wy) v = max(-1.0, min(1.0, 2.0*(x*z - w*y))) beta = math.asin(v) if abs(v) < 0.99999: alpha = math.atan2(2.0*(y*z + w*x), 1.0 - 2.0*(x*x + y*y)) gamma = math.atan2(2.0*(x*y + w*z), 1.0 - 2.0*(y*y + z*z)) else: alpha = 0.0 gamma = math.atan2(-2.0*(x*z - w*y), 1.0 - 2.0*(x*x + y*y)) return (alpha, beta, gamma)这类公式我从来不手推,都是从矩阵那一版推导出来之后,用随机测试跟"四元数 → 矩阵 → 欧拉角"的路径对比,误差在 1e-10 以内才敢用。手工推公式出错的概率,远高于你写测试花的时间。
一组往返测试大概长这样:
import random def roundtrip_test(n=20000): max_err = 0.0 for _ in range(n): a = random.uniform(-math.pi, math.pi) b = random.uniform(-math.pi/2 + 1e-4, math.pi/2 - 1e-4) g = random.uniform(-math.pi, math.pi) R1 = euler_zyx_to_matrix(a, b, g) q = matrix_to_quat(R1) R2 = quat_to_matrix(q) for i in range(3): for j in range(3): max_err = max(max_err, abs(R1[i][j] - R2[i][j])) return max_err正常结果应该在 1e-15 量级(双精度)。如果跑出 1e-3 这种量级,说明某个转换写错了,或者某个地方的归一化漏了。
5.4 一个必须强调的点:约定要写在类型里
我在不同的项目里见过至少四种不同的"欧拉角",它们的名字一模一样,含义完全不同:
| 差异维度 | 常见选项 | 用错的表现 |
|---|---|---|
| 旋转顺序 | XYZ / ZYX / ZXY / ZXZ | 姿态整体偏转,但单轴旋转时看起来正常 |
| 内外旋 | 内旋(绕自身轴)/ 外旋(绕固定轴) | 复合旋转方向错误 |
| 角度单位 | 度 / 弧度 | 转角大 57 倍 |
| 手性 | 右手 / 左手 | 某个轴的符号反了 |
| 朝向轴 | Z 向前 / Z 向上 / Y 向上 | 上下和前后搞反 |
五种差异组合起来,理论上有几百种可能。这就是为什么跨模块传姿态时,我宁可用四元数。四元数只有一种约定(Hamilton 积,右手,w 在前或在后是可查的),转换点集中,不会散落在各个模块里。
如果必须用欧拉角做接口,那就用一个结构体把约定信息带上,比如{alpha, beta, gamma, order, unit, frame}。多写几个字段,比事后调 bug 便宜太多。
6. 出问题时的排查实录
6.1 三种典型症状与第一反应
症状一:方向反了,正负号不对。第一反应应该是查手性和旋转方向约定。右手系里绕 Z 轴正向转是逆时针(从 +Z 往下看),如果你在屏幕上看到的是顺时针,要么是坐标系是左手系,要么是你的投影矩阵把 Z 反了。别急着改代码里的正负号,先确认约定,因为一个符号改了,其他地方会跟着错。
症状二:单轴旋转正常,复合旋转不对。这类问题九成是乘法顺序错了。检查方式很简单:构造一个"先绕 X 转 90 度再绕 Y 转 90 度"的姿态和一个"先绕 Y 再绕 X"的姿态,它们一定不同。如果你的代码算出相同结果,那乘法顺序肯定有问题。
症状三:插值过程中模型被拉扁,或者突然转一大圈。前者是拿矩阵或欧拉角做了线性插值,后者是四元数插值时没检查点积符号。两个都好定位,因为它们只在插值路径上出现,不在端点上出现。
6.2 常见问题速查表
| 现象 | 最可能原因 | 排查动作 | 修复方式 |
|---|---|---|---|
| 姿态整体偏移固定角度 | 参考坐标系不一致 | 检查父节点姿态是否被重复应用 | 统一到同一参考系再计算 |
| 靠近 ±90 度时姿态抖动 | 万向锁,欧拉角提取不稳定 | 打印中间角的 cos 值 | 内部换四元数,欧拉角只在展示层用 |
| 长时间运行后模型变形 | 矩阵累积漂移 | 跑正交性检查 | 定期极分解正交化 |
| 插值中途反向转一大圈 | 四元数双覆盖未处理 | 检查点积是否小于 0 | 插值前先取反对齐 |
| 某些角度出现 NaN | acos/asin 参数越界 | 检查是否做了 clamp | 全部入参加夹紧 |
| 旋转速度不均匀 | 用了线性插值代替球面插值 | 看角速度曲线 | 换 slerp |
| 导入的模型姿态不对 | 外部约定与内部不一致 | 核对顺序、内外旋、单位、手性 | 在导入层做一次转换 |
| 缩放混进了旋转 | 矩阵未经正交化或混入了错切 | 检查行列式和正交性 | 分离平移/旋转/缩放再重组 |
6.3 我踩过的几个具体坑
第一个坑是关于"四元数归一化"的。早期我以为只要在构造时归一化一次就够了,结果在做了几十次乘法之后,模长漂到了 1.02。这个误差看起来很小,但代入quat_to_matrix之后,矩阵的行列式变成了 1.06,模型被放大了 6%。当时的表现是"角色越跑越大",排查了很久以为是缩放动画的问题。后来在每次乘法之后都归一化,问题消失。代价是每帧多几次 sqrt,完全值得。
第二个坑是关于"欧拉角的角度环绕"的。有一段代码需要判断"角色是否转过了一定角度",我把欧拉角的 gamma 从 359° 变到 1°,差值算出来是 -358°,判定为"反向转了 358 度",触发了错误的动画。这不是数学错误,是表示法的固有问题——角度是周期量,做减法必须处理环绕。修复方式是差值规整到 [-180, 180],或者干脆改用四元数的测地距离来判断。
第三个坑是关于"负数缩放导致的镜像"。我一度在一个节点的变换矩阵里加了负缩放,然后从这个矩阵提取旋转,结果提取出来的四元数看着"没问题",但渲染出来的模型里外翻转了。原因是带负缩放的矩阵行列式为负,已经不是旋转矩阵,提取算法虽然能算出数,但算出来的旋转是错的。正确做法是从变换矩阵里先把缩放拆出来(三列各自的模长),剩下的才是旋转,而且如果缩放有负数,得单独处理。
第四个坑是关于"插值参数 t 的来源"。slerp 要求 t 在 [0, 1] 之间,线性推进。但我曾经把一个可能会越界的 t(因为计时器溢出)直接喂进去,结果插值出来的四元数模长变成了负数,归一化之后姿态完全歪掉。现在所有插值函数的入口都夹紧 t,不省这一步。
提示:姿态相关的问题,90% 都不是算法错,而是约定错。所以排查的第一动作永远是"打印中间量、对一遍约定",而不是"改公式试试看"。后者会让你在一个本来正确的实现上叠加一层错误,最后彻底失去方向。
6.4 关于工具函数的一点个人习惯
我给自己维护了一个几十行的小型姿态工具库,不管换什么语言都要先把它移植过去。里面就几样东西:三种表示的互转、单位化、正交化、正交性检查、slerp、坐标轴角格式化输出。所有这些函数都遵循两个硬规则:入参先归一化,出参先夹紧。这两条规则挡住了我至少七成的姿态相关 bug。
另外就是测试。我会写一个固定种子的随机往返测试,跑两万组三元组,检查"欧拉 → 矩阵 → 四元数 → 欧拉"和"四元数 → 矩阵 → 四元数"的往返误差。只要这个测试跑通,基本可以放心大胆地改后面的逻辑,因为它把三种表示的转换关系锁死了。写这个测试花了大概一小时,之后省下来的调试时间应该超过一百小时。
最后再分享一个判断"该用哪种表示"的土办法:如果你需要在两个姿态之间做任何形式的混合——插值、加权平均、平滑跟随、IK 迭代——那就用四元数或者矩阵,绝不用欧拉角。如果你需要给人看或者给人输入——面板、曲线、参数配置——那就用欧拉角,并且只在边界处转换。如果你需要和其他系统交换数据或者做严格的几何运算——碰撞检测的朝向、坐标系变换、投影——那就用矩阵,因为它能被直接检验。这条线划清楚之后,剩下的都是细节工作。