Skip to content

为什么需要四元数

你已经学会用欧拉角(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 ✅
旋转组合复杂 ❌简单简单 ✅
内存占用394
数值稳定一般易误差稳定 ✅
性能高 ✅

关键要点:

  • 欧拉角有万向节死锁
  • 旋转矩阵插值困难
  • 四元数是最佳旋转表示
  • 实际项目中混合使用:输入欧拉角,内部四元数,输出矩阵
  • 四元数的使用比理解数学原理更重要

接下来的章节将详细讲解四元数的具体使用方法。

为什么需要四元数 has loaded