先玩一下:拖三个滑块,盯住两根轴箭头(不是盯环)。把俯仰拉到 90°,看红色滚转轴怎么滑到青色偏航轴上——那一下就是万向锁的全部。在新窗口打开完整演示 ↗(画面可拖动换视角)
结论
① 万向锁丢的是「参数」的自由度,不是「姿态」的自由度。
偏航 30° 俯仰 90° 滚转 40° ┐
偏航 60° 俯仰 90° 滚转 70° ┘ 三个数字全变了 → 姿态完全相同(矩阵最大差 1.1e-16)
物体没有被锁住:它照样能转到任何姿态。是三个数字里有一个变得多余了。
② 判据是 $\cos(\text{俯仰}) = 0$,不是"俯仰等于 90°"。
退化是连续的:$1/\cos$ 型的条件数随俯仰角无界增长——60° 时 3.8 倍,85° 时 23 倍, 89° 时 117 倍,90° 时无穷。所以"业务上注意别转到 90°“没有可操作的安全边界。
③ 锁死时姿态只取决于一个组合角,而且换欧拉角顺序躲不掉。
+90°:姿态只取决于 (偏航 − 滚转) −90°:姿态只取决于 (偏航 + 滚转)
RTR4 原话:“There is no perfect sequence that avoids gimbal lock.”
提到万向锁,多数资料会给你一段陀螺仪动画,然后说: 「当两个转轴重合时,就失去一个自由度,物体转不动了。」
这句话有两个地方经不起追问,答案就是上面那两条结论:
- 物体真的转不动了吗?没有——它照样能转到任何姿态,是三个数字里有一个变多余了。
- 那个「一个自由度」,是谁的自由度?是三个参数的,不是姿态的。
这篇文章回答这两个问题。文中的每个数字都能用文末附录的脚本复现,也能在开头那个演示里拖着滑块自己看。
一、万向锁看起来是什么样
欧拉角是三层嵌套的旋转:外层带动中层、中层带动内层。这一点必须先立住,后面的「搬轴」全靠这个结构。
外层(偏航,绕世界 Z)
└─ 中层(俯仰,绕自己那一层转 90° 就出事)
└─ 内层(滚转)
└─ 物体

看图注意两点:左图三个环两两垂直,三根转轴指向三个不同方向;右图内环与外环变成了共面的,红色滚转轴箭头与青色偏航轴箭头落在同一条直线上。
轴被搬到了哪里,可以精确算出来(身体 X 轴就是滚转轴):
| 俯仰 | 滚转轴(身体 X)的去向 | 与偏航轴(世界 Z)的关系 |
|---|---|---|
| +90° | 世界 $-\mathbf{Z}$ | 共线但反向 |
| −90° | 世界 $+\mathbf{Z}$ | 共线且同向 |
「反向」还是「同向」会在第三节变成一个符号,先记住这一点。

