UNREAL ENGINE · NETWORKED CHARACTER MOVEMENT · TECHNICAL NOTE

原生 UE 角色移动同步全流程
主控端 → DS → 3P 模拟端

本文基于 F:\PX_Trunk\UE(UE 5.8.2 分支源码)逐行梳理 UCharacterMovementComponent 的原生网络移动机制:主控端如何本地预测并上报、Dedicated Server 如何校验/重放/纠正并复制权威状态、其他客户端(3P 模拟端)如何接收、平滑与驱动角色动画。全文附交互动画沙盘,可单步观察每一次数据往返。

主控端 ROLE_AutonomousProxy DS ROLE_Authority 3P ROLE_SimulatedProxy 引擎 UE 5.8.2 (Branch UE5) 源码 F:\PX_Trunk\UE\Engine\Source\Runtime\Engine 默认路径 ServerMovePacked / ClientMoveResponsePacked

00一句话总览

UE 的角色移动不是"服务器算好位置发给所有人",而是三端各自承担不同职责的一条闭环流水线:主控端不等服务器、先按输入自己动并保存每次移动的"存档";DS 收到输入后在权威世界里重放同一段移动,判断客户端算得对不对;3P 端只接收 DS 定期广播的权威快照,用网格偏移把离散快照补成连续画面。

主控端 预测 + 上报
读输入 → PerformMovement 立刻动 → 生成 FSavedMove_Character → 打包 FCharacterNetworkMoveData → ServerMovePacked RPC 上行。本地永远领先服务器至少一个 RTT。
DS 权威 + 仲裁
校验时间戳 → MoveAutonomous 用客户端输入重放 → ServerMoveHandleClientError 比位置 → 回 Ack 或纠正;同时按 NetUpdateFrequency 把 ReplicatedMovement 复制给所有连接。
3P 模拟端 跟随 + 表现
收 OnRep_ReplicatedMovement → SmoothCorrection 把差值记到 MeshTranslationOffset → SmoothClientPosition 逐帧衰减 → SimulatedTick / 动画更新。Capsule 是权威位置,Mesh 是被平滑的视觉位置。
三个关键不等式
  1. 主控端 ≠ 服务器:主控端位置是"预测值",可能被服务器纠正;服务器位置才是权威值。
  2. 3P ≠ 主控端:3P 不预测输入,只跟随离散快照,因此永远晚于主控端(RTT/2 + 复制间隔 + 平滑缓冲)。
  3. Capsule ≠ Mesh:3P 上 Capsule 直接跳到权威位置,Mesh 通过相对偏移被平滑,二者短暂分离是设计而非 bug。

01动画沙盘:一次移动的三端旅程

下面的动画按真实调用顺序逐步演示一帧输入从主控端出发、经过 DS、最终落在 3P 与角色动画上的全过程。点击「单步」逐阶段观察;调大「位置误差」会触发服务器纠正分支;切换「平滑模式」可看到 3P 曲线的变化。

速度 1.0×
RTT 80ms
位置误差 3cm
3P 平滑
主控端 / 1P ROLE_AutonomousProxy DS / 服务器 ROLE_Authority 3P 模拟端 ROLE_SimulatedProxy ServerMovePacked ↑ MoveResponse ↓ ReplicatedMovement ↓
主控端预测位置 DS 权威位置 3P 显示(平滑后)位置 纠正包
CURRENT STEP
待开始
点击「播放」或「单步」开始。动画将依次演示输入采样、本地预测、SavedMove、上行 RPC、DS 校验与重放、纠错/确认、属性复制、3P 平滑与动画更新。
—
PREDICTION STATE(主控端)
ClientTimeStamp0.000
SavedMoves 队列0
位置误差3 cm
SMOOTHING STATE(3P)
MeshTranslationOffset0.0 cm
bNetworkSmoothingCompletetrue
NetworkSmoothingModeExponential
Capsule 直接取权威位置;Mesh 相对偏移按平滑模式逐帧衰减到 0,视觉上就不会"瞬移"。
提示:把「位置误差」调到 10cm 以上可触发 ClientAdjustPosition 纠正分支(红色数据包 + SavedMove 回滚重放);把「3P 平滑」设为 Disabled 可看到 3P 位置阶梯式瞬移。
DEMO A · 位置随时间(延迟与平滑的代价)
位移 时间 →
三条曲线随上方动画进度绘制:主控端立即响应、DS 晚 RTT/2、3P 再晚一个复制间隔并被平滑缓冲推后。
DEMO B · 网络平滑(Capsule 跳变 vs Mesh 连续)
橙点=权威 Capsule(每次快照瞬间跳变);绿点=Mesh(按指数/线性逼近);右侧绿条=当前 MeshTranslationOffset。此子动画持续循环,与上方参数联动。

02三端角色与设计动机

先厘清一个最容易混淆的点:Role 描述的是"某台机器上这一份 Actor 副本的权限",不是"这个角色属于谁"。同一个玩家角色,在 owning client、DS、以及另外 9 个客户端上各有一份独立的 Actor 实例,三份实例的 GetLocalRole() 完全不同,因此同一个 UCharacterMovementComponent::TickComponent 会走进三个不同的分支。

端LocalRole / RemoteRole驱动来源位置是谁算的本端主要工作
主控端 1P
owning client
ROLE_AutonomousProxy / ROLE_Authority本地玩家输入(APlayerController)本地预测值,可被纠正完整 PerformMovement、生成/合并 FSavedMove_Character、发 ServerMovePacked、收 Ack/纠正后回滚重放
DS
dedicated server
ROLE_Authority / ROLE_AutonomousProxy(对主控连接)
ROLE_SimulatedProxy(对其他连接)
收到的客户端输入 / AI权威值时间戳校验、MoveAutonomous 重放、误差判定、回 Ack/纠正、GatherCurrentMovement 复制
3P 模拟端
其他客户端
ROLE_SimulatedProxy / ROLE_Authority网络属性复制(离散快照)服务器快照 + 本地平滑OnRep_ReplicatedMovement、SmoothCorrection / SmoothClientPosition、SimulatedTick、动画更新

2.1 一帧之内,三份副本各自走哪个分支

入口都是 UCharacterMovementComponent::TickComponent(CharacterMovementComponent.cpp:1663)。它先 ConsumeInputVector() 取输入,再按 LocalRole 分流:

// CharacterMovementComponent.cpp:1745-1808(节选,行为概述)
if (CharacterOwner->GetLocalRole() > ROLE_SimulatedProxy)      // Authority 或 AutonomousProxy
{
    const bool bIsClient = (LocalRole == ROLE_AutonomousProxy && IsNetMode(NM_Client));
    if (bIsClient && ClientData && ClientData->bUpdatePosition)
        ClientUpdatePositionAfterServerUpdate();               // 收到纠正后,回滚 + 重放

    if (bShouldPerformControlledCharMove)                       // 本地受控
        ControlledCharacterMove(InputVector, DeltaTime);        // → ReplicateMoveToServer → PerformMovement
    else if (RemoteRole == ROLE_AutonomousProxy)
        ServerAutonomousProxyTick(DeltaTime);                   // DS 上远程客户端角色的中间帧补偿
}
else if (CharacterOwner->GetLocalRole() == ROLE_SimulatedProxy) // 3P
{
    SimulatedTick(DeltaTime);                                   // 跟随快照 / 有限外推 / RootMotion
}
一句话记住三个分支
主控端:ControlledCharacterMove(输入 → 预测 → 上报);DS:收 RPC 时 MoveAutonomous,帧间 ServerAutonomousProxyTick;3P:SimulatedTick。

2.2 为什么必须这样设计

① 手感:不能等 RTT
如果按键要等服务器回包才动,玩家会感到 RTT 级别的输入延迟。客户端预测让主控端在本帧就完成移动,服务器只做事后仲裁。
② 权威:不能信客户端位置
客户端上报的是输入(加速度、时间戳、视角、按键标志)和一份用于比对的自身位置,DS 用自己的碰撞与物理重放,位置以服务器为准,从根上限制加速/穿墙类作弊。
③ 带宽:3P 不需要输入流
3P 端没有输入也没有 SavedMove,无法重放;只需按 NetUpdateFrequency 收低频位置快照,再用平滑和外推补出连续画面,配合 relevancy 裁剪可大幅省带宽。
④ 表现:动画需要连续量
动画蓝图要的是连续的速度/加速度/移动模式,而网络给的是离散快照。SmoothClientPosition 与 SimulateMovement 正是把离散数据变成连续姿态数据的适配层。

2.3 一次输入的完整数据流(文字版)

[主控端 / AutonomousProxy]                         [DS / Authority]                     [其他客户端 / SimulatedProxy]
 输入 → ConsumeInputVector                          
   │                                                 
   ├─ ControlledCharacterMove                         
   │    └─ ReplicateMoveToServer                      
   │         ├─ UpdateTimeStampAndDeltaTime           
   │         ├─ CreateSavedMove / SetMoveFor          
   │         ├─ (可合并) CombineWith(PendingMove)     
   │         ├─ PerformMovement  ← 本端立即动        
   │         ├─ PostUpdate → SavedMoves.Push          
   │         └─ CallServerMovePacked                  
   │              └─ ClientFillNetworkMoveData         
   │                   └─ ServerMovePacked ───▶ ACharacter::ServerMovePacked_Implementation
   │                        (unreliable RPC)     └─ ServerMovePacked_ServerReceive
   │                                               ├─ MoveDataContainer.Serialize(反序列化)
   │                                               └─ ServerMove_HandleMoveData
   │                                                    └─ ServerMove_PerformMovement(Old/Pending/New)
   │                                                         ├─ VerifyClientTimeStamp
   │                                                         ├─ GetServerMoveDeltaTime
   │                                                         ├─ MoveAutonomous → PerformMovement
   │                                                         └─ ServerMoveHandleClientError(仅 NewMove)
   │                                                              ├─ OK  → ServerSendMoveResponse → ClientAckGoodMove
   │                                                              └─ 超限 → ClientAdjustPosition → 回滚 + 重放
   │                                           (独立通道)GatherCurrentMovement → ReplicatedMovement
   │                                             (属性复制, NetUpdateFrequency) ──────▶ OnRep_ReplicatedMovement
   │                                                                                                    ├─ PostNetReceiveVelocity
   │                                                                                                    ├─ PostNetReceiveLocationAndRotation
   │                                                                                                    │    └─ SmoothCorrection
   │                                                                                                    ├─ SmoothClientPosition(逐帧)
   │                                                                                                    └─ SimulatedTick / 动画更新

