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 欧拉角、万向节死锁与四元数 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。