《Cocos 游戏联机实战》4.2 服务器和解与重演

收到服务端快照后客户端该怎么处理:对比直接接受、平滑插值与回滚重演三种做法,讲清回滚重演的完整流程与误差阈值如何避免「橡皮筋」,给出 reconciler 的 L1 纯 TS 实现与「丢包 30% 下预测位置仍收敛」「误差超阈值触发硬切」的 vitest 断言,并估算每帧重演的 CPU 代价。

4.2 服务器和解与重演

4.1 让客户端在权威到达之前先动了起来,但那只是「猜」。现在服务端的快照回来了——{ackSeq: N, state}——客户端必须回答一个问题:我手上这份预测状态,和权威对不上时,到底该听谁的、怎么改?

这一节只做一件事:把这个「对不上」处理干净。既不能把玩家的操作感改没,也不能让角色在屏幕上抽搐。

先交代快照的形态:本节说的 Snapshot 不是 3.1 那种实体级增量,而是客户端把实体快照应用到本地世界后、针对自己角色的那一份——{ ackSeq, state }。真实消息里同时还带 serverTimeMs(1.3 的时钟对齐用),本节只用 ackSeq 对账,serverTimeMs 留给 4.3 的时间轴对齐。

4.2.1 收到快照后,三种做法

面对一份快照,客户端有且只有三种处理方式。它们的差别不在「对不对」,而在把代价放在了哪里:

做法现象代价
直接接受服务端状态本地位置瞬间跳到权威值,哪怕只差几像素也照跳无 CPU,但每次有偏差都「瞬移」,抖动明显
平滑插值到服务端状态画面平滑,但预测被「拖着走」,操作手感变钝小,但与预测目标冲突
回滚重演结果正确、手感连续,只在不一致时才动CPU 与实现复杂度

第一种错得最直接:快照 20 Hz 到达,如果每次都硬贴,本地位置就会以 20 Hz 的节奏一顿一顿。第二种看似聪明,实则把「平滑」用错了地方——平滑是给远端玩家的(4.3 讲),自己的角色必须跟手,拿插值去抹平自己角色的预测误差,等于主动增加输入延迟。第三种才是正解:只有检测到真实误差时才回滚,其余时间原样保留预测。

4.2.2 回滚重演的完整流程

回滚重演(rollback & replay)的流程一共四步,每一步都建立在 4.1 的输入队列之上:

  1. 丢已确认的输入。 用快照的 ackSeq,把未确认队列里 seq <= ackSeq 的输入删掉——它们的结果已经被权威覆盖,不再需要重演。
  2. 从权威状态出发,重放剩余输入。 把 state 设回服务端快照给的权威值,然后按顺序把队列里剩下的每一个输入重新 step 一遍,得到一个「如果服务端也算这些输入、会得到什么」的状态,记为 replayed。
  3. 比误差。 把 replayed 和当前的预测状态比一比。如果本地预测是确定的、且过去没有出过错,两者应当完全相等,误差为 0。 误差真正度量的是「我的预测和权威在过去某个点上分道扬镳了多远」。
  4. 按误差大小决定怎么改。 误差小 → 平滑收敛;误差大 → 硬切。

第 2 步是理解回滚重演的钥匙:重演不是「重算历史」,而是「拿权威当新的起点,把还没被确认的输入再走一遍」。 因此重演的帧数恒等于队列长度,也就恒等于 RTT 内的逻辑帧数——这一点在 4.2.6 会变成具体的 CPU 数字。

4.2.3 误差阈值与「橡皮筋」

如果对任何误差都硬切,会出现经典的手感灾难——橡皮筋(rubber-banding):玩家在本地跑到某处,服务端说「你没那么快」,客户端立刻把角色拽回去,玩家下一帧又往前冲,又被拽回来,屏幕上的角色来回弹。

解法是分档,用两个阈值把误差分成三段:

误差区间处理理由
error <= deadZone什么都不做几像素的抖动属于浮点/顺序噪声,不值得动
deadZone < error <= smoothThreshold平滑收敛(向权威靠近一部分)小偏差,硬切反而更明显,缓一缓更自然
error > smoothThreshold硬切(直接采用重演结果)大偏差说明预测真的错了,越拖越糟

关键在 deadZone:它不是「忽略误差」,而是「忽略噪声」。 没有死区,任何亚像素级的数值抖动都会触发纠正,纠正在 60 Hz 下累积起来就成了新的抖动源。而 smoothThreshold 要设得比「正常预测误差」大——正常误差来自「重演结果里也包含未确认输入」这一事实之外的真实分歧,通常只有几像素;设成几十像素,就能只在真正被服务端否决时才硬切。

4.2.4 L1:给预测器补上和解所需的方法

和解要用到两个 4.1 里还没给的方法:一个把状态朝目标靠拢的 nudge,和它依赖的 lerpState。先补上:

