UE 移动同步的问题及优化方案
UE原生的移动同步,有做许多方面的优化,在卡顿的情况下,可能会出现频繁的拉回位置,本文讲解这些情况要怎么处理
目录
- 整体回顾:客户端为什么会被拉回
- 拉回的具体触发条件
- 频繁拉回的根因分类与解决思路
- 小偏差不回滚时的累积问题
- 大偏差时的回滚平滑:SmoothCorrection
- PVP 命中判定:Lag Compensation 与时间回溯
- 回溯到底回滚谁:攻击方还是被攻击方
- 客户端发送 RPC 的典型场景
- 一句话总结
1. 整体回顾:客户端为什么会被拉回
1.1 整体流程
在 “DS 权威 + 客户端预测” 模型下,移动同步的链路如下:
客户端按 W
→ 本地立刻预测移动(PerformMovement)
→ 同时把这帧的输入打包成 FSavedMove,存到 SavedMoves 列表
→ 通过 ServerMove RPC 发给服务器
↓
服务器拿同样的输入重跑一遍
↓
比较"客户端发来的位置" vs "服务器自己算的位置"
↓
┌──────────────────┴──────────────────┐
差异 < 容差 差异 > 容差
↓ ↓
发 ACK 发 ClientAdjustPosition
(GoodMove,确认) (拉回 / Correction)
↓ ↓
客户端丢弃已确认 SavedMove 客户端把角色拉回服务器位置
再重放 SavedMoves 中
未确认的所有输入
1.2 通俗解释
客户端先 “赌” 自己算对了,把 “输入 + 自己算出来的位置” 一起寄给服务器裁判;裁判用同样规则重算一遍:
- 算得差不多 → 给个戳 “通过”;
- 算得差太多 → 把正确答案寄回来,客户端必须用正确答案 “倒带重放”,把后续还没盖戳的操作全部重新跑一遍。
所谓 “拉回”,本质就是裁判判客户端这一步算错了,把它的角色强行掰回到权威位置。
2. 拉回的具体触发条件
服务器决定要不要发 ClientAdjustPosition(拉回)的核心函数是 UCharacterMovementComponent::ServerCheckClientError()。这是代码事实,触发拉回的判定包含以下几类:
2.1 位置误差超容差
// 简化版伪码(基于 UE 5.3 源码思路)
const float LocDiff = (ServerLoc - ClientLoc).SizeSquared();
if (LocDiff > FMath::Square(MaxPositionErrorSquared)) // 默认 3 cm 左右
{
return true; // 触发 Correction
}
- 默认容差由
NetworkMaxSmoothUpdateDistance/MaxPositionErrorSquared等参数决定,量级一般是几厘米到十几厘米。 - 超过这个阈值,服务器就认为 “客户端预测偏离太远”。
2.2 移动模式不一致
客户端说自己在 Walking,服务器算出来应该是 Falling(比如踩空了),就会拉回 + 修正 MovementMode。
2.3 状态属性不一致
- 蹲伏 / 飞行 / 游泳等状态不一致;
- Root Motion 时间戳对不上;
- 自定义 SavedMove 字段(比如战斗状态、Buff 标志)不一致。
2.4 反作弊兜底
- 时间戳异常(客户端 TimeStamp 跳变、回退);
- 速度超出
GetMaxSpeed()上限太多; - 单位时间内移动距离超过物理可能性。
2.5 触发条件总览表
| 触发条件 | 严重程度 | 是否一定拉回 |
|---|---|---|
| 位置差 < 容差 | 低 | 否(直接 ACK) |
| 位置差 > 容差 | 中 | 是 |
| 移动模式不同 | 高 | 是 |
| 自定义状态不同 | 视实现 | 通常是 |
| 时间戳异常 | 高(疑似作弊) | 是,且记日志 |
| 速度超物理上限 | 高 | 是 |
3. 频繁拉回的根因分类与解决思路
频繁拉回会让玩家持续看到角色 “抽搐”,体验极差。在网络抖动、轻微延迟下尤其明显。
核心问题:服务器一旦稍微延迟,或客户端收到的同步信息稍有滞后,预测结果就和服务器判定不匹配——如果直接每次都触发 Correction,玩家就会感受到一阵阵的拉拽。
频繁拉回的根因可以归为四类,每一类都有对应的处理思路:
3.1 根因一:网络抖动(Jitter)
现象:RTT 不稳定,包来得忽快忽慢,服务器收到的 ServerMove 时间戳分布不均匀,重算出来的位置就和客户端对不上。
核心解决方案:
-
服务器端做时间戳平滑
服务器收到 ServerMove 时,不是用 “收到时刻” 作为执行时间,而是用客户端发来的TimeStamp+ 一个抖动缓冲(Jitter Buffer),让服务器执行节奏更平稳。这是 UE CMC 内置的机制(ServerData->CurrentClientTimeStamp)。 -
适度放大容差
NetworkMaxSmoothUpdateDistance这种参数在差网络下可以适当调大,避免微小漂移触发 Correction。 -
Move Combining(移动合并)
客户端在网络差时不每帧都发 RPC,而是把多个小移动合并成一个FSavedMove(满足合并条件时),减小服务器重放误差。这是代码事实,CMC 默认开启。
3.2 根因二:浮点精度差异
现象:客户端和服务器跑同一段输入,由于平台浮点运算微小差异,每帧累积出几毫米误差,长时间下来就差出容差。
解决思路:
- 这不是必须解决的,因为容差就是为了吃掉这种误差;
- 真要追求完全一致,可以换 定点数(Fixed Point) 实现关键计算。这是合理推论,UE 原生 CMC 没这么做,但格斗游戏 / RTS 同步会这么做。
3.3 根因三:客户端瞬时输入丢包
现象:客户端连续按了 W、A、D,但中间某个 ServerMove 包丢了。
解决思路:CMC 自带 Dual Move 和 Old Move 机制。
// 客户端发 RPC 时不只发当前 Move,还会"附带"上一个 Move 的关键信息
ServerMoveDual(...); // 当前 + 前一个
ServerMoveOldStart(...); // 重发更早的丢失 Move
代码事实:
UCharacterMovementComponent::CallServerMovePacked会同时打包当前 Move 和上一个 Move。
这样即使丢一个包,下一个包也能让服务器把信息补回来,避免直接 Correction。
3.4 根因四:业务逻辑没纳入预测
现象:客户端施放了一个让自己加速的技能,本地立刻加速跑,但服务器还没收到技能 RPC,服务器算出来的速度还是普通速度——位置必然对不上,必拉回。
解决思路:把会影响移动的状态全部纳入 SavedMove,并实现:
// 自定义 FSavedMove 子类
virtual bool CanCombineWith(...) const override; // 状态变了就不能合并
virtual void SetMoveFor(...) override; // 把技能状态打包进 Move
virtual void PrepMoveFor(...) override; // 服务器重放时恢复状态
virtual uint8 GetCompressedFlags() const override; // 用 flag 复用现有同步通道
这是代码事实:CMC 通过 FSavedMove_Character 的扩展机制让玩家把任意业务状态接入预测。本项目(WarriorRPG)的技能更多走 GAS,不依赖这套,所以不会触发频繁拉回。
3.5 根因汇总
| 根因 | 解决方向 | 是否引擎自带 |
|---|---|---|
| 网络抖动 | Jitter Buffer / 增大容差 | 是 |
| 浮点精度 | 容差吸收 / 定点数(合理推论) | 容差吸收是 |
| 丢包 | Dual Move / Old Move | 是 |
| 业务状态没纳入预测 | 自定义 SavedMove + Flags | 框架是,业务自己写 |
4. 小偏差不回滚时的累积问题
一个常见的疑问:既然小偏差不做回滚,那时间长了偏差会不会越积越大,最后变成大偏差?
4.1 核心结论
不会累积。 因为 ACK 不仅是 “通过”,它会把服务器的权威位置一并捎带回来,客户端会用一个 “渐进吸附” 的方式悄悄拉到权威位置上,不让玩家感知。
4.2 具体机制
CMC 处理流程是这样的(代码事实):
- 服务器端:判定误差小,发送 ACK(
ClientAckGoodMove),ACK 里包含确认的 TimeStamp。 - 客户端:收到 ACK 后调用
AckMove,把对应 SavedMove 之前的所有 Move 从队列里清掉。 - 关键点:ACK 走的也是属性复制——服务器端的 Pawn 位置一直在以
ReplicatedMovement形式同步给所有客户端,但对于 Autonomous Proxy(自己),引擎不会用这个值"硬覆盖"自己的位置,而是把它作为一个 “参考真值” 存起来。 - 客户端在每一帧的本地预测中,会以极小的速率朝着服务器权威位置做一个 “无声的修正”——这部分逻辑分布在
ClientAdjustPosition_Implementation和SmoothCorrection里。
4.3 通俗解释
想象你在跑步机上跑,跑步机带子(服务器位置)和你脚下的位置(预测位置)会有几毫米的偏差。
不会一下把你拽过去(那就是 Correction),而是带子悄悄微调速度,让你脚下的位置慢慢和带子对齐。
你感觉不到任何顿挫,但跑了 10 秒之后,你脚下的位置已经和带子完美对齐了。
4.4 为什么不会越积越大
- 每次 ACK 都会把 “客户端预测起点” 重新对齐到服务器位置——后续的预测是基于已对齐位置继续往下算的,误差不会跨 Move 累积;
- 即使有微量漂移,只要单次漂移没超过容差就不触发 Correction,超过了就走大偏差回滚——所以偏差有天花板。
5. 大偏差时的回滚平滑:SmoothCorrection
当偏差超过容差,必须做回滚时,怎么处理才能尽可能保证用户体验?
5.1 朴素回滚的问题
最直接的实现:服务器发回 ClientAdjustPosition → 客户端 SetActorLocation(ServerPos) → 角色瞬间瞬移。
问题:玩家会看到自己角色 “啪” 地一下挪到另一个位置,体验非常差。
5.2 UE 的解决方案:分离"逻辑位置"和"显示位置"
CMC 引入了 Network Smoothing(网络平滑)机制(代码事实):
// 关键字段
FNetworkPredictionData_Client_Character::MeshTranslationOffset;
FNetworkPredictionData_Client_Character::MeshRotationOffset;
收到 Correction 时:
- 逻辑位置(CapsuleComponent / Pawn 的 RootComponent)立即跳到服务器位置——这一步保证物理碰撞、命中检测都用最新权威位置;
- 显示位置(Mesh 的视觉表现)保持在原来的位置不动,但记录一个
MeshTranslationOffset= 旧位置 - 新位置; - 之后每帧把这个 offset 以一定速度趋近 0,让 Mesh 视觉上 “滑” 到逻辑位置上。
这就是为什么你回滚时看不到瞬移——Capsule 真的瞬移了,但你看到的角色模型在做平滑插值。
5.3 平滑模式选择
ENetworkSmoothingMode 有三种(代码事实):
| 模式 | 说明 | 适用 |
|---|---|---|
Disabled |
不平滑,直接瞬移 | 调试 / 小偏差 |
Linear |
线性插值到目标 | 通用 |
Exponential |
指数衰减插值(默认) | 体验最好,慢的快、快的慢 |
Replay |
回放系统专用 | 录像回放 |
5.4 进一步:关键大偏差也分级处理
更细致的做法(合理推论,需要业务自己实现):
- 小偏差(< 容差)→ ACK,悄悄修正;
- 中偏差(容差 ~ 50cm)→ Correction + Mesh 平滑;
- 大偏差(> 50cm,比如卡墙、传送)→ 直接瞬移(关闭 Smoothing),因为再平滑也救不回来,反而显得诡异。
5.5 通俗解释
引擎玩了一个 “灵魂出窍” 的把戏:
真正的 “你”(碰撞胶囊)该在哪就在哪——保证打架、踩坑、挨刀都对;
但你眼里看到的 “你”(角色模型)会从原来的位置慢慢飘过去,让你不觉得突兀。
等飘到了,灵魂归位,玩家全程没感觉。
6. PVP 命中判定:Lag Compensation 与时间回溯
PVP 类游戏(吃鸡 / 和平精英 / CSGO)下,位置偏差比较大时会出现一个经典问题:A 客户端打 B,A 看到打到了,但服务器和 B 那边位置不一致,B 看着没打到——到底以哪边为准?
6.1 问题本质
根本矛盾:A 端、B 端、服务器三方,由于网络延迟,对 “B 在某时刻的位置” 看法不同。
T=100ms:A 屏幕上 B 在 (10, 0),A 开枪
↓
T=130ms:服务器收到 A 的射击 RPC
但服务器上 B 已经跑到 (15, 0) 了
↓
如果用服务器当前位置判定:没打中 → A 玩家:???我明明瞄准头了!
A 不是瞄不准,是 A 看到的 B 是 30ms 前的 B——网络延迟导致的时空错位。
6.2 解决方案:服务器时间回溯(Lag Compensation)
业界标准做法(CSGO、守望先锋、和平精英都是这套思路):
服务器维护每个角色的 “历史位置缓冲区”,做命中判定时回溯到 A 当时实际看到的时间点,用那时的 B 位置来判定。
6.3 核心步骤(代码事实 + 工业惯例)
1. 服务器对每个 Character 保存最近 1 秒的位置/朝向/胶囊大小快照
存储结构:环形缓冲,约 64~128 帧
2. A 开枪时,客户端 RPC 上报:
- 射击射线 / 子弹起点终点
- 客户端时间戳(Client Time)
- 或客户端 ping 估计值
3. 服务器收到后:
- 计算 RewindTime = ServerNow - (A 的 Ping/2 + A 的插值延迟)
- 把 B、C、D 等所有可能被命中的角色,回溯到 RewindTime 时刻的位置
- 在那个"过去"的世界里做射线检测
- 命中即认定有效,扣 B 的血
- 把所有人的位置恢复回当前
6.4 UE 中的实现位置
UE 原生引擎不直接提供完整的 Lag Compensation 框架,需要业务自己实现(合理推论)。但官方有:
NetworkPhysics模块(5.3+ 实验性)- Lyra 项目里的射击 demo 实现了简易版本
- 第三方插件(如 ALSV4 / 各种 Shooter Template)
完整实现一般包含:
// 伪代码
class FLagCompensationManager
{
TMap<APawn*, TCircularBuffer<FPawnSnapshot>> History;
void TickRecord(); // 每帧记录所有 Pawn 的位置
bool ServerSideRewindHit(APawn* Shooter, FVector Start, FVector End,
float ClientTimestamp, FHitResult& OutHit)
{
const float RewindTime = ClientTimestamp;
// 对所有 Target Pawn 设置回到 RewindTime 时的胶囊位置
// 做线检测
// 恢复
}
};
6.5 客户端要不要也参与
关键设计:A 客户端开枪那一瞬间显示的命中特效(火花、血迹),是客户端自己预测出来的——这叫 Hit Feedback Prediction。
- 服务器最终判定可能不命中 → 客户端展示的 “血迹” 是误判,但很短暂、玩家不会太在意;
- 服务器判定命中 → 走伤害扣血流程;
- 这种 “宁可错放视觉效果,也要保证打击感” 是 FPS 网游的通用妥协。
6.6 通俗解释
服务器是 “裁判 + 时光机”。
A 说:“我刚才在 100ms 那一刻开了一枪,瞄的是这个角度。”
裁判说:“好,我把整个世界倒回 100ms 那一刻——B 当时在哪?”
然后在那个 “过去的世界” 里看 A 是否瞄准了 B。
如果是 → 命中算数。这样 A 觉得公平(我瞄到了就该中),B 也只是 “被击杀的瞬间觉得自己已经走开了”——这种小别扭比 “明明瞄准了却不中” 的挫败感小得多。
7. 回溯到底回滚谁:攻击方还是被攻击方
7.1 核心结论:只回滚被攻击方,不回滚攻击方
具体来说:
| 对象 | 是否回滚 | 原因 |
|---|---|---|
| 攻击方 A | 不回滚 | 射线起点用的是 “A 当前位置” 或 “A 客户端上报的射击起点”,不需要回滚——A 自己感知的是当前的自己 |
| 被攻击方 B | 回滚到 RewindTime | 因为 A 看到的 B 是过去的 B,要还原 “A 当时看到的世界” |
| C/D(其他可能被射线穿过的人) | 也回滚 | 射线上挡住子弹的所有人都得用历史位置,不然会出现 “我明明瞄准 B,被一个 C 用过去的姿势挡住” 这种灵异事件 |
| 场景静态物体 | 不回滚 | 它们不动 |
| 场景动态物体(电梯、平台) | 理论上要回滚 | 高质量游戏会处理(守望先锋会回滚移动平台),中等质量游戏会忽略 |
7.2 为什么 A 不回滚
A 开枪那一瞬间,A 自己屏幕上看到的 A 位置就是 “现在”。A 的射击起点 = A 当前位置(或 A 上报的位置),不是过去的位置。
如果把 A 也回滚,会出现一个怪现象:
A 在 T=100ms 开枪后,立刻在 T=110ms 跑到掩体后
服务器在 T=130ms 收到射击 RPC
如果把 A 回滚到 T=100ms 位置——这时候 A 还在掩体外
但物理上 A 早就进掩体了——回滚 A 没意义
只有 “被打的对象” 需要回滚,因为只有他们的位置变化会影响 “是否被瞄准” 的结果。
7.3 时间戳的来源与防作弊
时间戳的可信度问题:
1. 客户端发上来的时间戳不能完全相信——可能伪造
2. 服务器自己估算 RewindTime:
RewindTime = ServerNow - (RTT/2 + ClientInterpolationDelay)
3. RTT 由服务器自己测,不依赖客户端上报
4. 客户端可上报 ClientTimestamp 作为校验,但服务器有上限保护:
- RewindTime 不能超过历史缓冲长度(如 1 秒)
- 不能是"未来"
这样即使客户端伪造一个超大的 RewindTime(比如 10 秒前 B 还没出生),服务器也会被夹到合理范围。
7.4 通俗解释
你拍照的时候,移动的是被你拍的人,不是你自己。
服务器倒带是为了 “还原 A 看到的画面”——A 自己当然不需要还原(他就是观察者),
还原的是画面里那些动来动去的目标。
9. 一句话总结
把全文核心思想串起来:
客户端预测 + 服务器权威 + 客户端回滚 是 UE 原生 CMC 的基础范式;
小偏差靠 ACK 顺带的位置渐进吸附消化,不会累积;
大偏差靠 Capsule 瞬移 + Mesh 视觉平滑(SmoothCorrection)让玩家无感;
频繁拉回的根因主要是网络抖动、丢包、和业务状态没纳入预测——前两个引擎自带 Jitter Buffer / Dual Move 解决,后一个要靠扩展 FSavedMove;
PVP 命中判定靠服务器维护历史快照 + Lag Compensation 回溯被打方位置——攻击方不回滚,被攻击方和路径上的其他人回滚,时间戳由服务器估算并夹在合理范围;
客户端发 RPC 主要在 “上报输入”、“主动操作”、“请求确权” 三种场景,能用属性复制就别用 RPC。
附录:相关源码与文档索引
| 想看什么 | 去哪看 |
|---|---|
| ServerCheckClientError | CharacterMovementComponent.cpp 搜索该函数 |
| Network Smoothing | CharacterMovementComponent.cpp SmoothCorrection |
| Move Combining | FSavedMove_Character::CanCombineWith |
| Dual Move | CallServerMovePacked |
| Lag Compensation 参考 | Lyra Sample / Valorant 公开技术分享 / GDC Vault “Overwatch Netcode” |
| 项目内基础文档 | UE_CMC移动系统_网络预测与防作弊机制详解.md、UE原生移动系统同步.md |
openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐

所有评论(0)