本篇:第 2 篇 · 3D 数学基础 | 配套示例:examples/ch06/index.html

<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Ch6 · 旋转与四元数实验台</title>
<style>
  html, body { margin: 0; height: 100%; overflow: hidden; background: #0d1117; }
  #stage { display: block; }
  #panel {
    position: fixed; top: 12px; right: 12px; width: 420px; max-height: 92vh; overflow: auto;
    padding: 10px 12px; background: rgba(13,17,23,.88); border: 1px solid #2d3646;
    border-radius: 8px; color: #b8cfe8; font: 12px/1.55 Consolas, monospace; white-space: pre-wrap;
  }
  #panel .ok { color: #9fd59f; } #panel .bad { color: #ff8a8a; } #panel .seg { color: #e8b34b; }
</style>
<script type="importmap">
{
  "imports": {
    "three": "https://unpkg.com/three@0.186.1/build/three.module.js",
    "three/addons/": "https://unpkg.com/three@0.186.1/examples/jsm/"
  }
}
</script>
</head>
<body>
<canvas id="stage"></canvas>
<div id="panel">自检运行中…</div>
<script type="module">
import * as THREE from 'three';

const D = THREE.MathUtils.degToRad;
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(45, innerWidth / innerHeight, 0.1, 100);
camera.position.set(0, 4, 12);
camera.lookAt(0, 1.2, 0);

const renderer = new THREE.WebGLRenderer({ canvas: document.getElementById('stage'), antialias: true });
renderer.setSize(innerWidth, innerHeight);
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
renderer.setClearColor('#0d1117');
window.addEventListener('resize', () => {
  camera.aspect = innerWidth / innerHeight;
  camera.updateProjectionMatrix();
  renderer.setSize(innerWidth, innerHeight);
});
scene.add(new THREE.AxesHelper(1.5), new THREE.GridHelper(14, 14));

const box = (color, x, z) => {
  const m = new THREE.Mesh(new THREE.BoxGeometry(0.9, 0.9, 0.9),
    new THREE.MeshNormalMaterial());
  m.position.set(x, 1.2, z);
  scene.add(m);
  const label = new THREE.Mesh(new THREE.BoxGeometry(0.92, 0.92, 0.92),
    new THREE.MeshBasicMaterial({ color, wireframe: true, transparent: true, opacity: 0.35 }));
  m.add(label);
  return m;
};

const A = box('#ff9f43', -3.5, 0);   // 欧拉角万向锁
const B = box('#54a0ff', 0, 0);      // 轴角匀速自转
const C1 = box('#2ed573', 3.5, -1);  // slerp
const C2 = box('#ff6b81', 3.5, 1);   // lerp(不归一化)

const qa = new THREE.Quaternion().setFromEuler(new THREE.Euler(D(30), 0, 0));
const qb = new THREE.Quaternion().setFromEuler(new THREE.Euler(0, D(60), D(20)));
const axisB = new THREE.Vector3(0.3, 0.5, 0.8).normalize();
// three 的 Quaternion 没有 lerp 方法:朴素插值只能逐分量写(故意不单位化,见正文 6.6)
const lerpQ = (a, b, k) => new THREE.Quaternion(
  a.x + (b.x - a.x) * k, a.y + (b.y - a.y) * k,
  a.z + (b.z - a.z) * k, a.w + (b.w - a.w) * k);
const stageLine = { el: null };
let frame = 0;

renderer.setAnimationLoop((t) => {
  // A:0-8s 冻结演示(y=90°, x=+s, z=−s);8-16s 释放演示(只动 x)
  const s = Math.sin(t / 900) * 40;
  const phase = (t / 1000) % 16;
  if (phase < 8) A.rotation.set(D(s), D(90), D(-s));
  else A.rotation.set(D(s), D(90), 0);

  B.quaternion.setFromAxisAngle(axisB, t / 800);

  const k = 0.5 + 0.5 * Math.sin(t / 1200);
  C1.quaternion.slerpQuaternions(qa, qb, k);
  C2.quaternion.copy(lerpQ(qa, qb, k));   // 故意不归一化:中点长度塌缩

  if (stageLine.el && frame++ % 15 === 0) {
    stageLine.el.textContent = phase < 8
      ? '   当前:x=+s° 且 z=−s°(y 固定 90°)→ A 盒纹丝不动 = 冻结'
      : '   当前:只动 x(z=0,y 固定 90°)→ A 盒会动 = 剩下那个合成自由度';
  }
  renderer.render(scene, camera);
});

