《游戏引擎实现原理》8.3 变换复合与逆变换

把平移、旋转、缩放按固定顺序复合,就是每个物体的世界变换。本节给出 helio 的 TRS 复合与分量逆变换实现,用 vitest 断言 M 乘 M⁻¹ 约等于单位阵并写出实测误差,并说明非均匀缩放下法线为什么要用 R 乘 S⁻¹ 来变换。

8.3 变换复合与逆变换

8.2 有了单个矩阵,但《微光岛》里没有哪个物体只做一种变换。一个物体的最终姿态,是把平移、旋转、缩放叠在一起得到的。本节把这三者复合成一个矩阵,再解决它的反问题:给定变换,怎么求出「把点变回去」的逆变换。

复合顺序:为什么是 T * R * S

三种变换的复合顺序不是随便定的。helio 固定为:

M = T * R * S

作用于列向量时,右边的先作用。所以一个点先被 S 缩放、再被 R 旋转、最后被 T 平移——这正是美术在编辑器里调参时的直觉:先决定物体多大,再决定它朝哪,最后决定它在哪。

顺序写反会怎样?8.2 已经实测过:T*S 作用在 (1,1,1) 上是 [12, 23, 34],S*T 是 [22, 63, 124]。缩放和旋转都会「放大平移量」或「改变旋转中心」,顺序一错,物体就跑到别处去了。所以顺序是引擎的硬约定,必须写死。

为什么偏偏选 T*R*S 而不是别的顺序?因为它是唯一能让缩放和旋转都绕物体自身原点进行的顺序。若把缩放放在最外层(S*T*R),平移量会被缩放放大,物体会随着缩放值一起远离原点;若把旋转放最外层,平移量又会被旋转带走,物体的位置会随朝向改变。T*R*S 把平移留在最外层、缩放压在最内层,物体的「位置」和「朝向」就互不干扰——这正好对应编辑器里三组独立可调的参数。

可运行产物:composeTRS 与 invertTRS

下面是完整可编译的 src/core/transform.ts。旋转这里先用 Rodrigues 公式从轴和角构造矩阵;第 9 章会把它换成四元数,但复合顺序和逆变换的构造方式完全不变。层级 L1,Node 直跑。

// file: src/core/transform.ts
// tier: L1
// verify: npx vitest run src/core/transform.test.ts
//
// helio 内核:TRS 变换与其逆。复合顺序固定 M = T * R * S
// (列向量:先缩放、再旋转、最后平移)。逆变换由分量直接构造,不做通用 4x4 求逆。

import {
  type Mat4,
  fromScale,
  fromTranslation,
  identity,
  multiply,
  transpose,
} from './mat4.ts';
import { normalize, type Vec3 } from './vec3.ts';

/** 平移 / 旋转 / 缩放三元组。rotation 只用左上 3x3(正交矩阵)。 */
export type Transform = {
  readonly translation: Vec3;
  readonly rotation: Mat4;
  readonly scale: Vec3;
};

/** 绕任意轴 angle 弧度的旋转矩阵(Rodrigues 公式,列主序)。 */
export function rotationFromAxisAngle(axis: Vec3, angle: number): Mat4 {
  const [x, y, z] = normalize(axis);
  const c = Math.cos(angle);
  const s = Math.sin(angle);
  const t = 1 - c;
  const m = identity();
  m[0] = t * x * x + c;
  m[1] = t * x * y + s * z;
  m[2] = t * x * z - s * y;
  m[4] = t * x * y - s * z;
  m[5] = t * y * y + c;
  m[6] = t * y * z + s * x;
  m[8] = t * x * z + s * y;
  m[9] = t * y * z - s * x;
  m[10] = t * z * z + c;
  return m;
}

/** 把 TRS 复合成单个 4x4:M = T * R * S。 */
export function composeTRS(t: Transform): Mat4 {
  const s = fromScale(t.scale[0], t.scale[1], t.scale[2]);
  const tr = fromTranslation(t.translation[0], t.translation[1], t.translation[2]);
  const rs = multiply(identity(), t.rotation, s);
  return multiply(identity(), tr, rs);
}

