2.3 回滚与追帧
2.2 节的 tryAdvance() 有一个硬伤:只要缺一个玩家的输入,它就返回 null,整个游戏停在原地等。真实网络里输入迟到、乱序、偶尔丢包都是常态——如果每次都老老实实等,玩家会频繁感到卡顿。
这一节给出帧同步的标准解法:别等,先按预测推进;真实的输入到了,如果和预测不一致,就回滚到那一帧重演。代价是要保留最近若干帧的状态快照。这一节把快照环与回滚重演写成可跑的 L1 代码,并解决配套的「追帧」问题。
2.3.1 三种策略:等、跳、回滚
输入没到时,客户端只有三种选择:
| 策略 | 做法 | 优点 | 代价 |
|---|---|---|---|
| 等 | 停在当前帧,直到输入齐 | 一定同步 | 最慢的人拖所有人,卡 |
| 跳帧 | 跳过没输入的帧,直接推进 | 不卡 | 两端跳的帧不同 → 状态分叉 |
| 回滚 | 先按预测推进,真值到了再回滚重演 | 不卡且最终一致 | 要存历史快照、要能重演 |
「跳帧」的致命处在于它破坏了确定性:你跳过了第 5 帧,别人跑了第 5 帧,两边状态就再也对不上了。所以帧同步只能用回滚——它把「等待」从「暂停」变成了「先猜后纠」。
回滚能成立的前提有两个,2.1 节刚好都准备好了:模拟是确定性的(所以重演能得到同一结果),状态可被完整复制(所以能存快照)。
2.3.2 状态快照环
回滚要能「回到第 k 帧」,就得存下每一帧的状态。用一个按帧号取模的环形缓冲,容量固定、自动淘汰最老的:
// assets/scripts/net/snapshot-ring.ts
export class SnapshotRing<T> {
private readonly slots: (T | undefined)[];
private readonly frames: number[];
private readonly capacity: number;
constructor(capacity: number) {
this.capacity = capacity;
this.slots = new Array<T | undefined>(capacity);
this.frames = new Array<number>(capacity).fill(-1);
}
set(frame: number, state: T): void {
const i = ((frame % this.capacity) + this.capacity) % this.capacity;
this.slots[i] = state;
this.frames[i] = frame;
}
get(frame: number): T | undefined {
const i = ((frame % this.capacity) + this.capacity) % this.capacity;
return this.frames[i] === frame ? this.slots[i] : undefined;
}
}
frames 数组用来识别「这个槽是不是还装着那一帧」——回绕之后,get(旧帧号) 会命中一个被新数据覆盖的槽,靠帧号比对就能返回 undefined,而不是错误的历史状态。
内存代价要算清楚。容量取 history 帧,每帧存一份完整状态:
| 项目 | 单份大小 | 120 帧快照环 |
|---|---|---|
| 单个实体状态(4 个 int32) | 16 B | 1.9 KB |
| 8 个实体 | 128 B | 15 KB |
| 32 个实体 + 道具 | ~1 KB | 120 KB |
history 一般取「最大可接受延迟」对应的帧数,60 Hz 下 120 帧 = 2 秒,足以覆盖绝大多数回滚深度。注意:环里存的是逻辑状态(定点整数),不是渲染对象;别把 Node 引用塞进来,那样既贵又难重演。
2.3.3 L1 实现:RollbackSim
把快照环、输入历史、预测与重演装进一个类。状态用《方块冲刺》双人竞速的最小形态:两名玩家各自的位置与速度。
// assets/scripts/net/rollback.ts
import { Fixed, fxAdd, fxFromFloat, fxMul } from './fixed';
import { SnapshotRing } from './snapshot-ring';
export interface RaceState {
x0: Fixed; vx0: Fixed; // 玩家 0
x1: Fixed; vx1: Fixed; // 玩家 1
}
const ACCEL = fxFromFloat(0.25);
const FRICTION = fxFromFloat(0.9);
export function stepRace(s: RaceState, keys: number): RaceState {
const a0 = (keys & 1) === 1 ? ACCEL : 0; // bit0:玩家 0 按右
const a1 = (keys & 2) === 2 ? ACCEL : 0; // bit1:玩家 1 按右
const vx0 = fxMul(fxAdd(s.vx0, a0), FRICTION);
const vx1 = fxMul(fxAdd(s.vx1, a1), FRICTION);
return { x0: fxAdd(s.x0, vx0), vx0, x1: fxAdd(s.x1, vx1), vx1 };
}
// 给定每帧组合输入,直接重演(回滚的「标准答案」)
export function replay(inputs: number[]): RaceState[] {
let s: RaceState = { x0: 0, vx0: 0, x1: 0, vx1: 0 };
const out: RaceState[] = [];
for (const keys of inputs) {
s = stepRace(s, keys);
out.push(s);
}
return out;
}
核心类只做三件事:预测推进、确认远端输入(必要时回滚)、按帧取状态。
export class RollbackSim {
private readonly ring: SnapshotRing<RaceState>;
private readonly local: number[] = [];
private readonly remote: number[] = [];
private head = -1; // 已模拟到的最后一帧
constructor(history = 120) {
this.ring = new SnapshotRing<RaceState>(history);
this.ring.set(0, { x0: 0, vx0: 0, x1: 0, vx1: 0 }); // 帧 0 之前的状态
}
lastFrame(): number { return this.head; }
// stateAt(f) = 帧 f 开始前的状态
stateAt(frame: number): RaceState {
const s = this.ring.get(frame);
if (!s) throw new Error(`snapshot for frame ${frame} evicted`);
return s;
}
// 预测推进一帧:本地输入已知,远端未知则沿用上一帧
predictStep(localKeys: number): void {
const f = this.head + 1;
this.local[f] = localKeys;
this.remote[f] = this.remote[f] ?? this.remote[f - 1] ?? 0;
this.simulate(f);
this.head = f;
}
// 收到远端第 frame 帧的真实输入;返回是否发生了回滚
applyRemote(frame: number, remoteKeys: number): boolean {
const predicted = this.remote[frame];
this.remote[frame] = remoteKeys;
if (predicted === remoteKeys) return false; // 预测对了,什么都不用做
for (let f = frame; f <= this.head; f += 1) this.simulate(f); // 回滚重演
return true;
}
private simulate(frame: number): void {
const before = this.ring.get(frame);
if (!before) throw new Error(`missing snapshot ${frame}`);
const keys = this.local[frame] | this.remote[frame];
this.ring.set(frame + 1, stepRace(before, keys));
}
}
两个细节值得点出:
- 快照存的是「帧开始前」的状态。
ring.set(f + 1, ...)把第 f 帧跑完的状态放在f + 1号槽,于是stateAt(f)拿到的就是「跑第 f 帧之前」的状态——回滚从这一帧重演,天然对齐。 - 预测错了才回滚。
applyRemote先比对新旧远端输入,相同就直接返回false;只有不一致时才从frame重演到head。多数帧的预测是对的,回滚是例外而非常态。
2.3.4 vitest:回滚后与直接重算一致
最关键的断言:预测错了、回滚重演之后,状态必须和「一开始就知道真实输入」完全一样。
import { describe, expect, it } from 'vitest';
import { replay, RollbackSim } from '../net/rollback';
describe('回滚重演', () => {
it('预测输入与真实输入不同时,回滚后状态与直接重算一致', () => {
const sim = new RollbackSim();
sim.predictStep(1); // 帧 0:本地按右(bit0)
sim.predictStep(1); // 帧 1
sim.predictStep(1); // 帧 2
// 帧 2 的远端真实输入其实是「按右」(bit1),与预测(沿用 0)不同
expect(sim.applyRemote(2, 2)).toBe(true); // 发生了回滚
// 「标准答案」:帧 2 的组合输入从一开始就是 1 | 2 = 3
const direct = replay([1, 1, 1 | 2]);
// stateAt(3) 是跑完帧 2 之后的状态,逐位相等
expect(sim.stateAt(3)).toEqual(direct[2]);
});
it('预测正确时不回滚', () => {
const sim = new RollbackSim();
sim.predictStep(1);
expect(sim.applyRemote(0, 0)).toBe(false);
});
});
第一个断言的 toEqual 比的是每个字段的整数,没有容差——回滚和直接重算要么完全相同,要么就是回滚实现有 bug。这正是 2.1 节确定性带来的红利:能用严格相等测试的,就别用近似。
2.3.5 追帧:落后太多时加速推进
回滚解决的是「单帧输入迟到」,但还有另一种落后:客户端整体比服务端慢了十几帧(刚进房间、切后台回来、连续丢包)。这时每帧只推进一步永远追不上。
做法是加速推进:本渲染帧里多跑几步逻辑,直到追上。
// 返回本渲染帧应该执行的逻辑步数
export function catchUpSteps(lag: number, maxPerFrame = 3): number {
if (lag <= 0) return 1; // 不落后:每帧 1 步
return Math.min(1 + lag, maxPerFrame); // 落后越多补越多,但有上限
}
上限 maxPerFrame 很关键:单帧补太多会让这一帧耗时暴涨,反过来又加剧落后,滑向 2.1 节讲过的死亡螺旋。默认 2–3 步是常见取值。
如果落后持续超过一个阈值(比如 60 帧 = 1 秒),说明加速也追不上,就别硬追了——请求重连、让服务端发一份全量快照,客户端从当前状态直接对齐。判据可以写成:
| 落后帧数 | 处理 |
|---|---|
| 0 | 正常,每帧 1 步 |
| 1 – 12 | 加速推进(每帧补到上限) |
| 13 – 60 | 加速 + 表现层先跳到最近快照,减少视觉滞后 |
| > 60 且持续 1 秒 | 判为追不上 → 走重连流程(第 5 章) |
「追不上就重连」比「无限追帧」更稳:前者是一次可控的重新同步,后者会让玩家看到越来越夸张的加速快进。
2.3.6 与第 4 章客户端预测的区别
这两处都叫「预测 / 回滚」,很容易混,必须分清:
| 维度 | 2.3 的回滚(帧同步) | 第 4 章的预测和解(状态同步) |
|---|---|---|
| 预测的是什么 | 别人的输入 | 自己的本地结果 |
| 权威是什么 | 输入(各端产生) | 服务端下发的状态快照 |
| 回滚触发 | 真实输入与预测输入不同 | 本地预测状态与服务端快照偏差超阈值 |
| 重演范围 | 从出错帧重算到当前帧 | 从快照点重放本地未确认输入 |
| 纠的是 | 「我算错了别人的操作」 | 「服务端不认同我的结果」 |
一句话:帧同步回滚的是「输入」,状态同步和解的是「状态」。本卷第 4 章会看到,和解流程里也有「回滚 + 重演」,但基准是服务端快照,不是本地输入历史。
2.3.7 接进 Cocos:驱动回滚循环
L2 侧只需一个组件,把「投递远端输入 → 预测推进」接进帧循环:
import { _decorator, Component } from 'cc';
const { ccclass } = _decorator;
// 与 L1 的 RollbackSim 对接的最小接口(真实工程从 net/rollback 引入)
interface RollbackLike {
applyRemote(frame: number, keys: number): boolean;
predictStep(localKeys: number): void;
}
@ccclass('RollbackDriver')
export class RollbackDriver extends Component {
private sim: RollbackLike | null = null;
private readonly pendingRemote = new Map<number, number>();
// 远端输入可能迟到,先存着,到帧循环里再统一应用
onRemoteInput(frame: number, keys: number): void {
this.pendingRemote.set(frame, keys);
}
update(): void {
if (!this.sim) return;
for (const [frame, keys] of this.pendingRemote) {
this.sim.applyRemote(frame, keys); // 预测错了会在这里回滚重演
}
this.pendingRemote.clear();
this.sim.predictStep(0); // 本地输入 + 预测远端,推进一帧
}
}
applyRemote 和 predictStep 都在 update 里同步执行完,回滚重演也是同步的——玩家看不到中间过程,只看到最终状态。这也是为什么快照环要够快:重演的每一帧都在这一帧的预算里跑完。
2.3.8 小结与下一节
第 2 章到这里闭环了:确定性(2.1) 让重演成为可能,输入对齐(2.2) 定义了推进的边界,回滚与追帧(2.3) 让输入迟到时游戏不卡也不失步。这套机制的代价是「所有人跑同一套模拟」——它适合双人竞速这种小规模对局,但当同屏实体很多、或需要服务端强裁决时,就要换到状态同步:只同步状态,不同步输入。下一节开始讲状态同步怎么把「服务端权威状态」搬到客户端。
阅读导航:上一节:2.2 输入广播与帧对齐 · 下一节:3.1 快照与增量同步 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。