游戏中的数学 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 门槛。每次重挂只提交一次新局部状态;渲染、碰撞和网络随后都从同一个世界矩阵派生。
奇异失败
缩放某轴为零会压扁一个维度,多个局部点映射到同一世界点,信息已经丢失,因此不存在唯一逆矩阵。这里不返回单位矩阵、不沿用旧缓存,也不把零缩放夹成任意小数,而是让 Invert 的 false 成为可测试结果。
// 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、固定组合顺序和“重挂时可选择保持世界姿态”的接口。第六章只替换旋转表示:用单位四元数组合姿态,但不让四元数承担位置、缩放或层级职责。