游戏中的数学 15:从单机坐标到可同步世界
世界不能只用 float
单机运动体已经有固定 Tick、碰撞和相机,但坐标绝对值持续增大时,浮点可表示间隔也会变大;网络再量化一次,误差来源便混在一起。最终章先把世界位置拆成整数分区与小范围局部坐标。
// $WORK_DIR/.tmp/game-math-lab/Program.cs
// CellPoint 与 GameMathLab.SplitCell
readonly record struct CellPoint(
double World,
long Cell,
double Local);
static CellPoint SplitCell(double world, double size)
{
var cell = (long)Math.Floor((world + size / 2) / size);
var local = world - cell * size;
return new(world, cell, local);
}
权威状态拥有 Cell+Local,渲染和网络只消费快照。分区大小固定为 256m,局部值保持在约 [-128,128);跨区只改变表示,不改变世界位置。
分区编号不能由每个客户端根据浮点位置独立猜测后参与权威逻辑。确定性 Tick 必须在越界时原子提交新 Cell 与新 Local;观察者若只收到其中一半,会看到整整一个分区的跳变。快照因此要把二者视为一个值对象。
分区重构
重构公式只有 world=cell·256+local。固定样本同时覆盖负坐标、边界附近与多分区正坐标;最大重构误差为零。使用 floor((world+128)/256) 而不是向零截断,负坐标才能保持对称区间。
相邻分区对象做碰撞时,先选择共同参考 Cell,再用局部差计算,不应先重构成巨大 float 世界坐标。远距离对象只做分区级粗筛,进入邻域后才转换局部值;精度策略由此与场景流送边界一致。
// Program.cs · Chapter15 分区样本与不变量
const double cellSize = 256;
var positions = new[] {
-520.25, -128.0, 0.0,
127.999, 300.5, 819.75
};
var encoded = positions
.Select(x => SplitCell(x, cellSize))
.ToArray();
var maxRebuild = encoded.Max(e =>
Math.Abs(e.Cell * cellSize + e.Local - e.World));
Check(maxRebuild < Eps,
"cell plus local reconstructs world coordinate");

