24 · 旋转组合:局部、世界与表示转换

返回系列目录 · 上一章 · 下一章

代码默认 import * as THREE from 'three';。本章先限制在纯旋转或不引入剪切的层级中。带非均匀缩放的复杂父子层级不能总用一个四元数精确描述世界姿态,相关边界见下一章。

右乘与左乘的空间含义

设 q 把物体局部方向变换到父空间。右乘一个增量 d:q'=q*d,先在局部输入侧旋转;左乘 q'=d*q,则在父空间输出侧追加旋转。当父空间就是世界空间时,后者才等同于世界旋转。

const quarterX = new THREE.Quaternion().setFromAxisAngle(
  new THREE.Vector3(1, 0, 0), Math.PI / 2
);
const quarterY = new THREE.Quaternion().setFromAxisAngle(
  new THREE.Vector3(0, 1, 0), Math.PI / 2
);
const localTurn = quarterY.clone().multiply(quarterX); // 先 X,后 Y
const parentTurn = quarterY.clone().premultiply(quarterX); // 先 Y,后 X
const front = new THREE.Vector3(0, 0, 1);
console.log(front.clone().applyQuaternion(localTurn).toArray()); // ≈ [0,-1,0]
console.log(front.clone().applyQuaternion(parentTurn).toArray()); // ≈ [1,0,0]

object.rotateOnAxis(axis,angle) 的轴在局部空间,rotateX/Y/Z() 是其便捷形式。rotateOnWorldAxis() 的实现直接左乘,官方前提是没有旋转过的父节点;存在旋转父节点时不要只凭方法名假定它替你转换了父空间。

世界朝向转为局部朝向

若父世界旋转为 qParent,子局部旋转为 qLocal,则:

qWorld=qParent qLocal,qLocal=qParent−1qWorld. qWorld=qParent\,qLocal, \qquad qLocal=qParent^{-1}qWorld.
const parentWorld = new THREE.Quaternion();
const desiredWorld = new THREE.Quaternion();
const modelForward = new THREE.Vector3(0, 0, 1);
const directionWorld = new THREE.Vector3(1, 0, 1).normalize();
desiredWorld.setFromUnitVectors(modelForward, directionWorld);

// object 为已有模型,父层级没有非均匀缩放引起的剪切。
if (object.parent) {
  object.parent.getWorldQuaternion(parentWorld);
  object.quaternion.copy(parentWorld).invert().multiply(desiredWorld);
} else {
  object.quaternion.copy(desiredWorld);
}

设父节点已经绕 Y 转 90°,希望子模型世界姿态为单位旋转,子局部就必须绕 Y 转 -90°抵消父旋转。这个检查能快速发现乘法顺序错误。

如果需要在世界空间追加增量 d,先把它变为父空间增量:

dParent=qParent−1 dWorld qParent,qLocal′=dParent qLocal. dParent=qParent^{-1}\,dWorld\,qParent, \qquad qLocal'=dParent\,qLocal.

这是同一旋转在不同基底下的表达,称为共轭变换。也可以先构造目标世界 quaternion,再用上一段转换回局部。

只指定前向还不能唯一决定姿态

看向目标只固定两个自由度,绕视线的滚转仍然自由。若模型局部 +Z 是前向,可用参考上方向构造正交基:

const forward = new THREE.Vector3(1, 0.2, 1).normalize();
const upHint = new THREE.Vector3(0, 1, 0);
const right = new THREE.Vector3().crossVectors(upHint, forward);
if (right.lengthSq() < 1e-10) {
  // 演示性的备用轴;连续动画可优先使用上一帧的 right 来减少跳变。
  upHint.set(1, 0, 0);
  right.crossVectors(upHint, forward);
}
right.normalize();
const up = new THREE.Vector3().crossVectors(forward, right).normalize();
const rotationMatrix = new THREE.Matrix4().makeBasis(right, up, forward);
const orientation = new THREE.Quaternion().setFromRotationMatrix(rotationMatrix);

当 forward 与 upHint 几乎平行,叉积很短,误差会被归一化放大;这个退化必须设计策略。参考 up 不是强制最终 up 完全等于它,而是在保持前向准确的前提下决定滚转。

普通 Object3D 的 lookAt() 对齐局部 +Z,相机和灯光按局部 -Z 看向目标。模型若导出时沿 -X 前进,还需要一个固定的轴校正旋转。不要到处交换符号,先写清模型前向轴与目标朝向约定。

表示转换的边界

Euler → Quaternion 使用 setFromEuler();Quaternion → Euler 使用 setFromQuaternion();Quaternion → Matrix4 使用 makeRotationFromQuaternion();纯旋转 Matrix4 → Quaternion 使用 setFromRotationMatrix()。

这些转换保存旋转,不保留历史路径、整圈数或 Euler 分量连续性。读取四元数再转换为 Euler 显示 GUI 时,角度跳到等价区间不代表物体突然转动。让 UI 连续显示累计转角需要单独维护控制状态。

实践:相机姿态与绕点公转不是一件事

给相机 quaternion 追加旋转只改变朝向,不改变相机位置。要围绕目标公转,还要旋转“相机位置减目标位置”的偏移,再加回目标:

const target = new THREE.Vector3(0, 1, 0);
const orbitRotation = new THREE.Quaternion().setFromAxisAngle(
  new THREE.Vector3(0, 1, 0), Math.PI / 12
);
// 假设 camera 无父节点,position 就是世界位置。
camera.position.sub(target).applyQuaternion(orbitRotation).add(target);
camera.lookAt(target);

世界空间偏移的旋转不应该混入平移;有父节点时先取得世界位置,计算新世界位置,再转换到父局部坐标。相机控制器若每帧也写入姿态,需要选择单一控制来源,避免两段逻辑互相覆盖。

练习

  1. 为带旋转父节点的物体实现真正的世界 Y 轴增量旋转,并与直接左乘比较。
  2. 检验基底构造中三个轴的长度、两两点积和行列式,判断是否为正交右手系。
  3. 将一个默认前向为 -X 的箭头对准目标,同时保持稳定的参考上方向。
  4. 设计相机环绕时穿过头顶的行为,说明是否限制俯仰、允许翻转或维持上一帧侧向。