游戏中的数学 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 preserves world transform");
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 transform remain authoritative
这是数据边界,不是异常恢复技巧。编辑器可以阻止提交零缩放,运行时也必须保留失败分支,因为动画、导入资源或脚本仍可能生成奇异矩阵。
病态性实验
矩阵尚可逆,也可能非常难逆。实验把 Y 缩放从 1 降到 0.0001,以 1 / scale 作为条件代理,记录固定点往返后的残差。比例到 1000 后,误差从微米级跃升到约 8.70e-5 米;到 10000 时达到约 1.18e-4 米。比例为 1 的残差精确等于零,而 log10(0) 没有定义;图中明确标注该零值并将其从对数折线中排除,不以任意正数占据虚假位置。
// 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

实验结果
同一组回归同时验证世界变换守恒和奇异矩阵拒绝。100 次重挂只用于压实数值边界,不承担吞吐结论。
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=148.7
EXIT code=0
本章 JSON 检查点为:层级深度 3、交替重挂 100 次、最大探针误差 2.1324806E-06 米、矩阵元素最大残差 1.9073486E-06,奇异矩阵求逆结果 false。
工程约束
实现对每次重挂直接计算一次逆矩阵,因为该操作频率较低,无需预先维护整棵树的逆矩阵缓存。只有层级更新进入热点并经测量确认构成瓶颈时,才增加版本号和缓存失效逻辑。奇异矩阵始终被拒绝,不能为减少分支而牺牲世界变换的状态所有权。
第六章以单位四元数替换可逆 Transform 中的旋转表示;固定组合顺序和“重挂时可选择保持世界变换”的接口继续有效,位置、缩放与层级职责仍由 Transform 系统承担。