注:Listen Server 上本机玩家的 Actor 同时是 Authority 且本地受控,属于特殊路径(TickComponent:1773 的 bIsaListenServerAutonomousProxy 分支);本文以 Dedicated Server 为主线。

03主控端:本地预测与 SavedMove

主控端(ROLE_AutonomousProxy)的唯一目标:不等服务器,本帧就把按键变成位移,同时把这次移动完整地"存档",以便服务器说"你算错了"时能回滚重来。全部逻辑集中在 TickComponent → ControlledCharacterMove → ReplicateMoveToServer。

3.1 一帧内的调用链

Tick 依赖(决定顺序的前提):AController::AddPawnTickDependency(Controller.cpp:495-517,关键 :503)把 Pawn 移动组件的 Tick 排在 PlayerController 之后,保证「先采集输入、后 ConsumeInputVector 消费」;ACharacter 又把 Mesh 的 Tick 排在移动组件之后(Character.cpp:152-156),保证动画读到的是本帧已更新的速度。

APlayerController::PlayerTick / ProcessPlayerInput / UpdateRotation
  └─ ACharacter::AddMovementInput → PendingMovementInputVector
       └─ UCharacterMovementComponent::TickComponent                    :1663
            ├─ ConsumeInputVector()                                     :1676
            ├─ [AutonomousProxy && NM_Client] bUpdatePosition ?
            │    └─ ClientUpdatePositionAfterServerUpdate()             :1753(见第 8 章)
            └─ ControlledCharacterMove(InputVector, DeltaTime)          :1771 → :6481
                 ├─ CheckJumpInput / ScaleInputAcceleration /
                 │    ConstrainInputAcceleration / ComputeAnalogInputModifier :6488-6492
                 └─ ReplicateMoveToServer(DeltaTime, Acceleration)       :6501 → :8968
                      ├─ UpdateTimeStampAndDeltaTime                     :8999 → :8922
                      ├─ 找 OldMove(IsImportantMove)                    :9001-9017
                      ├─ CreateSavedMove / SetMoveFor                     :9020-9027
                      ├─ CanCombineWith → CombineWith(PendingMove)        :9032-9071
                      ├─ Acceleration 钳制(与服务器一致)                :9084
                      ├─ PerformMovement(NewMove->DeltaTime)  ← 本端立即动 :9090
                      ├─ PostUpdate → SavedMoves.Push                     :9092-9098
                      ├─ CanDelaySendingMove / GetClientNetSendDeltaTime  :9100-9113
                      └─ CallServerMovePacked 或 CallServerMove            :9150-9157

3.2 逐步详解

1
输入采集与消费
APlayerController 在 PlayerTick 中处理输入并更新 ControlRotation,ACharacter::AddMovementInput 把世界空间输入向量累积到 PendingMovementInputVector。移动组件在 TickComponent:1676 用 ConsumeInputVector() 一次性取走(异步移动开启时改由 async 路径填输入,见 :1673-1677)。
2
输入 → 加速度
ControlledCharacterMove:6481 先 CheckJumpInput(保证跳跃状态先于加速度计算),再 Acceleration = ScaleInputAcceleration(ConstrainInputAcceleration(InputVector)) 与 AnalogInputModifier = ComputeAnalogInputModifier()。注意分支:LocalRole == ROLE_Authority 直接 PerformMovement(Listen Server 本机玩家);只有 AutonomousProxy && NM_Client 才走 ReplicateMoveToServer(:6495-6502)。
3
时间戳与 OldMove
UpdateTimeStampAndDeltaTime(:8922)按本端 DeltaTime 累加 CurrentTimeStamp,得到本次 move 的 TimeStamp / DeltaTime。随后在 SavedMoves 中(排除最后一个,因为它可能与下一次 move 合并)寻找第一个 IsImportantMove(LastAckedMove) 的 move 作为 OldMove(:9001-9017)——这是上行丢包冗余的关键:后续包会重发这个"重要但未确认"的 move(IsImportantMove 定义在 :13053)。另外 CurrentTimeStamp 每 MinTimeBetweenTimeStampResets = 240s(:815)会重置,重置时给未确认的 SavedMoves / LastAckedMove 打 bOldTimeStampBeforeReset 并同步到 RootMotion(:8925-8945),服务器侧 VerifyClientTimeStamp 再做对应扣减(见 5.2)。
4
创建并合并 SavedMove
CreateSavedMove() 分配对象,SetMoveFor(...) 记录本次移动的全部状态。若上一帧因节流留下 PendingMove 且 CanCombineWith 通过(时间戳未重置、加速度/模式/按键相近),则 CombineWith 把两段合为一段:先把角色回退到 PendingMove 的起点(GetRevertedLocation),做 OverlapTest 确认回退位置不会穿模,重设 FaceRotation / SaveBaseLocation / SetInitialPosition,再释放 PendingMove(:9032-9071)。这就是 UE 在高帧率下不必每帧发一个包的原因。
5
本地执行移动(预测)
先把 Acceleration 钳到 GetMaxAcceleration() 并重算 AnalogInputModifier(:9083-9085,与服务器 MoveAutonomous:10746-10748 完全对称,保证两端物理一致),清空客户端 RootMotion 缓存后调用 PerformMovement(NewMove->DeltaTime)(:9088-9090)。这一行就是"手感零延迟"的来源:碰撞、地面检测、速度积分、位移全部在本地完成,画面本帧即更新。
6
存档与发送节流
PostUpdate(..., PostUpdate_Record) 收尾后 SavedMoves.Push(NewMovePtr)(:9092-9098)。随后 CanDelaySendingMove + GetClientNetSendDeltaTime 计算发送间隔并钳制在 1/120 ~ 1/5 秒;若距上次发送太短,就把本次 move 暂存为 PendingMove 并 return(:9100-9113),等下一帧合并或补发。通过节流后才发 RPC(:9147-9157)。

3.3 FSavedMove_Character 里到底存了什么

字段组内容为什么需要
时间TimeStamp、DeltaTime、TimeStampBeforeResetAck / 纠正按时间戳对齐;服务器用相邻时间戳之差还原物理步长。
输入Acceleration、SavedControlRotation、CompressedFlags(跳跃/蹲/强制最大加速等)、bPressedJump、WantsToCrouch服务器重放与客户端回滚重放的"输入带",客户端上报的不是位置而是输入。
结果快照SavedLocation / SavedRotation / SavedVelocity / SavedRelativeLocation / EndPackedMovementMode / EndMovementBaseInterfaceData / EndBoneNameServerMoveHandleClientError 用客户端自报位置与服务器重放位置比对;合并 move 时用它回退起点。
控制标志bForceNoCombine、bForceMaxAccel、bWantsToCrouch、RootMotion 相关决定能否合并、是否属于"重要 move"、是否需要特殊序列化。
虚函数SetMoveFor / PrepMoveFor / PostUpdate / CanCombineWith / CombineWith / IsImportantMove / GetCompressedFlags项目自定义移动数据正是通过派生这个类来扩展(见第 12 章)。

3.4 三条容易忽略的规则

① 控制器必须先被确认
ReplicateMoveToServer(:8978-8983)检查 PC->AcknowledgedPawn == CharacterOwner,否则直接 return;PC->Player == nullptr 也 return。目的是避免在 Pawn 尚未被客户端确认时把不可靠 RPC 塞满可靠缓冲。
② 客户端也不完全"信自己"
上报的 Acceleration 会被 GetClampedToMaxSize(GetMaxAcceleration()) 限制(:9084),服务器在 MoveAutonomous:10746-10747 再做一次同样的约束;ServerExceedsAllowablePositionError 还会检查移动模式是否一致、加速度是否越界(:10640+)。
③ 时间戳会"重置"
长时间运行后 CurrentTimeStamp 会重置(MinTimeBetweenTimeStampResets);VerifyClientTimeStamp 检测重置并同步扣减(:9453-9465),合并逻辑也因此禁止跨重置合并(:9031-9032)。

3.5 关键代码(可直接对照源码)

// CharacterMovementComponent.cpp:6495-6502 —— 三端分流的起点
if (CharacterOwner->GetLocalRole() == ROLE_Authority)
{
    PerformMovement(DeltaSeconds);
}
else if (CharacterOwner->GetLocalRole() == ROLE_AutonomousProxy && IsNetMode(NM_Client))
{
    ReplicateMoveToServer(DeltaSeconds, Acceleration);
}

// :9083-9098 —— 钳加速度 → 本地预测 → 存档
Acceleration = NewMove->Acceleration.GetClampedToMaxSize(GetMaxAcceleration());
AnalogInputModifier = ComputeAnalogInputModifier();
CharacterOwner->ClientRootMotionParams.Clear();
CharacterOwner->SavedRootMotion.Clear();
PerformMovement(NewMove->DeltaTime);          // ← 本端画面立即响应
NewMove->PostUpdate(CharacterOwner, FSavedMove_Character::PostUpdate_Record);
if (CharacterOwner->IsReplicatingMovement())
{
    ClientData->SavedMoves.Push(NewMovePtr);
}

05DS:校验、重放与纠错

DS(ROLE_Authority)不信任客户端的位置,只信任客户端的输入:它用同一套 PerformMovement 在自己的世界里把这段输入重放一遍,再拿重放结果与客户端自报位置比对,决定"通过"还是"纠正"。

5.1 收包与反序列化

1
RPC 落到 ServerMovePacked_ServerReceive
:9949。先 HasValidData / IsActive 检查;再检查 DataBits.Num() 是否超过 NetPackedMovementMaxBits(4096),超了直接丢弃并打 Error(:9956-9962);复用 ServerMoveBitReader 设置数据,取 Iris 或传统 PackageMap 解析对象引用;最后 MoveDataContainer.Serialize(*this, BitReader, PackageMap) 反解出三段 move(:9982-9989)。
2
按 Old → Pending → New 顺序处理
ServerMove_HandleMoveData(:9994-10030)依次 SetCurrentNetworkMoveData + ServerMove_PerformMovement:先补 OldMove(丢包冗余),再 PendingMove(可用 FScopedCapsuleMovementUpdate 与 NewMove 合并成一次 transform 更新),最后 NewMove。处理完把 CurrentNetworkMoveData 置空。注意:误差判定只在 NewMove 上做一次(:10127-10131),避免在 dual move 中间误判。

5.2 时间戳校验(第一道闸门)