// assets/scripts/net/predict/sim.ts —— 追加
export function lerpState(a: PlayerState, b: PlayerState, t: number): PlayerState {
  return {
    x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t,
    vx: a.vx + (b.vx - a.vx) * t, vy: a.vy + (b.vy - a.vy) * t,
    onGround: b.onGround,
  };
}
// assets/scripts/net/predict/predictor.ts —— 追加到 Predictor
  /** 平滑收敛:把预测状态按 factor 比例朝 target 靠拢。 */
  nudge(factor: number, target: PlayerState): void {
    this.predicted = lerpState(this.predicted, target, factor);
  }

lerpState 也补进 predictor.ts 顶部的 import:import { lerpState, step, ... } from './sim'。

4.2.5 L1:和解器

和解器只依赖 Predictor 的公开方法,因此可以独立单测:

// assets/scripts/net/predict/reconciler.ts —— 纯 TS,无 cc 依赖
import { step, type PlayerState } from './sim';
import type { Predictor, Snapshot } from './predictor';

export interface ReconcileOptions {
  smoothThreshold: number; // 超过此误差直接硬切
  deadZone: number;        // 小于此误差视为噪声,不动
  smoothFactor: number;    // 平滑收敛系数 0–1
}

export interface ReconcileResult {
  hardCut: boolean;
  replayed: number; // 本次重演了多少个未确认输入
  error: number;
}

export class Reconciler {
  private options: ReconcileOptions;
  private lastReplayed = 0;

  constructor(options: ReconcileOptions) {
    this.options = options;
  }

  apply(predictor: Predictor, snapshot: Snapshot): ReconcileResult {
    const pending = predictor.dropAcked(snapshot.ackSeq);

    // 从权威状态出发,重放所有未确认输入,得到「本帧应有的状态」
    let replayed: PlayerState = { ...snapshot.state };
    for (const input of pending) replayed = step(replayed, input);

    const error = Math.hypot(
      predictor.state.x - replayed.x,
      predictor.state.y - replayed.y,
    );
    this.lastReplayed = pending.length;

    if (error > this.options.smoothThreshold) {
      predictor.reset(replayed); // 硬切:直接采用重演结果
      return { hardCut: true, replayed: pending.length, error };
    }
    if (error > this.options.deadZone) {
      predictor.nudge(this.options.smoothFactor, replayed); // 平滑收敛
    }
    return { hardCut: false, replayed: pending.length, error };
  }

  get replayCount(): number { return this.lastReplayed; }
}

注意 error 是拿当前预测状态和 replayed 比,而不是和 snapshot.state 直接比。这两者的差别很关键:snapshot.state 是「服务端处理到 ackSeq 时的状态」,而本地早就跑到更后面了;拿它俩直接比,永远会得到「未确认输入造成的那一段距离」,那不是误差,是正常超前。只有和 replayed 比,才是在比「同一个时刻、同一条输入序列下的两个结果」,误差才有意义。

4.2.6 vitest:丢包 30% 下收敛与超阈值硬切

第一个断言验证:即便 30% 的快照被丢弃,预测位置仍然收敛到服务端状态。为了让误差真实存在,测试里让服务端在固定帧做两次「权威修正」,客户端并不知情:

import { describe, it, expect } from 'vitest';
import { FakeClock } from '../net/transport/fake-clock';
import { SimulatedTransport } from '../net/transport/simulated-transport';
import { Predictor, type Snapshot } from '../net/predict/predictor';
import { Reconciler } from '../net/predict/reconciler';
import { step, type PlayerState } from '../net/predict/sim';

const START: PlayerState = { x: 0, y: 0, vx: 0, vy: 0, onGround: true };

describe('4.2 和解与重演', () => {
  it('丢包 30% 下,预测位置仍收敛到服务端状态', () => {
    const clock = new FakeClock();
    const transport = new SimulatedTransport(clock,
      { latencyMs: 60, jitterMs: 10, loss: 0.3, reorder: 0.1 }, 42);
    const predictor = new Predictor(START);
    const reconciler = new Reconciler({ smoothThreshold: 20, deadZone: 0.5, smoothFactor: 0.5 });

    // 测试替身:服务端权威推进(真实服务端见服务端书稿)
    let server: PlayerState = { ...START };
    transport.onMessage((msg) => {
      if (msg.type === 'snapshot') reconciler.apply(predictor, msg.payload as Snapshot);
    });

    for (let frame = 0; frame < 180; frame++) {
      const moveX = frame % 20 < 10 ? 1 : 0;
      const seq = predictor.applyInput(moveX, false);
      server = step(server, { seq, moveX, jump: false });
      if (frame === 60 || frame === 120) server = { ...server, x: server.x + 6 }; // 权威修正
      transport.send({ type: 'snapshot', payload: { ackSeq: seq, state: { ...server } } });
      clock.advance(16);
      transport.pump();
    }
    clock.advance(300);
    transport.pump();

    expect(Math.abs(predictor.state.x - server.x)).toBeLessThanOrEqual(2);
  });
});