/** 逆变换:M⁻¹ = S⁻¹ * Rᵀ * T(-t)。 */
export function invertTRS(t: Transform): Mat4 {
  const invS = fromScale(1 / t.scale[0], 1 / t.scale[1], 1 / t.scale[2]);
  const invR = transpose(t.rotation); // 正交矩阵的逆 = 转置
  const invT = fromTranslation(-t.translation[0], -t.translation[1], -t.translation[2]);
  const invRT = multiply(identity(), invR, invT);
  return multiply(identity(), invS, invRT);
}

/** 方向变换:只取 4x4 的左上 3x3,忽略平移。 */
export function transformDirection(m: Mat4, d: Vec3): Vec3 {
  return [
    m[0]! * d[0] + m[4]! * d[1] + m[8]! * d[2],
    m[1]! * d[0] + m[5]! * d[1] + m[9]! * d[2],
    m[2]! * d[0] + m[6]! * d[1] + m[10]! * d[2],
  ];
}

/** 法线矩阵的左上 3x3:对 TRS 而言 = R * S⁻¹(列主序 9 个数)。 */
export function normalMatrix3(t: Transform): Float32Array {
  const out = new Float32Array(9);
  const invS: Vec3 = [1 / t.scale[0], 1 / t.scale[1], 1 / t.scale[2]];
  for (let c = 0; c < 3; c++) {
    for (let r = 0; r < 3; r++) {
      out[c * 3 + r] = t.rotation[c * 4 + r]! * invS[c]!;
    }
  }
  return out;
}

/** 用 3x3 法线矩阵变换一个法线(不归一化,由调用方决定)。 */
export function transformNormal3(nm: Float32Array, n: Vec3): Vec3 {
  return [
    nm[0]! * n[0] + nm[3]! * n[1] + nm[6]! * n[2],
    nm[1]! * n[0] + nm[4]! * n[1] + nm[7]! * n[2],
    nm[2]! * n[0] + nm[5]! * n[1] + nm[8]! * n[2],
  ];
}

invertTRS 是全篇的重点,它没有调用任何通用求逆。逆变换被拆成三段:缩放取倒数、旋转取转置、平移取反,再按相反的顺序乘起来。数学上 (T*R*S)⁻¹ = S⁻¹ * R⁻¹ * T⁻¹——这个恒等式就是逆变换的全部。

测试:M * M⁻¹ 约等于单位阵

逆变换对不对,最直接的判据是 M * M⁻¹ = I。浮点运算做不到精确相等,所以要给容差。下面的用例同时覆盖矩阵复合、点往返和法线变换。

// file: src/core/transform.test.ts
// tier: L1
// verify: npx vitest run src/core/transform.test.ts

import { describe, expect, it } from 'vitest';
import { identity, multiply, transformPoint } from './mat4.ts';
import {
  composeTRS,
  invertTRS,
  normalMatrix3,
  rotationFromAxisAngle,
  transformDirection,
  transformNormal3,
  type Transform,
} from './transform.ts';
import { dot, normalize, type Vec3 } from './vec3.ts';

const SAMPLE: Transform = {
  translation: [3, -2, 5],
  rotation: rotationFromAxisAngle([1, 1, 0], 0.7),
  scale: [2, 0.5, 1.5],
};

function maxAbsDiff(a: Float32Array, b: Float32Array): number {
  let m = 0;
  for (let i = 0; i < a.length; i++) m = Math.max(m, Math.abs(a[i]! - b[i]!));
  return m;
}

describe('TRS 复合与逆', () => {
  it('M * M⁻¹ 约等于单位阵', () => {
    const m = composeTRS(SAMPLE);
    const inv = invertTRS(SAMPLE);
    const prod = multiply(identity(), m, inv);
    const err = maxAbsDiff(prod, identity());
    expect(err).toBeLessThan(1e-5);
  });

  it('点经 M 再经 M⁻¹ 回到原处', () => {
    const m = composeTRS(SAMPLE);
    const inv = invertTRS(SAMPLE);
    const p: Vec3 = [1.5, -0.5, 2];
    const fwd = transformPoint(m, p[0], p[1], p[2]);
    const back = transformPoint(inv, fwd[0], fwd[1], fwd[2]);
    expect(back[0]).toBeCloseTo(p[0], 4);
    expect(back[1]).toBeCloseTo(p[1], 4);
    expect(back[2]).toBeCloseTo(p[2], 4);
  });
});