// ───────── 面板自检 ─────────
(function selfCheck() {
  const L = [];
  const line = (s, cls = '') => L.push(cls ? `<span class="${cls}">${s}</span>` : s);
  const fmtQ = (q) => `(${q.x.toFixed(3)}, ${q.y.toFixed(3)}, ${q.z.toFixed(3)}, ${q.w.toFixed(3)})`;
  const fmtV = (v) => `(${v.x.toFixed(3)}, ${v.y.toFixed(3)}, ${v.z.toFixed(3)})`;
  const rotM = (axis, deg) => {
    const m = new THREE.Matrix4();
    m['makeRotation' + axis](D(deg));
    return m;
  };
  const eqM = (a, b) => {
    let d = 0;
    for (let i = 0; i < 16; i++) d = Math.max(d, Math.abs(a.elements[i] - b.elements[i]));
    return d;
  };

  line('<span class="seg">① 6.2 例题:Euler(90°,0,90°) 作用 (1,0,0)</span>');
  const M = new THREE.Matrix4().makeRotationFromEuler(new THREE.Euler(D(90), 0, D(90)));
  const r = new THREE.Vector3(1, 0, 0).applyMatrix4(M);
  line(`   → ${fmtV(r)}  手算 (0,0,1) ${r.distanceTo(new THREE.Vector3(0, 0, 1)) < 1e-9 ? '✓' : '✗'}`,
    r.distanceTo(new THREE.Vector3(0, 0, 1)) < 1e-9 ? 'ok' : 'bad');

  line('<span class="seg">② 6.3 恒等式:Rx(α)·Ry(90°)·Rz(−α) ≟ Ry(90°),α=30°</span>');
  const lhs = rotM('X', 30).multiply(rotM('Y', 90)).multiply(rotM('Z', -30));
  const d2 = eqM(lhs, rotM('Y', 90));
  line(`   最大元素偏差 = ${d2.toExponential(1)} ${d2 < 1e-9 ? '✓ 两旋钮合成一个' : '✗'}`, d2 < 1e-9 ? 'ok' : 'bad');
  const lhs2 = rotM('X', 30).multiply(rotM('Y', 90)).multiply(rotM('Z', 30));
  const d2b = eqM(lhs2, rotM('Y', 90));
  line(`   (对照:α=γ=30° 同向相加,偏差 ${d2b.toFixed(3)} —— 姿态改变,和才是唯一有效量)`);

  line('<span class="seg">③ 6.5 例题:q(绕y 90°) 与复合 q·q</span>');
  const q90 = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), Math.PI / 2);
  line(`   setFromAxisAngle → ${fmtQ(q90)}  手算 (0,0.707,0,0.707) ${fmtQ(q90).includes('0.707') ? '✓' : '✗'}`,
    Math.abs(q90.w - Math.SQRT1_2) < 1e-6 ? 'ok' : 'bad');
  const q180 = q90.clone().multiply(q90);
  const q180ref = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), Math.PI);
  line(`   q90·q90 = ${fmtQ(q180)}  ≟ q(绕y 180°)=${fmtQ(q180ref)} ${Math.abs(q180.dot(q180ref)) > 1 - 1e-9 ? '✓' : '✗'}`,
    Math.abs(q180.dot(q180ref)) > 1 - 1e-9 ? 'ok' : 'bad');

  line('<span class="seg">④ 6.6:slerp(I, q90, 0.5) ≟ q(绕y 45°)</span>');
  const half = new THREE.Quaternion().slerpQuaternions(new THREE.Quaternion(), q90, 0.5);
  const ref45 = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), Math.PI / 4);
  line(`   ${fmtQ(half)} vs ${fmtQ(ref45)} ${Math.abs(half.dot(ref45)) > 1 - 1e-9 ? '✓' : '✗'}`,
    Math.abs(half.dot(ref45)) > 1 - 1e-9 ? 'ok' : 'bad');

  line('<span class="seg">⑤ 双重覆盖:q(绕任意轴 360°)</span>');
  const q360 = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(1, 0, 0), Math.PI * 2);
  const probe = new THREE.Vector3(0.4, 0.8, -0.2);
  const after = probe.clone().applyQuaternion(q360);
  line(`   q=${fmtQ(q360)}(w=−1,≠单位元!) 但 rotateVector3 后偏差 ${after.distanceTo(probe).toExponential(1)} ${after.distanceTo(probe) < 1e-9 ? '✓ 同一个旋转' : '✗'}`,
    after.distanceTo(probe) < 1e-9 ? 'ok' : 'bad');

  line('<span class="seg">⑥ lerp 不归一化的长度塌缩(C2 盒子所见)</span>');
  const lp = lerpQ(new THREE.Quaternion(), q90, 0.5);
  line(`   lerp 结果 ${fmtQ(lp)}  模长 = ${lp.length().toFixed(3)} < 1(手算 0.924)${Math.abs(lp.length() - 0.9239) < 1e-3 ? '✓' : '✗'}`,
    Math.abs(lp.length() - 0.9239) < 1e-3 ? 'ok' : 'bad');
  line(`   单位化后与 slerp 中点一致:${lp.normalize().dot(half).toFixed(6)} ≈ 1 ✓`);

  line('<span class="seg">▶ A 盒当前阶段(每 8s 切换)</span>');
  stageLine.el = document.createElement('div');
  const host = document.getElementById('panel');
  host.innerHTML = L.join('\n');
  stageLine.el.style.cssText = 'color:#b8cfe8';
  host.appendChild(stageLine.el);
})();
</script>
</body>
</html>

