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

游戏中的数学 01:坐标、单位与误差不是注释

先定合同

同一个 1.2,可能是米、秒,也可能是弧度;同一个“相等”,在坐标原点附近和百万米大世界里也不是同一尺度。本系列先不做移动体,而是把右手坐标系、米、秒、弧度、60Hz 与误差比较固定下来。第一条调用链很短:输入单位先归一,再进入数值比较,最后把误差写进实验数据。

// $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 的相邻可表示数

状态所有权也从这里开始:业务层拥有“这是 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");

WriteData(1, new {
    sum,
    expected = 0.3,
    absolute_error = abs,
    relative_error = rel,
    millimetres = 1200,
    metres = 1.2,
    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,
  "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 的对数关系

当天运行

命令运行同一份 GameMathLab,15 章共享一次进程和固定输入。本章的两条检查计入总通过数;进程退出码为零,未跳过任何章节。

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 ulp@1=2.220E-016
TOTAL pass=15 fail=0 skip=0 chapters=15 elapsed_ms=112.8
EXIT code=0

取舍与接口

本章选择 double 作为实验与协议推导的基准,换来更宽的数值余量;引擎接入仍可用 float,但必须把误差预算写进边界测试。没有引入单位类型库,因为当前最小工程只需少量显式换算;当单位字段跨模块、跨语言或进入内容表时,再升级为强类型或生成代码。

下一章只接收本章确定的米制位置、米制位移和 NearlyEqual 合同,在此基础上构造 Vec2。先有共同单位,向量加法才是几何运算,而不是把两个无标签数字碰巧相加。