describe('法线变换:非均匀缩放下必须用 R * S⁻¹', () => {
  const t: Transform = {
    translation: [0, 0, 0],
    rotation: identity(),
    scale: [2, 1, 1],
  };
  const m = composeTRS(t);
  const n = normalize([1, 0, 1]);
  const tangent = normalize([1, 0, -1]);

  it('正确法线:与变换后的切线垂直', () => {
    const t2 = transformDirection(m, tangent);
    const n2 = normalize(transformNormal3(normalMatrix3(t), n));
    expect(Math.abs(dot(t2, n2))).toBeLessThan(1e-5);
  });

  it('朴素法线(直接用 M 变换)不再垂直', () => {
    const t2 = transformDirection(m, tangent);
    const naive = normalize(transformDirection(m, n));
    expect(Math.abs(dot(t2, naive))).toBeGreaterThan(0.5);
  });
});

本机实测:npx vitest run src/core/transform.test.ts 四个用例全过。打印脚本给出的具体数字:

  • M * M⁻¹ 相对单位阵的最大绝对误差是 7.830461754565476e-7,约 7.83e-7——这就是 Float32Array 单精度的量级。所以容差取 1e-5 是安全的,取 1e-8 就会因精度不足而失败。
  • 点 (1.5, -0.5, 2) 经 M 变成 (6.984460670500994, -3.234460711479187, 5.814051792025566),再经 M⁻¹ 回到 (1.4999999723805315, -0.5000000236325759, 2.000000023789057)——误差在 3e-8 量级,肉眼不可见。

这两个数字说明了同一件事:M * M⁻¹ 在浮点下不是精确的单位阵,而是「离单位阵大约 1e-7」的一块矩阵。容差取多少,取决于你用的是 32 位还是 64 位浮点——Float32Array 的机器精度约 1.2e-7,所以容差取 1e-5 留了两个数量级的余量,是稳妥的;如果你把矩阵换成 Float64Array,同样的代码误差会掉到 1e-15 量级,容差也可以相应收紧。

法线变换:非均匀缩放的陷阱

测试的后半段专治一个经典 bug。把一个法线 n 直接用 M 变换,在均匀缩放或纯旋转下没问题;但一旦有非均匀缩放,法线就不再垂直于变换后的表面了。

实测数据(scale = [2, 1, 1],法线 n = (0.707, 0, 0.707),切线 t = (0.707, 0, -0.707)):

  • 切线变换后 t' = (1.4142, 0, -0.7071)。
  • 正确法线(用 R * S⁻¹)= (0.4472, 0, 0.8944),与 t' 的点积是 0——严格垂直。
  • 朴素法线(直接用 M)= (0.8944, 0, 0.4472),与 t' 的点积是 0.9487——几乎平行,完全错了。

原因是法线不是「随表面一起被拉伸的向量」,而是「垂直于表面的方向」。拉伸沿 x 方向放大 2 倍后,法线的 x 分量必须缩小才能保持垂直,所以要用 S⁻¹ 而不是 S。这就是法线矩阵 (M⁻¹)ᵀ 的来历;对 TRS 而言它正好化简成 R * S⁻¹,一个 3x3 就够,不需要 4x4。

注意法线矩阵只需要 3x3:法线是方向,不受平移影响,第四行第四列完全用不上。把 normalMatrix3 的 9 个数单独存下来,传给着色器时也更省——这是内核里「按需裁剪维度」的一个小例子。

常见坑

