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)
三个角各自管一根轴:
| 角 | 轴 | 直观含义 | 在《微光岛》里 |
|---|---|---|---|
| yaw | Y | 左右转头 | 角色面朝哪个方向 |
| pitch | X | 抬头低头 | 相机俯仰 |
| roll | Z | 侧倾 | 受击时的镜头晃动 |
矩阵用长度 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 个用例全部通过。把中间量打印出来是这样的:
| 输入 | yaw | pitch | roll | 输出矩阵(行主序,3 位小数) | 与 A 的最大差 |
|---|---|---|---|---|---|
| A | 90° | 90° | 0° | [0,-1,0, 0,0,1, -1,0,0] | 0 |
| B | 0° | 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 四元数运算与最短弧插值 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。