游戏中的数学 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,不能把三个点等距画在数轴上。

状态所有权也从这里开始:业务层拥有“这是 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 为假;若结算、状态机或快照去重依赖这个判断,同一逻辑状态会分叉。另一种失败是先丢失单位再比较,例如把 1200 与 1.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 / 1000 与 1.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
}
]
}

实验结果
这条回归同时约束单位转换、浮点残差与 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。共同单位是向量加法具有几何意义的前提,避免无标签数值之间的偶然相加。