游戏中的数学 04:矩阵如何搬运一个坐标系
局部点出发
前三章的点、方向和正交关系都在同一个坐标系里。本章第一次跨越边界:局部点先缩放、再绕 Z 轴旋转、最后平移到世界空间。使用 System.Numerics 的组合约定时,矩阵明确写成 scale * rotation * translation,不能把教科书中的另一种向量约定直接套过来。
// $WORK_DIR/.tmp/game-math-lab/Program.cs · Chapter04
var trs =
Matrix4x4.CreateScale(1.5f, 0.75f, 2f) *
Matrix4x4.CreateRotationZ(0.6f) *
Matrix4x4.CreateTranslation(10, -4, 3);
Check(
Matrix4x4.Invert(trs, out var inverse),
"TRS matrix is invertible");

Transform 组件拥有局部参数,场景系统拥有父子组合后的世界矩阵;数学函数只执行 Transform 与 Invert。渲染、碰撞和网络都可以消费世界结果,但不能各自重排 TRS,否则同一个实体会出现三种位置。
齐次语义
矩阵把一个坐标系的基和原点一起编码。点受平移影响,方向不受平移影响;在齐次表示中,它们对应 w=1 与 w=0。实验同时用 Transform 处理点、用 TransformNormal 处理方向,并把“变换后的点减去变换后的原点”作为独立对照。
// Program.cs · Chapter04 local -> world -> local
var localPoint = new Vector3(2, -1, 0.5f);
var worldPoint = Vector3.Transform(localPoint, trs);
var restoredPoint = Vector3.Transform(worldPoint, inverse);
var residual = Vector3.Distance(
localPoint,
restoredPoint);
var direction = Vector3.Normalize(new Vector3(1, 2, 0));
var transformedDirection = Vector3.TransformNormal(direction, trs);
var pointDelta = Vector3.Transform(direction, trs)
- Vector3.Transform(Vector3.Zero, trs);
var directionError = Vector3.Distance(
transformedDirection, pointDelta);
系统边界的核心不变量是:同一 API 约定下,Local → World → Local 应回到原点附近。这里比较的是米制位置残差,不是矩阵元素逐项相等;不同运算顺序可能得到不同矩阵,却都长得像一组合法浮点数。
世界矩阵若进入缓存,缓存版本必须由局部参数和父节点版本共同决定。只在本节点移动时失效,会让父节点更新后继续返回旧世界坐标;这种错误不会触发矩阵求逆失败,只能靠固定探针的往返断言发现。
一万点往返
固定种子 4104 生成一万个三维点,每轴范围为 [-10,10) 米。所有点走同一条往返链,记录最大残差而不是平均值,避免少数边界样本被均值掩盖。
// Program.cs · Chapter04 fixed experiment
var random = new Random(4104);
double max = 0;
for (var i = 0; i < 10_000; i++)
{
var point = new Vector3(
random.NextSingle() * 20 - 10,
random.NextSingle() * 20 - 10,
random.NextSingle() * 20 - 10);
var world = Vector3.Transform(point, trs);
var back = Vector3.Transform(world, inverse);
max = Math.Max(max, Vector3.Distance(point, back));
}
Check(max < 1e-4,
"local-world-local residual stays bounded");
最大误差为 3.015783e-6 米,低于本章 1e-4 米预算约 33 倍。预算不是 double 精度,而是 Matrix4x4 和 Vector3 的 float 运算链最终必须满足的玩法空间约束。
固定种子还保证接入层能够重放同一批点,而不是只比较一次偶然结果。
累计残差
单次通过不代表反复坐标转换可以无限使用。实验让同一点连续往返 200 次,每次都记录它与初始 (1,2,3) 的距离;图展示变换次数与累计位置误差,数据来自同一 TRS 与逆矩阵。
// Program.cs · Chapter04 repeated round-trip
var accumulated = new List<P>();
var point = new Vector3(1, 2, 3);
for (var i = 1; i <= 200; i++)
{
point = Vector3.Transform(
Vector3.Transform(point, trs),
inverse);
accumulated.Add(new(
i,
Vector3.Distance(point, new(1, 2, 3))));
}
WriteData(4, new {
samples = 10_000,
max_roundtrip_error_m = max,
direction_translation_error = directionError,
matrix_order = "scale*rotation*translation"
});
Done(4,
$"samples=10000 max_roundtrip_error={max:E3}m " +
$"direction_translation_error={directionError:E3}");

失败边界
两个失败条件必须分开。矩阵不可逆时,Invert 返回 false,流程应在变换边界终止;矩阵可逆但误差超预算时,回归断言终止。当前固定样本若把预算错误收紧到 1e-6 米,会被真实最大误差 3.016e-6 米拒绝,说明容差必须依据运算类型和用途,而不是追求更多小数位。
case observed result
TRS invertible true continue
max error < 1e-4 m 3.016e-6 m pass
max error < 1e-6 m 3.016e-6 m would fail
inverse unavailable false stop before use
sample count 10000 complete
random seed 4104 reproducible
coordinate range per axis [-10, 10) m bounded
error budget 1.000e-4 m bounded
{
"samples": 10000,
"max_roundtrip_error_m": 3.0157830224197824E-06,
"direction_translation_error": 1.7441362003542963E-07,
"matrix_order": "scale*rotation*translation"
}
当天运行
运行命令使用固定种子并覆盖实验 JSON 与两张 SVG。全 15 章退出码为零;本章一万个点全部参与最大值统计,没有抽样跳过。
cd "$WORK_DIR"
dotnet run --project .tmp/game-math-lab/GameMathLab.csproj \
-- --out .tmp/game-math-lab/out
CH04 PASS samples=10000 max_roundtrip_error=3.016E-006m direction_translation_error=1.744E-007
TOTAL pass=15 fail=0 skip=0 chapters=15 elapsed_ms=112.8
EXIT code=0
取舍与接口
本章复用平台矩阵类型,没有自写 Mat4,减少乘法、逆矩阵和布局约定的重复实现;代价是文章结论绑定 System.Numerics 的组合语义,接入 Unity、Godot 或 Three.js 时必须用相同固定点重新验证,而不能只转抄乘法顺序。
下一章接收 trs、逆矩阵和往返误差合同,再把单个局部坐标扩展为三层父子层级。重挂父节点时,目标不是保持局部矩阵,而是明确选择是否保持世界姿态。