《Cocos 游戏联机实战》2.3 回滚与追帧

输入迟到或丢包时,等会卡、跳帧会失步,回滚才是帧同步的正解。本节对比三种策略,给出状态快照环与回滚重演的纯 TS 实现、内存代价估算,用 vitest 断言预测错误时回滚后状态与直接重算逐位一致,并讲清追帧的步数上限与「追不上就重连」的判据。

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 B1.9 KB
8 个实体128 B15 KB
32 个实体 + 道具~1 KB120 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 快照与增量同步 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「cocos」更多文章

  1. 《Cocos 游戏联机实战》9.3 延迟补偿与体验权衡
  2. 《Cocos 游戏联机实战》9.2 网络状态 UI 与提示
  3. 《Cocos 游戏联机实战》9.1 同步表现与插值动画