🎯 核心概念
四元数是一种用 4 个数字表示 3D 旋转的数学工具。
// 四元数的结构
quaternion = {
x: 0.0, // 虚部 i
y: 0.707, // 虚部 j
z: 0.0, // 虚部 k
w: 0.707 // 实部
}
// 表示: 绕 Y 轴旋转 90°📐 为什么需要 4 个数字?
数学表示
四元数 q = w + xi + yj + zk
其中:
- w, x, y, z 是实数
- i, j, k 是虚数单位
满足:
i² = j² = k² = ijk = -1
ij = k, jk = i, ki = j
ji = -k, kj = -i, ik = -j旋转的几何意义
// 绕任意轴 axis(ax, ay, az) 旋转角度 θ
const halfAngle = θ / 2;
const s = Math.sin(halfAngle);
quaternion = {
x: ax * s,
y: ay * s,
z: az * s,
w: Math.cos(halfAngle)
}关键点: 使用 θ/2 而不是 θ,这是四元数的数学特性!
🎨 可视化理解
1️⃣ 欧拉角 vs 四元数
欧拉角 (直观但有问题):
┌─────────────────┐
│ Pitch (俯仰) │ = 30°
│ Yaw (偏航) │ = 45°
│ Roll (翻滚) │ = 60°
└─────────────────┘
问题: 旋转顺序影响结果
四元数 (抽象但稳定):
┌─────────────────┐
│ x = 0.189 │
│ y = 0.462 │
│ z = 0.191 │
│ w = 0.844 │
└─────────────────┘
优势: 唯一表示,无顺序问题2️⃣ 单位四元数与旋转
// 单位四元数 (用于表示旋转)
x² + y² + z² + w² = 1
// 示例: 绕 Y 轴旋转 90°
const angle = Math.PI / 2; // 90°
const halfAngle = angle / 2; // 45°
quaternion = {
x: 0 * Math.sin(halfAngle), // = 0
y: 1 * Math.sin(halfAngle), // = 0.707
z: 0 * Math.sin(halfAngle), // = 0
w: Math.cos(halfAngle) // = 0.707
}
// 验证: 0² + 0.707² + 0² + 0.707² = 1 ✅🔧 实际代码示例
从零开始理解
import { Quaternion, Euler } from 'three';
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// 示例 1: 创建四元数 (绕 Y 轴旋转 90°)
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// 方法 A: 从欧拉角转换 (推荐)
const euler = new Euler(0, Math.PI / 2, 0, 'XYZ');
const quat1 = new Quaternion().setFromEuler(euler);
console.log(quat1);
// { x: 0, y: 0.707, z: 0, w: 0.707 }
// 方法 B: 从轴角转换
const axis = { x: 0, y: 1, z: 0 }; // Y 轴
const angle = Math.PI / 2; // 90°
const quat2 = new Quaternion().setFromAxisAngle(axis, angle);
console.log(quat2);
// { x: 0, y: 0.707, z: 0, w: 0.707 }
// 方法 C: 直接设置 (不推荐,难以理解)
const quat3 = new Quaternion(0, 0.707, 0, 0.707);在代码中的应用
function rotation() {
// 🎯 目标: 让角色转向移动方向
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// 步骤 1: 计算目标角度 (用欧拉角思维)
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// 假设 velocity = (1, 0, 0) [向右移动]
const rotationAngle = Math.atan2(velocity.x, velocity.z);
// rotationAngle = Math.atan2(1, 0) = Math.PI / 2 (90°)
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// 步骤 2: 转为欧拉角对象
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
const rotationEuler = euler().set(0, rotationAngle, 0);
// rotationEuler = Euler { x: 0, y: 1.57, z: 0 }
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// 步骤 3: 转为四元数 (物理引擎需要)
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
/**
* 内部计算过程:
* halfAngle = 1.57 / 2 = 0.785 (45°)
*
* x = 0 * sin(0.785) = 0
* y = 1 * sin(0.785) = 0.707
* z = 0 * sin(0.785) = 0
* w = cos(0.785) = 0.707
*/
const rotationQuaternion = quat().setFromEuler(rotationEuler);
// rotationQuaternion = { x: 0, y: 0.707, z: 0, w: 0.707 }
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// 步骤 4: 获取当前旋转
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// 假设当前角色面朝前方 (0°)
const startQuaternion = quat().copy(player.current.rotation());
// startQuaternion = { x: 0, y: 0, z: 0, w: 1 }
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// 步骤 5: 球面线性插值 (关键!)
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
/**
* Slerp 的数学原理:
*
* result = (sin((1-t)θ) / sin(θ)) * start + (sin(tθ) / sin(θ)) * end
*
* 其中:
* - θ 是两个四元数之间的夹角
* - t 是插值系数 (0.2)
*
* 几何意义:
* - 在 4D 空间的单位球面上进行插值
* - 沿着球面的最短弧线移动
* - 保证旋转速度恒定
*/
startQuaternion.slerp(rotationQuaternion, 0.2);
/**
* 假设第一帧执行后:
* startQuaternion ≈ { x: 0, y: 0.141, z: 0, w: 0.990 }
*
* 对应角度 ≈ 16.2° (已旋转了 20% 的 90°)
*
* 第二帧:
* startQuaternion ≈ { x: 0, y: 0.270, z: 0, w: 0.963 }
* 对应角度 ≈ 31.3°
*
* ...逐渐接近 90°
*/
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// 步骤 6: 应用到刚体
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
player.current.setRotation(startQuaternion, true);
}🧮 四元数运算详解
1️⃣ Slerp (球面线性插值)
// 源码简化版
class Quaternion {
slerp(target, t) {
// 1. 计算夹角余弦值
let cosHalfTheta = this.w * target.w
+ this.x * target.x
+ this.y * target.y
+ this.z * target.z;
// 2. 如果两个四元数几乎相同,直接复制
if (Math.abs(cosHalfTheta) >= 1.0) {
return this;
}
// 3. 计算夹角
const halfTheta = Math.acos(cosHalfTheta);
const sinHalfTheta = Math.sqrt(1.0 - cosHalfTheta * cosHalfTheta);
// 4. 如果夹角太小,用线性插值
if (Math.abs(sinHalfTheta) < 0.001) {
this.w = this.w * 0.5 + target.w * 0.5;
this.x = this.x * 0.5 + target.x * 0.5;
this.y = this.y * 0.5 + target.y * 0.5;
this.z = this.z * 0.5 + target.z * 0.5;
return this;
}
// 5. 球面插值
const ratioA = Math.sin((1 - t) * halfTheta) / sinHalfTheta;
const ratioB = Math.sin(t * halfTheta) / sinHalfTheta;
this.w = this.w * ratioA + target.w * ratioB;
this.x = this.x * ratioA + target.x * ratioB;
this.y = this.y * ratioA + target.y * ratioB;
this.z = this.z * ratioA + target.z * ratioB;
return this;
}
}2️⃣ 可视化 Slerp
在 4D 单位球面上:
起点 A: (0, 0, 0, 1) → 0°
终点 B: (0, 0.707, 0, 0.707) → 90°
Slerp 路径 (t 从 0 到 1):
B (90°)
╱
╱ ← 球面上的弧线
╱
A (0°)
线性插值 Lerp (错误):
B
╱
╱ ← 穿过球内部的直线 (会改变速度)
╱
A
Slerp 优势:
✅ 始终在球面上
✅ 旋转速度恒定
✅ 走最短路径🎓 常见场景示例
场景 1: 角色转向目标
// 当前方向
const currentQuat = player.rotation;
// 目标位置
const target = new THREE.Vector3(10, 0, 5);
const direction = target.sub(player.position).normalize();
// 计算目标旋转
const targetAngle = Math.atan2(direction.x, direction.z);
const targetQuat = new THREE.Quaternion().setFromEuler(
new THREE.Euler(0, targetAngle, 0)
);
// 平滑插值
currentQuat.slerp(targetQuat, 0.1);
player.setRotation(currentQuat);场景 2: 相机跟随
useFrame(() => {
// 计算理想相机位置
const idealOffset = new THREE.Vector3(0, 5, 10);
idealOffset.applyQuaternion(player.quaternion);
const idealPosition = player.position.clone().add(idealOffset);
// 计算理想相机朝向
const idealLookAt = player.position.clone();
idealLookAt.y += 1.5; // 看向角色头部
const direction = idealLookAt.sub(idealPosition).normalize();
const targetQuat = new THREE.Quaternion().setFromUnitVectors(
new THREE.Vector3(0, 0, -1),
direction
);
// 平滑插值
camera.position.lerp(idealPosition, 0.1);
camera.quaternion.slerp(targetQuat, 0.1);
});场景 3: 多段旋转动画
const keyframes = [
{ time: 0, quat: new THREE.Quaternion(0, 0, 0, 1) }, // 0°
{ time: 1, quat: new THREE.Quaternion(0, 0.707, 0, 0.707) }, // 90°
{ time: 2, quat: new THREE.Quaternion(0, 1, 0, 0) }, // 180°
];
function animate(currentTime) {
// 找到当前时间段
let i = 0;
while (i < keyframes.length - 1 && currentTime > keyframes[i + 1].time) {
i++;
}
const start = keyframes[i];
const end = keyframes[i + 1];
// 计算插值系数
const t = (currentTime - start.time) / (end.time - start.time);
// Slerp 插值
const currentQuat = start.quat.clone().slerp(end.quat, t);
player.setRotation(currentQuat);
}🐛 常见误区
❌ 误区 1: 直接操作四元数分量
// ❌ 错误: 手动修改分量会破坏单位性
quaternion.y += 0.1;
// ✅ 正确: 使用方法
const euler = new Euler(0, 0.1, 0);
quaternion.setFromEuler(euler);❌ 误区 2: 用 Lerp 代替 Slerp
// ❌ 错误: 线性插值会改变旋转速度
quaternion.x = lerp(start.x, end.x, t);
quaternion.y = lerp(start.y, end.y, t);
quaternion.z = lerp(start.z, end.z, t);
quaternion.w = lerp(start.w, end.w, t);
// ✅ 正确: 使用 Slerp
quaternion.slerp(targetQuat, t);❌ 误区 3: 忘记归一化
// ❌ 四元数运算后可能失去单位性
const combined = quat1.multiply(quat2);
// ✅ 需要归一化
combined.normalize();📊 性能对比
// 测试: 1000 次旋转插值
// 欧拉角 Lerp
console.time('Euler Lerp');
for (let i = 0; i < 1000; i++) {
eulerStart.x = lerp(eulerStart.x, eulerEnd.x, 0.1);
eulerStart.y = lerp(eulerStart.y, eulerEnd.y, 0.1);
eulerStart.z = lerp(eulerStart.z, eulerEnd.z, 0.1);
}
console.timeEnd('Euler Lerp');
// ~0.5ms
// 四元数 Slerp
console.time('Quaternion Slerp');
for (let i = 0; i < 1000; i++) {
quatStart.slerp(quatEnd, 0.1);
}
console.timeEnd('Quaternion Slerp');
// ~1.2ms
// 结论: 欧拉角更快,但四元数更稳定准确💡 总结
四元数的本质
4D 空间的单位向量: 表示 3D 旋转
数学工具: 避免万向锁,平滑插值
物理引擎标准: Rapier/Cannon/Ammo 都使用四元数
使用建议
// ✅ 推荐流程
计算目标角度 (欧拉角)
↓
转为四元数
↓
Slerp 插值
↓
应用到对象关键公式
绕轴 (ax, ay, az) 旋转 θ:
q.x = ax * sin(θ/2)
q.y = ay * sin(θ/2)
q.z = az * sin(θ/2)
q.w = cos(θ/2)
单位性: x² + y² + z² + w² = 1四元数不需要完全理解数学,只需知道它是更好的旋转表示方式! 🚀