UE原生的移动同步有做许多方面的优化在卡顿的情况下可能会出现频繁的拉回位置本文讲解这些情况要怎么处理目录整体回顾客户端为什么会被拉回拉回的具体触发条件频繁拉回的根因分类与解决思路小偏差不回滚时的累积问题大偏差时的回滚平滑SmoothCorrectionPVP 命中判定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 源码思路constfloatLocDiff(ServerLoc-ClientLoc).SizeSquared();if(LocDiffFMath::Square(MaxPositionErrorSquared))// 默认 3 cm 左右{returntrue;// 触发 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 子类virtualboolCanCombineWith(...)constoverride;// 状态变了就不能合并virtualvoidSetMoveFor(...)override;// 把技能状态打包进 MovevirtualvoidPrepMoveFor(...)override;// 服务器重放时恢复状态virtualuint8GetCompressedFlags()constoverride;// 用 flag 复用现有同步通道这是代码事实CMC 通过FSavedMove_Character的扩展机制让玩家把任意业务状态接入预测。本项目WarriorRPG的技能更多走 GAS不依赖这套所以不会触发频繁拉回。3.5 根因汇总根因解决方向是否引擎自带网络抖动Jitter Buffer / 增大容差是浮点精度容差吸收 / 定点数合理推论容差吸收是丢包Dual Move / Old Move是业务状态没纳入预测自定义 SavedMove Flags框架是业务自己写4. 小偏差不回滚时的累积问题一个常见的疑问既然小偏差不做回滚那时间长了偏差会不会越积越大最后变成大偏差4.1 核心结论不会累积。因为 ACK 不仅是 “通过”它会把服务器的权威位置一并捎带回来客户端会用一个 “渐进吸附” 的方式悄悄拉到权威位置上不让玩家感知。4.2 具体机制CMC 处理流程是这样的代码事实服务器端判定误差小发送 ACKClientAckGoodMoveACK 里包含确认的 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 客户端打 BA 看到打到了但服务器和 B 那边位置不一致B 看着没打到——到底以哪边为准6.1 问题本质根本矛盾A 端、B 端、服务器三方由于网络延迟对 “B 在某时刻的位置” 看法不同。T100msA 屏幕上 B 在 (10, 0)A 开枪 ↓ T130ms服务器收到 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完整实现一般包含// 伪代码classFLagCompensationManager{TMapAPawn*,TCircularBufferFPawnSnapshotHistory;voidTickRecord();// 每帧记录所有 Pawn 的位置boolServerSideRewindHit(APawn*Shooter,FVector Start,FVector End,floatClientTimestamp,FHitResultOutHit){constfloatRewindTimeClientTimestamp;// 对所有 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 在 T100ms 开枪后立刻在 T110ms 跑到掩体后 服务器在 T130ms 收到射击 RPC 如果把 A 回滚到 T100ms 位置——这时候 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 解决后一个要靠扩展 FSavedMovePVP 命中判定靠服务器维护历史快照 Lag Compensation回溯被打方位置——攻击方不回滚被攻击方和路径上的其他人回滚时间戳由服务器估算并夹在合理范围客户端发 RPC主要在 “上报输入”、“主动操作”、“请求确权” 三种场景能用属性复制就别用 RPC。附录相关源码与文档索引想看什么去哪看ServerCheckClientErrorCharacterMovementComponent.cpp搜索该函数Network SmoothingCharacterMovementComponent.cppSmoothCorrectionMove CombiningFSavedMove_Character::CanCombineWithDual MoveCallServerMovePackedLag Compensation 参考Lyra Sample / Valorant 公开技术分享 / GDC Vault “Overwatch Netcode”项目内基础文档UE_CMC移动系统_网络预测与防作弊机制详解.md、UE原生移动系统同步.md