本文属于系列「游戏中的数学:从坐标到可同步运动」第 5 / 15 篇

游戏中的数学 05:逆变换与父子层级不能混写

重挂场景

一个武器节点从旧父节点移到新父节点,如果直接保留原局部矩阵,它的世界位置会跳变。本章沿用第四章的组合约定:先算旧世界矩阵,再乘新父节点的逆矩阵得到新局部矩阵,最后重新组合。被守住的状态是世界姿态,不是局部数字。

// $WORK_DIR/.tmp/game-math-lab/Program.cs · Chapter05
var parentWorldA = parentLocalA * rootA;
var parentWorldB = parentLocalB * rootB;
var childLocal = Matrix4x4.CreateTranslation(3, 1, 0);
var world = childLocal * parentWorldA;

三层局部坐标系与父子组合顺序

场景图拥有父子关系,Transform 系统拥有局部与世界矩阵缓存,玩法节点只请求“保持世界姿态重挂”或“保持局部姿态重挂”。若 API 不暴露这个选择,调用者会把局部位置当世界位置写回,层级越深,错误越难定位。

世界姿态守恒

新局部矩阵满足 newLocal * newParentWorld = world。这不是把平移分量相减,而是完整保留根节点、父节点、旋转、非均匀缩放和组合顺序。先确认目标父世界矩阵可逆,才允许计算新局部。

// Program.cs · Chapter05 reparent
var probe = new Vector3(0.5f, 0.25f, 0);
double error = 0, matrixError = 0;
const int cycles = 100;
for (var i = 0; i < cycles; i++)
{
    var target = i % 2 == 0 ? parentWorldB : parentWorldA;
    Check(Matrix4x4.Invert(target, out var inverse),
        "target parent is invertible");
    childLocal = world * inverse;
    var restored = childLocal * target;
    error = Math.Max(error, Vector3.Distance(
        Vector3.Transform(probe, world),
        Vector3.Transform(probe, restored)));
    matrixError = Math.Max(matrixError,
        MatrixMaxAbsDiff(world, restored));
    world = restored;
}

非原点探针覆盖一次组合后的空间位置,但单点仍不足以独立证明完整姿态。因此实验还逐项比较两个世界矩阵的 16 个元素,记录最大绝对残差;平移、三个基向量和齐次项都进入同一门禁。

正常时序

正常路径是“根与父组合 → 读取旧世界 → 求目标父世界逆 → 计算新局部 → 重组并验证世界探针”。实验确实构造 root → parent → child 三层,并在父 A、父 B 之间交替重挂 100 次;世界姿态误差必须低于 1e-5 米。

// Program.cs · Chapter05 conservation check
Check(
    error < 1e-5,
    "reparent keeps world pose");
Check(matrixError < 1e-5,
    "reparent preserves every matrix element");

WriteData(5, new {
    hierarchy_depth = 3,
    reparent_cycles = 100,
    reparent_world_error_m = error,
    reparent_matrix_max_error = matrixError,
    singular_inverse = false,
    residuals
});

Done(5,
    $"depth=3 cycles=100 reparent_error={error:E3}m " +
    $"matrix_error={matrixError:E3} " +
    "singular_inverse=false");

100 次交替重挂的探针最大误差为 2.1324806e-6 米,16 个矩阵元素的最大绝对残差为 1.9073486e-6,两者都低于 1e-5 门槛。每次重挂只提交一次新局部状态;渲染、碰撞和网络随后都从同一个世界矩阵派生。

奇异失败

缩放某轴为零会压扁一个维度,多个局部点映射到同一世界点,信息已经丢失,因此不存在唯一逆矩阵。这里不返回单位矩阵、不沿用旧缓存,也不把零缩放夹成任意小数,而是让 Invertfalse 成为可测试结果。

// Program.cs · Chapter05 singular branch
var singular = Matrix4x4.CreateScale(1, 0, 1);

Check(
    !Matrix4x4.Invert(singular, out _),
    "singular scale is rejected");

// 失败时序:
// new parent is singular
// -> inverse unavailable
// -> reparent rejected
// -> old parent and world pose remain authoritative

这是数据边界,不是异常恢复技巧。编辑器可以阻止提交零缩放,运行时也必须保留失败分支,因为动画、导入资源或脚本仍可能生成奇异矩阵。

条件恶化

矩阵尚可逆,也可能非常难逆。实验把 Y 缩放从 1 降到 0.0001,用 1 / scale 作为条件代理,记录一点往返后的残差。比例到 1000 后误差从微米级跃升到约 8.70e-5 米;到 10000 时达到约 1.18e-4 米。

// Program.cs · Chapter05 condition experiment
var residuals = new List<P>();
foreach (var scale in new[] { 1.0, .1, .01, .001, .0001 })
{
    var matrix =
        Matrix4x4.CreateScale(1, (float)scale, 1) *
        Matrix4x4.CreateRotationZ(.7f);

    Matrix4x4.Invert(matrix, out var inverse);
    var source = new Vector3(2, 3, 0);
    var back = Vector3.Transform(
        Vector3.Transform(source, matrix),
        inverse);

    residuals.Add(new(
        1 / scale,
        Vector3.Distance(back, source)));
}
scale_y  scale_ratio  roundtrip_error_m
1        1            0
0.1      10           9.536743e-7
0.01     100          7.152557e-7
0.001    1000         8.702311e-5
0.0001   10000        1.184943e-4

缩放比例与逆变换往返残差

当天运行

同一固定程序验证世界姿态守恒和奇异矩阵拒绝。实验没有性能结论;样本数只用于数值边界,耗时报告是整套 15 章的执行时间。

cd "$WORK_DIR"
dotnet run --project .tmp/game-math-lab/GameMathLab.csproj \
  -- --out .tmp/game-math-lab/out
CH05 PASS depth=3 cycles=100 reparent_error=2.132E-006m matrix_error=1.907E-006 singular_inverse=false
TOTAL pass=15 fail=0 skip=0 chapters=15 elapsed_ms=112.8
EXIT code=0

本章 JSON 检查点为:层级深度 3、交替重挂 100 次、最大探针误差 2.1324806E-06 米、矩阵元素最大残差 1.9073486E-06,奇异矩阵求逆结果 false

取舍与接口

当前实现每次重挂直接求一次逆矩阵,代码短且操作低频;没有提前维护整棵树的逆矩阵缓存。只有层级更新进入热点并被测量为瓶颈时,才值得增加版本号和缓存失效逻辑。奇异矩阵则始终拒绝,不能为了减少分支牺牲世界姿态所有权。

交给下一章的是可逆 Transform、固定组合顺序和“重挂时可选择保持世界姿态”的接口。第六章只替换旋转表示:用单位四元数组合姿态,但不让四元数承担位置、缩放或层级职责。