9.2 四元数运算与最短弧插值
9.1 把欧拉角的问题钉死了:三个角度覆盖三维旋转,必然有奇异点。四元数用四个数加一条单位长度约束绕开它。但四元数不是「摆在那里就没事」——它的乘法顺序、共轭、归一化各有讲究,插值更有一个反直觉的分支:当两个四元数的点积为负时,必须先取反其中一个,否则旋转会绕远路。
这一节实现四元数的完整运算,并把这个分支真跑出来:同一对起点终点,不处理 dot < 0 时插值在中点转了 175°,处理之后只转 5°,两条轨迹相差整整 180°。
四元数是什么:一个标量部,一个向量部
四元数写成 q = (x, y, z, w),(x, y, z) 是向量部,w 是标量部。描述「绕单位轴 a 旋转角度 θ」时:
q = ( a·sin(θ/2), cos(θ/2) )
注意是半角。这个半角是四元数所有反直觉行为的来源:转 360° 对应的四元数是 (0, 0, 0, −1),不是单位四元数——q 和 −q 表示同一个旋转,要转满 720° 才回到 q 本身。这就是「双重覆盖」。
| 性质 | 含义 |
|---|---|
| 单位长度 | x² + y² + z² + w² = 1,旋转四元数必须归一化 |
| 双重覆盖 | q 与 −q 表示同一个旋转 |
| 逆 | 单位四元数的逆就是它的共轭 q* |
| 复合 | 乘法 a·b 表示「先 b 后 a」的旋转 |
乘法、共轭与归一化
四元数乘法(Hamilton 积)不是逐分量相乘,而是一串交叉项;共轭把向量部取反、标量部保留;归一化保证长度回到 1。用单位四元数旋转向量,走的是 v' = q · v · q*(把 v 当成标量部为 0 的四元数)。
这里有一个工程上的取舍要提前说清:引擎里每一帧都在乘四元数,舍入误差会让长度缓慢偏离 1,所以「存储时归一化一次」不够,关键路径上(插值之后、上传 GPU 之前)要重新归一化。这一点和 8.1 里「向量运算零分配」的约定一样,是运行时纪律,不是数学问题。
可运行产物:quaternion.ts
下面是完整可编译的 src/core/quaternion.ts。层级 L1,Node 26 直跑。
// file: src/core/quaternion.ts
// tier: L1
// verify: npx vitest run src/core/quaternion.test.ts
//
// helio 内核:单位四元数(x, y, z, w)的乘法、共轭、归一化与 slerp 最短弧插值。
// 约定 q = (x, y, z, w),w 是标量部;q 与 -q 表示同一个旋转。
export type Quat = { x: number; y: number; z: number; w: number };
export type Vec3 = { x: number; y: number; z: number };
export const DEG2RAD = Math.PI / 180;
export const RAD2DEG = 180 / Math.PI;
/** 单位四元数(无旋转)。 */
export const QUAT_IDENTITY: Quat = { x: 0, y: 0, z: 0, w: 1 };
/** 绕单位轴 axis 旋转 angle(弧度)。 */
export function fromAxisAngle(axis: Vec3, angle: number): Quat {
const h = angle * 0.5;
const s = Math.sin(h);
return { x: axis.x * s, y: axis.y * s, z: axis.z * s, w: Math.cos(h) };
}
export function quatDot(a: Quat, b: Quat): number {
return a.x * b.x + a.y * b.y + a.z * b.z + a.w * b.w;
}
export function quatNegate(q: Quat): Quat {
return { x: -q.x, y: -q.y, z: -q.z, w: -q.w };
}
/** Hamilton 乘积:先应用 b 再应用 a 的复合旋转。 */
export function quatMul(a: Quat, b: Quat): Quat {
return {
x: a.w * b.x + a.x * b.w + a.y * b.z - a.z * b.y,
y: a.w * b.y - a.x * b.z + a.y * b.w + a.z * b.x,
z: a.w * b.z + a.x * b.y - a.y * b.x + a.z * b.w,
w: a.w * b.w - a.x * b.x - a.y * b.y - a.z * b.z,
};
}
/** 共轭:单位四元数的共轭即逆旋转。 */
export function quatConjugate(q: Quat): Quat {
return { x: -q.x, y: -q.y, z: -q.z, w: q.w };
}
export function quatLength(q: Quat): number {
return Math.sqrt(quatDot(q, q));
}
export function quatNormalize(q: Quat): Quat {
const len = quatLength(q);
if (len === 0) return QUAT_IDENTITY;
const inv = 1 / len;
return { x: q.x * inv, y: q.y * inv, z: q.z * inv, w: q.w * inv };
}
/** 用单位四元数旋转向量:v' = q v q*。 */
export function rotateVec3(q: Quat, v: Vec3): Vec3 {
const { x, y, z, w } = q;
const tx = 2 * (y * v.z - z * v.y);
const ty = 2 * (z * v.x - x * v.z);
const tz = 2 * (x * v.y - y * v.x);
return {
x: v.x + w * tx + (y * tz - z * ty),
y: v.y + w * ty + (z * tx - x * tz),
z: v.z + w * tz + (x * ty - y * tx),
};
}
/**
* 球面线性插值。shortest = true 时先处理 dot < 0:
* 把 b 取反成 -b(同一个旋转的另一半),使插值走「最短弧」而不是绕远路。
*/
export function quatSlerp(a: Quat, b: Quat, t: number, shortest = true): Quat {
let cosom = quatDot(a, b);
let target = b;
if (shortest && cosom < 0) {
cosom = -cosom;
target = quatNegate(b);
}
if (cosom > 0.9995) {
// 两旋转几乎相同:线性插值再归一化,避免 sin(theta) ≈ 0 时的除零
return quatNormalize({
x: a.x + (target.x - a.x) * t,
y: a.y + (target.y - a.y) * t,
z: a.z + (target.z - a.z) * t,
w: a.w + (target.w - a.w) * t,
});
}
const theta = Math.acos(cosom);
const sinTheta = Math.sin(theta);
const wa = Math.sin((1 - t) * theta) / sinTheta;
const wb = Math.sin(t * theta) / sinTheta;
return {
x: wa * a.x + wb * target.x,
y: wa * a.y + wb * target.y,
z: wa * a.z + wb * target.z,
w: wa * a.w + wb * target.w,
};
}
/** 该四元数代表的旋转角(弧度,恒取非负的较小值)。 */
export function rotationAngle(q: Quat): number {
const w = Math.min(1, Math.abs(q.w));
return 2 * Math.acos(w);
}
/** 两个旋转之间的夹角(弧度):2·acos(|dot|)。 */
export function angleBetween(a: Quat, b: Quat): number {
const d = Math.min(1, Math.abs(quatDot(a, b)));
return 2 * Math.acos(d);
}
slerp 里三处值得逐字看:if (shortest && cosom < 0) 是整段的核心分支;cosom > 0.9995 是「两旋转几乎相同」的退化保护,此时 sin(theta) ≈ 0 会让除法炸掉,改用线性插值再归一化;rotationAngle 和 angleBetween 里对 w 取 Math.abs、对点积取绝对值,都是因为双重覆盖——q 和 −q 是同一个旋转,比角度时必须先折叠掉符号。
最短弧:dot < 0 时那个必须取反的分支
为什么 cosom < 0 要取反?cosom = dot(a, b) 是四元数在四维空间里的夹角余弦。cosom < 0 说明两者在四维球面上的夹角大于 90°——但它们在三维旋转里可能只差一个小角度,因为 −b 也是同一个旋转。slerp 如果老老实实沿四维球面插值,就会走那条大于 90° 的「远弧」;把 b 换成 −b(同一个旋转的另一半)之后,夹角余弦变正,插值才走最短的那条弧。
关键点:−b 表示的旋转和 b 完全一样,所以取反不会改变终点,只改变路径。这是「双重覆盖」从麻烦变成便利的地方——多出来的那个符号,正好用来挑一条更短的弧。
可运行产物:把远弧与最短弧都跑出来
断言路径差异,比看公式直观。下面是 src/core/quaternion.test.ts 的核心用例:起点是单位四元数(0°),终点是绕 Y 转 350°(等价于 −10°,故意造出 dot < 0)。
// file: src/core/quaternion.test.ts
// tier: L1
// verify: npx vitest run src/core/quaternion.test.ts
import { describe, expect, it } from 'vitest';
import {
DEG2RAD,
QUAT_IDENTITY,
RAD2DEG,
angleBetween,
fromAxisAngle,
quatConjugate,
quatDot,
quatMul,
quatNormalize,
quatSlerp,
rotateVec3,
rotationAngle,
} from './quaternion.ts';
const Y = { x: 0, y: 1, z: 0 };
const X = { x: 1, y: 0, z: 0 };
const deg = (x: number) => x * DEG2RAD;
const toDeg = (x: number) => x * RAD2DEG;
const close = (a: number, b: number, eps = 1e-9) => Math.abs(a - b) < eps;
describe('四元数基本运算', () => {
it('单位四元数旋转向量不变', () => {
const v = rotateVec3(QUAT_IDENTITY, { x: 1, y: 2, z: 3 });
expect(close(v.x, 1) && close(v.y, 2) && close(v.z, 3)).toBe(true);
});
it('绕 Y 转 90° 把 +X 转到 -Z', () => {
const q = fromAxisAngle(Y, deg(90));
const v = rotateVec3(q, { x: 1, y: 0, z: 0 });
expect(close(v.x, 0) && close(v.z, -1)).toBe(true);
});
it('乘法复合:绕 Y 转 90° 两次等于 180°', () => {
const q90 = fromAxisAngle(Y, deg(90));
const q180 = quatMul(q90, q90);
const v = rotateVec3(q180, { x: 1, y: 0, z: 0 });
expect(close(v.x, -1) && close(v.z, 0)).toBe(true);
});
it('共轭即逆旋转:q · q* = 单位', () => {
const q = fromAxisAngle(X, deg(37));
const p = quatMul(q, quatConjugate(q));
expect(close(p.w, 1) && close(p.x, 0) && close(p.y, 0) && close(p.z, 0)).toBe(true);
});
it('归一化后长度为 1', () => {
const q = quatNormalize({ x: 1, y: 2, z: 3, w: 4 });
expect(close(quatDot(q, q), 1)).toBe(true);
});
});
describe('slerp 最短弧:dot < 0 时必须取反', () => {
const q0 = QUAT_IDENTITY; // 0°
const q350 = fromAxisAngle(Y, deg(350)); // 350°(等价于 -10°)
it('q0 与 q350 的点积为负', () => {
expect(quatDot(q0, q350)).toBeLessThan(0);
});
it('不处理 dot < 0:t = 0.5 时走了 175° 的远弧', () => {
const far = quatSlerp(q0, q350, 0.5, false);
const angle = toDeg(rotationAngle(far));
expect(close(angle, 175, 1e-6)).toBe(true);
});
it('处理 dot < 0:t = 0.5 时走最短弧,只转 5°', () => {
const near = quatSlerp(q0, q350, 0.5, true);
const angle = toDeg(rotationAngle(near));
expect(close(angle, 5, 1e-6)).toBe(true);
});
it('两条路径在 t = 0.5 的结果相差 180°', () => {
const far = quatSlerp(q0, q350, 0.5, false);
const near = quatSlerp(q0, q350, 0.5, true);
expect(close(toDeg(angleBetween(far, near)), 180, 1e-6)).toBe(true);
});
it('最短弧在 t = 1 时精确到达目标旋转', () => {
const end = quatSlerp(q0, q350, 1, true);
expect(close(toDeg(angleBetween(end, q350)), 0, 1e-6)).toBe(true);
});
});
本机实测 npx vitest run src/core/quaternion.test.ts:10 个用例全部通过。
真实数字:175° 与 5°,以及 t 扫描的非单调
把中间量打印出来。起点 q0 = (0, 0, 0, 1),终点 q350 = (0, 0.08716, 0, −0.99619),dot(q0, q350) = −0.99619——确实为负,正是需要取反的场景。两条路径在 t = 0.5 的结果:
| 路径 | t = 0.5 的四元数 | 代表的旋转角 |
|---|---|---|
| 不处理 dot < 0(远弧) | (0, 0.99905, 0, 0.04362) | 175.000° |
| 处理 dot < 0(最短弧) | (0, −0.04362, 0, 0.99905) | 5.000° |
两条结果之间的夹角是 180°——angleBetween 算出来精确等于 180,dot 只有 −1.25e-16 的浮点噪声。同一个起终点、同一个 t,两条路径的朝向差了整整半圈。
更说明问题的是整条轨迹。沿 t 从 0 扫到 1:
| t | 远弧旋转角 | 最短弧旋转角 |
|---|---|---|
| 0 | 0.000° | 0.000° |
| 0.25 | 87.500° | 2.500° |
| 0.5 | 175.000° | 5.000° |
| 0.75 | 97.500° | 7.500° |
| 1 | 10.000° | 10.000° |
最短弧这一列是单调的:0 → 2.5 → 5 → 7.5 → 10,一路平稳转到终点。远弧这一列是非单调的:0 → 87.5 → 175 → 97.5 → 10,冲到 175° 之后又倒回来,最后才落到 10°。玩家看到的现象就是:角色朝向先飞速转过几乎一整圈,再反向转回来——这就是「旋转插值走了远路」在屏幕上的样子。根因只有一行:那个 if (shortest && cosom < 0) 分支没写。
常见坑
- 忘了处理
dot < 0。本节的核心坑,表现为插值绕远路、非单调。任何 slerp 实现都必须有这个分支。 dot接近 1 时直接除sin(theta)。theta ≈ 0会让sinTheta趋近 0,结果炸成NaN。用cosom > 0.9995的阈值退化成线性插值(本节的做法)。- 插值之后不重新归一化。连续乘法累积的舍入误差会让长度偏离 1,旋转被慢慢拉歪。关键路径上补一次
quatNormalize。 - 乘法顺序搞反。四元数乘法不可交换,
a·b是「先 b 后 a」。复合顺序错了,旋转结果看起来「差不多但方向不对」。 - 拿
q和−q当不同旋转。它们完全相同。比较、去重、算夹角之前都要先折叠符号(本节angleBetween里的Math.abs)。 - 用
w直接推旋转角。w可能为负(半角超过 90°),得用2·acos(|w|)。
四元数把「表示旋转」这件事做稳了,也把「插值旋转」做稳了。9.3 换个方向:不再插值旋转,而是插值位置与数值——贝塞尔、Catmull-Rom 样条与缓动函数,以及它们各自的过冲与单调性陷阱。
阅读导航:上一节:9.1 欧拉角、万向节死锁与四元数 · 下一节:9.3 曲线、样条与缓动 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。