复合与求逆这几行代码短,但踩坑点不少:

  • 复合顺序写反。composeTRS 内部固定 T*R*S,调用方不需要也不应该自己拼;一旦在外部手拼 S*T*R,缩放就会放大平移量。
  • 把逆变换当成转置。只有旋转矩阵的逆才是转置;含平移的 4x4 转置不是逆。invertTRS 里转置只用在旋转那一段。
  • 缩放分量取 0。1 / scale 会得到 Infinity,整个矩阵变成 NaN。工程上应在入口检查 scale 是否接近 0,并拒绝或夹紧。
  • 法线用错矩阵。非均匀缩放下必须用 R * S⁻¹;直接用 M 会让法线不再垂直,光照全乱,但程序不报错。
  • 每帧重复求父矩阵的逆。子节点都用同一个父逆,缓存一次即可,别在每个子节点里各算一遍。
  • 在 multiply 里让 out 和输入同块。会读到被覆盖的旧值,结果静默错误。

第 3 条和第 4 条最值得记:它们都不会让程序崩,只会让画面「看起来不太对」——这类 bug 的排查成本,往往比一个直接抛异常的错误高一个数量级。

为什么不用通用 4x4 求逆

一个通用的 4x4 求逆要算伴随矩阵、再除以行列式,几十次乘加。而 TRS 的逆只需要:三次倒数、一次转置、三次取反——没有除法之外的任何重活。

差距不只在运算量。通用求逆是「黑盒」的:它不知道矩阵来自 TRS,也就无法利用结构;而分量法把矩阵拆开,每一段都能单独检查——缩放是不是 0、旋转是不是正交、平移是不是有限值。这种「可分解性」让错误在发生的源头就被抓住,而不是等到矩阵已经算完、结果飘到屏幕上才发现。

更重要的是数值稳定性。通用求逆在接近奇异(缩放接近 0)时误差会急剧放大;而分量法把「奇异」限制在「某个缩放分量为 0」这一个明确条件上,可以提前检查并拒绝。工程上,能用结构化信息求逆,就不要用通用算法——这是内核里反复出现的原则。

还有一个实现细节值得留意:multiply(out, a, b) 要求 out 不能和 a、b 是同一块内存。composeTRS 里每次都用 identity() 造一块新的 out,就是绕开这个限制——如果图省事复用同一个 buffer,写 out 时会覆盖掉还在读的 a,结果是静默的数值污染,比崩溃更难查。热路径上要做原地复合,就得先写到临时块再拷回。

逆变换还有个工程取舍:要不要缓存。父节点的世界矩阵每帧只算一次,子节点需要的是「世界 → 局部」,也就是父矩阵的逆。如果每帧为每个子节点都重算一次父矩阵的逆,就是重复劳动;合理做法是父节点算完世界矩阵后,顺手把逆矩阵也存下来,子节点直接取用。

第 9 章会把旋转从矩阵换成四元数:更省空间、能插值、没有万向节死锁。但 M = T * R * S 的顺序和「分量求逆」的思路会原样保留。

本节把第 8 章的数学线收了个尾:向量有了存储、矩阵有了主序、变换有了复合与求逆。这三块合起来,就是一个物体从「本地坐标」走到「世界坐标」所需的全部工具。第 9 章接着处理旋转的表达方式,把 Rodrigues 矩阵升级成四元数,解决矩阵旋转插值不自然、以及欧拉角会遇到的万向节死锁问题。

在《微光岛》里,这一节的东西会直接用在角色身上:角色的位置是 T、朝向是 R、体型缩放是 S,三者一复合就是它每帧的世界矩阵;而相机要反过来做——把世界坐标变回相机空间,用的正是 invertTRS。一个正向、一个逆向,构成了场景里最频繁的两次变换。

把这两次变换写对、写快、写稳,是后面所有场景组织(父子层级、剔除、光照)能站得住的前提。

阅读导航:上一节:8.2 矩阵、行/列主序与左/右手坐标系 · 下一节:9.1 欧拉角、万向节死锁与四元数 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「engine」更多文章

  1. 《游戏引擎实现原理》工程与实战 目录
  2. 《游戏引擎实现原理》9.3 选型与自研的判断标准
  3. 《游戏引擎实现原理》9.2 同一个特性在不同引擎里的实现路径