把环隐藏掉再看一次,避免环的形状干扰判断。右图那两根箭头是一条笔直的线——这就是万向锁的全部外观。
二、它到底锁住了什么:参数塌缩,不是姿态塌缩
先给一个反例。俯仰固定在 90°:
偏航 30° 俯仰 90° 滚转 40° → 姿态 A
偏航 60° 俯仰 90° 滚转 70° → 姿态 B
|A − B| 的最大元素差 = 1.1e-16
三个数字全变了,姿态一模一样。所以「转不动」是错的:姿态空间毫发无伤,是参数到姿态的映射塌缩了。
顺带说清:雅可比矩阵是什么
这是全文唯一一个"外来工具”,值得花三分钟。一句话:雅可比矩阵就是多变量函数的导数矩阵。
一维时,导数 $f'(x)$ 回答"输入动一点点,输出动多少",确切地说是 $f(x+\delta) \approx f(x) + f'(x)\delta$。 多维时这句话照样成立,只是"乘一个数"变成了"乘一个矩阵":
$$f(\mathbf{x}+\boldsymbol{\delta}) \approx f(\mathbf{x}) + J(\mathbf{x})\,\boldsymbol{\delta}$$对 $f:\mathbb{R}^n \to \mathbb{R}^m$,$J$ 是 $m\times n$ 矩阵,第 $i$ 行第 $j$ 列就是 $\partial f_i/\partial x_j$。 它把「参数空间里的一小步」线性地翻译成「结果空间里的一小步」。
具体到本文:函数 $f$ 是「$(\text{偏航}, \text{俯仰}, \text{滚转}) \to$ 姿态」,$n=m=3$,所以 $J$ 是 $3\times3$ 的。 (严格说姿态不是三维向量——但任何旋转都能在局部用"一根转轴 + 一个角度"表示,也就是旋转向量, 所以取 $f$ 的值域为 $\mathbb{R}^3$ 是合法的。)
有了它,前面那几个说法就都有了精确形式:
| 说法 | 精确形式 |
|---|---|
| 「三个旋钮各管一个方向」 | $J$ 的秩 = 3(秩 = 这个线性映射的像空间维数) |
| 「有一个方向变多余了」 | 秩掉到 2 |
| 「参数化病态」 | $J$ 的条件数 $\sigma_{\max}/\sigma_{\min}$ 变大 |
| 「误差被放大」 | 反过来的放大倍数就是 $1/\sigma_{\min}$ |
名称由来:以 19 世纪德国数学家 Carl Gustav Jacob Jacobi 命名,他研究多元微积分时引入了这套偏导矩阵。
现在可以算了。把姿态对三个角度的偏导组成矩阵,看它的秩:
| 俯仰 | 秩 | 含义 |
|---|---|---|
| 0° / 30° / 60° / 85° / 89° | 3 | 三个旋钮各管一个独立方向 |
| 89.999° / 90° | 2 | 三个旋钮只产生两个独立方向 |
秩在这里的直观含义:它数的是「三个旋钮能产生的瞬时转动方向里有几个是独立的」。秩 3 就是三个都独立;秩 2 就是有一个方向重复了——转它等于转另一个。
一句话收束:姿态空间一直是三维的,是我们用三个数字去标它,在某处标重了。
三、为什么「加」会变成「减」
既然锁死时少了一个独立方向,那姿态到底由什么决定?实测两条等价关系:
+90°:姿态只取决于 (偏航 − 滚转)
−90°:姿态只取决于 (偏航 + 滚转)
为什么符号会翻? 回看第一节那张表:内层轴被搬到世界 $-\mathbf{Z}$ 时与偏航轴反向,两个旋钮的效果互相抵消,所以取差;被搬到 $+\mathbf{Z}$ 时同向,效果叠加,所以取和。
还有一个漂亮的旁证:让偏航和滚转反向各转 $d$(这会改变姿态),姿态变化的角度恰好是 $2d$:
反向各 10° → 姿态差 20.000°
反向各 30° → 姿态差 60.000°
反向各 60° → 姿态差 120.000°
恰好是 $2d$,因为两个旋钮在绕同一根轴转,转角直接相加。
这不是我一个人的结论。RTR4 §4.2.2(书内 p.73)原文:
“With such a value of p, we have lost one degree of freedom since the matrix only depends on one angle, r + h or r − h (but not both at the same time).”
书里用的符号是 $h/p/r$(head/pitch/roll),结论与前文完全一致。
四、这不是一个「坏点」,而是一条连续退化的路
很多人对万向锁的心智模型是「90° 是个坏点,绕开它就行」。这个模型错在把奇点当成了孤立的点。
把「姿态里的一点点变化,翻译回角度需要多少变化」算出来,就是雅可比矩阵的条件数 $\sigma_{\max}/\sigma_{\min}$。实测:
$$\sigma_{\min} \approx 0.769 \cos(\text{俯仰}) \quad\Longrightarrow\quad \text{条件数} \propto \frac{1}{\cos(\text{俯仰})}$$| 俯仰 | 60° | 80° | 85° | 89° | 89.9° | 90° |
|---|---|---|---|---|---|---|
| 条件数 | 3.8 | 11.7 | 23 | 117 | 1173 | $\infty$ |
| 误差放大 $1/\sigma_{\min}$ | 2.6× | 7.6× | 15× | 75× | 745× | $\infty$ |

看图注意:纵轴是对数刻度。曲线在两端垂直上冲,说明放大倍数没有上界。
这条曲线的实际后果是:想让误差放大不超过 10 倍,俯仰就得夹在 $\pm 78°$ 以内;想不超过 100 倍,夹在 $\pm 88°$ 以内。这个阈值是业务拍的,不是数学给的——数学只告诉你"越靠近越糟,而且没有尽头"。
顺带修正一个常见的做法:“把相机俯仰夹在 ±89°“对数值病态几乎帮不上忙——那里误差放大还有 75 倍。它主要解决的是控制手感与视线翻转,别把它当成奇点的解法。
第二类后果更直观:两个旋钮的效果越来越分不开。让偏航和滚转各转 30°:
俯仰 60° → 物体实际转 15.364°
俯仰 85° → 物体实际转 2.587°
俯仰 89.9° → 物体实际转 0.052°
俯仰 90° → 物体实际转 0.000°

