《移动游戏产品的架构·网络、资源与热更新》5.2 预测、回滚与显示平滑

预测让操作立刻有反馈,回滚让预测不会变成第二套真相。本节用方舟 20Hz 输入、10Hz 快照与 62/240ms 的 RTT 分布,拆解预测窗口有多长、如何只重放未确认的输入,以及按误差大小在混合与切换之间做选择的显示平滑,并给出可运行模型。

场景:按下技能键,画面先动还是先等

《潮汐方舟》里,玩家按下技能键到看到角色起手,中间隔着一段网络。如果客户端老老实实等服务端确认,这段时间就是 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 才能覆盖绝大多数;如果按平均值做缓冲,剩下的一半玩家会在弱网里频繁看到修正。正确做法是按分位数配置,并给极端情况留一个上限。

回滚:只重放未确认的输入

回滚的核心不是「撤销」,而是「从权威状态重新推演」。客户端持续保存尚未被服务端确认的输入,每条输入带一个单调递增的序号;服务端在快照里带回「已处理到哪个序号」。收到快照后,客户端做三步:

  1. 用快照里的权威位置替换本地预测位置。
  2. 丢弃序号小于等于确认号的输入,它们已经被服务端算过。
  3. 按序号顺序重放剩余的输入,得到新的预测位置。

这个过程的成本与「未确认输入条数」成正比,而不是与「已经发过多少条」成正比。正常情况下,未确认窗口只有 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 延迟补偿的客户端边界 。

常见错误与边界

  1. 按 RTT 平均值配置预测窗口。P50 只覆盖一半会话,另一半会在弱网里频繁修正;应按 P95 配置并设上限。
  2. 把回滚理解成回退所有输入。回滚只重放未确认部分,已确认输入必须丢弃,否则会把服务端算过的操作再算一遍。
  3. 重放路径里读本地时钟或随机数。同样的起始状态和输入会得到不同结果,预测与权威无谓分叉;重放必须确定性。
  4. 确认号不推进时继续重放。未确认窗口无限增长会把 CPU 拖垮,也会掩盖连接问题;应先排查确认链路。
  5. 直接把角色瞬移到权威位置。小误差瞬移比误差本身更刺眼;小误差应混合,大误差才切换。
  6. 混合逻辑位置而不是显示位置。逻辑状态必须立刻以权威为准,混合只作用于渲染偏移,否则下一次快照会叠加错误。
  7. 用渲染帧数配置混合时长。30fps 与 60fps 设备的修正速度会差一倍;应按时间配置再换算帧。
  8. 连续小误差各自混合。角色会持续追赶、轻微抖动;应合并修正或适度提高阈值。
  9. 本地预测命中后直接扣血。表现可以预演,事实不能本地写;否则回滚时血条与连杀数字会回退。
  10. 边界:本节不处理服务端重放与反作弊。客户端回滚只保证本地预测收敛,不保证输入合法;命中复核、速度校验与战报对账属于服务端职责。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

  1. 《移动游戏产品的架构·商业化与发行工程》附录 C 商业化与发行评审清单
  2. 《移动游戏产品的架构·商业化与发行工程》附录 B 双案例商业化事实表
  3. 《移动游戏产品的架构·商业化与发行工程》附录 A 商业化与发行术语速查