量化预算
网络快照只在一个分区内编码局部坐标。16 位覆盖 256m,量化步长为 256/65535≈0.00390631m,四舍五入最大误差不应超过半步。程序扫描 10,000 个等距输入验证这一上界。
位置量化器在边界做 clamp,超范围值饱和到端点而不是绕回。饱和必须计数,因为它意味着发送端遗漏跨区更新;接收端不能把饱和值误判成合法高速移动。角度则使用独立周期映射:先归一到 [0,360),按 2^bits 个环形桶舍入,再用最短角差计算误差。
// Program.cs · QuantizeSigned 与误差扫描
static double QuantizeSigned(
double value, double extent, int bits)
{
if (extent <= 0 || bits < 2 || bits > 30)
throw new ArgumentOutOfRangeException();
var levels = (1L << bits) - 1;
var n = Math.Clamp(
(value + extent) / (2 * extent), 0, 1);
var q = Math.Round(n * levels);
return q / levels * (2 * extent) - extent;
}
var qErrors = new List<double>();
for (var i = 0; i < 10_000; i++) {
var x = -128 + 256.0 * i / 9999;
qErrors.Add(Math.Abs(
QuantizeSigned(x, 128, 16) - x));
}
Check(qErrors.Max() <= 256.0 / 65535 / 2 + 1e-9,
"16-bit quantization respects half-step bound");
// Program.cs · 周期角度与实际快照字段
readonly record struct QuantizedSnapshot(
int CellX, int CellY,
ushort LocalX, ushort LocalY,
ushort Yaw, uint Tick);
static ushort QuantizeAngle(double degrees, int bits)
{
var wrapped = ((degrees % 360) + 360) % 360;
var bins = 1 << bits;
return (ushort)((long)Math.Round(
wrapped / 360 * bins) % bins);
}
var snapshot = new QuantizedSnapshot(
-2, 3,
QuantizeToUInt16(-8.25, 128),
QuantizeToUInt16(51.75, 128),
QuantizeAngle(725, 16),
42);
static byte[] EncodeSnapshot(QuantizedSnapshot s)
{
var bytes = new byte[18];
BinaryPrimitives.WriteInt32LittleEndian(bytes.AsSpan(0, 4), s.CellX);
BinaryPrimitives.WriteInt32LittleEndian(bytes.AsSpan(4, 4), s.CellY);
BinaryPrimitives.WriteUInt16LittleEndian(bytes.AsSpan(8, 2), s.LocalX);
BinaryPrimitives.WriteUInt16LittleEndian(bytes.AsSpan(10, 2), s.LocalY);
BinaryPrimitives.WriteUInt16LittleEndian(bytes.AsSpan(12, 2), s.Yaw);
BinaryPrimitives.WriteUInt32LittleEndian(bytes.AsSpan(14, 4), s.Tick);
return bytes;
}
var orientation = Quaternion.CreateFromAxisAngle(
Vector3.UnitZ, (float)Math.Atan2(20, 100));
var velocity = Vector3.Transform(
new Vector3((float)Math.Sqrt(10400), 0, 0), orientation);
var motion = MoveCircleAgainstWall(
new(-1, 0), new(velocity.X, velocity.Y),
1.0 / 60, .25, 0, .1);
var screen = Project(
new(motion.Position.X, motion.Position.Y, -5),
1920, 1080, 60, 1920.0 / 1080);
var cell = SplitCell(motion.Position.X + 512, 256);
var outgoing = new QuantizedSnapshot(
(int)cell.Cell, 0, QuantizeToUInt16(cell.Local, 128),
QuantizeToUInt16(motion.Position.Y, 128),
QuantizeAngle(11.309932, 16), 43);
var wire = EncodeSnapshot(outgoing);
Check(motion.Hit && float.IsFinite(screen.X) &&
wire.Length == 18 && DecodeSnapshot(wire) == outgoing,
"one mover reaches collision, projection and snapshot");
10,000 个 [-720°,720°] 固定角度样本的最大最短角差为 0.002746307°,低于 16 位环形桶半步 360/65536/2。样例 725° 先环绕为 5°,写入 Yaw=910。程序使用显式小端编码器逐字段写入缓冲区,并断言输出恰为 18 字节;这个结论不依赖 C# 结构体的内存布局。

