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 的输入队列之上:
- 丢已确认的输入。 用快照的
ackSeq,把未确认队列里seq <= ackSeq的输入删掉——它们的结果已经被权威覆盖,不再需要重演。 - 从权威状态出发,重放剩余输入。 把
state设回服务端快照给的权威值,然后按顺序把队列里剩下的每一个输入重新step一遍,得到一个「如果服务端也算这些输入、会得到什么」的状态,记为replayed。 - 比误差。 把
replayed和当前的预测状态比一比。如果本地预测是确定的、且过去没有出过错,两者应当完全相等,误差为 0。 误差真正度量的是「我的预测和权威在过去某个点上分道扬镳了多远」。 - 按误差大小决定怎么改。 误差小 → 平滑收敛;误差大 → 硬切。
第 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 ms | 60 Hz | 20 Hz | 3 | 60 |
| 100 ms | 60 Hz | 20 Hz | 6 | 120 |
| 300 ms | 60 Hz | 20 Hz | 18 | 360 |
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 工程决策
- 永远和
replayed比,不和snapshot.state比。 后者会把「正常超前」误判成误差。 - 必须留死区。 没有
deadZone,数值噪声会变成新的抖动源。 - 硬切要打点。 硬切频率是「预测质量」最直接的指标,频繁硬切说明客户端和服务端规则不一致。
- 只重演未确认输入。 重演范围一旦扩大到历史帧,CPU 会随对局时长线性上涨。
- 平滑与硬切可以共存。 小误差缓、大误差快,玩家只会记得「偶尔卡一下」,不会记得「一直在抖」。
预测负责「先动」,和解负责「改对」。但这两件事都只针对自己的角色。屏幕上还有另一个人,他的位置由别人那边的输入决定——客户端既不能预测他的输入,也不该让他的位置一跳一跳。这就轮到下一节的插值与外推。
阅读导航:上一节:4.1 本地预测 · 下一节:4.3 插值与外推 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。