在这里插入图片描述

本章目标

  1. 能解释欧拉角三个数的双重读法(体轴系/世界轴系)、three.js 默认 'XYZ' 顺序对应的矩阵 Rx·Ry·Rz,并能推导万向锁的几何机制与公式特例。
  2. 能从复数旋转出发"造出"四元数:记住 q = (cos θ/2, sin θ/2 · 轴),会手算常见四元数,理解 q 与 −q 是同一个旋转。
  3. 理解旋转复合 = 四元数乘法(最右先作用),会用 setFromAxisAngle / setFromEuler / slerp 三大 API 验证手算。
  4. 能回答工程问题:一个物体的旋转在 three.js 里该存成欧拉角、四元数还是矩阵——以及什么时候必须换。

欧拉角、四元数、矩阵,三种"旋转的方言"互相翻译。这一章把翻译规则和最深层的那个"半角"之谜讲透。这是数学篇的收官章:Ch4 的变换链、Ch5 的矩阵发货单,加上本章的旋转存储,三件套凑齐。

6.1 旋转的三种形态

表示数据量长处短板
欧拉角 (x, y, z)3 个数人可读、可编辑、直观(“抬头 30° 偏航 90°”)顺序歧义、万向锁、插值难看
轴角 (轴, θ)4 个数(退化存储)理论美(欧拉定理)不便连续存储
四元数 (x, y, z, w)4 个数无锁、乘法复合、slerp 平滑、省内存人读不懂
旋转矩阵(3×3 块)9 个数直接作用于向量冗余(9 数存 3 自由度)、浮点漂移需正交化

three.js 的日常:Object3D.rotation 是欧拉角(给人看的入口),内部转成四元数 quaternion(给引擎用的存储),渲染时合成矩阵(给 GPU 用的执行)——三种形态在同一条流水线上接力。所以 mesh.rotation.x = 0.5 之后再去读 mesh.quaternion,你会看到翻译结果;反过来直接写 mesh.quaternion 也能让 rotation 同步(onChangeCallback 机制)。

6.2 欧拉角:三个数,两种读法