ServerMove_PerformMovement:10033 先取出 TimeStamp / Acceleration / CompressedMoveFlags / ControlRotation,把 base 相对加速度转回世界空间(:10045-10053),然后调用 VerifyClientTimeStamp(:10061 → :9442):

  • IsClientTimeStampValid 判断是否过期 / 乱序 / 超前;不合法直接 return,只打日志(:9450, :9486-9501, :10063-10073)。
  • 检测到时间戳重置时同步扣减 MinTimeBetweenTimeStampResets,并把重置应用到活跃的 RootMotion(:9453-9465)。
  • 合法且非重置时进入 ProcessClientTimeStampForTimeDiscrepancy(:9483 → :9504):用 ClientDelta - ServerDelta 累积时间漂移,配合 AGameNetworkManager 的 MovementTimeDiscrepancy* 参数做检测与"偿还",防止客户端改时钟加速(默认关闭,见第 11 章)。
  • PC->NotifyServerReceivedClientData 判定服务器是否就绪;未就绪则把加速度清零(:10076-10085)。

5.3 权威重放(第二道闸门:物理一致性)

// :10088-10119(节选)
const float DeltaTime = ServerData->GetServerMoveDeltaTime(ClientTimeStamp, ...);
ServerData->CurrentClientTimeStamp = ClientTimeStamp;
ServerData->ServerAccumulatedClientTimeStamp += DeltaTime;
ServerData->ServerTimeStamp = MyWorld->GetTimeSeconds();
CharacterController->SetControlRotation(ClientControlRotation);
PC->UpdateRotation(DeltaTime);
MoveAutonomous(ClientTimeStamp, DeltaTime, ClientMoveFlags, ClientAccel);  // :10119

// :10743-10758 —— MoveAutonomous 内部
UpdateFromCompressedFlags(CompressedFlags);
CharacterOwner->CheckJumpInput(DeltaTime);
Acceleration = ConstrainInputAcceleration(NewAccel);
Acceleration = Acceleration.GetClampedToMaxSize(GetMaxAcceleration());   // 与客户端 :9084 对称
PerformMovement(DeltaTime);                                             // 权威世界里重放

GetServerMoveDeltaTime(调用点 :10208 / :10088,实现 :12695-12710,含时间差异 override 与 p.NetServerMaxMoveDeltaTimeScalar = 1.75 的放宽)用相邻客户端时间戳之差而不是服务器帧长来还原 DeltaTime,这样即使包到达不均匀,服务器重放的物理步长也与客户端本地预测一致。MoveAutonomous(:10730)在 PerformMovement 之后还会 TickCharacterPose(DeltaTime)(:10766-10787),让服务器上的动画事件、状态机与客户端保持同步;Listen Server 上还会对远端角色调用 SmoothCorrection(:10791-10797)。

5.4 误差判定:Good Move 还是 Correction

ServerMoveHandleClientError(:10254,由 :10130 调用)把客户端自报位置从 base 相对坐标还原到世界坐标(:10279-10288),与服务器重放后的 ServerLoc(:10290)比较。中间还有一段"落地/离地信任窗口"逻辑:由 p.ClientAuthorityThresholdOnBaseChange 与 p.MaxFallingCorrectionLeash 控制,在跳跃离开/落回移动平台时允许暂时信任客户端,避免突兀纠正(:10307-10458)。

结果触发条件写入 PendingAdjustment 的内容
Good MoveServerCheckClientError 返回 false(位置差在 MAXPOSITIONERRORSQUARED 内、移动模式一致、加速度未越界),且未强制更新。:10467, :10562-10564仅 TimeStamp + bAckGoodMove = true。客户端只需把这个 move 从队列里丢掉。
CorrectionbForceClientUpdate 或 ServerCheckClientError 返回 true(:10589-10630,含 p.NetForceClientAdjustmentPercent 强制触发)。NewLoc / NewVel / NewRot / NewBase / NewBaseBoneName / MovementMode / DeltaTime / TimeStamp,bAckGoodMove = false(:10469-10530)。
还有一条反向路径:服务器"采纳客户端"
在 else 分支里,若 ServerShouldUseAuthoritativePosition 判定通过,服务器会把自己的位置设为客户端上报的位置并套用客户端的移动模式 / base(:10538-10559)。这是特定配置下的信任策略,不是常规路径,但读代码时容易看漏。

另外 ServerMoveHandleClientError 开头有更新频率节流(WithinUpdateDelayBounds,:10270-10277),SendClientAdjustment 里还有 NetworkMinTimeBetweenClientAdjustments* 节流,避免每个包都触发纠正风暴。

5.5 响应如何发出(第 8 章的入口)

SendClientAdjustment(:11121)并非在误差判定里立即发送:ServerMoveHandleClientError 只把裁决写入 ServerData->PendingAdjustment,真正的下发在服务器帧末复制阶段,由 APlayerController::SendClientAdjustment()(PlayerController.cpp:1924-1937)转发到移动组件。进入后:若 PendingAdjustment.TimeStamp <= 0 直接 return;bAckGoodMove = true 走确认节流后 ServerSendMoveResponse(:10887)或老 ClientAckGoodMove;否则走纠正节流,packed 路径同样 ServerSendMoveResponse,老路径则按 RootMotion 状态分派到 ClientAdjustPosition / ClientAdjustRootMotionPosition / ClientAdjustRootMotionSourcePosition(:11166-11215)。无论哪条路径,最终都是一个 FClientAdjustment 结构被打包送回 owning client。

DS 在这条链路里同时做两件独立的事
① 对主控端:收 ServerMove → 重放 → 回 Ack/纠正(本章);② 对所有客户端:按自己的复制节拍把权威状态写进 ReplicatedMovement 并广播(第 6 章)。这两条通道的节拍、可靠性、载荷都不同,不要混为一谈——3P 端永远收不到 ServerMove,也永远收不到 ClientAdjustPosition。

073P 模拟端:接收与网络平滑

3P 模拟端(ROLE_SimulatedProxy)没有输入、没有 SavedMove,只有一串离散的权威位置快照。UE 的做法是:Capsule 立即跳到权威位置(保证逻辑位置正确),Mesh 留在原来的视觉位置(保证画面连续),再把两者的差值记进 MeshTranslationOffset,逐帧衰减到 0。

7.1 收包 → RepNotify → OnRep 的完整链条

UActorChannel / FObjectReplicator 收到属性 bunch
  ├─ AActor::PreNetReceive() / PostNetReceive()          ActorReplication.cpp:152
  │    └─ ACharacter::PreNetReceive / PostNetReceive    Character.cpp:1639 / :1646
  │         └─ 比较 MovementMode / GravityDirection 变化
  │              → bNetworkMovementModeChanged / bNetworkGravityDirectionChanged
  │              → bNetworkUpdateReceived = true          :1650-1652
  └─ RepNotify: ACharacter::OnRep_ReplicatedMovement    Character.cpp:1754
       └─ 若没有在播 RootMotion Montage / Source → Super::OnRep_ReplicatedMovement()
            └─ AActor::OnRep_ReplicatedMovement          ActorReplication.cpp:186
                 ├─ IsReplicatingMovement() 守卫           :191-194
                 ├─ SyncReplicatedPhysicsSimulation(bRepPhysics 变化时)
                 ├─ RootComponent->OnReceiveReplicatedState :237
                 └─ [LocalRole == ROLE_SimulatedProxy && 无 AttachParent]
                      ├─ PostNetReceiveVelocity(LinearVelocity)   :277
                      └─ PostNetReceiveLocationAndRotation()      :278
                           └─ ACharacter::PostNetReceiveLocationAndRotation  Character.cpp:2004
                                ├─ bNetworkSmoothingComplete = false       :2019
                                ├─ bJustTeleported |= 位置变化              :2020
                                ├─ SmoothCorrection(OldLoc, OldRot, NewLoc, NewRot) :2021
                                ├─ OnUpdateSimulatedPosition(...)          :2022
                                └─ bNetworkUpdateReceived = true           :2024
两个守卫值得记住
① ACharacter::OnRep_ReplicatedMovement:1756-1763 在播放网络 RootMotion 时会跳过标准位置修正,交给 OnRep_RootMotion 处理(见第 9 章);② AActor::OnRep_ReplicatedMovement:191-194 检查 IsReplicatingMovement(),因为该属性是 REPNOTIFY_Always,回放快进等场景也可能触发,此时数据无效必须忽略。

7.2 SmoothCorrection:一次快照到来时做什么

CMC.cpp:8245。开头断言只应跑在客户端代理上(checkSlow(GetNetMode() != NM_DedicatedServer),:8254),并置 bNetworkSmoothingComplete = false。

1
Disabled 模式:直接瞬移
SetWorldLocationAndRotation(NewLocation, NewRotation, ..., ETeleportType::TeleportPhysics) 后标记完成(:8265-8269)。
2
累计视觉偏移
NewToOldVector = OldLocation - NewLocation,累加到 ClientData->MeshTranslationOffset(:8278-8298)。若位移超过 MaxSmoothNetUpdateDist,只按最大距离累加;超过 NoSmoothNetUpdateDist 则直接清零——宁可瞬移,也不做长距离橡皮筋拉扯。
3
移动 Capsule,但不移动 Mesh
Linear:记录 OriginalMeshTranslationOffset / OriginalMeshRotationOffset / MeshRotationTarget,用 FScopedPreventAttachedComponentMove 阻止 Mesh 跟随,只 UpdatedComponent->SetWorldLocation(NewLocation)(:8301-8317)。Exponential:计算保持世界朝向所需的 MeshRotationOffset,同样阻止 Mesh 移动后 SetWorldLocationAndRotation(NewLocation, NewRotation)(:8318-8327)。此刻 Capsule 已是权威位置,Mesh 仍在旧位置,二者分离量正好是 MeshTranslationOffset。
4
更新平滑时间戳
若本地跑得比服务器快,把 SmoothingClientTimeStamp 往回拉一半(:8332-8340);把 ServerLastTransformUpdateTimeStamp 写入 SmoothingServerTimeStamp(:8342-8349),"用服务器时间而不是包到达间隔"来驱动插值。

7.3 SmoothClientPosition:之后每帧做什么

CMC.cpp:8426 = SmoothClientPosition_Interpolate(:8450)+ SmoothClientPosition_UpdateVisuals(:8600)。

