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

游戏中的数学 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 组件拥有局部参数,场景系统拥有父子组合后的世界矩阵;数学函数只执行 TransformInvert。渲染、碰撞和网络都可以消费世界结果,但不能各自重排 TRS,否则同一个实体会出现三种位置。

齐次语义

矩阵把一个坐标系的基和原点一起编码。点受平移影响,方向不受平移影响;在齐次表示中,它们对应 w=1w=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 精度,而是 Matrix4x4Vector3float 运算链最终必须满足的玩法空间约束。

固定种子还保证接入层能够重放同一批点,而不是只比较一次偶然结果。

累计残差

单次通过不代表反复坐标转换可以无限使用。实验让同一点连续往返 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、逆矩阵和往返误差合同,再把单个局部坐标扩展为三层父子层级。重挂父节点时,目标不是保持局部矩阵,而是明确选择是否保持世界姿态。