先约定 three.js 的事实:Euler 默认顺序 'XYZ',数学上对应矩阵

M = Rx(x) · Ry(y) · Rz(z)(回忆 Ch5:最右先碰到向量)

同一串矩阵存在两种等价的读法,读错就理解不了万向锁:

  • 外(世界)轴读法:先绕世界 z 转、再绕世界 y、最后绕世界 x——旋转轴固定,物体在动。
  • 内(体)轴读法:先绕物体自己的 x 转、再绕转过的 y、最后绕又转过的 z——物体不动,坐标系在转。

两句话是同一件事(Rx·Ry·Rz 矩阵恒等)。三种读法的差别只在顺序名字:three.js 说的 “XYZ” 取体轴读法——“先绕自身 x,再自身 y,再自身 z”。

手算例题:欧拉角 (90°, 0, 90°),求世界点 p=(1,0,0) 被带到哪。体轴读法逐步:绕自身 x 转 90°:y→z、z→−y,x 不动,p 还在 (1,0,0);绕自身 y(没被 x 转动影响的轴?被 x 转 90° 后体轴 y 指向世界 −z、体轴 z 指向世界 y)——体轴跟踪太绕,换外轴读法:先 Rz(90):(1,0,0)→(0,1,0);再 Ry(0):不动;最后 Rx(90):y→z 即 (0,1,0)→(0,0,1)。结果 (0,0,1)。示例面板第①组用 Matrix4.makeRotationFromEuler 验证同一数 ✓。

6.3 万向锁:中间那个 90° 会吃掉一个自由度

想象三枚嵌套环(航拍云台的经典结构):外环管偏航(y),中环管俯仰(x),内环管翻滚(z)。中环直立到 90° 的瞬间,外环和内环的转轴重合了——此后"偏航 30°"和"翻滚 30°"是同一个动作。你丢了一个自由度,却还握着两个旋钮。

翻译成矩阵(用 6.2 的 XYZ 记号,中间位置放 y 是为了演示方便;对 three.js 的 XYZ 顺序,锁发生在第二个体轴转到 ±90° 时,即 y=±90°):

恒等式:Rx(α) · Ry(90°) · Rz(γ) = Rx(α+γ) · Ry(90°)

证明思路(一步共轭):Ry(90°) 把 z 轴搬到 x 轴(Ry(90°)·ẑ = x̂),因此"先 Rz(γ) 再 Ry(90°)“等于"先 Ry(90°) 再 Rx(γ)”——γ 旋转换了轴变成 α 的一部分,两个旋钮合成一个:结果只依赖 α+γ。

三个工程后果:

  1. 姿态控制:飞机抬头 90° 后,“滚转"和"偏航"指令效果相同,仪表会跳变——这就是"万向节锁”(gimbal lock)名字的由来。
  2. 动画:用欧拉角做关键帧插值,穿过锁位时姿态突然"甩"一下(两个数同时大跳变、效果却接近)。
  3. UI:rotation 面板里你调 x,物体却像被 z 影响——不是 bug,是顺序耦合(order 决定谁先谁后,Euler 的第四个构造参数可改 'YXZ' 等六种)。

示例的 A 盒子现场演示:锁定态 y=90°,x 与 z 反向扫动(+s/−s,和恒为 0)→ 姿态完全冻结;只动 x → 姿态改变(合成后的那个自由度还在)。这正是恒等式的直观画面。

6.4 轴角:任何旋转都有一根不动的轴

欧拉定理(旋转的,不是第 4 章那位):任何 3D 旋转都等价于绕某根固定轴转某个角度——轴上的点全不动,其余点绕它画圆。轴角 (axis, θ) 是旋转的"指纹"。

  • API:q.setFromAxisAngle(axis, angle);m.makeRotationAxis(axis, angle);从矩阵反解:m.decompose 或 q.setFromRotationMatrix 后 axis = 虚部归一化, θ = 2·acos(w)。
  • 回收 Ch5 坑 1 的开门问题:绕"不是自身原点"的轴转 = 平移过去、转回来:M = T(轴心) · Ry(θ) · T(−轴心)。three.js 写法:mesh.position.sub(hinge) 挪到轴心系、旋转、加回——或者干脆用父子结构(把门挂进 hinge 的 Group,转 group)。层级结构是万向锁的工程解之一,Ch10 展开。