模式插值方式完成条件 / 特点
Exponential
默认
每帧 MeshTranslationOffset *= (1 - DeltaSeconds / SmoothLocationTime),其中 SmoothLocationTime = SmoothNetUpdateTime(静止时取一半,收敛更快);旋转用 FQuat::FastLerp 逼近 MeshRotationTarget(:8522-8556)。偏移接近 0 且旋转到位 → bNetworkSmoothingComplete = true。实现简单、对抖动鲁棒,是 UE 默认。
LinearSmoothingClientTimeStamp += dt,按 LerpPercent = (TargetDelta - RemainingTime) / TargetDelta 在 OriginalMeshTranslationOffset → 0 之间 LerpStable,并用 FastLerp 插值旋转(:8456-8507)。以服务器时间差的固定时长收敛;有 15% 的前瞻缓冲(:8469-8471)。适合更新率稳定的场景。
Disabled不插值,Mesh 直接跟随 Capsule 跳变。用于 RootMotion / 需要精确对齐的场景。
// :8654-8657 —— Exponential 模式把偏移写回 Mesh(Capsule 不动)
const FVector NewRelTranslation =
    (UpdatedComponent->GetComponentToWorld().InverseTransformVectorNoScale(
         ClientData->MeshTranslationOffset) / MeshParentScale)
    + CharacterOwner->GetBaseTranslationOffset();
const FQuat NewRelRotation =
    ClientData->MeshRotationOffset * CharacterOwner->GetBaseRotationOffset();
Mesh->SetRelativeLocationAndRotation(NewRelTranslation, NewRelRotation, ...);

Linear 模式的 UpdateVisuals(:8607-8642)还会直接改写 Capsule 的相对旋转,使旋转插值与 Mesh 位移协调。这份引擎源码在 :8623-8633 有一段 PX 项目的性能优化注释(绕过胶囊子类的异步物理覆写),属于插桩/优化,不改变上述语义。

7.4 SimulatedTick / SimulateMovement:帧间怎么"活起来"

两次快照之间,3P 也不能完全静止。TickComponent:1798-1808 走 SimulatedTick(:1898),常规情况下进入 SimulateMovement(:2208):

  • bNetworkUpdateReceived 门控:仅在收到新更新的那一帧应用 ReplicatedGravityDirection、ApplyNetworkMovementMode(GetReplicatedMovementMode())(:2249-2265, :2262),否则只处理 bForceNextFloorCheck 的地面刷新。
  • 使用复制来的 FRepMovement::LinearVelocity 做有限外推;若 bSimGravityDisabled 或"在地面且复制速度为零"(:2292-2300),直接把 Velocity 清零,避免在没有后续更新时越滑越远。
  • 与主控端不同:这里不消费输入、不生成 SavedMove、不调用 PerformMovement,也不做完整的预测重放;p.NetEnableSkipProxyPredictionOnNetUpdate 还能让收到网络更新的那一帧干脆跳过预测(CMC.cpp:143-150)。
  • RootMotion 是例外分支:SimulatedTick:1903-1968 先 TickCharacterPose 再 SimulateRootMotion,并在需要时调用 SimulatedRootMotionPositionFixup(Character.cpp:1821)做位置修正。

7.5 网络平滑关键变量速查

变量所属含义
MeshTranslationOffset / OriginalMeshTranslationOffsetFNetworkPredictionData_Client_Character当前 / 初始的视觉位置偏移,逐帧衰减到 0。
MeshRotationOffset / MeshRotationTarget同上当前 / 目标的视觉旋转偏移。
bNetworkSmoothingComplete移动组件平滑是否结束;每次收到快照被置 false。
bNetworkUpdateReceived移动组件本帧是否收到网络更新,门控 SimulateMovement 里的模式/重力刷新。
SmoothingClientTimeStamp / SmoothingServerTimeStamp / LastCorrectionDeltaClientData基于服务器时间的插值时钟与上次更新间隔。
SmoothNetUpdateTime / SmoothNetUpdateRotationTimeClientDataExponential 衰减的时间常数(与复制间隔相关)。
MaxSmoothNetUpdateDist / NoSmoothNetUpdateDistClientData单次快照位移过大时的限制 / 放弃平滑阈值。
一句话收束
3P 的逻辑位置永远等于服务器权威位置(Capsule 直接 SetActorLocationAndRotation),视觉位置是被平滑的历史位置(Mesh + MeshTranslationOffset)。动画、碰撞查询、命中判定用的都是前者;玩家眼睛看到的是后者。这个分离正是第 1 章动画沙盘里"绿点滞后于橙点"的原因。

08闭环:Ack / 纠正与回滚重放

到这里,流水线要闭环回到主控端:DS 的裁决("这次预测对了"或"错了")必须送回主控端,主控端据此丢弃存档、或回滚后重放。这一步决定了"客户端预测"是否真的自洽。

8.1 两条响应路径

路径DS → 主控端主控端动作
Good MoveSendClientAdjustment:11137-11151 → ServerSendMoveResponse:10887 → ClientMoveResponsePacked(旧路径 ClientAckGoodMove(TimeStamp))ClientHandleMoveResponse:11039 判定 IsGoodMove() → ClientAckGoodMove_Implementation(:11706)
CorrectionSendClientAdjustment:11153-11215 → 同样 ServerSendMoveResponse(packed),载荷里带完整 FClientAdjustment;旧路径按 RootMotion 状态分派不同 RPCClientHandleMoveResponse:11045-11096 → ClientAdjustPosition_Implementation(:11295)

两条路径都受节流保护:确认有 NetworkMinTimeBetweenClientAckGoodMoves(:11140),纠正有 NetworkMinTimeBetweenClientAdjustments*(:11157-11163);大纠正用更短的间隔。

8.2 Good Move:丢弃已确认的存档

// :11706-11737
void UCharacterMovementComponent::ClientAckGoodMove_Implementation(float TimeStamp)
{
    FNetworkPredictionData_Client_Character* ClientData = GetPredictionData_Client_Character();
    int32 MoveIndex = ClientData->GetSavedMoveIndex(TimeStamp);
    if (MoveIndex == INDEX_NONE) return;   // 找不到(已过期)就忽略
    ClientData->AckMove(MoveIndex, *this); // :12620 —— 把该 move 及之前全部移出 SavedMoves
}

AckMove(:12620)把 SavedMoves 中时间戳 ≤ 本次 Ack 的全部 move 移入 FreeMoves 复用池,并更新 LastAckedMove。此后客户端的预测队列只保留服务器尚未确认的部分,这既控制了内存,也让 IsImportantMove / OldMove 的选取始终有意义。

8.3 Correction:接受权威状态,标记"下帧回滚"

ClientAdjustPosition_Implementation(:11295)做的事情按顺序是:

  1. 守卫 HasValidData / IsActive(:11308);若 bClientIgnoreMovementCorrections 为真,只 AckMove 修剪队列而不改位置(:11313-11329)。
  2. 解析 movement base;若 base 在本端尚未生成且位置是相对坐标,忽略这次纠正(:11334-11343)。
  3. 按时间戳找到对应 move 并 AckMove(:11361)——纠正也会先丢弃已确认的旧存档,剩下的才是需要重放的。
  4. 把服务器的 NewLoc / NewVel / MovementMode / Base / Rotation 应用到角色(接受权威状态),最后 ClientData->bUpdatePosition = true(:11451)。
关键点
纠正不会自己立刻重放,只是置一个标志位;真正的回滚发生在下一帧的 TickComponent 开头(:1750-1758),这样输入采集、动画、物理都还在正常的帧流程里。

8.4 回滚 + 重放:ClientUpdatePositionAfterServerUpdate

// :8667-8752(结构节选)
if (!ClientData->bUpdatePosition) return false;
ClientData->bUpdatePosition = false;
if (ClientData->SavedMoves.Num() == 0) { /* 无需重放 */ return false; }

// 备份会被重放改写的状态
const float SavedAnalogInputModifier = AnalogInputModifier;
const FRootMotionMovementParams BackupRootMotionParams = RootMotionParams;
const bool bRealPressedJump = CharacterOwner->bPressedJump; ...
CharacterOwner->bClientUpdating = true;
bForceNextFloorCheck = true;
FScopedMeshMovementUpdate ScopedMeshUpdate(...);   // 合并所有 Mesh 变换,避免抖动

for (int32 i = 0; i < ClientData->SavedMoves.Num(); i++)
{
    FSavedMove_Character* const CurrentMove = ClientData->SavedMoves[i].Get();
    SetCurrentReplayedSavedMove(CurrentMove);
    CurrentMove->PrepMoveFor(CharacterOwner);        // 恢复该 move 的输入状态
    if (ShouldUsePackedMovementRPCs()) { ...ClientFillNetworkMoveData(*CurrentMove, NewMove); }
    MoveAutonomous(CurrentMove->TimeStamp, CurrentMove->DeltaTime,
                   CurrentMove->GetCompressedFlags(), CurrentMove->Acceleration);  // :8747
    CurrentMove->PostUpdate(CharacterOwner, PostUpdate_Replay);
}
// 之后恢复备份的输入 / Jump / Crouch / RootMotion 状态(:8760+)
回滚
角色已被 ClientAdjustPosition 放到服务器权威位置,相当于回到"该时间戳那一刻的正确状态"。
重放
对 所有仍未确认的 SavedMoves 逐个 PrepMoveFor → MoveAutonomous → PostUpdate,把 RTT 期间玩家按过的键重新演一遍。
结果
玩家看到的是"轻微回拉后继续前进",而不是"跳回几帧前"。这正是客户端预测能被接受的原因。

函数还会备份/恢复 AnalogInputModifier、RootMotion、bPressedJump、bWantsToCrouch、bForceMaxAccel 等状态(:8709-8717, :8760+),并给 PendingMove 打上 bForceNoCombine(:8755-8758),防止重放后的 move 被错误合并。若无 SavedMoves 可重放则直接返回(:8691-8707)。

8.5 完整闭环(把三端连起来)

① 1P 输入 → 本地预测(领先)
② SavedMove 存档 + ServerMovePacked 上行
③ DS 校验/重放 → ④ 判定 Good / Correction
        ┌───────────────┴────────────────┐
        ▼                                ▼
⑤A Ack → 丢弃已确认 SavedMoves     ⑤B ClientAdjustPosition → 置 bUpdatePosition
                                        ▼
                          ⑥ 下帧 ClientUpdatePositionAfterServerUpdate
                             回滚到权威位置 + 重放剩余 SavedMoves
        └──────────────────────┬───────────────────────────┘
                               ▼
              ⑦ DS 同时按 NetUpdateFrequency 广播 ReplicatedMovement
              ⑧ 3P 收 OnRep → SmoothCorrection → 逐帧平滑 → 动画更新