第二个断言验证硬切路径:构造一个远超阈值的错误快照,和解器必须判定为硬切,并把全部未确认输入重演一遍:

  it('误差超阈值时触发硬切', () => {
    const predictor = new Predictor(START);
    const reconciler = new Reconciler({ smoothThreshold: 20, deadZone: 0.5, smoothFactor: 0.5 });
    predictor.applyInput(1, false);
    predictor.applyInput(1, false);
    const bad: Snapshot = { ackSeq: -1, state: { ...START, x: 500 } };
    const res = reconciler.apply(predictor, bad);
    expect(res.hardCut).toBe(true);
    expect(res.replayed).toBe(2);
  });

两个断言合起来,覆盖了和解的两条分支:小误差走平滑(最终收敛)、大误差走硬切(一次到位)。 丢包只影响「多久收到一次纠正」,不影响「收到后能否纠正」——这正是预测能扛住弱网的底层原因。

4.2.7 CPU 代价估算:每帧重演多少帧

回滚重演的 CPU 开销 = 快照到达频率 × 每次重演的帧数,而重演帧数等于未确认队列长度(约等于 RTT 内的逻辑帧数)。给几个量级:

RTT逻辑帧率快照频率队列长度(≈)每秒 step 次数
50 ms60 Hz20 Hz360
100 ms60 Hz20 Hz6120
300 ms60 Hz20 Hz18360

step 是十几条整数运算,360 次/秒对现代设备是噪声级开销——即便在弱网下,回滚重演也几乎不可能成为瓶颈。真正会失控的是「每次都全量重演历史」,所以务必记住:只重演未确认输入,绝不重演已经 ack 的部分。若某天队列长度异常大(比如网络卡死导致几百帧未确认),应当先触发降级(5.3),而不是硬扛着重演。

4.2.8 接进 Cocos:把和解接进帧循环(L2)

和解器是纯逻辑,接进 Cocos 时它挂在「接收快照 → 更新节点」这条链上。快照由 5.1 的 Session 分发,组件只负责调用和解并处理硬切:

import { _decorator, Component, Node } from 'cc';
import { Predictor, type Snapshot } from './predictor';
import { Reconciler } from './reconciler';
const { ccclass, property } = _decorator;

/** 快照消息的最小形态。完整定义(含房间、匹配等消息)见 5.1 的 net/session/session.ts */
interface NetMessage {
    type: string;
    payload?: unknown;
}

@ccclass('ReconcileDriver')
export class ReconcileDriver extends Component {
    @property(Node)
    playerNode: Node | null = null;

    private predictor = new Predictor({ x: 0, y: 0, vx: 0, vy: 0, onGround: true });
    private reconciler = new Reconciler({ smoothThreshold: 20, deadZone: 0.5, smoothFactor: 0.5 });

    /** 由 Session 的消息分发器调用(见 5.1) */
    onSnapshot(msg: NetMessage): void {
        if (msg.type !== 'snapshot') return;
        const res = this.reconciler.apply(this.predictor, msg.payload as Snapshot);
        if (res.hardCut) console.warn(`reconcile hard cut, error=${res.error.toFixed(1)}`);
    }

    lateUpdate() {
        const s = this.predictor.state;
        this.playerNode?.setPosition(s.x / 100, -s.y / 100, 0);
    }
}

两个细节:和解在 onSnapshot 里做(事件驱动,不占每帧预算),节点位置在 lateUpdate 里刷新(保证晚于本地输入采样);硬切时打日志,方便 7.3 线上定位「玩家报告被拽回」的问题。

4.2.9 工程决策

  1. 永远和 replayed 比,不和 snapshot.state 比。 后者会把「正常超前」误判成误差。
  2. 必须留死区。 没有 deadZone,数值噪声会变成新的抖动源。
  3. 硬切要打点。 硬切频率是「预测质量」最直接的指标,频繁硬切说明客户端和服务端规则不一致。
  4. 只重演未确认输入。 重演范围一旦扩大到历史帧,CPU 会随对局时长线性上涨。
  5. 平滑与硬切可以共存。 小误差缓、大误差快,玩家只会记得「偶尔卡一下」,不会记得「一直在抖」。

预测负责「先动」,和解负责「改对」。但这两件事都只针对自己的角色。屏幕上还有另一个人,他的位置由别人那边的输入决定——客户端既不能预测他的输入,也不该让他的位置一跳一跳。这就轮到下一节的插值与外推。

阅读导航:上一节:4.1 本地预测 · 下一节:4.3 插值与外推 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「cocos」更多文章

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