先玩一下:拖三个滑块,盯住两根轴箭头(不是盯环)。把俯仰拉到 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°

偏航与滚转各转 30°,物体实际转了多少

看图注意:两端归零。靠近 ±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?
  • 本文所有数值均由附录脚本在本机算出