两条下行通道别混淆
⑤A/⑤B 是"给主控端的裁决"(RPC,单播,携带时间戳/权威状态,主控端用来维护 SavedMoves);⑦ 是"给所有人的权威快照"(属性复制,多播,3P 用来平滑跟随)。主控端因 COND_SimulatedOrPhysics 根本收不到 ⑦,它的闭环完全由 ⑤ 完成。

09动画系统:3P 角色姿态从哪来

「动画支持」在这份文档里有两层含义,本章讲的是第二层:
(a) 文档动画:第 1 章的交互式沙盘用 SVG/JS 动画演示数据流动、纠正分支与平滑衰减;(b) 引擎动画系统:3P 上的角色动画蓝图如何拿到移动数据并正确播放走/跑/跳/RootMotion。两层都覆盖,避免歧义。

9.1 动画蓝图能读到哪些"网络来的"数据

动画蓝图并不直接读网络包。它读的是本端 ACharacter / UCharacterMovementComponent 上的运行时状态,而这些状态在 3P 上由复制属性与 SimulateMovement 填充:

动画常用量3P 上的来源说明 / 代码位置
Velocity / SpeedFRepMovement::LinearVelocity → PostNetReceiveVelocity(ActorReplication.cpp:296),再由 SimulateMovement 做有限外推决定走/跑/待机混合与移动方向;地面且复制速度为 0 时会直接清零,防止越滑越远(CMC.cpp:2292-2300)。
AccelerationFRepMovement::Acceleration(仅 bRepAcceleration 置位时复制)由 ACharacter::GatherCurrentMovement 写入(Character.cpp:1742-1746),3P 消费见 CMC.cpp:7021;用于起步/急停姿态。
MovementMode(走/落水/飞行/自定义)ReplicatedMovementMode(uint8 压缩)声明 Character.h:702-703;ACharacter::PostNetReceive 比较变化置 bNetworkMovementModeChanged(Character.cpp:1651-1652),SimulateMovement 里 ApplyNetworkMovementMode 应用(CMC.cpp:2262)。
蹲伏 / 跳跃bIsCrouched(ReplicatedUsing=OnRep_IsCrouched)、bProxyIsJumpForceAppliedCharacter.h:645-650, :653-654;非 owning 客户端通过复制触发 OnRep_IsCrouched / OnCrouched 等。
重力方向 / 基于移动平台ReplicatedGravityDirection、ReplicatedBasedMovementCharacter.h:574-575, :535-536;OnRep_ReplicatedBasedMovement(Character.cpp:1658)在 base 变化时也会调 SmoothCorrection(:1729)。
RootMotion / MontageRepRootMotion(FRepRootMotionMontage)、RootMotionRepMovesCharacter.h:1159-1160, :1137;独立复制通道,见 9.3。
Transform(视觉)Capsule = 权威位置;Mesh = 加上 MeshTranslationOffset 的平滑位置动画通常挂在 Mesh 上,因此角色模型的视觉位置是平滑后的,而组件/碰撞位置是权威的。见第 7 章。
三个容易踩坑的补充事实(来自源码核对)
① bPressedJump 不复制(Character.h:657-658 只有 BlueprintReadOnly)。3P 判断跳跃应看 bProxyIsJumpForceApplied(:649-650)或 IsFalling();蹲伏则由 OnRep_IsCrouched(Character.cpp:413-429)在 3P 上调用 Crouch/UnCrouch(true) 并置 bNetworkUpdateReceived。
② 3P 的 Acceleration 未必是真加速度:UpdateProxyAcceleration()(CMC.cpp:7018-7033)在 bRepAcceleration 为真时才取 FRepMovement::Acceleration,否则退化为 Velocity.GetSafeNormal()(单位方向,量纲不是 cm/s²);而 p.EnableCharacterAccelerationReplication 原生默认 0(Character.cpp:41)。速度本身则由 APawn::PostNetReceiveVelocity 直接写入 MoveComponent->Velocity(Pawn.cpp:1211-1226)。
③ OnRep 的时机:FObjectReplicator::PostReceivedBunch() 先 PostNetReceive() 再 CallRepNotifies(true)(DataReplication.cpp:1625-1643),即一个 bunch 的所有属性反序列化完、旧值快照做完之后才触发 OnRep_ReplicatedMovement——这正是 ACharacter::PreNetReceive 能缓存旧 MovementMode 的原因。

9.2 Tick 顺序:先动 Capsule,再更新骨骼

  1. UCharacterMovementComponent::TickComponent(CMC.cpp:1663)用 FScopedMeshMovementUpdate(:1801)把 Mesh 的子更新推迟到所有移动完成之后,避免一阵抖动。
  2. 3P 分支调 SimulatedTick(:1898);常规路径 SimulateMovement 更新 Velocity / MovementMode / 位置。
  3. SmoothClientPosition 在每帧把 MeshTranslationOffset 写成 Mesh 的相对变换(:8600-8663)。
  4. 动画更新与 Tick 依赖:ACharacter 在构造时用 Mesh->PrimaryComponentTick.AddPrerequisite(CharacterMovement, ...) 强制动画在移动组件之后 tick(Character.cpp:152-156,注释原文 "force animation tick after movement component updates"),因此动画蓝图读到的是本帧已经更新过的 Velocity / MovementMode。USkeletalMeshComponent::TickPose → TickAnimation → UAnimInstance::UpdateAnimation(AnimInstance.cpp:671)依次执行 NativeUpdateAnimation(:795) 与 BlueprintUpdateAnimation(:800),AnimBP 在后者通过 TryGetPawnOwner() 自行读取 Pawn 的速度与状态——引擎内并没有 UCharacterAnimInstance,也没有内建的 Velocity / IsFalling 属性。播放 RootMotion 时,SimulatedTick 会显式先 TickCharacterPose(DeltaSeconds) 再 SimulateRootMotion(:1909-1929),保证动画先产生根运动位移、物理再消费它。DS 重放时 MoveAutonomous 也会 TickCharacterPose(:10766-10787),让两端动画事件/状态机同步。
  5. ACharacter::PostNetReceive(Character.cpp:1646)在收到 bunch 时预置 bNetworkMovementModeChanged 等标志,供下一帧 SimulateMovement 消费。
异步移动的例外
p.AsyncCharacterMovement = 1 时 TickComponent:1692-1721 走物理线程分支:显式 CharacterMesh->TickPose 并 ConsumeRootMotion,主线程不再 tick movement。这条实验路径不在本文主线内。

9.3 RootMotion:一条独立的位置通道

RootMotion 由动画驱动位置,无法靠"加速度 + 速度"还原,因此 UE 单独复制:

  • 服务器 ACharacter::PreReplication(Character.cpp:2028+)在 CurrentRootMotion.HasActiveRootMotionSources() 或播放网络 RootMotion Montage 时填充 FRepRootMotionMontage(位置、是否相对 base、Montage 位置、AuthoritativeRootMotion 等)。
  • 3P 端 OnRep_RootMotion(Character.cpp:1786)把快照压入 RootMotionRepMoves 队列(:1800+)。
  • SimulatedTick 在 root motion 分支里 TickCharacterPose → SimulateRootMotion → SimulatedRootMotionPositionFixup(CMC.cpp:1903-1955、Character.cpp:1821),用服务器快照修正本地动画推进的位置,并清空已消费队列(CMC.cpp:2000-2002)。
  • 有网络 RootMotion 时,ACharacter::OnRep_ReplicatedMovement 跳过标准位置修正(Character.cpp:1756-1763),避免两套位置源打架;ACharacter::PostNetReceiveLocationAndRotation 也在 UseRelativeLocation 时跳过 SmoothCorrection(:2012)。
  • 纠正包也区分 RootMotion:ClientHandleMoveResponse 按 bRootMotionSourceCorrection / bRootMotionMontageCorrection 分派到不同的 ClientAdjust*_Implementation(CMC.cpp:11050-11096);Linear 平滑下 SimulatedTick:1957-1967 还会修正 MeshRotationTarget,防止旋转插值与根运动旋转冲突。

9.4 动画与网络平滑的边界

动画读的是"状态",不是"包"
速度、加速度、移动模式、蹲伏标志都是本端组件/Actor 上的属性;网络只负责把它们填对。动画蓝图不需要知道 RTT、SavedMove 或 ReplicatedMovement。
平滑只改视觉变换
MeshTranslationOffset 影响的是 Mesh 的 RelativeLocation/Rotation,不改变 Velocity 或 MovementMode。所以"平滑让画面更顺"不会让动画状态机以为角色在倒退。
一个常见坑
在 3P 上用 Mesh 的世界位置做逻辑判断(射线、距离、命中)会拿到被平滑的滞后位置。应始终用 Capsule / GetActorLocation() 这类权威位置,并把命中判定放在 DS。

9.5 本文档提供的"动画支持"清单

动画位置演示什么
三端泳道时序动画第 1 章主沙盘输入 → 预测 → 上行 → DS 校验/重放 → Ack/纠正 → 复制 → 3P 平滑 → 动画更新;数据包沿箭头飞行,节点随步骤高亮。
纠正分支交互「位置误差」滑块 ≥ 10cm自动切换到 ClientAdjustPosition 红色纠正包 + SavedMove 队列回滚重放。
位置曲线动画DEMO A1P / DS / 3P 三条位置曲线随时间绘制,直观显示 RTT 与平滑缓冲带来的滞后。
网络平滑循环动画DEMO B橙色 Capsule 阶梯跳变 vs 绿色 Mesh 连续跟随,右侧 MeshTranslationOffset 衰减条持续循环。
三端角色动画系统说明本章动画蓝图的数据来源、Tick 顺序、RootMotion 独立复制通道、平滑与动画的边界。

10端到端时序与延迟构成

把一次按键放到时间轴上,就能看清"为什么 1P 和 3P 看到的永远不是同一帧"。下面所有时间点都以主控端采到输入为 t0,RTT 指主控端到 DS 的往返时延,NetUpdateInterval = 1 / NetUpdateFrequency。

10.1 端到端时间线