轴角是理论完备的(无锁、一根轴),但存储别扭(θ=0 时轴无定义、两个轴角复合后轴会变),所以工程上它几乎总是作为四元数的输入格式存在。

6.5 四元数:从复数旋转推到 Hamilton 的半角

第一步:复数为什么会旋转。 平面上点 (x, y) 看作复数 z = x + iy。乘以 i:(x+iy)·i = −y + ix——坐标变成 (−y, x),这恰好是逆时针 90°。推广:乘以 cos θ + i·sin θ 就是转 θ(你可以拿 45°、点 (1,0) 代进去验证:得 (0.707, 0.707),长度不变、角度 +45°)。旋转 = 乘一个单位长度的复数;连续两次旋转 = 两个单位复数相乘(角度相加)。

第二步:Hamilton 的 3D 扩展。 1843 年,Hamilton 想找"3D 版的复数"。复数用一个虚数单位 i(i²=−1),3D 需要三根轴各自"转 90°"——他最终引入三个虚数单位 i, j, k,满足 i² = j² = k² = ijk = −1,由此推出 ij = k 但 ji = −k(乘法不可交换——Ch5 "顺序是严肃的事"在代数内部的重现)。这种"1 个实部 + 3 个虚部"的数叫四元数:q = w + xi + yj + zk,three.js 按 (x, y, z, w) 存储。

第三步:旋转公式。 3D 旋转 θ 绕单位轴 v 对应单位四元数:

q = ( cos(θ/2),  sin(θ/2)·v )     作用:p' = q · p · q⁻¹   (p 写成纯虚四元数 (0, p))

两个"为什么",各自给一层答案:

  • 为什么是 q·p·q⁻¹( sandwich / 三明治):单乘一次 q 会把"复平面旋转"作用到三维上同时混入平移般的畸变;两侧夹击恰好把"垂直于轴的分量"旋转、"沿轴分量"抵消不动——效果只剩绕轴旋转。API 的 q.rotateVector3(v) 内部就是这个式子(不强制你手搓四元数乘法)。
  • 为什么是半角 θ/2:三明治结构里 q 出场两次(q 和 q⁻¹ 各带一个 θ/2),合起来作用才是 θ。半角的直接推论:q(360°) = (cos180°, sin180°·v) = (−1, 0,0,0) ≠ 单位元——转 360° 在四元数世界里是"到对面再回来",q 和 −q 表示同一个旋转(双重覆盖)。示例面板第⑤组用 setFromAxisAngle(y, 2π) 打出 w=−1 给你看。

手算例题(两个就够,示例会 API 对账):

  1. 绕 y 转 90°:轴 (0,1,0),q = (cos45°, sin45°·(0,1,0)) → (x,y,z,w) = (0, 0.707, 0, 0.707)。验证作用:q 把 x̂=(1,0,0) 转到?旋转 θ=90 绕 y 按 Ch5 的 Ry(90°) 公式应为 (0,0,−1)——rotateVector3 结果一致 ✓。
  2. 复合 = 乘法:先绕 y 转 90° 再转 90°。q·q(四元数乘式:w = w₁w₂ − v₁·v₂,v = w₁v₂ + w₂v₁ + v₁×v₂):w = 0.707² − 0.707² = 0;v = 0.707·(0,0.707,0)×2 = (0, 1, 0)(v₁×v₂=0 同轴)。得 (0, 1, 0, 0),即 (cos90°, sin90°·ŷ)——绕 y 的 180°(θ/2=90° → cos=0、sin=1),与 Ry(180°) 矩阵逐项吻合。

6.6 slerp:沿大圆匀速滑行

动画里"从姿态 A 过渡到姿态 B"必须插值旋转。四元数的插值叫 球面线性插值 slerp:把 q、−q 视作单位四维球面上的点,姿态空间是球面,"直线"就是大圆弧:

