UE原生的移动同步,有做许多方面的优化,在卡顿的情况下,可能会出现频繁的拉回位置,本文讲解这些情况要怎么处理

目录

  1. 整体回顾:客户端为什么会被拉回
  2. 拉回的具体触发条件
  3. 频繁拉回的根因分类与解决思路
  4. 小偏差不回滚时的累积问题
  5. 大偏差时的回滚平滑:SmoothCorrection
  6. PVP 命中判定:Lag Compensation 与时间回溯
  7. 回溯到底回滚谁:攻击方还是被攻击方
  8. 客户端发送 RPC 的典型场景
  9. 一句话总结

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 时间戳分布不均匀,重算出来的位置就和客户端对不上。

核心解决方案

  1. 服务器端做时间戳平滑
    服务器收到 ServerMove 时,不是用 “收到时刻” 作为执行时间,而是用客户端发来的 TimeStamp + 一个抖动缓冲(Jitter Buffer),让服务器执行节奏更平稳。这是 UE CMC 内置的机制(ServerData->CurrentClientTimeStamp)。

  2. 适度放大容差
    NetworkMaxSmoothUpdateDistance 这种参数在差网络下可以适当调大,避免微小漂移触发 Correction。

  3. Move Combining(移动合并)
    客户端在网络差时不每帧都发 RPC,而是把多个小移动合并成一个 FSavedMove(满足合并条件时),减小服务器重放误差。这是代码事实,CMC 默认开启。

3.2 根因二:浮点精度差异

现象:客户端和服务器跑同一段输入,由于平台浮点运算微小差异,每帧累积出几毫米误差,长时间下来就差出容差。

解决思路

  • 这不是必须解决的,因为容差就是为了吃掉这种误差;
  • 真要追求完全一致,可以换 定点数(Fixed Point) 实现关键计算。这是合理推论,UE 原生 CMC 没这么做,但格斗游戏 / RTS 同步会这么做。

3.3 根因三:客户端瞬时输入丢包

现象:客户端连续按了 W、A、D,但中间某个 ServerMove 包丢了。

解决思路:CMC 自带 Dual MoveOld 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 处理流程是这样的(代码事实):

  1. 服务器端:判定误差小,发送 ACK(ClientAckGoodMove),ACK 里包含确认的 TimeStamp。
  2. 客户端:收到 ACK 后调用 AckMove,把对应 SavedMove 之前的所有 Move 从队列里清掉。
  3. 关键点:ACK 走的也是属性复制——服务器端的 Pawn 位置一直在以 ReplicatedMovement 形式同步给所有客户端,但对于 Autonomous Proxy(自己),引擎不会用这个值"硬覆盖"自己的位置,而是把它作为一个 “参考真值” 存起来。
  4. 客户端在每一帧的本地预测中,会以极小的速率朝着服务器权威位置做一个 “无声的修正”——这部分逻辑分布在 ClientAdjustPosition_ImplementationSmoothCorrection 里。

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 时:

  1. 逻辑位置(CapsuleComponent / Pawn 的 RootComponent)立即跳到服务器位置——这一步保证物理碰撞、命中检测都用最新权威位置;
  2. 显示位置(Mesh 的视觉表现)保持在原来的位置不动,但记录一个 MeshTranslationOffset = 旧位置 - 新位置;
  3. 之后每帧把这个 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移动系统_网络预测与防作弊机制详解.mdUE原生移动系统同步.md
Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