时刻主控端DS3P 模拟端
t0采样输入,ConsumeInputVector,PerformMovement —— 画面已动仍停留在上一次权威状态仍停留在上一次快照
t0 + 1 帧SavedMove 入队、打包、ServerMovePacked 发出——
t0 + RTT/2继续本地预测,SavedMoves 变长收包 → VerifyClientTimeStamp → MoveAutonomous 重放 → 权威位置更新 → 误差判定—
t0 + RTT/2 ~ RTT—回 Ack / ClientAdjustPosition;并等待自己的下一次 NetUpdate 节拍—
t0 + RTT收到响应:good move → 丢弃已确认 SavedMoves;correction → 置 bUpdatePosition,下一帧回滚 + 重放——
t0 + RTT/2 + ½·NetUpdateInterval(平均)(COND_SimulatedOrPhysics → 收不到这份属性;位置闭环走 ⑤ 的 Ack / 纠正)GatherCurrentMovement 写入 ReplicatedMovement 并复制—
t0 + RTT + ½·NetUpdateInterval——收 OnRep_ReplicatedMovement → SmoothCorrection 记录 MeshTranslationOffset
其后若干帧——SmoothClientPosition 把 Mesh 偏移衰减到 0,SimulatedTick 外推,动画蓝图更新姿态

10.2 延迟构成(谁晚了多少)

观察量大致延迟由什么决定
主控端自己的按键响应≈ 1 个客户端帧(16.7ms @60fps)本地 PerformMovement;与网络无关
DS 感知到这次输入RTT/2 + 发包间隔上行 RPC;发包间隔由 GetClientNetSendDeltaTime / CanDelaySendingMove / 合并策略控制(:9100-9113, :13102, :13121)
主控端得知对/错RTT + 最多 1 帧ClientHandleMoveResponse / ClientUpdatePositionAfterServerUpdate
3P 第一次看到这次移动RTT + ½·NetUpdateInterval上行 RTT/2 + 服务器复制节拍(平均等待半个 interval)+ 下行 RTT/2
3P 视觉上"追平"权威位置再加平滑缓冲Exponential 的时间常数 / Linear 的插值时长;缓冲越长越平滑、也越滞后
1P 与 3P 的画面位置差约 RTT/2 + 若干帧 + 平滑缓冲这就是命中判定必须在 DS 做、不能用客户端看到的 3P 位置做判定的原因
推论
3P 看到的角色永远"慢半拍",这不是同步 bug,而是预测 + 低频复制 + 平滑三者叠加的必然结果。NetUpdateFrequency 越高越跟手但越费带宽;平滑缓冲越大越顺滑但越滞后——调参就是在这两者之间取舍。

10.3 三套时间戳,各管一段

时间戳所在端 / 结构作用
ClientTimeStamp / CurrentTimeStamp主控端 FNetworkPredictionData_Client_Character、每个 FSavedMove_Character、FCharacterNetworkMoveData由 UpdateTimeStampAndDeltaTime(:8922)累加本端 DeltaTime 得到,随每个 move 上行;是"这段输入对应客户端哪一时刻"的标识,也是 Ack / 纠正要对齐的索引。
CurrentClientTimeStamp / ServerAccumulatedClientTimeStamp / ServerTimeStampDS FNetworkPredictionData_Server_Character(:10210-10213)VerifyClientTimeStamp(:9442)拒绝过期/乱序/超前包;ProcessClientTimeStampForTimeDiscrepancy(:9504)处理客户端时钟漂移;GetServerMoveDeltaTime 用相邻客户端时间戳之差还原该 move 的 DeltaTime,保证服务器重放的物理步长与客户端一致,而不是用服务器自己的帧长。
SmoothingClientTimeStamp / SmoothingServerTimeStamp / ServerLastTransformUpdateTimeStamp3P FNetworkPredictionData_Client_Character(:8333-8349)让插值进度基于服务器时间而不是包到达间隔,从而抵抗网络抖动;若本地跑得太快会被拉回一半(LerpStable(..., 0.5))。

10.4 丢包、乱序与抖动怎么被消化

  1. 上行不可靠,但自带冗余:ServerMovePacked 是 unreliable RPC;ReplicateMoveToServer 在同一个容器里同时装 NewMove / PendingMove / OldMove(OldMove 取自未确认的"重要 move",:9001-9017),丢一个包也能靠后续包补齐。
  2. 合并与节流:CanCombineWith 允许把连续的相似 move 合成一个(:9032-9071);CanDelaySendingMove + GetClientNetSendDeltaTime 把发送频率钳在 1/120 ~ 1/5 秒之间(:9100-9113),避免 60fps 每帧一个包。引擎还内置模拟丢包的调试 CVar(:9126-9144)。
  3. DS 侧乱序:时间戳过期直接丢弃(:10175-10188),并用累积时间戳而不是到达顺序推进,避免"晚到的包把位置拉回去"。
  4. 3P 侧抖动:平滑缓冲吸收到达间隔的波动;但当单次位移超过 MaxSmoothNetUpdateDist / NoSmoothNetUpdateDist 时(:8288-8298),会限制或清零 MeshTranslationOffset,宁可瞬移也不做长距离拉扯。

10.5 三个常见误解

❌「3P 的位置就是服务器位置」
Capsule 是,Mesh 不是。Capsule 被 SetActorLocationAndRotation 直接置为权威值;Mesh 通过 MeshTranslationOffset 被平滑,两者在平滑期间短暂分离(SmoothCorrection:8245)。
❌「收到纠正会把角色瞬移回旧位置」
客户端接受权威状态后,会立即把所有未确认的 SavedMoves 重新 PerformMovement 一遍(ClientUpdatePositionAfterServerUpdate:8667),因此玩家看到的是轻微回拉而不是跳回过去。
❌「ReplicatedMovement 每帧都发」
它由 NetUpdateFrequency / MinNetUpdateFrequency、相关性(relevancy / NetCullDistanceSquared)与休眠(NetDormancy)共同决定,通常远低于客户端帧率。

11关键 CVar / 配置速查

以下取值均来自 F:\PX_Trunk\UE\Engine\Source\Runtime\Engine 源码中的定义(非记忆值),行号为该 CVar / 变量的定义处。标 Cheat 的只在非 Shipping 包可用,主要用于复现与调试本文描述的流程。

11.1 RPC 与打包(决定上行形态)

CVar / 变量默认值作用定义位置
p.NetUsePackedMovementRPCs1最关键的一个开关:1 = 走 ServerMovePacked / ClientMoveResponsePacked(UE5 默认);0 = 回退到已废弃的 ServerMove / ServerMoveDual / ClientAdjustPosition 老 RPC 集合。CMC.cpp:119-125
p.NetPackedMovementMaxBits4096单个 packed RPC 允许的最大 bit 数,防止恶意数据导致服务器过度分配内存。:127-132
p.NetEnableMoveCombining1允许客户端把相似连续 move 合并(CanCombineWith / CombineWith),降低上行包数。:174-180
p.NetEnableMoveCombiningOnStaticBaseChange1在静态几何之间切换 base 时是否仍允许合并。:182-188
p.NetMoveCombiningAttachedLocationTolerance
...RotationTolerance
0.01 / 0.01附着对象上合并 move 时允许的微小相对位移/旋转抖动。:190-202
p.NetStationaryRotationTolerance0.1视角小幅变化时维持发送节流的容差。:204-209
p.NetUseClientTimestampForReplicatedTransform1用客户端时间戳变化来跟踪 ReplicatedServerLastTransformUpdateTimeStamp(影响 3P 平滑时间基准),关闭则改用服务器 tick 时间。:211-218
p.NetServerMaxMoveDeltaTimeScalar1.75服务器允许接受的 move DeltaTime 相对客户端 MaxMoveDeltaTime 的倍数(≥1),放宽后能减少因长帧引发的纠正。:227-233
net.NetServerMoveTimestampExpiredWarningThreshold1.0过期时间戳超过该阈值时打 Warning(否则只打 Log),配合 VerifyClientTimeStamp。:256-261
p.NetEnableSkipProxyPredictionOnNetUpdate1允许模拟端在收到网络位置更新的那一帧跳过本地预测(需组件上的 bNetworkSkipProxyPredictionOnNetUpdate 也为 true)。:143-150
p.NetEnableListenServerSmoothing1Listen Server 上对远端客户端角色启用 Mesh 平滑。:134-141
p.AsyncCharacterMovement0实验性的物理线程异步移动;开启后 TickComponent 走完全不同分支(:1692-1721),本文不展开。:263-266

11.2 复制与平滑(组件 / Actor 属性,不是 CVar)

属性默认 / 来源作用
NetworkSmoothingModeExponential
CMC.cpp:719
3P 平滑算法。UE 5.8 的 ENetworkSmoothingMode 只有 3 个值(EngineTypes.h:1052-1065):Exponential(默认,指数衰减)、Linear(按服务器时间戳线性插值)、Disabled(直接瞬移)。实现分别在 :8456 / :8522;回放平滑不是第四个枚举值,它走 ReplayLastTransformUpdateTimeStamp 与 p.ReplayLerpAcceleration 另一套机制。
MaxSmoothNetUpdateDist
NoSmoothNetUpdateDist
来自 AGameNetworkManager 的 NetworkMaxSmoothUpdateDistance / NetworkNoSmoothUpdateDistance
CMC.cpp:12481-12482
单次快照位移过大时限制或清零 MeshTranslationOffset(:8288-8298),避免长距离拉扯。
MaxMoveDeltaTime0.125 秒
GameNetworkManager.cpp:37 → CMC.cpp:12491
单个 move 允许的最大 DeltaTime;客户端与服务器都用它约束,也是合并与时间戳推进的依据。
NetUpdateFrequency / MinNetUpdateFrequencyAActor 可编辑属性
Actor.h:953-960
服务器多久考虑复制一次该 Actor;3P 看到的位置更新节拍 ≈ 1 / NetUpdateFrequency。
NetCullDistanceSquared / NetDormancy / bReplicateMovementActor.h:898, :948-950相关性裁剪、休眠、是否复制移动——三者决定某台 3P 客户端是否收得到这份 ReplicatedMovement。

11.3 误差判定 / 时间漂移

变量 / CVar默认值作用
MAXPOSITIONERRORSQUARED3.0DS 判断"客户端位置是否可接受"的核心阈值,比较式见 GameNetworkManager.cpp:173(LocDiff | LocDiff > MAXPOSITIONERRORSQUARED)。超过即触发纠正。
ClientErrorUpdateRateLimit0.0两次误差检查之间的最小间隔,用于限制纠正频率(GameNetworkManager.cpp:45, :160)。
bMovementTimeDiscrepancyDetection / ...Resolutionfalse / false是否启用客户端/服务器时间漂移检测与自动修正(GameNetworkManager.cpp:48-49),由 ProcessClientTimeStampForTimeDiscrepancy 消费。
MovementTimeDiscrepancyMaxTimeMargin / MinTimeMargin / ResolutionRate / DriftAllowance0.25 / -0.25 / 1.0 / 0.0漂移容忍窗口、修正速率与允许的漂移量(GameNetworkManager.cpp:50-53)。
p.ClientAuthorityThresholdOnBaseChange0.0上下移动平台时,允许信任客户端到该距离以内,避免跳 platform 时的突变纠正(CMC.cpp:296-301)。
p.MaxFallingCorrectionLeash / ...Buffer0.0 / 10.0空中时允许保留的最大位置差及缓冲,减少跳跃离开移动平台时的纠正(CMC.cpp:303-315)。
p.NetUseBaseRelativeAcceleration / ...Velocity1 / 1动态移动平台上把加速度/速度视为相对 base(CMC.cpp:279-289),与 packed RPC 配合使用。

