场景:按下技能键,画面先动还是先等
《潮汐方舟》里,玩家按下技能键到看到角色起手,中间隔着一段网络。如果客户端老老实实等服务端确认,这段时间就是 RTT 的一半再加上等待下一个快照的间隔:P50 下约 131ms,P95 下约 220ms。按 50ms 一条输入折算,玩家要等 2.6 到 4.4 条输入才看到反应。
这个等待在 60fps 下是 8 到 13 帧。对移动类操作来说,超过两三帧就能被感知;对技能起手来说,玩家会觉得「按了没反应」,然后重复按键。预测要解决的就是这段空白:先按本地输入把画面推进,等权威快照到了再对账。
但预测不是免费的。预测得越激进,回滚时要修正的偏差越大;修正得越生硬,玩家越容易看到角色「被拽回去」。预测、回滚与显示平滑是同一件事的三个阶段,必须一起设计。
预测的三段时间
把一次操作拆成三段,才能看清每一段由谁负责、能压缩到哪里。
| 阶段 | 时间构成 | 方舟 P50 | 方舟 P95 | 谁负责 |
|---|---|---|---|---|
| 上行 | 输入到服务端的单向延迟 | 约 31ms | 约 120ms | 网络与服务端接入 |
| 服务端处理 | 排队、模拟、打包 | 策略参数 | 策略参数 | 服务端 |
| 下行 | 快照到客户端的单向延迟 | 约 31ms | 约 120ms | 网络与客户端接入 |
表中的上下行按 RTT 的一半估算,实际单向延迟不一定对称,但这个近似足以说明量级。客户端能压缩的只有第一段和第三段的一部分:更快的接入、更早的发送、更及时的收包。服务端处理时间则是另一本账。
预测窗口取「半个 RTT 加一个快照间隔」是合理的起点:输入发出后,最快也要半个 RTT 才被服务端看到,再等到下一个 100ms 快照边界才能回包。于是 P50 下约 131ms,P95 下约 220ms。这个窗口决定客户端最多要提前跑多少条输入,也决定回滚时最多要重放多少条。
窗口不能用平均值配置。P50 的 131ms 只覆盖一半会话,P95 的 220ms 才能覆盖绝大多数;如果按平均值做缓冲,剩下的一半玩家会在弱网里频繁看到修正。正确做法是按分位数配置,并给极端情况留一个上限。
回滚:只重放未确认的输入
回滚的核心不是「撤销」,而是「从权威状态重新推演」。客户端持续保存尚未被服务端确认的输入,每条输入带一个单调递增的序号;服务端在快照里带回「已处理到哪个序号」。收到快照后,客户端做三步:
- 用快照里的权威位置替换本地预测位置。
- 丢弃序号小于等于确认号的输入,它们已经被服务端算过。
- 按序号顺序重放剩余的输入,得到新的预测位置。
这个过程的成本与「未确认输入条数」成正比,而不是与「已经发过多少条」成正比。正常情况下,未确认窗口只有 2 到 5 条输入;网络抖动时可能涨到十几条。如果窗口无限增长,说明确认号没有推进,应该先排查连接,而不是继续重放。
重放必须是确定性的。同样的起始状态加同样的输入序列,必须得到同样的结果;任何在重放路径里读本地时钟、读随机数、读外部状态的代码,都会让预测与权威结果无谓地分叉。输入本身也要做合法性检查,非有限数值或异常大的位移应该被拒绝,而不是让它们污染整个重放链。
序号还有一层作用:它是幂等的锚点。服务端重复下发同一个确认号、客户端重复收到同一条快照,都不应该产生额外效果。回滚只依赖「序号大于确认号」这一条判断,重复包自然被过滤掉。
重放也要算预算。一次回滚的输入条数乘以单条输入的模拟成本,就是这一帧额外的 CPU 开销;如果回滚恰好和场景加载、特效爆发撞在同一帧,就会表现为卡顿。稳妥的做法是限制单次重放的条数,超过就先做一次粗对齐、把剩余输入分帧重放,而不是在一帧里把整个未确认窗口跑完。限制值不是固定数字,要跟目标机型的帧预算一起测。
显示平滑:小误差混,大误差切
回滚算出新的预测位置后,不能直接把角色瞬移过去。误差很小时,瞬移的视觉冲击比误差本身更大;误差很大时,慢慢混过去又会显得角色在漂移。合理的策略是按误差大小分档。
| 误差 | 处理 | 理由 |
|---|---|---|
| 0 | 不处理 | 预测与权威一致,无需修正 |
| 小于阈值 | 在若干帧内线性混入 | 视觉上接近连续,玩家感知不到 |
| 达到或超过阈值 | 直接切换到权威位置 | 大偏差继续混合会像滑步,切过去更诚实 |
阈值和混合帧数都是策略参数,不是物理常量。阈值应该与角色的移动速度、碰撞体尺寸和镜头距离一起调;混合帧数应该与渲染帧率解耦,按时间而不是按帧数配置,否则高低帧率设备的手感会不一致。
混合的实现要注意「混合的是显示位置,不是逻辑位置」。逻辑位置在回滚完成时就已经是权威值,混合只影响渲染层的偏移。这样即使混合过程中又收到新快照,逻辑状态仍然是对的,只是显示偏移需要重新计算。
还有一种情况要单独处理:连续多次小误差。单次混合看起来没问题,但如果每个快照都产生一次小误差,角色会持续处于「追赶」状态,表现为轻微抖动。这时应该把多次修正合并成一次,或者适当增大阈值,让小的权威差异不被反复渲染。
什么可以预测,什么只能等
预测的边界由玩法决定,不由技术决定。判断标准是「预测错了会不会产生不可逆后果」。
| 对象 | 能否预测 | 原因 |
|---|---|---|
| 角色移动 | 可以 | 错了只是位置修正,不改变结算 |
| 技能前摇与特效 | 可以 | 表现层可以回退,玩家能接受 |
| 冷却与能量条 | 可以 | 本地显示,服务端确认后校准 |
| 命中与伤害 | 谨慎 | 可以预演表现,不能写结算 |
| 掉落与奖励 | 不可以 | 涉及经济与随机,必须服务端决定 |
| 死亡与战斗结果 | 不可以 | 不可逆,且影响战报与排名 |
「谨慎」这一档最容易被做错。客户端可以本地判定命中并播放受击动画,让打击感成立;但不能本地扣血、不能本地触发连杀、不能本地发放奖励。表现可以预测,事实不能预测。两者混在一起,回滚时就会出现「血条回退」「连杀数字消失」这类玩家可见的 bug。
还可以再收紧一层判断:如果预测错误需要向玩家解释,就尽量不要预测。位置偏一点玩家感觉不到,但奖励少发一次就必须给说法;需要解释的预测,回滚成本远高于它带来的那点即时反馈。
可运行模型:predict.ts
下面的模型把输入应用、预测推演、回滚对账与修正规划放在一个文件里。它不依赖网络和渲染,输入序列与权威位置都由调用方注入;默认频率取自方舟的 20Hz 输入与 10Hz 快照。
// file: src/net/predict.ts
// tier: L1
// verify: node --test test/net/predict.test.ts
export interface Vec2 {
readonly x: number;
readonly y: number;
}
export interface InputFrame {
readonly seq: number;
readonly dx: number;
readonly dy: number;
}
export const ARK_INPUT_HZ = 20;
export const ARK_SNAPSHOT_HZ = 10;
function assertVec(vec: Vec2, name: string): void {
if (!Number.isFinite(vec.x) || !Number.isFinite(vec.y)) {
throw new TypeError(`${name} must contain finite coordinates`);
}
}
export function applyInput(position: Vec2, input: InputFrame): Vec2 {
return { x: position.x + input.dx, y: position.y + input.dy };
}
export function predictPosition(start: Vec2, inputs: readonly InputFrame[]): Vec2 {
assertVec(start, "start");
let cursor = start;
for (const input of inputs) {
if (!Number.isFinite(input.dx) || !Number.isFinite(input.dy)) {
throw new TypeError(`input ${input.seq} has a non-finite delta`);
}
cursor = applyInput(cursor, input);
}
return cursor;
}
export interface ReconcileResult {
readonly position: Vec2;
readonly ackSeq: number;
readonly replayed: number;
readonly dropped: number;
}
export function reconcile(
serverPosition: Vec2,
ackSeq: number,
pending: readonly InputFrame[],
): ReconcileResult {
assertVec(serverPosition, "serverPosition");
if (!Number.isFinite(ackSeq)) {
throw new RangeError(`ackSeq must be finite: ${ackSeq}`);
}
const ordered = [...pending].sort((a, b) => a.seq - b.seq);
const remaining = ordered.filter((input) => input.seq > ackSeq);
return {
position: predictPosition(serverPosition, remaining),
ackSeq,
replayed: remaining.length,
dropped: ordered.length - remaining.length,
};
}
export type CorrectionMode = "none" | "blend" | "snap";
export interface CorrectionPlan {
readonly mode: CorrectionMode;
readonly error: number;
readonly blendFrames: number;
readonly target: Vec2;
}
export function planCorrection(
predicted: Vec2,
authoritative: Vec2,
snapDistance: number,
maxBlendFrames: number,
): CorrectionPlan {
assertVec(predicted, "predicted");
assertVec(authoritative, "authoritative");
if (!Number.isFinite(snapDistance) || snapDistance <= 0) {
throw new RangeError(`snapDistance must be positive: ${snapDistance}`);
}
if (!Number.isInteger(maxBlendFrames) || maxBlendFrames < 1) {
throw new RangeError(`maxBlendFrames must be a positive integer: ${maxBlendFrames}`);
}
const error = Math.hypot(authoritative.x - predicted.x, authoritative.y - predicted.y);
if (error === 0) {
return { mode: "none", error, blendFrames: 0, target: authoritative };
}
if (error >= snapDistance) {
return { mode: "snap", error, blendFrames: 0, target: authoritative };
}
const blendFrames = Math.min(
maxBlendFrames,
Math.max(1, Math.ceil((error / snapDistance) * maxBlendFrames)),
);
return { mode: "blend", error, blendFrames, target: authoritative };
}
export function describeReconcile(result: ReconcileResult, plan: CorrectionPlan): string {
const action = {
none: "无需纠正",
blend: `用 ${plan.blendFrames} 帧混入权威位置`,
snap: "直接切到权威位置",
}[plan.mode];
return (
`确认到 #${result.ackSeq},丢弃 ${result.dropped} 条、重放 ${result.replayed} 条输入;` +
`误差 ${plan.error.toFixed(2)},${action}`
);
}
模型导出五个入口:输入应用、位置预测、回滚对账、修正规划与摘要。reconcile 先按序号排序,再过滤掉确认号及之前的输入,只从权威位置重放剩余部分;planCorrection 用欧氏距离判断修正方式,误差为零不处理,达到阈值直接切换,其余按比例分配混合帧数并受上限约束。测试覆盖空输入、多帧累加、确认号覆盖、乱序输入、零误差、小误差混合、大误差切换、非法参数与摘要文本。
模型里的「帧数」是逻辑帧,不是渲染帧。生产实现要把它换算成时间,并在渲染循环里按经过的时间推进混合进度,否则 30fps 与 60fps 设备的修正速度会差一倍。
双案例对照:消消几乎不需要回滚
消消是回合制提交,单局 14–22 次,每次 600B–1.2KB,客户端没有持续预测的输入流,回滚退化为「提交失败后按服务端结果刷新」。
方舟必须做完整的预测与回滚:一场 8–12 分钟、20Hz 输入、10Hz 快照,未确认窗口常态有数条输入,弱网时还会更长。
这不意味着消消可以完全不做乐观展示。关卡结算、星星动画、货币变化仍然可以先展示再确认,只是它的粒度是「一次提交」而不是「一帧输入」。两套产品可以共用同一套「先展示、后对账」的思路,但回滚机制的复杂度应该跟输入频率匹配。
与相邻章节的关系
5.1 划定了客户端可以做预测、不能做裁决的范围,本节把这个范围落成回滚算法;5.3 会讨论延迟补偿中客户端时间戳与历史缓冲的边界。修正后的显示平滑与渲染预算相关,可参考帧预算 ;本节只讨论逻辑位置与显示位置的分离,不展开渲染管线。
小结
- 预测窗口取「半个 RTT 加一个快照间隔」:方舟 P50 约 131ms、P95 约 220ms,按 50ms 输入折算为 2.6 到 4.4 条。
- 回滚不是撤销,而是从权威状态重放未确认输入;成本与未确认条数成正比,确认号停住应先查连接。
- 重放必须确定性,不能在路径里读时钟、随机数或外部状态;非法输入应被拒绝而不是污染重放链。
- 显示平滑按误差分档:零误差不处理,小误差混合,达到阈值直接切换;混合的是显示位置,不是逻辑位置。
- 表现可以预测,事实不能预测:移动、前摇、冷却可以,命中结算、掉落、死亡与战斗结果只能等服务端。
阅读导航:上一节:5.1 状态同步与帧同步:客户端各做什么 · 下一节:5.3 延迟补偿的客户端边界 。
常见错误与边界
- 按 RTT 平均值配置预测窗口。P50 只覆盖一半会话,另一半会在弱网里频繁修正;应按 P95 配置并设上限。
- 把回滚理解成回退所有输入。回滚只重放未确认部分,已确认输入必须丢弃,否则会把服务端算过的操作再算一遍。
- 重放路径里读本地时钟或随机数。同样的起始状态和输入会得到不同结果,预测与权威无谓分叉;重放必须确定性。
- 确认号不推进时继续重放。未确认窗口无限增长会把 CPU 拖垮,也会掩盖连接问题;应先排查确认链路。
- 直接把角色瞬移到权威位置。小误差瞬移比误差本身更刺眼;小误差应混合,大误差才切换。
- 混合逻辑位置而不是显示位置。逻辑状态必须立刻以权威为准,混合只作用于渲染偏移,否则下一次快照会叠加错误。
- 用渲染帧数配置混合时长。30fps 与 60fps 设备的修正速度会差一倍;应按时间配置再换算帧。
- 连续小误差各自混合。角色会持续追赶、轻微抖动;应合并修正或适度提高阈值。
- 本地预测命中后直接扣血。表现可以预演,事实不能本地写;否则回滚时血条与连杀数字会回退。
- 边界:本节不处理服务端重放与反作弊。客户端回滚只保证本地预测收敛,不保证输入合法;命中复核、速度校验与战报对账属于服务端职责。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。