《游戏引擎实现原理》9.1 欧拉角、万向节死锁与四元数

描述一个旋转最直觉的方式是三个角度——欧拉角。本节用可编译的 TypeScript 把它的失效点跑出来:pitch 转到 90° 时,两组完全不同的欧拉角给出逐元素相同的旋转矩阵,误差只有 6.12e-17。我们把死锁现场、丢掉的自由度与四元数的引入动机一次讲清。

9.1 欧拉角、万向节死锁与四元数

8.3 用矩阵把平移、旋转、缩放复合起来,但「怎么描述一个旋转」是另一件事。最直觉的答案是三个角度——欧拉角:偏航、俯仰、翻滚。它可读、可编辑、能直接喂给关卡编辑器。代价是它有一个著名的失效点:万向节死锁。

这一节不背结论,而是用一段可编译的代码把死锁真跑出来:让 pitch 转到 90°,观察 yaw 与 roll 两个自由度塌缩成一个——两组完全不同的欧拉角,算出逐元素相同的旋转矩阵。跑出来的数字就是这一节的结论。

本章与站内既有内容的分工

旋转这件事站内已经有人讲过数学推导,本节只补工程实现与实测:

既有文章它负责什么本节只做什么
/graphics-math-foundations/从数学上推导四元数的定义、旋转公式与插值,讲清「为什么四元数能表示旋转」不讲推导,只做引擎里的实现与实测:把万向节死锁在 Node 里复现出退化矩阵,给出可断言、可复现的真实数字

本卷的数学约定已经在前面定下:8.1 规定了向量与矩阵的 TypedArray 布局,8.2 规定了行/列主序与左右手系。本节沿用同一套约定——行主序、右手系、R = Rz · Ry · Rx——不再重复讨论。

欧拉角转矩阵:三行公式,一个约定

欧拉角把一次旋转拆成绕三个坐标轴的三次旋转。helio 采用的顺序是「先滚(roll,绕 Z)、再俯(pitch,绕 X)、后偏(yaw,绕 Y)」,写成矩阵就是:

R = Rz(yaw) · Ry(pitch) · Rx(roll)

三个角各自管一根轴:

角轴直观含义在《微光岛》里
yawY左右转头角色面朝哪个方向
pitchX抬头低头相机俯仰
rollZ侧倾受击时的镜头晃动

矩阵用长度 9 的 number[] 按行主序存:m[r * 3 + c]。这个约定很关键——换一个主序,下面所有公式都要转置,但死锁的结论不变,因为死锁是旋转表示本身的性质,与存储布局无关。

可运行产物:欧拉角转矩阵

下面是完整可编译的 src/core/euler.ts。层级 L1,Node 26 直跑,配套单测在下一节给出。

// file: src/core/euler.ts
// tier: L1
// verify: npx vitest run src/core/euler.test.ts
//
// helio 内核:欧拉角(yaw-pitch-roll)到 3x3 旋转矩阵的转换。
// 矩阵按行主序存储:m[r * 3 + c]。约定 R = Rz(yaw) · Ry(pitch) · Rx(roll)。

export const DEG2RAD = Math.PI / 180;

/** 3x3 旋转矩阵,行主序:m[r * 3 + c]。 */
export type Mat3 = number[];

/** 欧拉角(弧度):绕 Y 的 yaw、绕 X 的 pitch、绕 Z 的 roll。 */
export type Euler = { yaw: number; pitch: number; roll: number };

/**
 * 欧拉角转 3x3 旋转矩阵(R = Rz · Ry · Rx)。
 * pitch = ±90° 时 cos(pitch) = 0,矩阵第一列只剩一个非零元素,
 * 于是 yaw 与 roll 退化成同一个自由度——这就是万向节死锁。
 */
export function eulerToMat3(e: Euler): Mat3 {
  const cy = Math.cos(e.yaw);
  const sy = Math.sin(e.yaw);
  const cp = Math.cos(e.pitch);
  const sp = Math.sin(e.pitch);
  const cr = Math.cos(e.roll);
  const sr = Math.sin(e.roll);
  return [
    cy * cp, cy * sp * sr - sy * cr, cy * sp * cr + sy * sr,
    sy * cp, sy * sp * sr + cy * cr, sy * sp * cr - cy * sr,
    -sp, cp * sr, cp * cr,
  ];
}