11.4 调试 / 复现流程用的 Cheat CVar

CVar默认怎么用定义位置
p.NetShowCorrections Cheat0把客户端纠正位置画出来(红=错误位置,绿=纠正后),配合纠正分支观察。:421-427
p.NetCorrectionLifetime Cheat4.0 s纠正可视化保留时长。:429-435
p.NetForceClientAdjustmentPercent Cheat0强制让指定比例的误差检查返回失败,用来稳定复现 ClientAdjustPosition + 回滚重放。:442-449
p.NetForceClientServerMoveLossPercent / ...Duration Cheat0 / 0模拟上行丢包,代码在 ReplicateMoveToServer 的 :9126-9144 生效,用来验证 OldMove 冗余。:451-467
p.VisualizeMovement / p.NetVisualizeSimulatedCorrections Cheat0 / 0移动与模拟端纠正的 world 调试绘制。:469-483
p.DebugTimeDiscrepancy Cheat01=记录检测日志,2=记录检测+修正日志。:485-491
p.ClientRecordMovePackedRpcStatsToCsv
p.ClientRecordNetCorrectionDistanceToCsv
false把 packed RPC 次数/大小、纠正距离写进 CSV,用于量化带宽与纠正频率(需 CSV Profiler)。:402-416
Net.RepMovement.DrawDebug0画出收到的 ReplicatedMovement Capsule(ActorReplication.cpp:184),调试下行复制很直观。ActorReplication.cpp:184
把本文流程"跑起来看"的推荐组合
p.NetShowCorrections 1 + p.NetForceClientAdjustmentPercent 0.05 → 稳定看到主控端回滚重放;p.NetForceClientServerMoveLossPercent 0.1 → 观察 OldMove 冗余如何补齐;Net.RepMovement.DrawDebug 1 → 在 3P 端看到权威 Capsule 与平滑 Mesh 的分离。以上均需非 Shipping 包。

12PX 项目落地差异

前 11 章讲的是引擎原生流程。PX 项目在 F:\PX_Trunk\PX\Source\GPFramework\GPGameplay\Character\Movement\ 下用 UGPCharacterMovementComponent : UCharacterMovementComponent 做了深度定制。本章只列与「主控端 → DS → 3P」链路直接相关的差异,便于读者把本文结论映射到本项目的实际代码。

口径提醒
① 项目侧 .h/.cpp 在分析期间被外部进程持续编辑,下列行号取自一次统一快照,请以函数名/符号名为准;② 项目内已有白皮书含示意性伪代码(如 PhysUpdateOnSimulatedProxy、InterpolatePosition、bNetworkSkipProxyPredictionOnNetUpdate 在当前代码中未找到),不能直接当实现引用;③ ue_master_wt 是稀疏检出,缺 Engine/Source/Runtime/Engine 模块,无法做哈希比对,只能凭文件内标记确认引擎被改过(见 13.4)。

12.1 五个最实质的差异

链路阶段原生 UE(本文主体)PX 项目归类
① 主控端打包CallServerMovePacked 用 FCharacterNetworkMoveDataContainer + 局部 FCharacterServerMovePackedBits(引擎 CMC.cpp:9203)构造函数用 SetNetworkMoveDataContainer / SetMoveResponseDataContainer 换成 FPX* 三件套;CallServerMovePacked 覆写(GP cpp ≈3160)改用成员 FNetBitWriter GPServerMoveBitWriter + Iris ObjectReferencePackageMap 捕获引用 + FNetTokenExportScope。ReplicateMoveToServer 本身只是透传 Super。替换数据结构 + 打包路径
② SavedMoveFSavedMove_Character 存时间戳/加速度/位置/模式/base 等FSavedMove_GPCharacter(GP h ≈2320)新增 AimAtLocation、CustomMoveMode + SessionId + CustomModeData(TArray<uint8>)、AbilityMovementPredictionKey、ExtraCharacterVelocity、ObstacleAvoidanceVelocity、MaxAcceleration_Walking;SetMoveFor / CanCombineWith / IsImportantMove / PrepMoveFor / PostUpdate 均先 Super 再合并这些字段。字段扩展
③ DS 收包与仲裁ServerMove_HandleMoveData → ServerMove_PerformMovement → MoveAutonomous → ServerMoveHandleClientError(引擎 :9994 / :10033 / :10730 / :10254)ServerMove_PerformMovement 完整重写、不调 Super(GP cpp ≈2889):保留时间戳校验与 MoveAutonomous 骨架,插入 PredictionKey 对齐、AbilityMoveDatas 排队(上限 96)、AcceptClientPXMovementModeSession、CustomMoveMode->ServerMove,并回填 ExtraVelocity / 避障速度 / MaxAcceleration_Walking / AimAtLocation。三个校验函数均 override:自定义模式可「客户端权威」豁免、技能预测窗口未同步时暂不纠正、斜面上只做 XY 平面误差判定。VerifyClientTimeStamp 与 MoveAutonomous 未 override。业务级接管
④ DS 响应FCharacterMoveResponseDataContainer(Ack 或 FClientAdjustment)FPXCharacterMoveResponseDataContainer 用 1 bit bHasCustomMode 条件序列化 CustomMode / SessionId / CustomModeData;ServerFillResponseData 取「与 PendingAdjustment 同时生成的原子快照」避免状态撕裂;ClientHandleMoveResponse 先做 SessionId 合法性校验并转发 CustomMode->ClientHandleMoveResponse。响应结构扩展
⑤ 3P 模拟端OnRep → SmoothCorrection → SmoothClientPosition → SimulateMovement(含本地 FindFloor / AdjustFloorHeight 贴地)差异最大:SimulateMovement → NewSimulateMovement(不调 Super),SmoothCorrection → NewSmoothCorrection,SmoothClientPosition 分级降频 + 自写 NewSmoothClientPosition_UpdateVisuals;收包帧用 SetWorldLocationAndRotationNoPhysics 无 sweep 直拍胶囊,包间只做一次带 sweep 的 MoveSmooth,移除本地贴地,贴地完全交给下一个网络包;滑索本地驱动期间短路、不让服务器覆写 Capsule。整链路重写

12.2 自定义网络数据结构(PXCharacterMovementTypes.h/.cpp)

结构要点
FPXCharacterNetworkMoveData : FCharacterNetworkMoveData先 Super::Serialize 再追加:三个 FVector_NetQuantize10(AimAtLocation / 避障速度 / 额外速度)、Ar << CustomMoveMode(UObject 引用,仅非空才继续写 SessionId 与 CustomModeData)、MaxAcceleration_Walking、1 bit bHasAbilityMovementPrediction 条件写 Key、SerializeOptionalValue 写 MovementBaseIndex(缺省 -1 不传)。这就是项目白皮书说的「按需传输」。
FPXCharacterNetworkMoveDataContainer构造函数把 New/Pending/OldMoveData 指向 MyMoveData[0..2](引擎官方推荐的派生方式);Serialize 保存时把三段 move 里的 MovementBase 去重收进数组、改写为下标,加载时按 MovementBaseIndex 还原指针,压缩重复对象引用。
FPXCharacterMoveResponseDataContainer见 12.1 ④。AckGoodMove 时取 GetCustomMode()->GetMoveResponseData(),纠正时取原子快照。
派生 PredictionDataFNetworkPredictionData_Client_GPCharacter 新增 SimulatedSavedMoves / SimulatedFreeMoves / CurrentSimulatedTimeStamp(供 3P 平滑纠正回溯);FNetworkPredictionData_Server_GPCharacter 新增 AccumulatedLocDiff 与 PendingPXMovementModeResponse 快照。

12.3 3P 链路为什么被重写(与本文第 7 章对照读)

原生:本地贴地 + 平滑
引擎 SimulateMovement 在 MoveSmooth 之后还有 FindFloor / AdjustFloorHeight / SetPostLandedPhysics(引擎 CMC.cpp:2318-2385),由客户端自己推断地面状态。
项目:贴地交给下一个包
NewSimulateMovement 明确注释「普通 SimProxy 不再本地推断地面状态」,禁止再触发 FindFloor / 高度图 / AdjustFloorHeight;Velocity 恒等于 RepMovement.LinearVelocity;包间只做一次前向 sweep,Hit 只截断位移,不 StepUp / 不 Slide / 不改 Mode。DS 的 PhysWalking 保证复制位置贴地,收包帧再直拍胶囊。参见 PX\Docs\PerfExplore\3p-movesmooth-sweep-skip\01-clarify.md 关键事实 #1~#5。
回退通道
px.SimProxy.MovementEnginePure = 1 时 SimulateMovement / SmoothCorrection / SmoothClientPosition / AdjustFloorHeight 全部转 Super::,一键回到引擎原生路径。这是排查 3P 表现问题时最有用的开关。

12.4 与链路相关的项目配置

位置设置
PX\Config\DefaultGame.ini:1-8MAXPOSITIONERRORSQUARED=10000.0f、MAXCLIENTUPDATEINTERVAL=0.40、bMovementTimeDiscrepancyDetection/Resolution=true、MovementTimeDiscrepancyMaxTimeMargin=1.0f / MinTimeMargin=-3.0 —— 覆写了原生误差阈值与时间漂移参数(对照第 11 章原生默认 3.0 / false / ±0.25)。
DefaultGame.ini:10-14ClientNetSendMoveDeltaTime=0.03(Throttled 0.05 / Stationary 0.0833)、ClientNetSendMoveThrottleAtNetSpeed=10000、...OverPlayerCount=50 —— 直接决定主控端上行发包节流。
DefaultEngine.ini:1822-1851net.Iris.UseIrisReplication=1、net.Iris.UseFrequencyBasedPolling=1、net.Iris.EnableForceNetUpdate=1(注释:英雄轮询钉到 DS tick rate、每帧轮询)、net.PX.* 一系列 Iris 优化开关 —— 决定 3P 收到 ReplicatedMovement 的频率。
DefaultEngine.ini:1970-1971PackageMapClassPath=/Script/PXGameplay.PXPackageMapClient —— 自定义 PackageMap,影响移动包内 UObject / NetToken 引用解析。
组件构造函数(GP cpp ≈1353-1482)bAlwaysCheckFloor=false、MaxSimulationIterations=4、bEnablePhysicsInteraction=false、NetworkSmoothingMode=Exponential、NetworkMaxSmoothUpdateDistance=300 / NetworkNoSmoothUpdateDistance=500(原生默认 256 / 384)、NetworkLargeClientCorrectionDistance=150.f。

