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

游戏中的数学 01:坐标约定、单位制与数值误差

基础约定

同一个 1.2,可能是米、秒,也可能是弧度;同一个“相等”,在坐标原点附近和百万米大世界里也不是同一尺度。本卷以数值约定为起点,暂不引入移动体,而是固定右手坐标系、米、秒、弧度、60Hz 与误差比较。后续二维推导均位于 XY 平面,+Z 由右手法则确定;三角函数接收弧度,角度制只用于输入与结果标注。第一条调用链为:输入单位归一、数值比较、误差写入实验数据。

// $WORK_DIR/.tmp/game-math-lab/Program.cs · Chapter01
var sum = 0.1 + 0.2;
var abs = Math.Abs(sum - 0.3);
var rel = abs / 0.3;

var ulp = new[] { 1e-6, 1e-3, 1.0, 1e3, 1e6 }
    .Select(x => new P(x, Math.BitIncrement(x) - x))
    .ToArray();

1.0 恰好是二进制指数分段的边界。按本文的前向 ULP 定义,1 → BitIncrement(1) 的间隔是 2^-52,而 BitDecrement(1) → 1 只有 2^-53;左侧是 0.5 ULP,不能把三个点等距画在数轴上。

浮点数轴上 1.0 的相邻可表示数

状态所有权也从这里开始:业务层拥有“这是 1200 毫米”的语义,数学层只接收已经换算成米的 1.2;误差策略由调用边界拥有,不能由每个玩法系统临时发明。否则寻路用厘米、物理用米、协议用毫米,数值都合法,组合后却没有共同含义。

误差定义

固定绝对误差只适合固定量级。这里采用缩放比较:差值上限等于 epsilon × max(1, |a|, |b|)max(1, …) 保住零附近的绝对误差区间,数值变大后则自动切换为相对尺度。

// Program.cs · NearlyEqual / DegToRad
static bool NearlyEqual(double a, double b, double epsilon) =>
    Math.Abs(a - b) <= epsilon * Math.Max(
        1,
        Math.Max(Math.Abs(a), Math.Abs(b)));

static double DegToRad(double degrees) =>
    degrees * Math.PI / 180;

这不是“所有数学都用同一个 epsilon”。位置、单位向量、矩阵逆残差会有不同预算;统一的是比较形式、单位和记录方式。边界参数由上层不变量决定,底层函数只执行,不猜测“一毫米是否可接受”。

误差预算还必须跟着数据跨越系统边界:序列化前先确定量化步长,反序列化后再用同一单位比较。若发送端按毫米取整、接收端却按米使用固定小数,两个端点即使各自断言通过,也无法证明同一位置保持一致。

单位归一

实验先将 1200 毫米换算为 1.2 米,再以 1e-12 比较。浮点加法不要求按位相等,只要求残差存在且小于已知边界。两个断言分别约束单位转换与 IEEE-754 行为。

// Program.cs · Chapter01 checks
Check(
    abs > 0 && abs < 1e-15,
    "IEEE-754 addition residual is bounded");

Check(
    NearlyEqual(1200.0 / 1000.0, 1.2, 1e-12),
    "millimetres convert to metres");

var gapBelowOne = 1.0 - Math.BitDecrement(1.0);
var gapAboveOne = Math.BitIncrement(1.0) - 1.0;
Check(Math.Abs(gapAboveOne / gapBelowOne - 2) < 1e-12,
    "power-of-two boundary has asymmetric adjacent gaps");

WriteData(1, new {
    sum,
    expected = 0.3,
    absolute_error = abs,
    relative_error = rel,
    millimetres = 1200,
    metres = 1.2,
    gap_below_one = gapBelowOne,
    gap_above_one = gapAboveOne,
    ulp
});

固定阈值失效

失败来源不是浮点运算异常,而是将十进制显示值误作二进制精确值。sum == 0.3 为假;若结算、状态机或快照去重依赖这个判断,同一逻辑状态会分叉。另一种失败是先丢失单位再比较,例如把 12001.2 直接交给 NearlyEqual,比较器无法替调用者恢复毫米语义。

// 回归断言:精确相等必须失败,合同比较必须通过
var exactEqual = (0.1 + 0.2) == 0.3;
var contractEqual = NearlyEqual(0.1 + 0.2, 0.3, 1e-12);

Check(!exactEqual, "decimal display is not binary equality");
Check(contractEqual, "bounded comparison accepts the residual");
固定输入 判断 结果
0.1 + 0.2 == 0.3 按位数值相等 false
NearlyEqual(0.1 + 0.2, 0.3, 1e-12) 缩放误差 true
1200 / 10001.2 米制归一后比较 true

可表示间隔

ULP 不是常数。量级从 1e-6 增到 1e6,相邻 double 的间隔从约 2.118e-22 增到 1.164e-10。这就是大地图不能继续用“全世界统一绝对 epsilon”的数值原因;图中横纵轴均为对数尺度,五个点直接来自本章 JSON。

{
  "sum": 0.30000000000000004,
  "absolute_error": 5.551115123125783E-17,
  "relative_error": 1.8503717077085943E-16,
  "millimetres": 1200,
  "metres": 1.2,
  "gap_below_one": 1.1102230246251565E-16,
  "gap_above_one": 2.220446049250313E-16,
  "ulp": [
    {
      "X": 0.000001,
      "Y": 2.117582368135751E-22
    },
    {
      "X": 0.001,
      "Y": 2.168404344971009E-19
    },
    {
      "X": 1,
      "Y": 2.220446049250313E-16
    },
    {
      "X": 1000,
      "Y": 1.1368683772161603E-13
    },
    {
      "X": 1000000,
      "Y": 1.1641532182693481E-10
    }
  ]
}

数值量级与 ULP 的对数关系

实验结果

这条回归同时约束单位转换、浮点残差与 1.0 两侧的非对称间隔。任何一项失配都会使验证程序直接失败,错误数值不会继续进入向量层。

cd "$WORK_DIR"
dotnet run --project .tmp/game-math-lab/GameMathLab.csproj \
  -- --out .tmp/game-math-lab/out
CH01 PASS 0.1+0.2=0.30000000000000004
  abs=5.551E-017 rel=1.850E-016
  gap_down=1.110E-016 gap_up=2.220E-016
TOTAL pass=15 fail=0 skip=0 chapters=15 elapsed_ms=148.7
EXIT code=0

精度选择

实验与协议推导采用 double 作为数值基准,以获得更宽的数值余量;引擎接入仍可使用 float,但误差预算必须进入边界测试。仅存在少量显式换算时不引入单位类型库;当单位字段跨模块、跨语言或进入内容表后,再升级为强类型或生成代码。

第二章的向量层只接收已经归一为米的位置、位移和 NearlyEqual 合同,再据此构造 Vector2。共同单位是向量加法具有几何意义的前提,避免无标签数值之间的偶然相加。