UE5.8 Network Prediction:延迟、预测与回滚
一次恼人的射击测试
上周三晚上,我们项目组在测试大厅跑了一场四人对抗。地图是张中型竞技场,玩家之间隔着集装箱和矮墙。策划小李端着步枪,瞄准敌人胸口扣下扳机,客户端立刻播放了枪火、弹道和命中火花。他以为自己拿到了一次漂亮的击杀,可右上角没有跳伤害数字,敌人依旧活蹦乱跳。
会议室里有人说这是客户端没同步,有人说服务器丢包。我把网络模拟器打开,把延迟调到 ,丢包率设为 ,问题变得更容易观察。小李再次开枪,本地画面显示命中,但服务器反馈回来的是未命中。再过半秒,他的人物从矮墙边缘被拉回原地,刚才跳过去的动作仿佛被橡皮筋拽了回去。
这就是网络预测没处理好的典型症状。UE5 默认已经做了很多事,但复杂玩法下仍需要理解底层机制。这篇文章把那天晚上踩到的坑,以及后续几天读 Network Prediction Plugin 源码和 CharacterMovementComponent 代码的笔记整理出来。
Network Prediction Plugin 简介
UE5.8 把网络预测相关的能力进一步收敛到 Network Prediction Plugin。它的目标是把「输入采集、本地模拟、服务器校验、状态回滚、错误修正」做成一套可扩展的框架,而不只是 CharacterMovementComponent 的私有实现。
这个插件的核心抽象可以概括为三个部分:
- Network Prediction Interface(NPI):定义了预测系统与外部交互的边界,包括输入、输出和同步状态。
- Network Simulation(NS):负责在本地按帧推进预测状态。
- Network Prediction Proxy(NPP):作为客户端与服务端之间的代理,处理复制、校验和回滚。
flowchart TD
subgraph engine ["UE5.8 引擎层"]
npi["Network Prediction Interface"]
ns["Network Simulation"]
npp["Network Prediction Proxy"]
rep["Replication 系统"]
end
subgraph game ["项目玩法层"]
cmc["CharacterMovementComponent"]
custom["自定义预测组件"]
anim["动画系统"]
end
cmc --> npi
custom --> npi
npi --> ns
ns --> npp
npp --> rep
anim --> cmc从架构图能看出,CharacterMovementComponent 是第一个接入这套框架的子系统,但项目里完全可以基于同样的接口实现射击、技能、载具甚至物理对象的预测。插件存在的意义就是把原本散落在各处的重复逻辑统一起来。
这里有一个容易混淆的概念。Network Prediction Plugin 不等同于 CharacterMovementComponent。后者是角色移动的具体实现,前者是更通用的框架。5.8 中,移动组件开始逐步迁移到这套接口上,但项目里大部分旧代码仍直接使用移动组件的旧接口。理解两者的关系,是后续所有调试的前提。
本地模拟 + 服务器校正
网络游戏最基本的问题是:玩家按下 W,画面到底应该什么时候响应?
如果等服务器确认后再动,角色会晚一个 RTT 才开始移动。在 延迟下就是 的 RTT,手感会明显发粘。UE 的答案是客户端先动,服务器再校正。这个策略叫做客户端预测,或者本地模拟加权威校正。
具体流程是这样的。玩家在客户端生成输入,客户端立刻把输入喂给自己的模拟器,更新本地状态,同时把输入通过网络发给服务器。服务器收到输入后,在权威状态下执行同样的模拟逻辑,得到权威状态。服务器把权威状态复制回客户端。客户端比较本地预测状态和权威状态,如果有偏差,就进行修正。
flowchart TD
A["玩家输入"] --> B["客户端模拟"]
B --> C["更新本地画面"]
B --> D["发送输入到服务器"]
D --> E["服务器权威模拟"]
E --> F["生成权威状态"]
F --> G["复制状态回客户端"]
G --> H{本地与权威一致?}
H -->|"是"| I["保留本地结果"]
H -->|"否"| J["计算误差"]
J --> K["平滑修正本地状态"]这个流程里最关键的假设是:客户端和服务器的模拟逻辑必须一致。如果两端同一个输入算出的结果不同,那偏差会持续累积,玩家看到的角色会不停抽搐。UE 的做法是把移动相关逻辑写成可在两端复用的代码,比如 FSavedMove_Character 和 UCharacterMovementComponent::MoveSmooth。
一个常见的坑是碰撞响应不一致。客户端和服务器用的物理场景理论上应该一样,但浮点精度、加载顺序、时间步长都可能导致微小差异。这个差异在高速度、斜面、复杂几何体附近会被放大。那天晚上小李被拉回矮墙边缘,就是因为客户端认为他跳过去了,服务器判定他撞到了墙,权威位置还在墙这边。
输入历史与状态回滚
射击判定比移动预测更难处理。子弹飞行时间短,命中与否往往取决于扣下扳机那一瞬间双方的位置和姿态。但网络延迟让「那一瞬间」在客户端和服务器眼里并不一致。
假设小李开枪时,他本地看到敌人在位置 。这个画面已经是 之前的旧信息。等服务器收到开枪请求,敌人已经移动到 。如果服务器只在当前时刻做命中判定,那高延迟玩家的射击体验会极差。
解决思路是服务器回滚。服务器收到射击请求时,不会用当前状态判定,而是根据请求里附带的时间戳,把目标状态回滚到开枪那一刻,再做命中检测。这个过程中需要保存一段时间的输入历史和状态快照。
sequenceDiagram
participant C as "客户端"
participant S as "服务器"
participant H as "输入历史缓存"
participant T as "目标角色"
C->>S: 射击请求 (Time = t0)
Note over C,S: 网络延迟 Δt
S->>H: 查找 t0 时刻输入
H->>T: 回滚目标到 t0 状态
S->>T: 执行命中判定
T-->>S: 命中结果
S-->>C: 确认/拒绝
S->>T: 恢复当前状态UE5.8 的 Network Prediction Plugin 提供了一套输入历史缓冲区。每一帧客户端生成的输入都会被编号并发送给服务器,服务器按时间顺序存放。需要回滚时,从缓冲区里取出对应时刻的输入,把状态重新模拟一遍。
回滚的数学表达可以写成:
S_{t_0}^{\text{server}} = R\bigl(S_{t_0 - \Delta t}^{\text{server}},\, I_{t_0 - \Delta t},\, I_{t_0 - \Delta t + 1},\, \dots,\, I_{t_0 - 1}\bigr)其中 表示状态, 表示输入, 是回滚重放函数, 是延迟对应的帧数。服务器把目标状态回滚到 ,做判定后再用后续输入把状态推回当前。
这种机制对连续型玩法非常友好,比如移动、技能、载具。但回滚也有代价。保存历史状态需要内存,重放模拟需要 CPU。如果回滚窗口太长,或者每帧状态很大,服务器压力会明显上升。UE5.8 默认的移动回滚窗口约为 到 ,具体数值可以在项目设置里调整。
平滑插值与错误修正
客户端预测不可能永远正确。当服务器校正状态和本地状态不一致时,直接硬切会让画面跳动,体验很差。UE 的做法是在本地做平滑插值,把错误修正分散到多帧内完成。
误差向量可以定义为:
其中 是服务器权威位置, 是本地预测位置。误差修正的方式不是直接令 ,而是让本地位置以一定速度向权威位置靠拢。
常见的插值公式是:
这里 是修正时间常数, 是单帧时间。 越小,修正越快,画面越接近权威状态,但抖动越明显; 越大,修正越平滑,但玩家会感觉操作响应变慢。
在 UCharacterMovementComponent 里,这个逻辑主要体现在 NetworkSmoothingMode 上。5.8 继续支持 Linear 和 Exponential 两种模式。Linear 按固定速度插值,适合误差较小的场景;Exponential 按误差比例插值,大误差时修正快,小误差时修正慢。
graph LR
A["收到权威位置"] --> B["计算位置误差"]
B --> C{误差 < 阈值?}
C -->|"是"| D["轻微插值修正"]
C -->|"否"| E["快速收敛"]
D --> F["更新本地显示位置"]
E --> F除了位置,旋转也需要平滑。角色朝向的误差通常用球面插值处理,而不是简单线性插值。UE 里对应的函数是 FQuat::Slerp。如果朝向误差超过一定角度,比如 90°,直接插值会让角色看起来转了大半圈,这种情况下更合适的做法是直接同步。
速度修正比位置修正更微妙。 CharacterMovementComponent 不会直接修正速度,而是修正位置,再由位置反推速度。这样能保证物理连续性。如果玩家正在空中跳跃,直接改速度会破坏抛物线轨迹。
Networked Movement Component 原理
UE 的角色移动预测并不是通用框架凭空实现的,它依托于 UCharacterMovementComponent。这个组件负责处理行走、奔跑、跳跃、飞行、游泳、蹲伏、爬行等各种移动模式,并且自带网络同步。
移动同步的基本单位是 FSavedMove。每帧玩家输入会被打包成一个 FSavedMove 对象,里面记录了加速方向、跳跃按键、蹲伏状态、鼠标朝向等信息。客户端把 FSavedMove 发送给服务器,服务器在自己的移动组件上重放。
flowchart LR
A["玩家输入"] -->|"封装"| B[FSavedMove]
B -->|RPC| C["服务器接收"]
C -->|"应用"| D[CharacterMovementComponent]
D -->|"生成"| E["权威位置"]
E -->|"复制"| F["客户端校正"]服务器一次收到多个 FSavedMove 时,会按顺序依次处理。网络波动导致输入乱序到达时,这个顺序执行机制能保证模拟结果尽量一致。每个 FSavedMove 都有一个时间戳或序列号,服务器可以判断是否已经处理过。
移动组件里有一个关键变量 NetworkSmoothingMode。它控制客户端如何把权威位置平滑到显示位置。5.8 默认使用 Linear 模式,对大多数第三人称射击项目已经足够。如果项目对延迟特别敏感,比如格斗游戏或竞速游戏,可以尝试 Exponential 模式,但需要更多调参。
跳跃的预测处理是移动组件里最容易出问题的部分。玩家按下空格,客户端立刻起跳,服务器稍后收到输入并执行起跳。如果服务器判定玩家脚下没有地面,或者起跳时已经走出平台边缘,它会拒绝这次跳跃,客户端就要把角色拉回地面。那天晚上小李从矮墙边缘被拉回,就属于这种情况。
减少跳跃拉回的办法有几个。一是让客户端在起跳前做更严格的地面检测,降低预测失败的概率。二是让服务器的地面判定对边缘情况更宽容,比如延长可起跳时间窗口。三是优化关卡碰撞,减少玩家容易误判的几何体。
自定义预测组件
CharacterMovementComponent 能解决角色移动,但游戏玩法远不止移动。射击的子弹、技能的弹道、载具的漂移、可推动物体的运动,都可能需要自定义预测组件。UE5.8 的 Network Prediction Plugin 提供了扩展点,让项目可以接入同一套框架。
自定义预测组件通常继承 UNetworkPredictionComponent,然后实现几个关键接口:
- ProduceInput:把玩家输入转换成预测系统能理解的结构。
- Simulate:根据当前状态和输入推进下一帧状态。
- Reconcile:比较本地预测状态与服务器权威状态,计算误差。
- Rollback:在需要时回滚到历史状态并重放。
下面是一个极度简化的自定义预测组件示例,用于一个预测型弹道技能。代码只有核心骨架,实际项目需要根据具体玩法填充。
UCLASS(ClassGroup = Custom, meta = (BlueprintSpawnableComponent))
class MYGAME_API UMyPredictedProjectileComponent : public UNetworkPredictionComponent
{
GENERATED_BODY()
public:
void ProduceInput(float DeltaTime, FMyProjectileInput& Input) const;
void Simulate(float DeltaTime, const FMyProjectileInput& Input, FMyProjectileState& State);
void Reconcile(const FMyProjectileState& ServerState);
protected:
UPROPERTY(ReplicatedUsing = OnRep_State)
FMyProjectileState CurrentState;
TArray<FMyProjectileState> StateHistory;
TArray<FMyProjectileInput> InputHistory;
UFUNCTION()
void OnRep_State();
};
void UMyPredictedProjectileComponent::Simulate(
float DeltaTime,
const FMyProjectileInput& Input,
FMyProjectileState& State)
{
const FVector Velocity = Input.Direction * Input.Speed;
State.Position += Velocity * DeltaTime;
State.Position.Z -= 0.5f * 980.0f * DeltaTime * DeltaTime;
}这段代码展示了自定义预测组件的最小结构。ProduceInput 收集输入,Simulate 推进状态,Reconcile 处理服务器校正。真实项目中,Simulate 里还要处理碰撞、重力、阻力、风力和其他Gameplay逻辑。
自定义预测组件最大的挑战是保证客户端和服务器模拟一致。任何使用随机数、时间相关函数或者物理查询的地方都要小心。如果客户端用了 FMath::RandRange,服务器必须同步随机种子,否则弹道会分叉。如果用了 GetWorld()->GetTimeSeconds(),两端时间基准不同也会导致差异。
预测与动画
网络预测不只影响逻辑,也影响动画。角色在本地预测移动时,动画系统需要根据预测速度播放 Walk、Run、Idle。当服务器校正位置时,动画也要跟着调整,否则会出现脚滑、动作跳变。
一个典型问题是预测性跳跃。客户端按下空格后起跳,Animation Blueprint 从 Idle 切到 JumpStart。服务器稍后拒绝了这次跳跃,角色被拉回地面。如果动画系统没有收到回滚通知,角色会继续播 JumpLoop 甚至 JumpEnd,造成画面里人在地面但动画在空中。
解决思路是让动画系统也参与预测。Network Prediction Plugin 在 5.8 中开始暴露更多事件给 Anim Instance。项目可以在 Animation Blueprint 的 Event Graph 里监听预测状态变化,根据当前是预测态还是权威态切换动画。
sequenceDiagram
participant P as "玩家输入"
participant M as MovementComponent
participant A as "Animation Blueprint"
participant S as "服务器"
P->>M: 按下跳跃
M->>M: 本地预测起跳
M->>A: 状态: InAir = true
A->>A: 播放 JumpStart
M->>S: 发送输入
S->>S: 判定起跳失败
S-->>M: 校正: InAir = false
M->>A: 状态回滚
A->>A: 切回 Idle动画混合时间也需要和预测修正时间配合。如果预测修正 是 秒,而动画 Blend Time 是 秒,那角色位置已经修正完了,动画还在过渡,看起来就是脚在滑。相反,如果 Blend Time 太短,位置突变时动画会抖动。通常把 Blend Time 设得和修正时间同一个数量级比较稳妥。
根运动动画的网络同步是另一个深水区。如果角色位移由动画根骨骼驱动,那客户端和服务器的动画进度必须严格一致,否则两端位置会漂移。UE5.8 对 Networked Root Motion 做了改进,根运动曲线可以参与预测和回放。使用时要确保动画资源本身的根运动是稳定的,没有抖动或循环误差。
调试工具
网络预测的问题往往很难复现。延迟、丢包、抖动三个因素组合起来,同一个 bug 可能在本地正常、联机才出现。UE5.8 提供了一些调试工具,能把模拟过程可视化。
最常用的命令是 showdebug netprediction。打开后,屏幕上会显示当前预测的输入序列号、本地状态、服务器状态、误差大小、回滚窗口长度等信息。这个视图对定位「为什么被拉回」非常有用。
# 在 PIE 或打包客户端的控制台输入
showdebug netprediction
showdebug characterNetworkPredictionInsights 插件提供了更详细的时序图。它可以记录一段时间内客户端和服务器的输入、状态、校正事件,并用图表展示。用这个插件可以清晰看到哪一帧出现了误差,以及误差是如何被修正的。
控制台变量 net.MaxRepArraySize 和 net.MaxRepArrayMemory 可以调整复制数组的大小限制。如果自定义预测组件保存的历史状态太多,可能会触发复制上限,导致状态同步不完整。调试时要关注这些阈值。
对于 CharacterMovementComponent,还有几个常用命令:
p.NetShowCorrections 1
p.NetShowCorrections 2
p.NetNetworkSmoothingMode 0 # Linear
p.NetNetworkSmoothingMode 1 # Exponentialp.NetShowCorrections 会在角色周围画出彩色方框或连线,标记本地预测位置和服务器权威位置之间的差异。绿色表示误差很小,黄色表示需要注意,红色表示发生了明显校正。策划小李那次被拉回矮墙边缘,如果开了这个显示,马上就能看到红色连线。
除了引擎内置工具,项目还应该自己加日志。在关键预测节点打印输入序列号、状态哈希和事件类型,出问题后按时间线排查。自定义预测组件建议实现一个 DumpState 函数,把当前状态序列化成字符串,方便抓包分析。
写在最后
网络预测的本质是在延迟和手感之间找平衡。完全等待服务器确认会太迟钝,完全信任客户端预测又会带来作弊和回滚。UE5.8 通过 Network Prediction Plugin 提供了一套通用框架,把输入历史、本地模拟、权威校正、状态回滚、平滑插值这些机制统一起来。
CharacterMovementComponent 是这个框架最成功的应用案例。角色移动的预测、同步和修正在 UE 里已经相当成熟,大多数项目不需要从零写。但射击判定、技能弹道、载具运动这些玩法,往往需要自定义预测组件。理解插件的接口和约束,才能让自定义逻辑也获得稳定的网络表现。
那天晚上小李的问题,最后定位在两个地方。一是射击判定没有走服务器回滚,只是按当前位置判;二是矮墙边缘的跳跃预测和服务器地面检测不一致。改了命中判定逻辑并优化了碰撞后,测试体验明显改善。
网络问题不会凭空消失,但好的预测框架能把延迟藏起来,让玩家专注于游戏本身。