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

游戏中的数学 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° 先环绕为 ,写入 Yaw=910。程序使用显式小端编码器逐字段写入缓冲区,并断言输出恰为 18 字节;这个结论不依赖 C# 结构体的内存布局。

缓冲时长、量化位数与实测显示 RMSE

快照时间轴

固定网络实验运行 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 张数学图的工程;数值误差、曲线、距离和射线等章节仍各自保留独立的合同测试,不假装共享一份可变运行状态。