看图注意:两端归零。靠近 ±90° 时,两个旋钮越来越在做同一件事、互相抵消。
第三类后果出在代码里。从旋转矩阵反解欧拉角时(RTR4 Eq 4.25):
$$p = \arcsin(e_{21}), \qquad h = \operatorname{atan2}(-e_{20},\ e_{22}), \qquad r = \operatorname{atan2}(-e_{01},\ e_{11})$$$\cos p \to 0$ 时,后两个式子的分子分母同时趋零。atan2(0, 0) 是没有定义的——这不是"算得不准”,是"根本没定义”,必须先判断再取一个角置零(RTR4 Eq 4.26 处理的就是这件事)。
三条合起来看:判据是 $\cos(\text{俯仰}) = 0$,但"躲开 90°“不是一个可操作的对策。 你想多安全,就得夹得多紧,而那个阈值是拍出来的。要解决就得换表示法。
五、工业界怎么处理
一句话总纲:欧拉角是「人机接口」,四元数是「内部表示」。 搞混这一点是多数万向锁 bug 的来源。
| 场景 | 做法 |
|---|---|
| 关键帧动画 | 动画师用曲线编辑器调欧拉角(可读可调),导出前转四元数做插值 |
| 相机控制 | 把俯仰夹住以绕开奇点与视线翻转(注意第四节:这解决的是手感,不是数值) |
| 姿态解算 | 手机传感器融合直接输出四元数(Android 的 TYPE_ROTATION_VECTOR 前四个分量就是四元数),从源头不产生欧拉角 |
为什么"换个欧拉角顺序"救不了? 因为奇点只是被挪走了,没有被消除。有些资料推荐 $z/x/z$ 顺序,说它只在绕 $x$ 转 $\pi$ 时才锁——这是事实,但换个方向又会锁。RTR4 p.73 说得很直接:
“There is no perfect sequence that avoids gimbal lock.”
根本原因:用 3 个参数表示 3 维旋转,奇点必然存在。要覆盖住这个空间,需要 4 个分量(四元数)或更多;3 个不够。所以这不是选哪个顺序的问题,是表示法本身的问题。
桌面与移动的差异主要落在代价上:
桌面 算力充裕,四元数与矩阵之间来回转换、归一化、球面插值 slerp 都是标配
移动 传感器融合直接给出四元数,省掉一次反解;四元数归一化用 rsqrt,
比矩阵重正交化便宜,对低端设备友好
小结
三句话:
- 万向锁锁的是参数——三个数字里有一个变多余了,姿态空间一直是三维的
- 判据是 $\cos(\text{俯仰}) = 0$,而且退化是连续的:躲角度不是解法
- 出路是换表示法(四元数),并把欧拉角降格成"给人看的接口”
行动点:下次相机或角色转到某个角度突然不听话,先算一下 $\cos(\text{俯仰})$ 是不是快接近 0 了。如果是,别去调控制器参数——那是表示法的问题。
附录一:可直接跑的验证脚本
纯 Python 标准库,无依赖。复制下来直接运行,输出的每个数字都能和正文对上。
#!/usr/bin/env python3
"""万向锁的数值验证:秩、条件数、等价关系。纯标准库,无依赖。"""
import math
D2R, R2D = math.pi / 180, 180 / math.pi
def mm(A, B):
return [[sum(A[i][k] * B[k][j] for k in range(3)) for j in range(3)] for i in range(3)]
def Rz(a):
c, s = math.cos(a), math.sin(a); return [[c, -s, 0.], [s, c, 0.], [0., 0., 1.]]
def Ry(a):
c, s = math.cos(a), math.sin(a); return [[c, 0., s], [0., 1., 0.], [-s, 0., c]]
def Rx(a):
c, s = math.cos(a), math.sin(a); return [[1., 0., 0.], [0., c, -s], [0., s, c]]
def ap(M, v):
return [sum(M[i][k] * v[k] for k in range(3)) for i in range(3)]
def euler(a, b, g):
"""外旋 XYZ ≡ 内旋 ZYX:偏航 a → 俯仰 b → 滚转 g"""
return mm(mm(Rz(a), Ry(b)), Rx(g))
def rotvec(M):
"""旋转矩阵 → 旋转向量(log map)"""
tr = M[0][0] + M[1][1] + M[2][2]
th = math.acos(max(-1., min(1., (tr - 1) / 2)))
if th < 1e-9:
return [0., 0., 0.]
v = [M[2][1] - M[1][2], M[0][2] - M[2][0], M[1][0] - M[0][1]]
return [x * th / (2 * math.sin(th)) for x in v]
def jacobian(a, b, g, h=1e-6):
"""姿态对三个角度的偏导(中心差分)"""
cols = []
for d in ((h, 0, 0), (0, h, 0), (0, 0, h)):
p = rotvec(euler(a + d[0], b + d[1], g + d[2]))
m = rotvec(euler(a - d[0], b - d[1], g - d[2]))
cols.append([(p[i] - m[i]) / (2 * h) for i in range(3)])
return [[cols[j][i] for j in range(3)] for i in range(3)]
def sym_eig3(A):
"""对称 3×3 的特征值(Jacobi 旋转)"""
a = [r[:] for r in A]
for _ in range(60):
if max(abs(a[0][1]), abs(a[0][2]), abs(a[1][2])) < 1e-14:
break
for p, q in ((0, 1), (0, 2), (1, 2)):
if abs(a[p][q]) < 1e-16:
continue
th = 0.5 * math.atan2(2 * a[p][q], a[q][q] - a[p][p])
c, s = math.cos(th), math.sin(th)
for k in range(3):
ap_, aq_ = a[k][p], a[k][q]
a[k][p] = c * ap_ - s * aq_; a[k][q] = s * ap_ + c * aq_
for k in range(3):
ap_, aq_ = a[p][k], a[q][k]
a[p][k] = c * ap_ - s * aq_; a[q][k] = s * ap_ + c * aq_
return sorted(a[i][i] for i in range(3))
def singular_values(J):
"""JᵀJ 的特征值 → 奇异值(升序)"""
JtJ = [[sum(J[k][i] * J[k][j] for k in range(3)) for j in range(3)] for i in range(3)]
return [math.sqrt(max(v, 0.0)) for v in sym_eig3(JtJ)]
def rank(M, tol=1e-4):
A = [r[:] for r in M]; r = 0
for c in range(3):
piv = max(range(r, 3), key=lambda i: abs(A[i][c]))
if abs(A[piv][c]) < tol:
continue
A[r], A[piv] = A[piv], A[r]
for i in range(3):
if i != r and A[i][c] != 0:
f = A[i][c] / A[r][c]
for j in range(3):
A[i][j] -= f * A[r][j]
r += 1
return r
def geo_angle(A, B):
"""两个旋转矩阵之间的夹角(度)"""
tr = sum(A[i][k] * B[i][k] for i in range(3) for k in range(3))
return math.degrees(math.acos(max(-1., min(1., (tr - 1) / 2))))
# ── ① 秩、σ_min、条件数 ──
print("俯仰 秩 σ_min 条件数")
for d in (0, 30, 60, 80, 85, 89, 89.9, 89.99, 90):
J = jacobian(0.3, d * D2R, 0.7)
s = singular_values(J)
c = (s[2] / s[0]) if s[0] > 1e-15 else float("inf")
print(f"{d:>5}° {rank(J)} {s[0]:.3e} {c:>10.1f}")
# ── ② 等价关系:锁死时姿态由什么决定 ──
for sign, lbl in ((+1, "+90°"), (-1, "-90°")):
b = sign * math.pi / 2
r0 = euler(30 * D2R, b, 40 * D2R)
for tag, r1 in (("同向各 +30°", euler(60 * D2R, b, 70 * D2R)),
("反向各 ±30°", euler(60 * D2R, b, 10 * D2R))):
md = max(abs(r0[i][j] - r1[i][j]) for i in range(3) for j in range(3))
hit = " ← 姿态不变" if md < 1e-9 else ""
print(f"俯仰 {lbl} {tag} → 最大差 {md:.2e}{hit}")
# ── ③ 内层轴(身体 X)被搬到哪 ──
for d in (90, -90):
v = ap(Ry(d * D2R), [1, 0, 0])
print(f"俯仰 {d:>4}°:身体 X 轴 → [{v[0]:+.3f} {v[1]:+.3f} {v[2]:+.3f}](偏航轴 = [0 0 1])")
# ── ④ 两个旋钮各转 30°,物体实际转了多少 ──
for d in (60, 85, 89.9, 90):
b = d * D2R
print(f"偏航滚转各 +30° @ 俯仰 {d:>5}° → 姿态差 "
f"{geo_angle(euler(30*D2R, b, 40*D2R), euler(60*D2R, b, 70*D2R)):>8.3f}°")
# ── ⑤ 反向各转 d → 姿态变化恰好 2d ──
for d in (10, 30, 60):
b = math.pi / 2
print(f"反向各 {d:>2}° @ 俯仰 90° → 姿态差 "
f"{geo_angle(euler(30*D2R, b, 40*D2R), euler((30+d)*D2R, b, (40-d)*D2R)):>8.3f}°")
运行输出的关键几行(下面是实际跑出来的,不是示意):
俯仰 秩 σ_min 条件数
60° 3 3.795e-01 3.8
80° 3 1.316e-01 11.7
85° 3 6.646e-02 23.4
89° 3 1.339e-02 117.3
89.9° 3 1.342e-03 1172.9
89.99° 3 1.342e-04 11729.6
90° 2 8.904e-09 176791469.8
俯仰 +90° 同向各 +30° → 最大差 1.11e-16 ← 姿态不变
俯仰 -90° 反向各 ±30° → 最大差 5.55e-17 ← 姿态不变
俯仰 90°:身体 X 轴 → [+0.000 +0.000 -1.000](偏航轴 = [0 0 1])
偏航滚转各 +30° @ 俯仰 60° → 姿态差 15.364°
偏航滚转各 +30° @ 俯仰 85° → 姿态差 2.587°
偏航滚转各 +30° @ 俯仰 89.9° → 姿态差 0.052°
反向各 30° @ 俯仰 90° → 姿态差 60.000°
最后那一行 90° 值得停一下:理论上 $\sigma_{\min} = 0$、条件数应为 $\infty$,脚本却给出 $8.9\times10^{-9}$ 与 $1.77\times10^8$。 这不是算错了,而是中心差分在奇点上的必然结果——$\sigma_{\min}$ 在那里对浮点误差的敏感度本身就是无穷。这也顺带说明:“奇点处数值不可靠"是字面意义上的不可靠,不是"精度差一点”。
注意
euler()用的是外旋 XYZ ≡ 内旋 ZYX(偏航 → 俯仰 → 滚转)。换别的顺序结论的形式会变(哪个角跟哪个角组合、符号怎么取),但"秩掉到 2、姿态只取决于一个组合角"这个结构不变。
附录二:这条链上的常见追问
万向锁是什么
→ 为什么中间轴转到 90° 就锁(内层轴被搬到与外层轴共线)
→ 怎么证明"丢了一个自由度"(雅可比矩阵秩 3 → 2)
→ 那 85° 有没有影响(有,但有分寸:条件数 23,误差放大 15 倍 ——
说"85° 已经锁死了"同样是错的)
→ 3 个参数为什么必然有奇点(3 维旋转空间的参数化,需要 4 个分量才能覆盖住)
→ 四元数为什么能解决(单位四元数是 4 维空间里的球面,覆盖旋转空间两次,处处无奇点)
→ 那欧拉角为什么没被淘汰(动画师的曲线编辑器;它是接口,不是内部表示)
→ 手机上怎么处理的(传感器融合直接输出四元数,从源头不产生欧拉角)
参考
- RTR4 §4.2.1–4.2.2(书内 p.71–73),Real-Time Rendering, 4th Edition
- §4.2.1 末尾点出欧拉角的两个局限:两组不同欧拉角可给出同一姿态(插值不该转)、gimbal lock
- Eq 4.22($E(h,p,r) = R_z(r)R_x(p)R_y(h)$)、Eq 4.25(反解)、Eq 4.26(锁死时的特例)
- p.73:
"There is no perfect sequence that avoids gimbal lock."
- RTR4 参考文献 [499]:Frykholm, What Is Gimbal Lock and Why Do We Still Have to Worry about It?
- 本文所有数值均由附录脚本在本机算出