快照时间轴
固定网络实验运行 12 秒:20Hz 发送,60Hz 渲染,默认插值目标落后当前时间 100ms;延迟为 80±20ms,丢包概率 10%,随机种子固定为 1515。收到的快照按服务器时间排序;有前后样本时插值,只有左侧样本时保持最后位置。除启动前尚无任何快照的帧外,每个显示帧都计入误差。
// Program.cs · SimulateSnapshots 核心时序
const double duration = 12;
const double sendDt = .05;
const double renderDt = 1.0 / 60;
const double buffer = .1;
for (var t = 0.0; t <= duration; t += sendDt) {
if ((t >= 5 && t < 5.5) ||
random.NextDouble() < .1) {
dropped++;
continue;
}
var delay = .08 + (random.NextDouble() * 2 - 1) * .02;
var truth = Truth(t);
var quantized = new Vector2(
(float)QuantizeSigned(truth.X, 128, 16),
(float)QuantizeSigned(truth.Y, 128, 16));
packets.Add(new(t, t + delay, quantized));
}
var target = now - buffer;
var before = received.LastOrDefault(p => p.Server <= target);
var after = received.FirstOrDefault(p => p.Server >= target);
if (before is null || target < 0) continue; // 仅跳过启动
Vector2 shown;
if (after is not null && after.Server > before.Server + 1e-9) {
var f = (target - before.Server) /
(after.Server - before.Server);
shown = Vector2.Lerp(before.Position, after.Position, (float)f);
interpolated++;
} else {
shown = before.Position;
held++;
}
var error = Vector2.Distance(shown, Truth(target));
sum2 += error * error;
samples++;
正常时序是接收两侧快照后插值;失败时序是丢包或抖动让右侧缺失,此帧保持最后快照并照常与真值比较,不做无界外推。实验固定注入 5.0–5.5s 的发送空洞,默认配置从空洞结束到再次形成可插值目标用了 150ms;恢复点要求目标时间越过 5.5 秒且右侧确实是断流后的快照。
100ms 缓冲等于本实验最大单程到达延迟,但随机丢包与 500ms 空洞仍会产生保持帧。程序又遍历 10/12/14/16 位与 50/100/150ms 缓冲的 12 个组合:50ms 低于到达延迟,几乎无法形成右侧样本;150ms 把默认 16 位 RMSE 从 0.185576m 降到 0.151273m,代价是多 50ms 显示延迟。
固定运行
进程退出码为 0,15 章全部通过。默认配置收到 210 个快照、丢弃 30 个;启动后 713 个显示样本全部计入,其中 270 个插值、443 个保持。全程 RMSE 为 0.185576m,最大误差 1.398756m,固定空洞区间最大误差 1.308509m。这组数真实包含 16 位量化、随机抖动、随机丢包、保持策略与固定空洞;18 字节只是当前字段预算。
cd "$WORK_DIR"
/usr/bin/time -p dotnet run \
--project .tmp/game-math-lab/GameMathLab.csproj \
-- --out .tmp/game-math-lab/out
CH15 PASS crossings=6 rebuild=0.000E+000m qmax=0.001953m angle_max=0.002746deg bytes=18 integrated=pass received=210 dropped=30 samples=713 held=443 rmse=0.185576m outage_max=1.308509m recovery=150.0ms
TOTAL pass=15 fail=0 skip=0 chapters=15 elapsed_ms=112.8
exit_code=0
{
"cell_size_m": 256,
"max_rebuild_error_m": 0,
"quantized_position_bits": 16,
"quantization_max_error_m": 0.0019525687977477446,
"quantized_angle_bits": 16,
"angle_max_error_deg": 0.0027463073455464837,
"sample_snapshot": {
"CellX": -2, "CellY": 3,
"LocalX": 30656, "LocalY": 46015,
"Yaw": 910, "Tick": 42
},
"snapshot_bytes": 18,
"integrated_mover": {
"velocity_mps": {"x": 100, "y": 20},
"collision_position_m": {"x": -0.25, "y": 0.3333333},
"screen_px": {"x": 913.2346, "y": 477.64618},
"cell_x": 2,
"cell_y": 0,
"rebuild_error_m": 0,
"snapshot": {
"CellX": 2, "CellY": 0,
"LocalX": 32704, "LocalY": 32853,
"Yaw": 2059, "Tick": 43
},
"wire_bytes": 18,
"roundtrip": true
},
"network": {
"Received": 210,
"Dropped": 30,
"Samples": 713,
"InterpolatedSamples": 270,
"HeldSamples": 443,
"Rmse": 0.18557614338107617,
"MaxError": 1.3987560272216797,
"OutageMaxError": 1.3085088729858398,
"RecoveryMs": 149.99999999998525
}
}
全卷产物
18 字节是 16 位位置快照经过显式编码后的实测长度,不是所有游戏的标准答案。图中 10/12 位的 17 字节、14/16 位的 18 字节则是假设同样字段并紧凑位打包的线预算;更多位数降低量化误差却增加带宽,更长缓冲抵抗抖动却增加显示延迟。升级触发条件应来自包含保持帧的可见误差预算、分区跨越频率和实际丢包分布,而不是先造通用协议层。
终章另有一个集成样本:同一移动体先由四元数得到世界速度,在固定 Tick 内做连续碰撞并消耗切向余量,再投影到屏幕、拆成 Cell+Local,最后构造量化快照。它与各章的专项边界测试共同组成一条可运行、可断言、可用固定数据重建 30 张数学图的工程;数值误差、曲线、距离和射线等章节仍各自保留独立的合同测试,不假装共享一份可变运行状态。