slerp(q₁, q₂, t) = q₁·(sin((1−t)Ω)/sin Ω) + q₂·(sin(tΩ)/sin Ω),其中 Ω = arccos(q₁·q₂) 是两姿态在球面上的夹角。

对照朴素 lerp(逐分量线性+不修正)的两个病:① 中点长度小于 1(lerp(I, qy90, 0.5) 得 (0, 0.354, 0, 0.854),模 = √(0.354²+0.854²) = 0.924,示例面板同值),不再是单位四元数——物体"缩转";② 角速度不均匀(两头快中间慢)。示例的 C 组并排跑 slerp 与 lerp,肉眼可见 B 盒子在中段"泄力回缩"。

最短路径修正:q 与 −q 同一个旋转,但走弧长差半圈。工程标准做法:若 q₁.dot(q₂) < 0,先把其中一个取反再插值(three.js 的 slerp 内部已处理)。

6.7 API 速查与选型

需求写法
人编辑/Inspectorobject.rotation(欧拉,注意 order)
引擎存储/复合object.quaternion
绕轴 θ 创建q.setFromAxisAngle(axis, θ)
欧拉 → 四元q.setFromEuler(e)(e 含 order)
四元 → 矩阵m.makeRotationFromQuaternion(q)(compose 内部调用)
复合q1.multiply(q2):q1 在 q2 之后作用(最右先作用;原地改,clone 纪律)
插值q.slerp(q2, t) 原地版 / Quaternion.slerpQuaternions(q1,q2,t) 安全版
作用于向量q.rotateVector3(v)

选型一句话:存储与插值永远用四元数,输入界面才用欧拉角,矩阵只在合成与变换链出场。游戏引擎动画系统(含 Unity/Unreal)内部一律四元数,原因全在本章 6.3/6.5/6.6 三节里。

6.8 最小可运行示例:旋转实验台

examples/ch06/index.html:四台盒子各司其职——A 盒把 y 钉死在 90°,按"0–8s 冻结演示(x=+s°、z=−s° 同时反转)→ 8–16s 释放演示(只动 x)"循环,万向锁从定义变成动画;B 盒用 setFromAxisAngle 绕固定轴匀速自转(6.4 的几何真身);C/C2 盒拿同一对端点姿态分别走 slerp 与朴素 lerp——C2 故意不归一化,中点长度塌缩肉眼可见。面板自检逐帧运行本书公式:6.3 恒等式 Rx(α)·Ry(90°)·Rz(−α) ≟ Ry(90°) 的逐元素偏差、q(绕y90°) 与复合 q·q 的读数、"绕任意轴转 360° 得 w=−1"的双重覆盖现场、单位化 lerp 与 slerp 中点一致到 1.000000。开机先等面板打 ✓,再动手。

6.9 改造实验

  1. 把 A 盒的钉死角从 90° 改成 85°:冻结立刻解除,同样的 x/z 反号序列会动了——万向锁发生在"恰好 90°",它是几何事件不是玄学(6.3)。
  2. A 盒释放阶段从"只动 x"改成"只动 z":剩余自由度的方向换边——两个旋钮在奇异附近合成出"同一个旋钮"的过程(6.3),画面里来回验证。
  3. 把 C2 盒的朴素 lerp 换成 slerpQuaternions:中点塌缩消失、匀速回归——"四元数插值必须保单位长度"的一条图式教训(6.6)。
  4. B 盒的转轴从 (0,1,0) 改成 normalize(1,1,0):任意斜轴照样稳——轴角制对轴的方向毫无偏见,也没有"顺序"可谈(6.4),这正是引擎内部一律存四元数的根由(6.7 选型)。

6.10 常见坑

坑 1:用 q1.equals(q2) 判"姿态相同"。 双重覆盖让同姿态可以是 q 或 −q。正确判据:Math.abs(q1.dot(q2)) > 1 - 1e-6。

