快照插值缓冲的乱序恢复与抖动预算

时间问题

状态同步客户端接收到的 UDP 快照可能乱序、重复或暂时断档。渲染层若直接使用最后到达包,网络到达顺序会变成画面时间线。时段 2026-07-28-2000 的 Node.js 探针定义一个最小 SnapshotBuffer:以服务端毫秒时间排序,以 now - delay 为目标时间,并用 maxGapMs 限制可插值区间。

// .tmp/daily-labs/2026-07-28-2000/snapshot-buffer.mjs · SnapshotBuffer
class SnapshotBuffer {
  constructor(delayMs, maxGapMs) {
    this.delayMs = delayMs;
    this.maxGapMs = maxGapMs;
    this.items = [];
  }

  push(snapshot) {
    if (this.items.some(x => x.tick === snapshot.tick)) return false;
    this.items.push(snapshot);
    this.items.sort((a, b) => a.timeMs - b.timeMs);
    if (this.items.length > 8) this.items.shift();
    return true;
  }
}

快照插值缓冲主视觉

缓冲所有权

网络接收层只提交不可变快照,缓冲拥有排序、去重和保留窗口,渲染层只消费采样结果。两个不变量限定错误语义:相同 tick 只能出现一次;相邻样本间隔超过 120 毫秒时禁止外推。把去重留给渲染组件会让每个实体各自解释网络历史,既增加状态,也无法保证同一帧采用一致目标时间。

这里区分三种时间:服务端快照时间决定样本顺序,本地渲染时间决定采样时刻,网络到达时间只用于观测抖动。若用到达时间排序,晚到的旧包会被误认成最新状态;若每个实体自行读取当前时钟,同一快照帧还可能出现不同目标时间。

// .tmp/daily-labs/2026-07-28-2000/snapshot-buffer.mjs · sample
  sample(nowMs) {
    const target = nowMs - this.delayMs;
    if (this.items.length === 0) {
      return { mode: 'hold', value: null };
    }
    const right = this.items.findIndex(x => x.timeMs >= target);
    if (right === -1) {
      return { mode: 'hold', value: this.items.at(-1).value };
    }
    if (right === 0) {
      return { mode: 'hold', value: this.items[0].value };
  }
  const a = this.items[right - 1];
  const b = this.items[right];
  const gap = b.timeMs - a.timeMs;
  if (gap > this.maxGapMs) {
    return { mode: 'hold', value: a.value };
  }
  const t = (target - a.timeMs) / gap;
  return {
    mode: 'interpolate',
    value: a.value + (b.value - a.value) * t,
    alpha: t
  };
}

正常采样

固定输入按 150、50、100 毫秒的到达顺序写入,缓冲恢复为 50、100、150 毫秒。当前时间为 225 毫秒、插值延迟为 100 毫秒,因此目标时间为 125 毫秒,正好位于值 10 与 15 的中点,得到 alpha=0.5value=12.5。计算只依赖服务端时间,不依赖包抵达本机的先后。

插值因子由 (125-100)/(150-100) 直接复算。探针只用一维值隔离时间合同;在真实 Transform 上,同一因子可分别驱动位置线性插值与旋转球面插值,但不能改变样本选择和断档判定。

参数 数值
插值延迟 100 ms
最大样本间隔 120 ms
目标时间 125 ms
插值结果 12.5

快照时间预算技术图

断档退化

当相邻样本位于 0 与 300 毫秒时,目标虽落在两者之间,但 300 毫秒缺口已经超过预算。缓冲返回左样本的 hold,不推测不存在的速度。目标晚于最新样本时,findIndex 返回负一,此时必须保持最新值而不是最旧值;重复 tick 也被拒绝且不改变数组长度。保持可能产生短暂停顿,却把误差限制在最后确认状态;盲目外推画面更连续,但会把网络缺口转化为无限位置误差。

// .tmp/daily-labs/2026-07-28-2000/snapshot-buffer.mjs · 失败回归
run('LargeGapFallsBackToHold', () => {
  const buffer = new SnapshotBuffer(100, 120);
  buffer.push({ tick: 1, timeMs: 0, value: 0 });
  buffer.push({ tick: 2, timeMs: 300, value: 30 });
  const sample = buffer.sample(250);
  check(sample.mode === 'hold' && sample.value === 0,
    '超预算缺口不得外推');
});

run('DuplicateTickIsRejected', () => {
  const buffer = new SnapshotBuffer(100, 120);
  check(buffer.push({ tick: 7, timeMs: 70, value: 7 }),
    '首次快照应接收');
  check(!buffer.push({ tick: 7, timeMs: 90, value: 9 }),
    '重复 tick 应拒绝');
  check(buffer.items.length === 1, '重复项不得改变缓冲区');
});

run('AfterLatestHoldsLatestSnapshot', () => {
  const buffer = new SnapshotBuffer(100, 120);
  buffer.push({ tick: 1, timeMs: 100, value: 10 });
  buffer.push({ tick: 2, timeMs: 150, value: 15 });
  const sample = buffer.sample(400);
  check(sample.mode === 'hold' && sample.value === 15,
    '目标晚于最新样本时必须保持最新值');
});

运行证据

Node.js 运行退出码为零,四项断言全部通过。探针内部耗时以本次输出为准,只描述四条固定测试,不用于推导吞吐能力。真实行为数据写入 evidence.json,包括 100 毫秒延迟、120 毫秒上限、12.5 插值结果,以及目标晚于最新样本时保持值 15 的输出。

cd "$WORK_DIR/.tmp/daily-labs/2026-07-28-2000"
node snapshot-buffer.mjs
# exit_code=0
# passed=4 failed=0 skipped=0
# elapsed_ms=0.12
# fixed_output mode=interpolate value=12.5 alpha=0.5
# invariant=never_extrapolate_across_gap_over_budget
{
  "slot": "2026-07-28-2000",
  "passed": 4,
  "failed": 0,
  "skipped": 0,
  "fixed_input": {
    "delay_ms": 100,
    "max_gap_ms": 120,
    "target_ms": 125
  },
  "fixed_output": {
    "value": 12.5,
    "alpha": 0.5
  },
  "after_latest": {
    "mode": "hold",
    "value": 15
  }
}

容量边界

当前八项数组每次插入后排序,复杂度上限足以覆盖小规模实体探针,但不是大规模客户端的最终容器。当每实体快照数、实体数或乱序窗口使排序成本进入帧预算,应改为按 tick 的环形缓冲并在接收阶段批量整理;当长缺口频繁出现,则需要自适应延迟或与预测层协作。无论升级哪一层,插值缓冲只平滑已确认样本,不能接管玩法预测。

自适应延迟也不能逐帧任意变化,否则目标时间会前后跳跃。可接受的升级应对抖动分位数做低频评估,并限制延迟调整速率;一旦进入长缺口,仍由同一个 maxGapMs 合同决定保持或交给预测层。

缓冲容量达到八项时,当前探针删除最旧服务端时间样本。生产实现还应记录连续保持次数和被丢弃重复包数量:前者是延迟预算不足的信号,后者用于区分网络重传与客户端排序错误。只有这些信号持续进入帧预算,才值得升级容器或调节延迟。