Config 中未找到独立的 NetUpdateFrequency / MinNetUpdateFrequency / NetworkSmoothingMode / p.Net* 配置项:这些值走引擎默认或 C++ 构造函数 / CVar;Iris 侧的轮询频率由上述 net.* 开关与 ForceNetUpdate 决定。

12.5 项目内可继续深挖的资料

  • PX\Docs\MovementSystem\01_GPMoveComp_Optimization_Analysis.md —— 网络同步优化 / SavedMove 扩展 / 模拟端优化(部分行号与性能数字已过时)。
  • PX\Docs\MovementSystem\03_Movement_Technical_Whitepaper.md §4~§6 —— 数据流简图、技能预测窗口、NewSmoothCorrection、客户端权威位置;含示意代码,需与现网代码核对。
  • PX\Docs\PerfExplore\3p-movesmooth-sweep-skip\ —— 最有价值的 3P 专题:01-clarify.md 关键事实 #1~#5、03-plan.md 结论台账、04-conclusion.md 水平扫掠对照结论,以及 README.md 标注的「未正式启用、未接生产 GP」状态。
  • PX\Docs\MovementSystem\02_Mover_Migration_Plan.md —— 明确记录「自定义 CMC 预测 + 模拟端优化与 NetworkPrediction 插件不兼容,不建议迁移」,可解释项目为何坚持在这条原生链路上做扩展。

13附录:函数索引与代码位置

本章是全文的"代码地图":所有行号均取自 F:\PX_Trunk\UE\Engine\Source\Runtime\Engine 的实际文件,便于对照源码逐行复核。缩写:CMC.cpp = Private\Components\CharacterMovementComponent.cpp,CMC.h = Classes\GameFramework\CharacterMovementComponent.h。

13.1 端到端函数索引

阶段端函数 / 符号位置
主控端1PUCharacterMovementComponent::TickComponent(三端总入口、Role 分流)CMC.cpp:1663
1PControlledCharacterMove → ReplicateMoveToServer:6481 / :8968
1PFNetworkPredictionData_Client_Character::UpdateTimeStampAndDeltaTime:8922
1PFSavedMove_Character::SetMoveFor / CombineWith / PostUpdate / SavedMoves.Push:9027, :9051, :9092, :9098
1PPerformMovement(本地预测真正移动):2790(调用点 :9090)
1PCanDelaySendingMove / GetClientNetSendDeltaTime(发送节流):13102 / :13121
1PShouldUsePackedMovementRPCs(决定新旧 RPC 路径):1095
1PCallServerMovePacked / ServerMovePacked_ClientSend;旧路径 CallServerMove(DEPRECATED):9203 / :9931 / :9258
1PFCharacterNetworkMoveDataContainer::ClientFillNetworkMoveData / FCharacterNetworkMoveData::ClientFillNetworkMoveData:9795 / :9863
DSDSServerMovePacked_ServerReceive(收包反序列化)/ ACharacter::ServerMovePacked_Implementation(转发壳)/ ServerMove_HandleMoveData → ServerMove_PerformMovement(Old/Pending/New 三段):9949 / Character.cpp:2288 / :9994 / :10033
DSServerMove_Implementation(旧 RPC 路径的单个 move 入口;5.8 打包路径改用 ServerMove_PerformMovement:10033):10153
DSVerifyClientTimeStamp / ProcessClientTimeStampForTimeDiscrepancy:9442 / :9504
DSGetServerMoveDeltaTime / MoveAutonomous → PerformMovement(权威重放):10208 / :10730
DSServerMoveHandleClientError / ServerCheckClientError:10254 / GameNetworkManager.cpp:160
DSSendClientAdjustment / ServerSendMoveResponse:11121 / :10887
DSAActor::GatherCurrentMovement / ACharacter::GatherCurrentMovement(写入 ReplicatedMovement)ActorReplication.cpp:438 / Character.cpp:1734
DSFRepMovement 结构(量化等级、Acceleration、bRepPhysics)Classes\Engine\ReplicatedState.h:118
DSAActor::SetReplicatedMovementActor.cpp:7216
3P3PAActor::OnRep_ReplicatedMovement / ACharacter::OnRep_ReplicatedMovementActorReplication.cpp:186 / Character.cpp:1754
3PPostNetReceiveVelocity / AActor::PostNetReceiveLocationAndRotationActorReplication.cpp:296 / :285
3PACharacter::PostNetReceiveLocationAndRotation(触发 SmoothCorrection)Character.cpp:2004
3PSmoothCorrection(累计 MeshTranslationOffset / 时间戳)CMC.cpp:8245
3PSmoothClientPosition(Exponential / Linear 逐帧衰减):8426, :8456, :8522
3PSimulatedTick / SimulateMovement:1898 / :2208
3PACharacter::OnRep_RootMotion / SimulatedRootMotionPositionFixupCharacter.cpp:1786 / :1821
闭环
1P ← DS
1PClientHandleMoveResponse:11039
1PClientAckGoodMove_Implementation(丢弃已确认 SavedMoves):11706
1PClientAdjustPosition_Implementation(接受权威状态):11289
1PClientUpdatePositionAfterServerUpdate(回滚 + 重放 SavedMoves):8667(调用点 TickComponent:1753)

13.2 关键数据结构速查

结构作用主要使用端
FSavedMove_Character一次本地预测的"存档":TimeStamp、DeltaTime、Acceleration、Start/End Location & Rotation、CompressedFlags、MovementMode、MovementBase、RootMotion 等;支持 CanCombineWith / CombineWith / IsImportantMove。主控端
FNetworkPredictionData_Client_Character客户端预测状态:SavedMoves、PendingMove、FreeMoves、LastAckedMove、bUpdatePosition、MeshTranslationOffset / MeshRotationOffset、SmoothingClientTimeStamp / SmoothingServerTimeStamp、MaxSmoothNetUpdateDist。主控端 + 3P(平滑部分)
FNetworkPredictionData_Server_Character服务器侧:CurrentClientTimeStamp、ServerAccumulatedClientTimeStamp、ServerTimeStamp、PendingAdjustment、LastAckedMove、TimeDiscrepancy 状态。DS
FCharacterNetworkMoveData / ...Container / FCharacterServerMovePackedBitsPacked 上行:把 New/Pending/Old 三段 move 量化打包成位流,NetSerialize 后由 ServerMovePacked 承载。主控端 → DS
FClientAdjustment / FCharacterMoveResponseDataContainerPacked 下行响应:Ack(时间戳)或 Correction(位置/速度/朝向/移动模式/时间戳)。DS → 主控端
FRepMovement下行属性复制的载荷:Location / Rotation / LinearVelocity / AngularVelocity + 三个 EVectorQuantization 量化等级 + bRepPhysics / bRepAcceleration + Acceleration。DS → 所有客户端
FRepRootMotionMontage / FSimulatedRootMotionReplicatedMoveRootMotion Montage 在 3P 上的复制与缓冲(RootMotionRepMoves),供 SimulatedTick 消费与位置修正。DS → 3P

13.3 涉及的主要源码文件

文件本文用到的内容
Private\Components\CharacterMovementComponent.cpp / Classes\GameFramework\CharacterMovementComponent.h全文主体:Tick 分流、预测、SavedMove、ServerMove、MoveAutonomous、纠正、网络平滑、模拟端推进、CVar 定义。
Private\Character.cpp / Classes\GameFramework\Character.hOnRep 转发、GatherCurrentMovement(Acceleration 复制)、复制属性声明、RootMotion 复制与位置修正。
Private\ActorReplication.cpp · Private\Actor.cpp · Classes\GameFramework\Actor.hOnRep_ReplicatedMovement、PostNetReceive*、GatherCurrentMovement、ReplicatedMovement 属性、NetUpdateFrequency / relevancy / dormancy。
Classes\Engine\ReplicatedState.h · Private\Engine\ReplicatedState.cpp · Private\RepMovementNetSerializer.cppFRepMovement 结构与(Iris)量化序列化实现。
Private\GameNetworkManager.cppMAXPOSITIONERRORSQUARED、MaxMoveDeltaTime、TimeDiscrepancy 参数、ServerCheckClientError。
Private\PlayerController.cpp输入采集、UpdateRotation、NotifyServerReceivedClientData、AcknowledgedPawn。

13.4 版本与口径说明

"原生 UE" 的界定
本文以 F:\PX_Trunk\UE\Engine\Build\Build.version 标定的 UE 5.8.2(Branch UE5,CompatibleChangelist 55116800) 为准。该份引擎源码被 PX 项目插入了少量可观测性插桩,例如 //++@byteliu: cw stats 包裹的 CODE_WALK_STATS(...)(见 CMC.cpp:8970-8974)和 WITH_CODE_WALK 条件日志(见 ActorReplication.cpp:245-249),这些宏只做统计/日志,不改变移动与同步的控制流。但有 1 处是实质性改动:CMC.cpp:8623-8636 在 SmoothClientPosition_UpdateVisuals 的 Linear 分支里,把原生的 UpdatedComponent->SetWorldRotation(ClientData->MeshRotationOffset) 换成「直写相对旋转 + 显式虚调用 UpdateComponentToWorld()」,目的是绕过项目自定义胶囊子类的异步物理覆写(源码内 ++@byteliu 性能优化 注释)。它落在本文第 7 章的 3P 平滑链路上,因此严格来说 F:\PX_Trunk\UE 是「被 PX 动过的原生基线」;本文描述的语义以 Epic 原生实现为准,遇到该处已显式标注。

因此本文把 F:\PX_Trunk\UE 的实现视为"引擎原生流程";项目业务层的覆盖与扩展集中在 F:\PX_Trunk\PX\Source,单列于第 12 章,两者不要混为一谈。白皮书 PX\Docs\MovementSystem\03_Movement_Technical_Whitepaper.md 中的网络章节含有示意性伪代码(例如 InterpolatePosition),不能当作引擎实现引用,本文一律以引擎源码行号为准。