快照插值缓冲的乱序恢复与抖动预算
时间问题
状态同步客户端接收到的 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.5 和 value=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 合同决定保持或交给预测层。
缓冲容量达到八项时,当前探针删除最旧服务端时间样本。生产实现还应记录连续保持次数和被丢弃重复包数量:前者是延迟预算不足的信号,后者用于区分网络重传与客户端排序错误。只有这些信号持续进入帧预算,才值得升级容器或调节延迟。