游戏中的数学 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();

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

当天运行
命令运行同一份 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。先有共同单位,向量加法才是几何运算,而不是把两个无标签数字碰巧相加。