上篇把 CMC 的客户端预测和 SavedMove 缓存讲完后,后台收到最多的一个问题就是:服务器真正返回校正包时,客户端是怎么处理的?如果直接 SetActorLocation,那和没有预测有什么区别?这正好是今天要展开的三部曲——服务器回包、客户端回滚、重放追平。在 UE5 的多人游戏里,这套流程决定着移动手感是否顺滑、位置是否稳定。无论你是做射击游戏还是动作游戏,只要角色用了 CharacterMovementComponent 做网络同步,下面这些机制早晚要面对。这篇会配合常见坑位和排查命令,从数据流讲到验收标准,适合已经把基础网络同步跑通、但一多人就发现角色乱跳的同学。
1. CMC网络同步的整体链路与回包定位
1.1 先理清客户端预测和服务器权威的边界
CMC 的同步思路不是“服务器把每个坐标发给客户端”,而是一套输入协商机制。客户端本地先根据你的操作跑一遍移动,同时把输入、时间、起点状态封装成 SavedMove 发给服务器;服务器收到后用同样的输入重新模拟一次。模拟结果和客户端预测一致,就回一个确认;不一致,就带一个校正包回来。
这个“服务器权威 + 客户端预测”的组合,是为了解决最直接的问题:如果在服务器模拟完再把位置发回来,客户端手感会带上一个 RTT 的延迟,按一下 W 要几百毫秒后才动,这在竞技游戏里根本没法玩。客户端预测相当于在正式结果出来之前先本地播放一帧“预演”,服务器则是事后核对。
但要注意,预测并不是让客户端随便改位置。客户端确实可以在本地生成新的位置,但最终能不能被服务器接受,取决于服务器是否认可你提交的输入。服务器有一票否决权。如果服务器发现客户端的预测位置和它自己模拟的不一致,客户端就要把这段预测“吞回去”,重新从服务器确认的位置再来一遍。
所以这套链路的关键点在于:回包不是单纯地“把新坐标告诉客户端”,而是“把客户端之前的本地预测结果纠正到服务器认定的状态”。理解这个,后面的回滚和重放才有意义。
1.2 服务器回包到底回了什么
服务器回包主要分两种:一种是确认包,一种是校正包。
确认包就是 UE 里常见的 ClientAckGoodMove,服务器告诉客户端:你刚才发的某个移动,我已经跑过了,结果和你的预测一致,你可以彻底保留这段移动,不用再管了。这是最理想的情况,说明客户端预测没有偏差,服务器和客户端跑出了相同的结果。
校正包就是 ClientAdjustPosition。当服务器发现客户端提交的位置、速度和自己重新模拟的结果有差异时,会带上一个包裹回来,里面包含服务器计算出的同步位置、速度、移动模式、基座信息,以及一个和客户端提交时间戳对应的 MoveTime。这个 MoveTime 非常关键,它是双方对齐的“书签”。客户端收到校正包后,不是无脑瞬移到最后坐标,而是先根据这个 MoveTime 找到对应的历史移动,再决定从哪里开始纠正。
包里具体通常这几个字段:
| 字段 | 作用 |
|---|---|
| TimeStamp | 对应客户端生成的 MoveTime,用于在 SavedMoves 数组中定位 |
| ServerLocation | 服务器模拟后的权威位置 |
| ServerVelocity | 服务器模拟后的速度 |
| ServerBase | 当前站立基座(移动平台、其他角色等) |
| MovementMode | 移动模式,走路/飞行/游泳等 |
许多人第一次看源码时会被这些 RPC 名绕晕,容易把 ClientAdjustPosition 当成一个普通的“位置同步包”。其实它和普通属性复制完全不同:普通同步是周期性同步最终变换,而校正包是针对某个具体历史输入的反馈,必须放到客户端预测的时间线里才能正确处理——这也引出了第二段主题。
2. 客户端回滚:从快照到状态复位
2.1 SavedMove与同步状态快照的存储
要回滚,首先得给客户端留出“后悔药”的地方。UE5 CMC 在客户端移动时,会生成一个 FSavedMove,这个对象记录了本次移动需要的核心输入和起点状态,包括输入向量、DeltaTime、StartTime、StartLocation、StartVelocity、移动模式、是否跳跃、是否冲刺等。这些移动会按顺序存在 SavedMoves 数组里,等待服务器确认。
同时,真正执行移动前,CMC 还会从当前移动状态创建一个“同步状态快照”,保存位置、速度、基座、加速度等。也就是说,一个 SavedMove 不只是一个“输入记录”,它更像是一份“时间线档案”:从哪个状态开始,输入了什么,最终期望变成什么。
你可以把这份档案想象成视频剪辑里的关键帧快照。回滚不是粗暴地把画面切回过去,而是把编辑器里的时间线指针移回某个关键帧,然后从那一帧开始重新播放后续操作。移动回滚也一样,必须先恢复起始快照,否则直接重置位置会导致速度和朝向错误,重放时就会一路错下去。
2.2 回滚触发时机与范围
回滚的触发入口是 ClientUpdatePositionAfterServerUpdate。这名字很直白:客户端在接收到服务器校正后,更新自己的预测位置。它做的事情不是“把角色拖到服务器位置”,而是:
- 根据校正包的 MoveTime 在 SavedMoves 数组里定位对应的移动记录;
- 把角色当前状态恢复为该移动开始前的快照;
- 清理掉已经确认过的那部分记录,但保留从该移动开始到当前位置之间的所有未确认移动;
- 进入重放流程,重新执行这些未确认的移动。
这里要特别强调“范围”。在校正包对应的移动之前,服务器已经确认过的移动不需要重新回滚,因为那些状态是安全的。真正需要回滚的是“从那之后客户端又继续预测的移动”。如果服务器校正包对应的点比较早,后面累积了十几帧未确认移动,那么重放范围也相应变大。
为什么不能直接把位置 SetActorLocation 到服务器最新位置?因为网络包有延迟、有合并,服务器回包的坐标可能是几十毫秒前的结果。如果你直接把角色瞬移过去,客户端会明显看到拉到、停顿;而且瞬移后本地尚未确认的输入全部丢失,玩家会感觉自己操作被吞了。回滚加重放,就是为了在“纠正错误”和“保留本地输入手感”之间找一个平衡点。
2.3 多帧回滚的坑
回滚听起来简单,实际做起来坑特别多。我自己早期踩过的几个问题,现在还在新手群里反复出现。
第一个坑:回滚时只复位了位置,没复位速度和基座。角色站在移动平台上时,如果回滚后基座信息没恢复,重放时角色会原地御空,随后被服务器狠拽一下,看起来像瞬间平移了半个身位。速度快的时候还会穿墙。
第二个坑:回滚重放过程中没有暂停发送新的 ServerMove。如果重放还没结束,玩家又按了新的输入,客户端会把重放中的移动也当成新输入发给服务器,造成服务器收到重复历史数据。这个问题的现象非常隐蔽:角色动作没什么异常,但服务器日志里全是重复移动,CPU 消耗飙升,延迟越高越明显。解决办法是重放期间禁止新输入的采集,或者在移动组件里加一个“正在回放”状态,让 ReplicateMoveToServer 直接跳过。
第三个坑:SavedMoves 数组没有限制长度。客户端如果一直收不到服务器反馈,数组会越积越长,之后一旦收到一个迟到的校正包,就会从非常久远的快照开始重放,角色直接半天动不了,表现就是卡住然后瞬移。正常项目中都会限制 SavedMoves 的最大数量,比如保留 128 个移动,超出后直接从最早开始丢弃,配合时间戳超时判断。
3. 重放追平:让本地表现不跳变
3.1 重放的本质:从回滚点重新模拟
回滚之后,并不是“把你钉在历史位置等服务器再发包”,而是要立刻用本地保存的输入把角色追平。重放的本质,是把 SavedMoves 里记录的输入按顺序重新送进 CMC 的 MoveAutonomous 逻辑执行一遍,直到追上当前客户端时间。
这个过程很像录像带倒带到某一帧后重新播放。因为输入是确定性的,同样的起点、同样的输入、同样的模拟步长,理论上应该得到和服务器一致的结果。但由于浮点误差、服务器 tick 率不稳定、基座变化等原因,最终重放出来的位置不会和服务器完全一样,而是会落在服务器位置附近。正是这个“附近”,让客户端看起来并没有发生硬切换,而是平滑地被执行了一次微修正。
重放要特别注意的,是不要一路重放到当前时间就立刻开始新的预测。通常重放在一帧内完成,追上后客户端继续从重放结果向下预测,中间没有停顿。如果重放过程横跨多帧,就要小心移动插值状态被破坏,产生瞬移割裂感。
3.2 处理回包积累:冗余校正与输入缓存
实际开发中,你不会总遇到“一次校正包对应一段完整移动”的情况。网络条件差的时候,服务器可能把多个校正合并到一个 RPC 里发回来;某些旧包因为乱序,比新包还晚到;也有一些包会在客户端已经正常追平后又重复到达。
所以回包处理不能只会“无脑重放”,必须做去重和排序。后台收到 ClientAdjustPosition 时,先看包里的 MoveTime 是否晚于当前已经应用过的校正点,如果晚于,才执行回滚重放;如果早于,直接丢弃。这样可以避免反复回到旧时间点重放,让角色反复出现在不同历史位置。
输入缓存也很重要。客户端不能无限保存未确认移动,否则内存压力和 CPU 都会有很大负担。我的经验是把 SavedMoves 容量设成一个可配置参数,同时给每个移动记录一个过期时间戳。超过一定时间还没被服务器ack的移动,就不再保存,此时客户端只能被迫接受服务器同步位置,表现为一次瞬移。这个“被迫瞬移”虽然难看,但至少比永远追不上要好。
3.3 与插值、代理的配合
回滚重放只适用于本地控制的自主任代理。对于其他玩家看到的模拟代理由,他们的客户端不会拿到你的输入,只会拿到服务器同步过来的变换。对这些代理要做的是网络平滑插值,而不是回滚重放。
如果项目中这两个机制混在一起处理,就会出现别人看你角色时,位置总是比真实状态慢半拍或者疯狂抖动的现象。正确做法是:处理本地玩家移动状态时用 CMC 的自带校正,处理模拟代理时把 NetworkSmoothingMode 设置为适合项目模式,并配合旋转插值、速度缓存来消抖。
还有一个容易忽略的点:如果你做了基于 Root Motion 的移动,或者角色骑乘、坐载具,回滚时动画状态和移动模式必须一起复位。否则角色在回滚后,根骨骼位置和胶囊体位置错开,视觉上会像“贴图与碰撞体分离”。我见过一个项目里,角色二段跳后如果触发校正,脚底粒子特效会在空中炸开,原因就是回滚时没有同步 RootMotion 的动画时间。
4. 实操调试:日志、控制台命令与常见问题
4.1 关键日志和Debug命令
排查移动同步问题,最忌讳盲猜。先把日志打开,然后再看表现。UE5 里常用的几组命令:
Log LogCharacterMovementVerbose:输出移动组件内部详细过程,能看到移动速度、模式切换。Log LogNetVerbose:输出网络同步大包相关信息,主要看同步频率和体积。p.NetShowCorrections:显示校正包内容,比如校正点、被校正移动的时间戳。p.NetVisualizeCorrections:在视口里可视化校正,效果是所有角色的校正点都会画出连线,非常适合快速定位问题角色。stat CharacterMovement:查看移动模拟耗时。
如果是自己写的移动模式,我强烈建议在移动组件子类里加一个调试记录器,专门记录每次应用校正前的本地预测位置、服务器返回位置、重放后的位置。把这组数据打印出来,很多迷之抖动当场就能看出规律。我自己常用的方式是 DrawDebugSphere 画服务器位置,DrawDebugBox 画客户端位置,再画一条线连接两者,一帧一帧观察回滚前后的偏移方向。
4.2 常见问题速查表
| 现象 | 可能原因 | 处理建议 |
|---|---|---|
| 角色在回包后频繁抖动 | 多个校正包乱序,重复回滚到旧时间点 | 按 MoveTime 排序,只应用比当前校正点更新的包 |
| 角色朝一个方向漂移,然后突然被拉回 | 客户端预测与服务器模拟存在系统性偏差 | 检查移动步长、浮点误差、基座状态是否一致 |
| 回落后角色滑步严重 | 回滚只复位了位置,没有复位速度和动画姿态 | 回滚快照中完整保存生命周期数据,包括 RootMotion |
| 高延迟下角色瞬移频繁 | SavedMoves 被裁剪,未确认输入过多 | 加大缓存上限,或减少 ServerMove 发送频率 |
| 服务器 CPU 异常高 | 重放期间又发送了新的 ServerMove | 增加“正在回放”状态,重放期间屏蔽新输入采集 |
| 玩家离开移动平台后卡顿 | 回滚时基座信息未恢复 | 回滚时同步恢复 Attachment 和相对位置 |
4.3 性能优化与经验技巧
回滚重放本质上是 CPU 换手感,所以不能让它失控。网络同步移动本身就是高频操作,如果每个客户端一秒钟执行几十次回滚重放,开销会非常可观。
性能上,我通常做这几个控制:一是限制服务器回包频率,不在每帧都发校正。服务器可以收集一小段时间内的移动误差,只在误差超过阈值时发出校正包。二是在客户端限制单次回滚重放的移动数量。如果 SavedMoves 积累很多,可以只重放最近 N 个移动,然后用一个软校正把位置轻微拉近,而不是让角色从远古状态一路重播。三是尽量减少回包体积,位置、速度用压缩精度,基座和移动模式用 bit 标记,而不是直接序列化整个结构体。
还有一个容易被忽视的点:服务器 tick 率不稳定会导致模拟结果波动。做竞技项目时,服务器最好固定 tick 率,比如 30Hz 或 60Hz,并在移动组件里开启 SubStep 子步迭代。这样服务器在不同帧率下模拟结果能够保持稳定,回包校正量才会小。否则客户端回滚重放后,依然会和服务器这帧模拟出的位置差出一大截。
5. 工程实战:一个自定义移动案例的回滚重放实录
5.1 复现问题:二段跳后角色闪现
为了让上面的概念落进现实,我拿一个改过的“二段跳”案例来说。我们项目里给角色加了一个自定义移动模式,允许玩家在空中再按一次跳跃完成二段跳。客户端本地表现正常,服务器确认也正常,但一旦网络延迟超过 100ms,二段跳后角色偶尔会突然闪回地面,再原地弹起一下。
从现象看,这是典型的回滚重放状态没有完全恢复。二段跳不是单纯的移动速度改变,而是影响了角色跳跃次数、动画状态、以及自定义的跳跃冷却时间。客户端的 SavedMove 里虽然记录了输入,但自定义移动模式里的“跳跃次数”并没有随快照一起保存。回滚时角色回到地面点,跳跃次数还是 2,重放时发现当前不在可行走地面,没办法再次启动二段跳,只能原地落下等待服务器校正。
这个定位思路很重要:出现角色“闪现”时,不要只盯着位置,先检查哪些移动状态没有被纳入快照。移动模式的状态变量越多,回滚时漏掉恢复项的概率越大。
5.2 修复步骤
修复其实不复杂,但需要改得干净。
第一步,在自定义 FSavedMove 子类里重写 SetMoveFor 和 GetImportantBaseState 相关逻辑,把自定义的跳跃次数、冷却时间等字段写进移动记录。第二步,回滚快照也要把这些字段保存起来。第三步,在重放时,确保 SavedMove 里的这些字段能正确覆盖到运动组件上。这里的关键是,所有在移动模拟时影响速度计算或模式切换的字段,都必须进入快照体系,一个都不能漏。
改完以后,我们在本地模拟高延迟环境验证。打开p.NetShowCorrections,连续操作二段跳,可以看到校正包数量明显减少,即使有校正,重放后也能正常完成第二跳。再用p.NetVisualizeCorrections观察连线,位置偏差点从原来的半个角色缩小到几乎看不出来。
5.3 如何把回滚追平扩展成玩法
回滚重放天然带一种“修正过去”的味道,很多人会想把它做成玩法,比如时间回溯、残影、闪现。我自己也试过,确实可以做,但必须把“网络同步回滚”和“玩法回滚”分开,否则会互相干扰。
如果要做时间回溯,建议复用 SavedMove 的存储结构,但单独维护一份“回溯缓存”,不要把玩法回滚直接塞进 CMC 的客户端校正回调里。否则玩家发动回溯时,服务器也会收到校正包,服务器也会跟着回滚,整个游戏状态会乱成一锅粥。正确的做法是:用本地方案记录预测数据,回溯时只影响表现层和输入层,服务器端正常模拟原状态。这一点想清楚,你的玩法会稳定很多。
最后一小段经验
做了这么多年 CMC 网络同步,我最大的体会是:回滚重放不是某个函数拷一下就完事,它是移动状态设计的体检表。如果你在回包后出现各种诡异问题,不用怀疑,一定是有些状态没有纳入快照,或者是回包时序没有理清。把 SavedMove 当成一个个完整的“时空节点”来维护,服务器回包、客户端回滚、重放追平这条链路的逻辑就会非常清晰。平时调试时多画点 DebugDraw,多打印时间戳,很多“玄学”问题其实都有明确规律,只是你没看见那根连接两端位置的线。