坑 2:每帧 q.multiply(deltaQ) 长跑后模长漂移。 浮点误差让 |q| 慢慢偏离 1,旋转"缩水"。防御:每帧 q.normalize()(单位四元数求逆退化为共轭,漂移时数值稳定性开始变差)。

坑 3:把 rotation 当持久存储。 欧拉角读回写回(obj.rotation.setFromQuaternion(...) 再改一个数)经过一次翻译,顺序歧义可能改出完全不同的姿态(尤其出现 90° 分量时)。要改姿态,改四元数或用 API 辅助:q.setFromAxisAngle(...)、obj.rotateY(θ)(绕局部轴)、obj.rotateOnWorldAxis(ŷ, θ)(绕世界轴)。

坑 4:万向锁怪四元数。 四元数消除了表示奇异性,但父子链 + 多阶段目标姿态(如瞄准系统:先朝北再抬头)仍可能用欧拉角写逻辑,锁出现在你的代码结构里而不是引擎里。结构化解法:Ch10 的层级 + lookAt/轴角构造目标四元数。

6.11 练习

  1. 手算:写出"绕轴 (1,1,0)/√2 转 60°"的四元数 (x,y,z,w),精确到三位小数。(提示:cos30°、sin30°。)
  2. 推导:用 6.3 恒等式说明"y=90° 时 x 加 30° 与 z 加 30° 效果是否相同";给出你期望的合成结果。
  3. 预测:slerp(I, qy90, 0.5) 对应什么姿态?lerp 再归一化(单位化后)与之相同吗?不归一化呢?
  4. 动手:在示例里观察 A 盒冻结阶段,把 y 从 90° 改成 89°,冻结瞬间变成什么行为?

参考答案

  1. 轴单位化后 sin(θ/2)=sin30°=0.5 → 虚部 = 0.5×(0.707, 0.707, 0) = (0.354, 0.354, 0);w = cos30° = 0.866。q ≈ (0.354, 0.354, 0, 0.866)。
  2. 恒等式说结果只依赖 α+γ:x 加 30°(α:0→30)与 z 加 30°(γ:0→30)都让和 +30°,所以效果完全相同——等价于绕 x 一个 30°(锁位后的合成自由度的转轴)。
  3. 对应"绕 y 转 45°"(大圆中点 = 角度的算术平均,同轴特例)。lerp 后归一化:同轴情形 lerp 方向恰好也沿同轴弧(纯虚部比例不变?w 与 y 同向时 lerp 仍在同一大圆弧上),单位化后 = slerp 中点;不归一化:长度 0.924,不是合法旋转(作用到向量会缩短,示例 C2 的"泄力"即此)。
  4. 不再严格冻结:89° 时 α 与 γ 仍是两个独立通道(锁只在 ±90° 精确发生),但离锁越近,两通道输出"几乎相同动作"——冻结感变成"缓慢漂移",这正是接近锁位时控制"手感突然变化"的源头。

本章小结

  • 旋转三方言:欧拉角给人、四元数给引擎、矩阵给 GPU;three.js 的 rotation/quaternion 双向同步就是这条翻译流水线。
  • 万向锁不是 bug 是表示的奇异性:中间体轴 ±90° 时首尾两轴共线,两个旋钮合成一个自由度(Rx(α)Ry(90)Rz(γ) = Rx(α+γ)Ry(90))。
  • 四元数 = 复数旋转的 Hamilton 推广:q = (cos θ/2, sin θ/2·轴),三明治 qpq⁻¹ 作用,复合是乘法,q 与 −q 同旋转(半角的全部代价与红利)。
  • slerp 沿大圆匀速插值,自带最短路径修正;lerp 旋转 = 长度塌缩 + 变速,别用。
  • 数学篇至此收官:向量(Ch3)→ 空间链(Ch4)→ 矩阵(Ch5)→ 旋转(Ch6)。你已经握着 3D 程序员的全部底层词汇,后面每一章都只做"把这些数摆进画面"的事。

下一篇进入核心对象:第 7 章 BufferGeometry——你会发现几何体不过是"一堆向量 + 一条矩阵链",全是你已经会的东西。

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