🎯 什么是 Euler
欧拉角 (Euler Angles) 是一种用三个角度来描述 3D 空间中物体旋转的方法。
📐 基本概念
三个旋转轴
Y (上)
│
│
└────── X (右)
╱
╱
Z (前)
Euler 三个分量:
- X 轴旋转 (Pitch): 前后俯仰 (点头/抬头)
- Y 轴旋转 (Yaw): 左右转向 (摇头)
- Z 轴旋转 (Roll): 左右倾斜 (歪头)你的代码示例
const rotationEuler = euler().set(0, rotationAngle, 0);
// ↑ ↑ ↑
// X Y Z
// 不俯仰 左右转 不倾斜🔧 在 Three.js 中的实现
1️⃣ Three.js 的 Euler 类
// Three.js 源码 (简化版)
class Euler {
constructor(x = 0, y = 0, z = 0, order = 'XYZ') {
this._x = x; // 绕 X 轴的旋转角度 (弧度)
this._y = y; // 绕 Y 轴的旋转角度 (弧度)
this._z = z; // 绕 Z 轴的旋转角度 (弧度)
this._order = order; // 旋转顺序 (重要!)
}
set(x, y, z, order) {
this._x = x;
this._y = y;
this._z = z;
if (order !== undefined) this._order = order;
return this;
}
// 从四元数转换
setFromQuaternion(q, order) {
// 数学转换逻辑
}
// 转换为旋转矩阵
toMatrix4(matrix) {
// 矩阵计算
}
}2️⃣ @react-three/rapier 的 euler() 工具函数
// @react-three/rapier 源码 (简化版)
import { Euler as ThreeEuler } from 'three';
export function euler(x = 0, y = 0, z = 0, order = 'XYZ') {
return new ThreeEuler(x, y, z, order);
}
// 使用示例
const rot1 = euler(); // (0, 0, 0, 'XYZ')
const rot2 = euler().set(0, Math.PI, 0); // (0, 180°, 0, 'XYZ')🎨 旋转顺序 (Order) 的影响
为什么旋转顺序很重要?
// 不同顺序会产生不同结果!
const euler1 = new THREE.Euler(0.5, 0.5, 0, 'XYZ');
const euler2 = new THREE.Euler(0.5, 0.5, 0, 'ZYX');
// euler1 ≠ euler2 (即使角度相同)Six种旋转顺序
'XYZ' // 先绕 X,再绕 Y,最后绕 Z (默认)
'XZY' // 先绕 X,再绕 Z,最后绕 Y
'YXZ' // 先绕 Y,再绕 X,最后绕 Z
'YZX' // 先绕 Y,再绕 Z,最后绕 X
'ZXY' // 先绕 Z,再绕 X,最后绕 Y
'ZYX' // 先绕 Z,再绕 Y,最后绕 X可视化示例
顺序 'XYZ':
1. 先绕 X 轴旋转 45° (俯仰)
2. 再绕 Y 轴旋转 45° (转身)
3. 最后绕 Z 轴旋转 45° (倾斜)
顺序 'ZYX':
1. 先绕 Z 轴旋转 45° (倾斜)
2. 再绕 Y 轴旋转 45° (转身)
3. 最后绕 X 轴旋转 45° (俯仰)
结果: 完全不同的姿态!🔬 底层数学实现
欧拉角转旋转矩阵
// Three.js 内部实现 (简化版)
class Euler {
// 将欧拉角转换为 4x4 旋转矩阵
toMatrix4(matrix) {
const { _x: x, _y: y, _z: z, _order: order } = this;
// 计算三角函数值
const cos_x = Math.cos(x);
const sin_x = Math.sin(x);
const cos_y = Math.cos(y);
const sin_y = Math.sin(y);
const cos_z = Math.cos(z);
const sin_z = Math.sin(z);
// 根据旋转顺序构建矩阵
if (order === 'XYZ') {
// X 轴旋转矩阵
const Rx = [
[1, 0, 0, 0],
[0, cos_x, -sin_x, 0],
[0, sin_x, cos_x, 0],
[0, 0, 0, 1]
];
// Y 轴旋转矩阵
const Ry = [
[ cos_y, 0, sin_y, 0],
[ 0, 1, 0, 0],
[-sin_y, 0, cos_y, 0],
[ 0, 0, 0, 1]
];
// Z 轴旋转矩阵
const Rz = [
[cos_z, -sin_z, 0, 0],
[sin_z, cos_z, 0, 0],
[ 0, 0, 1, 0],
[ 0, 0, 0, 1]
];
// 最终矩阵 = Rz × Ry × Rx
matrix = Rz * Ry * Rx;
}
return matrix;
}
}🎯 在你的代码中的使用
function rotation() {
// 1️⃣ 计算目标角度
const rotationAngle = Math.atan2(velocity.x, velocity.z);
// 例: velocity = (1, 0, 0) → rotationAngle = Math.PI / 2 (90°)
// 2️⃣ 创建欧拉角对象
const rotationEuler = euler().set(0, rotationAngle, 0);
// rotationEuler = Euler {
// _x: 0,
// _y: 1.5707963267948966 (90° in radians),
// _z: 0,
// _order: 'XYZ'
// }
// 3️⃣ 转换为四元数
const rotationQuaternion = quat().setFromEuler(rotationEuler);
// 四元数更适合插值和物理引擎
}⚠️ 欧拉角的问题 (为什么要转四元数)
1️⃣ 万向锁 (Gimbal Lock)
// 当某个轴旋转 90° 时,会失去一个自由度
const euler1 = new THREE.Euler(0, Math.PI / 2, 0);
// 此时绕 X 和 Z 轴旋转会产生相同效果!可视化:
正常状态:
Y
│
└─── X
╱
Z
X 轴旋转 90° 后:
X (原来的 Y)
│
└─── Y (原来的 -Z)
╱
Z
现在 Y 和 Z 轴重叠,失去了一个旋转维度!2️⃣ 插值不平滑
// ❌ 欧拉角线性插值
function lerpEuler(start, end, t) {
return {
x: start.x + (end.x - start.x) * t,
y: start.y + (end.y - start.y) * t,
z: start.z + (end.z - start.z) * t,
};
}
// 问题: 可能会走"弯路"
// 从 (0, 0, 0) 到 (0, 180°, 0)
// 中间会经过 (0, 90°, 0) - 正确
// 但如果是 (0, 0, 0) 到 (0, -10°, 0)
// 线性插值会经过 (0, -5°, 0) 而不是直接转 10°3️⃣ 旋转顺序依赖
// 同样的角度,不同顺序,结果不同
const rot1 = new THREE.Euler(30, 45, 60, 'XYZ');
const rot2 = new THREE.Euler(30, 45, 60, 'ZYX');
// rot1 和 rot2 的最终姿态完全不同!✅ 四元数的优势
// ✅ 四元数球面线性插值 (Slerp)
const startQuat = new THREE.Quaternion().setFromEuler(eulerStart);
const endQuat = new THREE.Quaternion().setFromEuler(eulerEnd);
startQuat.slerp(endQuat, 0.5); // 平滑插值
// 优势:
// 1. 没有万向锁
// 2. 插值平滑 (走最短路径)
// 3. 无旋转顺序问题
// 4. 适合物理引擎📊 对比表格
💡 最佳实践
// ✅ 推荐流程
// 1. 用欧拉角计算 (直观)
const targetEuler = euler().set(0, angle, 0);
// 2. 转为四元数 (稳定)
const targetQuat = quat().setFromEuler(targetEuler);
// 3. 插值 (平滑)
currentQuat.slerp(targetQuat, 0.2);
// 4. 应用到对象
player.setRotation(currentQuat, true);🎓 总结
Euler 欧拉角:
✅ 人类友好的旋转表示方式
✅ 适合初始设置和配置
❌ 有万向锁问题
❌ 插值不平滑
在 Three.js 中的实现:
通过
THREE.Euler类封装内部转换为旋转矩阵
通常配合四元数使用
你的代码中的使用:
euler().set(0, angle, 0) // 计算目标旋转
↓
quat().setFromEuler() // 转为四元数
↓
slerp(target, 0.2) // 平滑插值
↓
setRotation() // 应用到刚体这就是为什么要先用 Euler 计算,再转四元数应用的原因! 🎯
/**
* 🎯 人物旋转函数
* 功能: 让角色模型平滑地朝向移动方向旋转
* 应用场景: 玩家移动时,模型面朝移动方向(如前进时面朝前,后退时转身等)
*/
function rotation() {
// 🛡️ 安全检查: 确保玩家刚体引用存在
if (!player.current) return;
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// 📐 步骤 1: 计算目标旋转角度
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
/**
* Math.atan2(y, x): 计算点 (x, y) 相对于原点的角度(弧度)
*
* 为什么用 atan2(velocity.x, velocity.z)?
* - Three.js 坐标系: X(左右), Y(上下), Z(前后)
* - 我们要在 XZ 平面上旋转(水平面)
* - atan2 参数顺序: atan2(对边, 邻边)
*
* 示例:
* - velocity = (0, 0, -1) [向前] → atan2(0, -1) = 0° (面朝 -Z 轴)
* - velocity = (1, 0, 0) [向右] → atan2(1, 0) = 90° (面朝 +X 轴)
* - velocity = (0, 0, 1) [向后] → atan2(0, 1) = 180° (面朝 +Z 轴)
* - velocity = (-1, 0, 0) [向左] → atan2(-1, 0) = -90° (面朝 -X 轴)
*/
const rotationAngle = Math.atan2(velocity.x, velocity.z);
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// 📐 步骤 2: 转换为欧拉角
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
/**
* euler(): 创建一个欧拉角对象
* .set(x, y, z): 设置绕 X、Y、Z 轴的旋转角度
*
* 参数:
* - x: 0 (不绕 X 轴旋转,即不前后俯仰)
* - y: rotationAngle (绕 Y 轴旋转,实现左右转向)
* - z: 0 (不绕 Z 轴旋转,即不左右倾斜)
*
* 为什么只旋转 Y 轴?
* - 角色站立时,Y 轴垂直于地面
* - 绕 Y 轴旋转 = 水平面上转身
* - 就像人站着左右转头/转身
*/
const rotationEuler = euler().set(0, rotationAngle, 0);
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// 📐 步骤 3: 转换为四元数
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
/**
* 为什么需要四元数?
*
* 欧拉角的问题:
* - ❌ 存在万向锁(Gimbal Lock)
* - ❌ 插值不平滑
* - ❌ 旋转顺序影响结果
*
* 四元数的优势:
* - ✅ 避免万向锁
* - ✅ 插值平滑(Slerp 球面线性插值)
* - ✅ 适合物理引擎
*
* quat(): 创建四元数对象
* .setFromEuler(): 从欧拉角转换为四元数
*/
const rotationQuaternion = quat().setFromEuler(rotationEuler);
/**
* 获取当前角色的旋转(四元数格式)
* player.current.rotation(): 返回刚体的当前旋转四元数
* quat().copy(): 复制四元数值到新对象
*/
const startQuaternion = quat().copy(player.current.rotation());
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// 📐 步骤 4: 球面线性插值(Slerp)
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
/**
* Slerp (Spherical Linear Interpolation): 球面线性插值
*
* 作用: 在两个旋转之间平滑过渡
*
* 参数:
* - rotationQuaternion: 目标旋转(要转向的角度)
* - 0.2: 插值系数(lerp factor)
*
* 插值系数说明:
* - 0.2 表示每帧移动 20% 的距离
* - 假设 60fps,完全转到目标约需: 1/0.2/60 ≈ 0.083 秒
* - 值越大 → 旋转越快,但可能不够平滑
* - 值越小 → 旋转越慢,但更平滑
*
* 可视化理解:
* 当前角度: 0°
* 目标角度: 90°
*
* 第 1 帧: 0° + (90° - 0°) × 0.2 = 18°
* 第 2 帧: 18° + (90° - 18°) × 0.2 = 32.4°
* 第 3 帧: 32.4° + (90° - 32.4°) × 0.2 = 43.92°
* ...
* 最终逐渐接近 90°
*
* 为什么不是 0.2 秒?
* - 0.2 是每帧的插值比例,不是时间
* - 每帧都会重新计算,形成平滑动画
*/
startQuaternion.slerp(rotationQuaternion, 0.2);
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// 📐 步骤 5: 应用到刚体
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
/**
* setRotation(): 设置刚体的旋转
*
* 参数:
* - startQuaternion: 插值后的四元数
* - true: 立即唤醒刚体(如果处于休眠状态)
*
* 为什么需要 true?
* - Rapier 物理引擎会让静止的刚体"休眠"以节省性能
* - 设置旋转时需要唤醒,否则不会生效
*/
player.current.setRotation(startQuaternion, true);
}