技术博客

CharacterMovement 客户端预测:SavedMove 与服务器校正回放

第 77 / 100 篇 · 2025-12-13 · ue5 · 网络复制CharacterMovement客户端预测

玩过多人 UE 游戏的人都体验过"被拽回去":你明明跑过了墙角,服务器一句话,角色瞬移回两秒前的位置。这个现象的完整机制在 UCharacterMovementComponent(Engine/Source/Runtime/Engine/Private/Components/CharacterMovementComponent.cpp)里,核心是客户端预测 + SavedMove 环形缓冲 + 服务器校正回放三段。基于 5.3 源码走一遍。

为什么必须预测

按键到画面如果走"客户端发包 → 服务器模拟 → 回包 → 客户端应用"的闭环,延迟就是完整的 RTT。RTT 60ms 时角色起步晚 60ms、停下也晚 60ms,手感像在水里走。竞技游戏的做法是:客户端不等服务器,本地立刻按输入模拟,同时把输入发给服务器;服务器持有权威状态,用同样的输入跑同样的代码,发现分歧就纠正客户端。

这要求两边模拟确定性一致——同一个输入序列在两边算出同一个结果。CharacterMovement 的加速、摩擦、重力全是定点化的浮点公式,不依赖帧率(DeltaTime 显式参与积分),就是为了这个对偶性。

SavedMove:未确认输入的账本

客户端每帧(实际是每个 tick 产生新移动时)把本次输入压成一个 FSavedMove_Character,塞进 ClientSavedMoves 链表。它记的不是结果而是输入:压缩后的 Acceleration(按 MaxAcceleration 归一化成 uint16)、Rotation、CompressedMoveFlags(跳跃、蹲下等位标志)、TimeStamp。

记输入而不是记状态,是因为回放时状态可以在当前权威状态上重算——重放一串输入远比回滚快照便宜。5.1 之后这套打包抽象成 FCharacterNetworkMoveDataContainer(CharacterMovementNetworkMoveData.h),项目可以自定义网络移动数据的压缩格式而不改协议骨架。

链表的三个游标:

  • NewMove:最新一次输入;
  • LastSentMove:已发往服务器的末尾;
  • LastAckedMove:服务器已确认的末尾。

每次 ServerMove RPC 把 LastSentMove 之后的 saved move 打包发走,服务器逐个模拟并返回确认到的 TimeStamp。

服务器端:校验与判决

服务器的 ServerMove 处理走三步(UCharacterMovementComponent::CallServerMove → ServerMove_Implementation → MoveAutonomous):

  1. 时间戳判决:客户端时间戳落后服务器本地时间太多(MAXMOVEUPDATE_TIMEDIFF,0.125s)直接丢弃,防重放和时钟漂移。
  2. 权威模拟:服务器在自己的角色状态上用该输入跑 PerformMovement,得到权威结果。
  3. 误差判决:把权威结果和客户端声称的结果(RPC 里捎带的 ClientLoc)比较。若平方位置误差超过 MAXPOSITIONERRORSQ(256,即 16cm 的平方),判定客户端发散,构造 ClientAdjustPosition 校正包。

误差本质是误差累积:客户端和服务器的物理状态哪怕只差一个 bit 的初速度,$t$ 秒后的位置偏差就是

$$\Delta x(t) = \Delta v_0 \cdot t + \frac{1}{2}\Delta a \cdot t^2$$

自由落体下 $\Delta a$ 由重力取整差贡献。超过阈值再纠正,是为了容忍无害的浮点噪声,只纠真实的分歧——阈值太小会把正常的跨机器浮点差也打成校正。

没超阈值就发 ClientMoveResponsePacked 确认(捎带 base 组件等压缩状态),客户端据此把对应 saved move 从链表截掉。

校正与回放

ClientAdjustPosition 到达后客户端做三件事:

  1. 把权威位置/速度写进移动组件,时间戳定位到被校正的那次 move;
  2. 把 LastAckedMove 之后的所有 saved move 按序重放(ClientUpdatePositionAfterServerUpdate → ReplayMove);
  3. 重置网络平滑的插值起点。

第 2 步是"看起来没发生任何事"的关键:重放让客户端在权威基础上把之后的合法输入重新施加一遍,大部分时候角色位置和校正前几乎重合,玩家无感。有感的情况只有一个:校正本身大(比如服务器认为你被什么东西挡了)或者要回放的 move 很多(丢包堆积)。

网络平滑负责最后的观感:校正不会直接把 capsule 瞬移过去,FNetworkSmoothingData 让网格体(Mesh)在几个 tick 内插值到正确位置(NetworkSmoothingMode 默认 Exponential),视觉上变成一次快速的追赶而不是闪现。所以你"被拽"时碰撞体其实已经瞬移了,你看到的是 mesh 在追它。

时序全貌

SavedMove 时序:预测 → 服务器校验 → 校正回放 客户端(预测方) 服务器(权威方) ServerMove:时间戳 + 压缩输入 + 声称位置 ServerMove ×N(丢包时重发未确认的移动) 权威模拟,误差 > 16cm? ClientAdjustPosition:权威位置/速度 + 时间戳 正常:MoveResponsePacked 确认(截断 saved 链表) 写回权威状态 → 重放 LastAckedMove 之后的输入 网格体插值追赶(网络平滑)

排障时的三个开关

  • p.NetShowCorrections 1:画出每次校正的起点终点和连线,一眼看出校正发生在哪、有多大。配 p.NetCorrectionLifetime 调停留帧数。
  • 看 saved 链表长度:丢包多的时候 LastAckedMove 追不上 NewMove,回放成本线性涨,这时候的卡顿不是模拟慢,是在校正重放。高延迟物理机架上要把 ServerMove 的批量发送上限和 net tick 频率一起调。
  • 别在移动组件上加随机性:预测系统假设两端模拟一致,FMath::Rand 这类非种子随机一旦进移动代码,每帧都在制造 16cm 级的误差和校正。

最后澄清一个常见误解:校正不是网络"抖动"导致的回滚,它是权威判决——客户端状态永远可牺牲,服务器说什么就是什么。把这条当成设计约束(客户端永远可能被纠正),你的多人玩法代码会干净很多。