/** 两矩阵逐元素最大绝对差。 */
export function mat3Diff(a: Mat3, b: Mat3): number {
  let d = 0;
  for (let i = 0; i < 9; i++) d = Math.max(d, Math.abs(a[i] - b[i]));
  return d;
}

/** 是否落在万向节死锁点附近:|cos(pitch)| ≈ 0。 */
export function isGimbalLocked(e: Euler, eps = 1e-9): boolean {
  return Math.abs(Math.cos(e.pitch)) < eps;
}

/** 把欧拉角转成 3 位小数矩阵,便于打印与比对(`+ 0` 消除 -0)。 */
export function round3(m: Mat3): Mat3 {
  return m.map((v) => Math.round(v * 1000) / 1000 + 0);
}

公式第三行 -sp 就是死锁的入口:pitch = ±90° 时 sin(pitch) = ±1,这一项把矩阵的第三行钉死成 ∓1, 0, 0;同时 cp = 0 让第一行与第二行的第一列同时归零。第一列只剩 -sp 一个非零元素——矩阵丢了一维信息。

死锁现场:两个不同输入,一个相同矩阵

断言「两组不同欧拉角给出同一矩阵」,比用眼睛看矩阵更可靠。下面是 src/core/euler.test.ts,其中 d() 把角度转弧度。

// file: src/core/euler.test.ts
// tier: L1
// verify: npx vitest run src/core/euler.test.ts

import { describe, expect, it } from 'vitest';
import { DEG2RAD, eulerToMat3, isGimbalLocked, mat3Diff, round3 } from './euler.ts';

const d = (deg: number) => deg * DEG2RAD;

describe('eulerToMat3', () => {
  it('单位欧拉角得到单位矩阵', () => {
    const m = round3(eulerToMat3({ yaw: 0, pitch: 0, roll: 0 }));
    expect(m).toEqual([1, 0, 0, 0, 1, 0, 0, 0, 1]);
  });

  it('仅 yaw = 90° 时绕 Y 轴转 90°', () => {
    const m = round3(eulerToMat3({ yaw: d(90), pitch: 0, roll: 0 }));
    expect(m).toEqual([0, -1, 0, 1, 0, 0, 0, 0, 1]);
  });
});

describe('万向节死锁:不同输入 → 同一旋转矩阵', () => {
  const a = { yaw: d(90), pitch: d(90), roll: 0 };
  const b = { yaw: d(0), pitch: d(90), roll: d(-90) };

  it('pitch = 90° 时两组不同欧拉角给出同一个矩阵', () => {
    const diff = mat3Diff(eulerToMat3(a), eulerToMat3(b));
    expect(diff).toBeLessThan(1e-15);
    expect(round3(eulerToMat3(a))).toEqual([0, -1, 0, 0, 0, 1, -1, 0, 0]);
  });

  it('pitch = 89° 时同样的两组欧拉角给出不同矩阵', () => {
    const a89 = { yaw: d(90), pitch: d(89), roll: 0 };
    const b89 = { yaw: d(0), pitch: d(89), roll: d(-90) };
    expect(mat3Diff(eulerToMat3(a89), eulerToMat3(b89))).toBeGreaterThan(0.01);
  });

  it('yaw 与 roll 同步增加同一个角度,矩阵完全不变(丢了一个自由度)', () => {
    const base = { yaw: d(90), pitch: d(90), roll: 0 };
    const shifted = { yaw: d(120), pitch: d(90), roll: d(30) };
    expect(mat3Diff(eulerToMat3(base), eulerToMat3(shifted))).toBeLessThan(1e-15);
  });

  it('只有 pitch = ±90° 落在死锁点', () => {
    expect(isGimbalLocked({ yaw: 0, pitch: d(90), roll: 0 })).toBe(true);
    expect(isGimbalLocked({ yaw: 0, pitch: d(-90), roll: 0 })).toBe(true);
    expect(isGimbalLocked({ yaw: 0, pitch: d(89), roll: 0 })).toBe(false);
  });
});

