Appearance
为什么需要四元数
你已经学会用欧拉角(Pitch、Yaw、Roll)表示 3D 旋转,也知道如何构建旋转矩阵。为什么还要学习四元数?
因为欧拉角有致命缺陷,而四元数能完美解决这些问题。
欧拉角的三大问题
问题1:万向节死锁(Gimbal Lock)
万向节死锁是欧拉角最严重的问题。
想象一个飞机的旋转:
- Pitch(俯仰):绕 X 轴旋转
- Yaw(偏航):绕 Y 轴旋转
- Roll(翻滚):绕 Z 轴旋转
按 XYZ 顺序应用旋转:
javascript
function applyEulerAngles(pitch, yaw, roll) {
const Rx = rotateX(pitch); // 绕 X 轴
const Ry = rotateY(yaw); // 绕 Y 轴
const Rz = rotateZ(roll); // 绕 Z 轴
return multiply(Rz, multiply(Ry, Rx)); // Z * Y * X
}万向节死锁发生的情况:
当 Pitch = 90° 时:
javascript
const pitch = Math.PI / 2; // 90度
const yaw = 0;
const roll = 0;
// 应用旋转后,Yaw 轴和 Roll 轴重合了!此时:
- Yaw 和 Roll 控制相同的旋转
- 失去一个自由度
- 无法表示某些方向
演示代码:
javascript
// 正常情况
const R1 = applyEulerAngles(0, Math.PI/4, 0);
// Yaw 45度:绕世界 Y 轴旋转 ✅
// 万向节死锁
const R2 = applyEulerAngles(Math.PI/2, Math.PI/4, 0);
// Pitch 90度后,Yaw 45度实际绕的是 Z 轴!❌
// 结果:Yaw 和 Roll 的效果相同,无法独立控制问题2:旋转插值不平滑
尝试在两个方向之间插值:
javascript
// 从朝向 (0°, 0°, 0°) 插值到 (0°, 360°, 0°)
function lerpEuler(from, to, t) {
return {
pitch: lerp(from.pitch, to.pitch, t),
yaw: lerp(from.yaw, to.yaw, t),
roll: lerp(from.roll, to.roll, t)
};
}
const from = { pitch: 0, yaw: 0, roll: 0 };
const to = { pitch: 0, yaw: 360 * Math.PI / 180, roll: 0 };
// 中间值:yaw = 180度
const mid = lerpEuler(from, to, 0.5);
console.log(mid); // { pitch: 0, yaw: π, roll: 0 }问题:
- 从 0° 到 360° 插值会绕一整圈
- 实际上两个方向是相同的(360° = 0°)
- 应该不旋转,但插值结果是旋转 180°
问题3:旋转组合复杂
欧拉角的旋转顺序很重要:
javascript
// 顺序1:先 Yaw 再 Pitch
const R1 = multiply(rotateX(pitch), rotateY(yaw));
// 顺序2:先 Pitch 再 Yaw
const R2 = multiply(rotateY(yaw), rotateX(pitch));
// R1 ≠ R2,结果完全不同!组合多个旋转:
javascript
// 相机先旋转 30°,再旋转 20°
const rotation1 = { pitch: 30, yaw: 0, roll: 0 };
const rotation2 = { pitch: 20, yaw: 0, roll: 0 };
// 错误:简单相加
const combined = {
pitch: rotation1.pitch + rotation2.pitch, // 50°
yaw: 0,
roll: 0
};
// ❌ 在某些旋转顺序下,结果不正确实际需要转换为矩阵相乘:
javascript
// 正确:转换为矩阵再组合
const M1 = eulerToMatrix(rotation1);
const M2 = eulerToMatrix(rotation2);
const combined = multiply(M2, M1);
// 再转换回欧拉角(可能遇到多解问题)
const result = matrixToEuler(combined);旋转矩阵的问题
旋转矩阵虽然没有万向节死锁,但有其他缺点:
| 缺点 | 说明 |
|---|---|
| 内存占用大 | 9个浮点数(3×3矩阵) |
| 插值困难 | 线性插值会破坏正交性 |
| 累积误差 | 多次运算后可能不再正交 |
| 理解困难 | 9个数字难以直观理解 |
矩阵插值问题
javascript
// 线性插值两个旋转矩阵
function lerpMatrix(M1, M2, t) {
const result = [];
for (let i = 0; i < 9; i++) {
result[i] = M1[i] * (1 - t) + M2[i] * t;
}
return result;
}
// 问题:结果不是旋转矩阵!
const R1 = rotateY(0);
const R2 = rotateY(Math.PI / 2);
const R_mid = lerpMatrix(R1, R2, 0.5);
// R_mid 的列向量长度不是 1,不满足正交性
// 需要重新正交化,复杂且性能低四元数的优势
四元数(Quaternion)是表示 3D 旋转的一种数学工具。
优势对比:
| 特性 | 欧拉角 | 旋转矩阵 | 四元数 |
|---|---|---|---|
| 内存占用 | 3个数 | 9个数 | 4个数 |
| 万向节死锁 | 有 ❌ | 无 ✅ | 无 ✅ |
| 插值平滑 | 困难 ❌ | 困难 ❌ | 简单 ✅ |
| 旋转组合 | 困难 ❌ | 简单 ✅ | 简单 ✅ |
| 数值稳定 | 一般 | 易累积误差 | 稳定 ✅ |
| 性能 | 中 | 低(9次乘法) | 高(4次乘法) |
四元数表示
四元数有4个分量:
$$ \mathbf{q} = (x, y, z, w) = (v, w) $$
其中:
- v = (x, y, z):向量部分(旋转轴)
- w:标量部分(旋转角度相关)
示例:
javascript
// 绕 Y 轴旋转 90度
const q = {
x: 0,
y: Math.sin(Math.PI / 4), // sin(45°)
z: 0,
w: Math.cos(Math.PI / 4) // cos(45°)
};
// 或简写为 (0, 0.707, 0, 0.707)四元数插值:Slerp
Slerp(球面线性插值)提供平滑的旋转插值:
javascript
function slerp(q1, q2, t) {
// 计算夹角
let dot = q1.x * q2.x + q1.y * q2.y + q1.z * q2.z + q1.w * q2.w;
// 确保最短路径
if (dot < 0) {
q2 = { x: -q2.x, y: -q2.y, z: -q2.z, w: -q2.w };
dot = -dot;
}
// 球面插值
const theta = Math.acos(dot);
const sinTheta = Math.sin(theta);
const w1 = Math.sin((1 - t) * theta) / sinTheta;
const w2 = Math.sin(t * theta) / sinTheta;
return {
x: w1 * q1.x + w2 * q2.x,
y: w1 * q1.y + w2 * q2.y,
z: w1 * q1.z + w2 * q2.z,
w: w1 * q1.w + w2 * q2.w
};
}
// 使用示例
const q1 = { x: 0, y: 0, z: 0, w: 1 }; // 无旋转
const q2 = { x: 0, y: 0.707, z: 0, w: 0.707 }; // 绕Y轴90度
const q_mid = slerp(q1, q2, 0.5); // 插值到45度
// 结果:平滑旋转,无跳跃四元数组合
四元数乘法组合旋转:
javascript
function multiplyQuaternion(q1, q2) {
return {
x: q1.w * q2.x + q1.x * q2.w + q1.y * q2.z - q1.z * q2.y,
y: q1.w * q2.y - q1.x * q2.z + q1.y * q2.w + q1.z * q2.x,
z: q1.w * q2.z + q1.x * q2.y - q1.y * q2.x + q1.z * q2.w,
w: q1.w * q2.w - q1.x * q2.x - q1.y * q2.y - q1.z * q2.z
};
}
// 先旋转 q1,再旋转 q2
const combined = multiplyQuaternion(q2, q1); // 注意顺序实际案例对比
案例1:相机平滑旋转
欧拉角方式:
javascript
class CameraEuler {
constructor() {
this.pitch = 0;
this.yaw = 0;
this.targetPitch = 0;
this.targetYaw = 0;
}
update(dt) {
// 线性插值
this.pitch += (this.targetPitch - this.pitch) * dt * 5;
this.yaw += (this.targetYaw - this.yaw) * dt * 5;
// 问题:可能产生万向节死锁
// 问题:yaw 从 350° 到 10° 会绕远路
}
lookAt(target) {
const direction = normalize(subtract(target, this.position));
// 转换为欧拉角
this.targetPitch = Math.asin(-direction.y);
this.targetYaw = Math.atan2(direction.x, direction.z);
// 处理 yaw 跨越 0°/360° 的情况
if (this.targetYaw - this.yaw > Math.PI) {
this.targetYaw -= 2 * Math.PI;
} else if (this.yaw - this.targetYaw > Math.PI) {
this.targetYaw += 2 * Math.PI;
}
}
}四元数方式:
javascript
class CameraQuaternion {
constructor() {
this.rotation = { x: 0, y: 0, z: 0, w: 1 }; // 单位四元数
this.targetRotation = { x: 0, y: 0, z: 0, w: 1 };
}
update(dt) {
// Slerp 插值
this.rotation = slerp(this.rotation, this.targetRotation, dt * 5);
// 自动选择最短路径,无需特殊处理 ✅
// 无万向节死锁 ✅
}
lookAt(target) {
const direction = normalize(subtract(target, this.position));
// 从方向向量构建四元数
this.targetRotation = lookRotation(direction, { x: 0, y: 1, z: 0 });
}
}案例2:角色动画混合
欧拉角方式:
javascript
// 混合两个动画的旋转
function blendAnimationsEuler(anim1, anim2, weight) {
return {
pitch: lerp(anim1.pitch, anim2.pitch, weight),
yaw: lerp(anim1.yaw, anim2.yaw, weight),
roll: lerp(anim1.roll, anim2.roll, weight)
};
}
// 问题:
// - 可能产生非自然的中间姿态
// - 角度跨越 0°/360° 时会跳跃
// - 万向节死锁导致某些姿态无法混合四元数方式:
javascript
// 混合两个动画的旋转
function blendAnimationsQuaternion(anim1, anim2, weight) {
return slerp(anim1.rotation, anim2.rotation, weight);
}
// 优势:
// - 始终产生最短路径的平滑过渡 ✅
// - 无跳跃 ✅
// - 无万向节死锁 ✅案例3:飞行模拟器
欧拉角的灾难:
javascript
// 飞机俯冲到 90度
aircraft.pitch = Math.PI / 2;
// 此时尝试偏航(左右转)
aircraft.yaw += deltaYaw;
// 问题:偏航实际变成了翻滚!❌
// 飞机无法正常转向四元数的解决:
javascript
// 飞机俯冲到 90度
aircraft.rotation = fromAxisAngle({ x: 1, y: 0, z: 0 }, Math.PI / 2);
// 应用偏航旋转
const yawRotation = fromAxisAngle({ x: 0, y: 1, z: 0 }, deltaYaw);
aircraft.rotation = multiplyQuaternion(yawRotation, aircraft.rotation);
// 结果:偏航始终绕世界 Y 轴,符合预期 ✅何时使用四元数
| 场景 | 推荐 |
|---|---|
| 用户输入(UI) | 欧拉角(直观) |
| 存储静态旋转 | 欧拉角(节省空间) |
| 旋转插值 | 四元数 ✅ |
| 旋转组合 | 四元数 ✅ |
| 角色动画 | 四元数 ✅ |
| 相机控制 | 四元数 ✅ |
| 物理模拟 | 四元数 ✅ |
| 最终渲染 | 旋转矩阵 |
典型工作流
混合使用欧拉角和四元数:
javascript
class Transform {
constructor() {
// 内部使用四元数存储
this.rotation = { x: 0, y: 0, z: 0, w: 1 };
}
// 输入:欧拉角(直观)
setRotationEuler(pitch, yaw, roll) {
this.rotation = eulerToQuaternion(pitch, yaw, roll);
}
// 输出:欧拉角(调试)
getRotationEuler() {
return quaternionToEuler(this.rotation);
}
// 旋转操作:四元数(高效)
rotateBy(axis, angle) {
const q = fromAxisAngle(axis, angle);
this.rotation = multiplyQuaternion(q, this.rotation);
}
// 插值:四元数(平滑)
lerpTo(target, t) {
this.rotation = slerp(this.rotation, target.rotation, t);
}
// 渲染:矩阵(GPU)
getMatrix() {
return quaternionToMatrix(this.rotation);
}
}常见疑问
Q:四元数难学吗?
A:四元数的数学原理复杂(4维空间、复数),但使用方法简单:
- 创建:
fromAxisAngle(axis, angle) - 组合:
multiply(q1, q2) - 插值:
slerp(q1, q2, t) - 转换:
toMatrix(q)
理解概念即可,无需深究数学推导。
Q:四元数有唯一性吗?
A:不唯一!q 和 -q 表示相同的旋转:
javascript
const q1 = { x: 0, y: 0.707, z: 0, w: 0.707 }; // 绕Y轴90度
const q2 = { x: 0, y: -0.707, z: 0, w: -0.707 }; // 相同旋转
// q1 和 q2 产生完全相同的旋转结果Slerp 会自动选择最短路径,无需担心。
Q:四元数能表示缩放吗?
A:不能。四元数只能表示旋转。
完整的变换需要结合:
- 位置:向量 (x, y, z)
- 旋转:四元数 (x, y, z, w)
- 缩放:向量 (sx, sy, sz)
javascript
class Transform {
constructor() {
this.position = { x: 0, y: 0, z: 0 };
this.rotation = { x: 0, y: 0, z: 0, w: 1 };
this.scale = { x: 1, y: 1, z: 1 };
}
getMatrix() {
const T = translationMatrix(this.position);
const R = quaternionToMatrix(this.rotation);
const S = scaleMatrix(this.scale);
return multiply(T, multiply(R, S)); // TRS顺序
}
}总结
为什么需要四元数:
| 问题 | 欧拉角 | 旋转矩阵 | 四元数 |
|---|---|---|---|
| 万向节死锁 | 有 ❌ | 无 | 无 ✅ |
| 插值平滑 | 困难 ❌ | 困难 ❌ | Slerp ✅ |
| 旋转组合 | 复杂 ❌ | 简单 | 简单 ✅ |
| 内存占用 | 3 | 9 | 4 |
| 数值稳定 | 一般 | 易误差 | 稳定 ✅ |
| 性能 | 中 | 低 | 高 ✅ |
关键要点:
- 欧拉角有万向节死锁
- 旋转矩阵插值困难
- 四元数是最佳旋转表示
- 实际项目中混合使用:输入欧拉角,内部四元数,输出矩阵
- 四元数的使用比理解数学原理更重要
接下来的章节将详细讲解四元数的具体使用方法。