本机实测 npx vitest run src/core/euler.test.ts:6 个用例全部通过。把中间量打印出来是这样的:

输入yawpitchroll输出矩阵(行主序,3 位小数)与 A 的最大差
A90°90°0°[0,-1,0, 0,0,1, -1,0,0]0
B0°90°-90°[0,-1,0, 0,0,1, -1,0,0]6.12e-17
A′120°90°30°[0,-1,0, 0,0,1, -1,0,0]4.98e-17

6.12e-17 是双精度浮点舍入的噪声量级(约 2⁻⁵⁴ 的若干倍),不是误差——A、B、A′ 三组输入的矩阵在数学上完全相同。作为对照,把 pitch 从 90° 挪到 89°,同样的两组欧拉角立刻分道扬镳,最大差变成 0.01745,也就是 sin(1°) 的精确值。死锁只发生在 pitch 恰好是 ±90° 的那一点上,差一度都还正常。

少掉的那个自由度

为什么 pitch = 90° 会塌缩?把 cp = 0、sp = 1 代进 eulerToMat3,矩阵退化成只含 (roll − yaw) 的形式:第一行是 [0, sin(roll−yaw), cos(roll−yaw)],第二行是 [0, cos(roll−yaw), −sin(roll−yaw)],第三行是 [−1, 0, 0]。

也就是说,矩阵只认 roll − yaw 这一个量。yaw 加 30°、roll 同时加 30°,差不变,矩阵纹丝不动——这正是上表 A′ 那一行(yaw 从 90° 变 120°、roll 从 0° 变 30°,差恒为 90°)得到同一矩阵的原因。

物理图像就是「万向节」三个字:三个嵌套的环,当内环和外环的转轴被对齐到同一条线上时,其中一个环的转动被另一个「吃掉」,两个角度在数值上仍然能独立变化,但合成的旋转不再独立——三自由度的表示塌成两自由度。这在游戏里表现为:镜头抬高到接近垂直时,左右转动会突然变成绕视线的翻滚,或者角色朝向卡死、怎么拖都不动。

换个角度量一遍:144 组输入只剩 12 个旋转

上面是「挑两组输入比矩阵」,现在换个量化方式:固定 pitch,让 yaw 和 roll 各取 12 档(0°、30°、…、330°),一共 144 组欧拉角,数一数它们究竟产生多少个不同的旋转矩阵。下面是完整可跑的 src/core/gimbal-sweep.ts。

// file: src/core/gimbal-sweep.ts
// tier: L1
// verify: node src/core/gimbal-sweep.ts
//
// 在固定 pitch 下扫 yaw × roll 各 12 档(步长 30°),
// 统计 144 组欧拉角里究竟能产生多少个不同的旋转矩阵。

import { DEG2RAD, eulerToMat3 } from './euler.ts';

const STEPS = 12;

function distinctCount(pitchDeg: number): number {
  const seen = new Set<string>();
  for (let i = 0; i < STEPS; i++) {
    for (let j = 0; j < STEPS; j++) {
      const m = eulerToMat3({
        yaw: i * 30 * DEG2RAD,
        pitch: pitchDeg * DEG2RAD,
        roll: j * 30 * DEG2RAD,
      });
      // round6 + 0:把 -0 与 +0 归一,避免「符号零」把同一个旋转拆成两个
      seen.add(m.map((v) => Math.round(v * 1e6) / 1e6 + 0).join(','));
    }
  }
  return seen.size;
}

console.log('pitch(°)   不同旋转数 / 144');
for (const p of [0, 30, 45, 60, 80, 89, 90]) {
  console.log(`${String(p).padStart(6)}   ${String(distinctCount(p)).padStart(3)}`);
}

本机实测 node src/core/gimbal-sweep.ts:

pitch不同旋转数 / 144
0°144
30°144
45°144
60°144
80°144
89°144
90°12

读法很直接:pitch 从 0° 到 89°,144 组欧拉角每一组都对应一个不同的旋转,映射一一对应;到了 90° 这一步,144 组输入被压成 12 个旋转——恰好等于 (roll − yaw) 在 mod 360° 下能取的 12 个不同值((j − i) · 30° 的 12 个余数)。144 塌到 12,塌缩比 12∶1,这就是死锁的量化版本,比任何文字描述都硬。

这里藏着一个真会咬人的细节:脚本里 Math.round(v * 1e6) / 1e6 + 0 的那个 + 0 不是装饰。cos(90°) 在浮点下是 6.12e-17 而不是精确的 0,乘上 cos(roll) 会得到极小的负数,toFixed 会把它打印成 -0.000000——同一个旋转于是被「符号零」拆成两个,统计出的数字会虚高到 90。把 -0 归一到 +0 之后才是真实的 12。写数值统计时,符号零是比舍入误差更隐蔽的坑。

四元数:绕开死锁的另一种表示

四元数用四个数描述旋转,且没有奇异点:

// snippet: 单位四元数的形状
export type Quat = { x: number; y: number; z: number; w: number };
// q 与 -q 表示同一个旋转;约束 x² + y² + z² + w² = 1。

它之所以没有死锁,是因为「三个角度」这种参数化方式本身才是问题的根源——只要用三个连续参数去覆盖三维旋转群,就必然存在至少一个奇异点(这是拓扑上的必然,不是实现缺陷)。四元数用四个数、加一条单位长度的约束去覆盖同一个群,绕开了这个强制。代价是它不可读、不直观,所以引擎里通常两者并存:编辑器用欧拉角给人看,运行时用四元数存和算,中间做一次无奇异点的转换。

两种表示放在一起对比,取舍一目了然:

维度欧拉角(3 个数)四元数(4 个数)
直观性高,人能直接读低,只有数值
奇异点有,pitch = ±90°无
冗余无(但奇异点处信息丢失)q 与 −q 同旋转(双重覆盖)
插值逐角插值会拐弯、会绕远slerp 沿大圆走最短弧
存储省 1 个数多 1 个数,换来无奇异
引擎里的角色编辑器显示 / 网络传输运行时存储 / 计算

多花一个 number,买的是「任何角度都不会突然塌掉」。对《微光岛》这种相机能抬头到接近垂直、又要平滑跟随的游戏来说,这个交易划算得没有悬念。

四元数的乘法、共轭、归一化和最短弧插值(slerp)是下一节 9.2 的内容。本节只需要记住动机:要一个不会在某个角度突然塌掉的旋转表示,就不能只用三个角度。

常见坑

  • 在死锁点附近做插值。pitch 逼近 ±90° 时,欧拉角插值会突然拐弯,动画抖动。旋转插值一律走四元数(9.2)。
  • 把三个角当成三个独立自由度。在死锁点它们不独立;任何「分别控制 yaw 和 roll」的假设在那里都失效。
  • 忘了主序约定。m[r * 3 + c] 与 m[c * 3 + r] 是转置关系,抄公式时主序对不上会得到看起来「差不多但总是转错方向」的结果(8.2 展开)。
  • 用 == 比较矩阵。浮点矩阵必须逐元素比差,6.12e-17 这种噪声会让直接相等判断失败——本节用 mat3Diff 就是为了这个。
  • 死锁点用 pitch === 90° 判断。cos(90°) 在浮点下不是精确的 0,得用 |cos(pitch)| < eps 的阈值判断。

死锁把「欧拉角不能用来存运行时旋转」这件事钉死了。9.2 实现四元数的全部运算,并把「最短弧插值」这个四元数独有的细节跑出来——尤其是 dot < 0 时那个必须取反的分支。

阅读导航:上一节:目录 · 下一节:9.2 四元数运算与最短弧插值 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「engine」更多文章

  1. 《游戏引擎实现原理》工程与实战 目录
  2. 《游戏引擎实现原理》9.3 选型与自研的判断标准
  3. 《游戏引擎实现原理》9.2 同一个特性在不同引擎里的实现路径