00一句话总览
UE 的角色移动不是"服务器算好位置发给所有人",而是三端各自承担不同职责的一条闭环流水线:主控端不等服务器、先按输入自己动并保存每次移动的"存档";DS 收到输入后在权威世界里重放同一段移动,判断客户端算得对不对;3P 端只接收 DS 定期广播的权威快照,用网格偏移把离散快照补成连续画面。
PerformMovement 立刻动 → 生成 FSavedMove_Character → 打包 FCharacterNetworkMoveData → ServerMovePacked RPC 上行。本地永远领先服务器至少一个 RTT。MoveAutonomous 用客户端输入重放 → ServerMoveHandleClientError 比位置 → 回 Ack 或纠正;同时按 NetUpdateFrequency 把 ReplicatedMovement 复制给所有连接。OnRep_ReplicatedMovement → SmoothCorrection 把差值记到 MeshTranslationOffset → SmoothClientPosition 逐帧衰减 → SimulatedTick / 动画更新。Capsule 是权威位置,Mesh 是被平滑的视觉位置。- 主控端 ≠ 服务器:主控端位置是"预测值",可能被服务器纠正;服务器位置才是权威值。
- 3P ≠ 主控端:3P 不预测输入,只跟随离散快照,因此永远晚于主控端(RTT/2 + 复制间隔 + 平滑缓冲)。
- Capsule ≠ Mesh:3P 上 Capsule 直接跳到权威位置,Mesh 通过相对偏移被平滑,二者短暂分离是设计而非 bug。
01动画沙盘:一次移动的三端旅程
下面的动画按真实调用顺序逐步演示一帧输入从主控端出发、经过 DS、最终落在 3P 与角色动画上的全过程。点击「单步」逐阶段观察;调大「位置误差」会触发服务器纠正分支;切换「平滑模式」可看到 3P 曲线的变化。
ClientAdjustPosition 纠正分支(红色数据包 + SavedMove 回滚重放);把「3P 平滑」设为 Disabled 可看到 3P 位置阶梯式瞬移。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 为什么必须这样设计
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 逐步详解
APlayerController 在 PlayerTick 中处理输入并更新 ControlRotation,ACharacter::AddMovementInput 把世界空间输入向量累积到 PendingMovementInputVector。移动组件在 TickComponent:1676 用 ConsumeInputVector() 一次性取走(异步移动开启时改由 async 路径填输入,见 :1673-1677)。ControlledCharacterMove:6481 先 CheckJumpInput(保证跳跃状态先于加速度计算),再 Acceleration = ScaleInputAcceleration(ConstrainInputAcceleration(InputVector)) 与 AnalogInputModifier = ComputeAnalogInputModifier()。注意分支:LocalRole == ROLE_Authority 直接 PerformMovement(Listen Server 本机玩家);只有 AutonomousProxy && NM_Client 才走 ReplicateMoveToServer(:6495-6502)。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)。CreateSavedMove() 分配对象,SetMoveFor(...) 记录本次移动的全部状态。若上一帧因节流留下 PendingMove 且 CanCombineWith 通过(时间戳未重置、加速度/模式/按键相近),则 CombineWith 把两段合为一段:先把角色回退到 PendingMove 的起点(GetRevertedLocation),做 OverlapTest 确认回退位置不会穿模,重设 FaceRotation / SaveBaseLocation / SetInitialPosition,再释放 PendingMove(:9032-9071)。这就是 UE 在高帧率下不必每帧发一个包的原因。Acceleration 钳到 GetMaxAcceleration() 并重算 AnalogInputModifier(:9083-9085,与服务器 MoveAutonomous:10746-10748 完全对称,保证两端物理一致),清空客户端 RootMotion 缓存后调用 PerformMovement(NewMove->DeltaTime)(:9088-9090)。这一行就是"手感零延迟"的来源:碰撞、地面检测、速度积分、位移全部在本地完成,画面本帧即更新。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、TimeStampBeforeReset | Ack / 纠正按时间戳对齐;服务器用相邻时间戳之差还原物理步长。 |
| 输入 | Acceleration、SavedControlRotation、CompressedFlags(跳跃/蹲/强制最大加速等)、bPressedJump、WantsToCrouch | 服务器重放与客户端回滚重放的"输入带",客户端上报的不是位置而是输入。 |
| 结果快照 | SavedLocation / SavedRotation / SavedVelocity / SavedRelativeLocation / EndPackedMovementMode / EndMovementBaseInterfaceData / EndBoneName | ServerMoveHandleClientError 用客户端自报位置与服务器重放位置比对;合并 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);
}
04上行:ServerMove 家族与打包
UE 5.8 有两套并存的移动 RPC。默认(p.NetUsePackedMovementRPCs = 1)走 Packed 路径;把它设为 0 才会回退到已标记 DEPRECATED 的老接口。看代码时若只盯着 ServerMove,会以为参数就是那 9 个固定字段——实际上现在的载荷是一个可序列化的位流容器,项目也正是在这一层做扩展的。
4.1 两条路径对照
| Packed(默认,UE5 原生路径) | 旧路径(p.NetUsePackedMovementRPCs = 0) | |
|---|---|---|
| 开关 | ShouldUsePackedMovementRPCs() 返回 1(:1095) | 返回 0;相关函数标 ///// DEPRECATED /////(:9257, :10135, :10152) |
| 客户端入口 | CallServerMovePacked(NewMove, PendingMove, OldMove) :9203 | CallServerMove(NewMove, OldMove) :9258 |
| RPC | ACharacter::ServerMovePacked(FCharacterServerMovePackedBits) | ServerMove / ServerMoveDual / ServerMoveDualHybridRootMotion / ServerMoveOld / ServerMoveNoBase |
| 载荷 | 位流(DataBits)+ PackageMap 引用导出,FCharacterNetworkMoveDataContainer 里装 New / Pending / Old 三段 | 固定参数:TimeStamp、FVector_NetQuantize10 InAccel、FVector_NetQuantize100 ClientLoc、CompressedFlags、ClientRoll、View(压缩旋转)、MovementBase、BaseBoneName、MovementMode |
| 服务器入口 | ServerMovePacked_ServerReceive :9949 → ServerMove_HandleMoveData :9994 | ServerMove_Implementation :10153 |
| 响应 | ClientMoveResponsePacked → ClientHandleMoveResponse :11039 | ClientAckGoodMove / ClientAdjustPosition / ClientAdjustRootMotion* |
| 可扩展性 | 好:派生 FCharacterNetworkMoveData / ...Container 即可加字段,无需改引擎 RPC 签名 | 差:加字段要改 UFUNCTION 签名 |
4.2 Packed 路径:从 SavedMove 到网络位流
GetNetworkMoveDataContainer() 返回组件持有的容器(含 NewMoveData / PendingMoveData / OldMoveData),ClientFillNetworkMoveData(New, Pending, Old)(:9795-9822)依次填充,并置 bHasPendingMove / bHasOldMove / bIsDualHybridRootMotionMove / bDisableCombinedScopedMove。FCharacterNetworkMoveData::ClientFillNetworkMoveData(:9863-9898)把 FSavedMove_Character 映射成网络结构:TimeStamp、ControlRotation、CompressedMoveFlags、MovementMode、Location(动态 base 上用相对位置,否则 FRepMovement::RebaseOntoZeroOrigin)、Acceleration(可由 p.NetUseBaseRelativeAcceleration 转成 base 相对)、以及 MovementBase / BoneName。注意:客户端上报位置只用于服务器比对,不是让服务器采用它。FCharacterNetworkMoveDataContainer::Serialize(:9825-9860)顺序固定:NewMove 必发 → 1 bit bHasPendingMove(有则 1 bit root-motion 标志 + PendingMove)→ 1 bit bHasOldMove(有则 OldMove)→ 1 bit bDisableCombinedScopedMove。每个 move 内部由 FCharacterNetworkMoveData::Serialize(:9901)做量化压缩(FVector_NetQuantize*、短整型旋转、bit flag)。ServerMoveBitWriter(避免每次分配),处理 Iris / 传统 PackageMap 的对象引用导出(:9209-9235),Memcpy 到 FCharacterServerMovePackedBits::DataBits,最后 ServerMovePacked_ClientSend(PackedBits)(:9251 → :9931)转发到 CharacterOwner->ServerMovePacked(PackedBits)。RPC 挂在 Actor 上而不是组件上,源码注释明确说明这样带宽开销更小(:9945)。该 RPC 声明为 UFUNCTION(unreliable, server, WithValidation)(Character.h:376-379),_Validate 恒返回 true——位流在 unpack 前无法校验,真正的防护放在 ServerMovePacked_ServerReceive 的 bit 数上限与反序列化里;响应 RPC ClientMoveResponsePacked 同为 unreliable, client, WithValidation(Character.h:385-388)。旧的 ServerMove* 族也全部 unreliable。4.3 FCharacterNetworkMoveData 字段速查
| 字段 | 来源 | 服务器用来做什么 |
|---|---|---|
TimeStamp | ClientMove.TimeStamp | 校验、还原 DeltaTime、Ack/纠正对齐 |
ControlRotation | SavedControlRotation | SetControlRotation(:10102),影响朝向 / 射击方向 |
Acceleration | ClientMove.Acceleration 或 base 相对形式 | MoveAutonomous 的输入,重放物理 |
Location | 客户端结果位置(绝对或相对 base) | ServerMoveHandleClientError 的误差比对基准 |
CompressedMoveFlags | GetCompressedFlags() | UpdateFromCompressedFlags(跳跃/蹲等) |
MovementMode | EndPackedMovementMode | 检测两端移动模式是否分歧(:10642-10644) |
MovementBase* / MovementBaseBoneName | EndMovementBaseInterfaceData / EndBoneName | 移动平台上的相对位置还原与 base 校验 |
4.4 冗余设计:为什么一个包里装三段 move
// CharacterMovementComponent.cpp:9825-9860(节选,序列化顺序)
NewMoveData->Serialize(...); // 本次 move,必发
Ar.SerializeBits(&bHasPendingMove, 1); // 节流暂存的上一帧 move
if (bHasPendingMove) { ...PendingMoveData->Serialize(...); }
Ar.SerializeBits(&bHasOldMove, 1); // 未确认的"重要 move",丢包冗余
if (bHasOldMove) { ...OldMoveData->Serialize(...); }
Ar.SerializeBits(&bDisableCombinedScopedMove, 1);
CanDelaySendingMove 被推迟的 move;服务器可用 FScopedCapsuleMovementUpdate 把两段合并成一次物理更新(:10006-10019)。IsImportantMove(LastAckedMove) 选出的未确认重要 move(例如起跳、速度突变)。因为 RPC 是 unreliable,后续包重复携带它,丢一两个包也能让 DS 补齐状态。GetClientNetSendDeltaTime 把发送间隔钳在 1/120 ~ 1/5 秒并配合 move 合并(:9100-9113, :13121);服务器侧 ServerMovePacked_ServerReceive 先用 NetPackedMovementMaxBits = 4096 拒绝超长位流,防止恶意数据导致过度内存分配(:9956-9962)。05DS:校验、重放与纠错
DS(ROLE_Authority)不信任客户端的位置,只信任客户端的输入:它用同一套 PerformMovement 在自己的世界里把这段输入重放一遍,再拿重放结果与客户端自报位置比对,决定"通过"还是"纠正"。
5.1 收包与反序列化
ServerMovePacked_ServerReceiveHasValidData / IsActive 检查;再检查 DataBits.Num() 是否超过 NetPackedMovementMaxBits(4096),超了直接丢弃并打 Error(:9956-9962);复用 ServerMoveBitReader 设置数据,取 Iris 或传统 PackageMap 解析对象引用;最后 MoveDataContainer.Serialize(*this, BitReader, PackageMap) 反解出三段 move(:9982-9989)。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 Move | ServerCheckClientError 返回 false(位置差在 MAXPOSITIONERRORSQUARED 内、移动模式一致、加速度未越界),且未强制更新。:10467, :10562-10564 | 仅 TimeStamp + bAckGoodMove = true。客户端只需把这个 move 从队列里丢掉。 |
| Correction | bForceClientUpdate 或 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。
ReplicatedMovement 并广播(第 6 章)。这两条通道的节拍、可靠性、载荷都不同,不要混为一谈——3P 端永远收不到 ServerMove,也永远收不到 ClientAdjustPosition。06下行:ReplicatedMovement 属性复制
这一章是最容易被忽略、却决定 3P 表现的一环:DS 对主控端的 Ack/纠正走的是 RPC,而对所有客户端(含主控端自己)的权威状态走的是属性复制。两条通道彼此独立、节拍不同、载荷不同。
6.1 服务器如何生成复制数据
AActor::GatherCurrentMovement()IsReplicatingMovement() 或有 AttachParent 时执行:处理物理模拟(bRepPhysics / 从 Chaos 缓存或 GetRigidBodyState 填充)、attachment 复制、并把当前 Location / Rotation / LinearVelocity / AngularVelocity 写入 ReplicatedMovement。ACharacter::GatherCurrentMovement() 追加加速度Super,再按 ShouldReplicateAcceleration()(由 AGameNetworkManager 的 EnableCharacterAccelerationReplication 控制)决定是否置 bRepAcceleration = true 并写入 CharacterMovement->GetCurrentAcceleration()。3P 端的动画/外推可以据此拿到更真实的加速度。NetUpdateFrequency / MinNetUpdateFrequency 检查该 Actor,并结合 relevancy(NetCullDistanceSquared、IsNetRelevantFor)与 NetDormancy 决定给哪些连接、这一帧是否真的发送。ReplicatedMovement 是 REPNOTIFY_Always 属性,因此每次收到都会触发 OnRep,即使值看起来没变(ActorReplication.cpp:188-189 的注释说明了这一点)。6.2 FRepMovement 里装了什么
| 字段 / 机制 | 说明 | 位置 |
|---|---|---|
Location / Rotation | 权威 Transform。Location 可按 LocationQuantizationLevel 量化;Pawn 默认把它设为 RoundTwoDecimals(厘米级),角色移动常用 RoundWholeNumber。 | ReplicatedState.h:118 Pawn.cpp:104 |
LinearVelocity / AngularVelocity | 权威速度。3P 收到后 PostNetReceiveVelocity 用于外推与动画;静止角色可避免无谓模拟。 | ActorReplication.cpp:296 |
Acceleration / bRepAcceleration | 可选字段,仅当 bRepAcceleration 置位时序列化(省带宽);消费见 CMC.cpp:7021。 | ReplicatedState.h:138-152 |
bRepPhysics | 标记根组件是否模拟物理,决定 3P 走物理状态同步还是普通 Transform 同步。 | ActorReplication.cpp:227-258 |
三个 EVectorQuantization 量化等级 | Location / Velocity / Rotation 各自独立的量化精度,在 NetSerialize(传统)或 FRepMovementNetSerializer(Iris)中执行,支持 delta 压缩。 | ReplicatedState.cpp:32-34, :93 RepMovementNetSerializer.cpp:40, :126 |
RebaseOntoZeroOrigin / RebaseOntoLocalOrigin | 大世界坐标(World Partition / Large World Coordinates)下的原点重映射,客户端收到后要 rebase 回本地原点。 | ActorReplication.cpp:288 |
6.3 谁会收到这份数据
| 接收方 | 是否使用 | 原因 |
|---|---|---|
| 主控端自己 AutonomousProxy | 根本收不到这个属性 | ReplicatedMovement 的复制条件是 COND_SimulatedOrPhysics(ActorReplication.cpp:593-596),只发给 ROLE_SimulatedProxy 或正在复制物理的 Actor;主控端是 AutonomousProxy 且角色不模拟物理,因此不会收到它。主控端的位置闭环完全靠第 8 章的 Ack / ClientAdjustPosition,与这条下行通道无关。 |
| 其他客户端 SimulatedProxy | 完全依赖 | 3P 没有输入、没有 SavedMove,唯一的数据来源就是这份低频快照 + 平滑/外推。 |
| 不相关 / 超距离 / 休眠的连接 | 收不到 | relevancy 与 NetDormancy 裁剪,这是 3P 带宽优化的主要手段。 |
6.4 随包附带的"平滑时间基准"
除了位置本身,ACharacter 还复制 ReplicatedServerLastTransformUpdateTimeStamp(Character.h:555-557),由 p.NetUseClientTimestampForReplicatedTransform 决定用客户端时间戳还是服务器 tick 时间生成(CMC.cpp:211-218)。3P 的 SmoothCorrection 把它写入 SmoothingServerTimeStamp(:8344-8349),让插值进度基于服务器时间,从而抵抗包到达间隔抖动。Linear 模式下还会强制每帧更新时间戳(Character.cpp:2111)。
6.5 上行 RPC 与下行复制的对照
| 上行 ServerMovePacked | 下行 ReplicatedMovement | |
|---|---|---|
| 方向 / 接收者 | 主控端 → DS(单播) | DS → 所有相关客户端(多播/按连接) |
| 触发 | 客户端每个发送节拍(约 1/120~1/5 s,可合并/延迟) | 服务器 NetUpdateFrequency 节拍 + relevancy |
| 可靠性 | unreliable RPC,靠三段 move 冗余 | 可靠属性复制(状态式,新值覆盖旧值) |
| 载荷 | 输入带(时间戳 + 加速度 + 视角 + 标志)+ 客户端自报位置 | 权威结果(Transform + Velocity + 可选 Acceleration + 时间戳) |
| 消费端 | DS 重放并仲裁 | 3P 平滑 + 外推 + 动画;主控端忽略 |
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。
SetWorldLocationAndRotation(NewLocation, NewRotation, ..., ETeleportType::TeleportPhysics) 后标记完成(:8265-8269)。NewToOldVector = OldLocation - NewLocation,累加到 ClientData->MeshTranslationOffset(:8278-8298)。若位移超过 MaxSmoothNetUpdateDist,只按最大距离累加;超过 NoSmoothNetUpdateDist 则直接清零——宁可瞬移,也不做长距离橡皮筋拉扯。OriginalMeshTranslationOffset / OriginalMeshRotationOffset / MeshRotationTarget,用 FScopedPreventAttachedComponentMove 阻止 Mesh 跟随,只 UpdatedComponent->SetWorldLocation(NewLocation)(:8301-8317)。Exponential:计算保持世界朝向所需的 MeshRotationOffset,同样阻止 Mesh 移动后 SetWorldLocationAndRotation(NewLocation, NewRotation)(:8318-8327)。此刻 Capsule 已是权威位置,Mesh 仍在旧位置,二者分离量正好是 MeshTranslationOffset。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 默认。 |
| Linear | SmoothingClientTimeStamp += 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 / OriginalMeshTranslationOffset | FNetworkPredictionData_Client_Character | 当前 / 初始的视觉位置偏移,逐帧衰减到 0。 |
MeshRotationOffset / MeshRotationTarget | 同上 | 当前 / 目标的视觉旋转偏移。 |
bNetworkSmoothingComplete | 移动组件 | 平滑是否结束;每次收到快照被置 false。 |
bNetworkUpdateReceived | 移动组件 | 本帧是否收到网络更新,门控 SimulateMovement 里的模式/重力刷新。 |
SmoothingClientTimeStamp / SmoothingServerTimeStamp / LastCorrectionDelta | ClientData | 基于服务器时间的插值时钟与上次更新间隔。 |
SmoothNetUpdateTime / SmoothNetUpdateRotationTime | ClientData | Exponential 衰减的时间常数(与复制间隔相关)。 |
MaxSmoothNetUpdateDist / NoSmoothNetUpdateDist | ClientData | 单次快照位移过大时的限制 / 放弃平滑阈值。 |
08闭环:Ack / 纠正与回滚重放
到这里,流水线要闭环回到主控端:DS 的裁决("这次预测对了"或"错了")必须送回主控端,主控端据此丢弃存档、或回滚后重放。这一步决定了"客户端预测"是否真的自洽。
8.1 两条响应路径
| 路径 | DS → 主控端 | 主控端动作 |
|---|---|---|
| Good Move | SendClientAdjustment:11137-11151 → ServerSendMoveResponse:10887 → ClientMoveResponsePacked(旧路径 ClientAckGoodMove(TimeStamp)) | ClientHandleMoveResponse:11039 判定 IsGoodMove() → ClientAckGoodMove_Implementation(:11706) |
| Correction | SendClientAdjustment:11153-11215 → 同样 ServerSendMoveResponse(packed),载荷里带完整 FClientAdjustment;旧路径按 RootMotion 状态分派不同 RPC | ClientHandleMoveResponse: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)做的事情按顺序是:
- 守卫
HasValidData / IsActive(:11308);若bClientIgnoreMovementCorrections为真,只AckMove修剪队列而不改位置(:11313-11329)。 - 解析 movement base;若 base 在本端尚未生成且位置是相对坐标,忽略这次纠正(:11334-11343)。
- 按时间戳找到对应 move 并
AckMove(:11361)——纠正也会先丢弃已确认的旧存档,剩下的才是需要重放的。 - 把服务器的
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 放到服务器权威位置,相当于回到"该时间戳那一刻的正确状态"。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 → 逐帧平滑 → 动画更新
COND_SimulatedOrPhysics 根本收不到 ⑦,它的闭环完全由 ⑤ 完成。09动画系统:3P 角色姿态从哪来
「动画支持」在这份文档里有两层含义,本章讲的是第二层:
(a) 文档动画:第 1 章的交互式沙盘用 SVG/JS 动画演示数据流动、纠正分支与平滑衰减;(b) 引擎动画系统:3P 上的角色动画蓝图如何拿到移动数据并正确播放走/跑/跳/RootMotion。两层都覆盖,避免歧义。
9.1 动画蓝图能读到哪些"网络来的"数据
动画蓝图并不直接读网络包。它读的是本端 ACharacter / UCharacterMovementComponent 上的运行时状态,而这些状态在 3P 上由复制属性与 SimulateMovement 填充:
| 动画常用量 | 3P 上的来源 | 说明 / 代码位置 |
|---|---|---|
Velocity / Speed | FRepMovement::LinearVelocity → PostNetReceiveVelocity(ActorReplication.cpp:296),再由 SimulateMovement 做有限外推 | 决定走/跑/待机混合与移动方向;地面且复制速度为 0 时会直接清零,防止越滑越远(CMC.cpp:2292-2300)。 |
Acceleration | FRepMovement::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)、bProxyIsJumpForceApplied | Character.h:645-650, :653-654;非 owning 客户端通过复制触发 OnRep_IsCrouched / OnCrouched 等。 |
| 重力方向 / 基于移动平台 | ReplicatedGravityDirection、ReplicatedBasedMovement | Character.h:574-575, :535-536;OnRep_ReplicatedBasedMovement(Character.cpp:1658)在 base 变化时也会调 SmoothCorrection(:1729)。 |
| RootMotion / Montage | RepRootMotion(FRepRootMotionMontage)、RootMotionRepMoves | Character.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,再更新骨骼
UCharacterMovementComponent::TickComponent(CMC.cpp:1663)用FScopedMeshMovementUpdate(:1801)把 Mesh 的子更新推迟到所有移动完成之后,避免一阵抖动。- 3P 分支调
SimulatedTick(:1898);常规路径SimulateMovement更新 Velocity / MovementMode / 位置。 SmoothClientPosition在每帧把MeshTranslationOffset写成 Mesh 的相对变换(:8600-8663)。- 动画更新与 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),让两端动画事件/状态机同步。 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 动画与网络平滑的边界
MeshTranslationOffset 影响的是 Mesh 的 RelativeLocation/Rotation,不改变 Velocity 或 MovementMode。所以"平滑让画面更顺"不会让动画状态机以为角色在倒退。GetActorLocation() 这类权威位置,并把命中判定放在 DS。9.5 本文档提供的"动画支持"清单
| 动画 | 位置 | 演示什么 |
|---|---|---|
| 三端泳道时序动画 | 第 1 章主沙盘 | 输入 → 预测 → 上行 → DS 校验/重放 → Ack/纠正 → 复制 → 3P 平滑 → 动画更新;数据包沿箭头飞行,节点随步骤高亮。 |
| 纠正分支交互 | 「位置误差」滑块 ≥ 10cm | 自动切换到 ClientAdjustPosition 红色纠正包 + SavedMove 队列回滚重放。 |
| 位置曲线动画 | DEMO A | 1P / DS / 3P 三条位置曲线随时间绘制,直观显示 RTT 与平滑缓冲带来的滞后。 |
| 网络平滑循环动画 | DEMO B | 橙色 Capsule 阶梯跳变 vs 绿色 Mesh 连续跟随,右侧 MeshTranslationOffset 衰减条持续循环。 |
| 三端角色动画系统说明 | 本章 | 动画蓝图的数据来源、Tick 顺序、RootMotion 独立复制通道、平滑与动画的边界。 |
10端到端时序与延迟构成
把一次按键放到时间轴上,就能看清"为什么 1P 和 3P 看到的永远不是同一帧"。下面所有时间点都以主控端采到输入为 t0,RTT 指主控端到 DS 的往返时延,NetUpdateInterval = 1 / NetUpdateFrequency。
10.1 端到端时间线
| 时刻 | 主控端 | DS | 3P 模拟端 |
|---|---|---|---|
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 位置做判定的原因 |
NetUpdateFrequency 越高越跟手但越费带宽;平滑缓冲越大越顺滑但越滞后——调参就是在这两者之间取舍。10.3 三套时间戳,各管一段
| 时间戳 | 所在端 / 结构 | 作用 |
|---|---|---|
ClientTimeStamp / CurrentTimeStamp | 主控端 FNetworkPredictionData_Client_Character、每个 FSavedMove_Character、FCharacterNetworkMoveData | 由 UpdateTimeStampAndDeltaTime(:8922)累加本端 DeltaTime 得到,随每个 move 上行;是"这段输入对应客户端哪一时刻"的标识,也是 Ack / 纠正要对齐的索引。 |
CurrentClientTimeStamp / ServerAccumulatedClientTimeStamp / ServerTimeStamp | DS FNetworkPredictionData_Server_Character(:10210-10213) | VerifyClientTimeStamp(:9442)拒绝过期/乱序/超前包;ProcessClientTimeStampForTimeDiscrepancy(:9504)处理客户端时钟漂移;GetServerMoveDeltaTime 用相邻客户端时间戳之差还原该 move 的 DeltaTime,保证服务器重放的物理步长与客户端一致,而不是用服务器自己的帧长。 |
SmoothingClientTimeStamp / SmoothingServerTimeStamp / ServerLastTransformUpdateTimeStamp | 3P FNetworkPredictionData_Client_Character(:8333-8349) | 让插值进度基于服务器时间而不是包到达间隔,从而抵抗网络抖动;若本地跑得太快会被拉回一半(LerpStable(..., 0.5))。 |
10.4 丢包、乱序与抖动怎么被消化
- 上行不可靠,但自带冗余:
ServerMovePacked是 unreliable RPC;ReplicateMoveToServer在同一个容器里同时装NewMove / PendingMove / OldMove(OldMove 取自未确认的"重要 move",:9001-9017),丢一个包也能靠后续包补齐。 - 合并与节流:
CanCombineWith允许把连续的相似 move 合成一个(:9032-9071);CanDelaySendingMove+GetClientNetSendDeltaTime把发送频率钳在1/120 ~ 1/5秒之间(:9100-9113),避免 60fps 每帧一个包。引擎还内置模拟丢包的调试 CVar(:9126-9144)。 - DS 侧乱序:时间戳过期直接丢弃(:10175-10188),并用累积时间戳而不是到达顺序推进,避免"晚到的包把位置拉回去"。
- 3P 侧抖动:平滑缓冲吸收到达间隔的波动;但当单次位移超过
MaxSmoothNetUpdateDist/NoSmoothNetUpdateDist时(:8288-8298),会限制或清零MeshTranslationOffset,宁可瞬移也不做长距离拉扯。
10.5 三个常见误解
SetActorLocationAndRotation 直接置为权威值;Mesh 通过 MeshTranslationOffset 被平滑,两者在平滑期间短暂分离(SmoothCorrection:8245)。NetUpdateFrequency / MinNetUpdateFrequency、相关性(relevancy / NetCullDistanceSquared)与休眠(NetDormancy)共同决定,通常远低于客户端帧率。11关键 CVar / 配置速查
以下取值均来自 F:\PX_Trunk\UE\Engine\Source\Runtime\Engine 源码中的定义(非记忆值),行号为该 CVar / 变量的定义处。标 Cheat 的只在非 Shipping 包可用,主要用于复现与调试本文描述的流程。
11.1 RPC 与打包(决定上行形态)
| CVar / 变量 | 默认值 | 作用 | 定义位置 |
|---|---|---|---|
p.NetUsePackedMovementRPCs | 1 | 最关键的一个开关:1 = 走 ServerMovePacked / ClientMoveResponsePacked(UE5 默认);0 = 回退到已废弃的 ServerMove / ServerMoveDual / ClientAdjustPosition 老 RPC 集合。 | CMC.cpp:119-125 |
p.NetPackedMovementMaxBits | 4096 | 单个 packed RPC 允许的最大 bit 数,防止恶意数据导致服务器过度分配内存。 | :127-132 |
p.NetEnableMoveCombining | 1 | 允许客户端把相似连续 move 合并(CanCombineWith / CombineWith),降低上行包数。 | :174-180 |
p.NetEnableMoveCombiningOnStaticBaseChange | 1 | 在静态几何之间切换 base 时是否仍允许合并。 | :182-188 |
p.NetMoveCombiningAttachedLocationTolerance...RotationTolerance | 0.01 / 0.01 | 附着对象上合并 move 时允许的微小相对位移/旋转抖动。 | :190-202 |
p.NetStationaryRotationTolerance | 0.1 | 视角小幅变化时维持发送节流的容差。 | :204-209 |
p.NetUseClientTimestampForReplicatedTransform | 1 | 用客户端时间戳变化来跟踪 ReplicatedServerLastTransformUpdateTimeStamp(影响 3P 平滑时间基准),关闭则改用服务器 tick 时间。 | :211-218 |
p.NetServerMaxMoveDeltaTimeScalar | 1.75 | 服务器允许接受的 move DeltaTime 相对客户端 MaxMoveDeltaTime 的倍数(≥1),放宽后能减少因长帧引发的纠正。 | :227-233 |
net.NetServerMoveTimestampExpiredWarningThreshold | 1.0 | 过期时间戳超过该阈值时打 Warning(否则只打 Log),配合 VerifyClientTimeStamp。 | :256-261 |
p.NetEnableSkipProxyPredictionOnNetUpdate | 1 | 允许模拟端在收到网络位置更新的那一帧跳过本地预测(需组件上的 bNetworkSkipProxyPredictionOnNetUpdate 也为 true)。 | :143-150 |
p.NetEnableListenServerSmoothing | 1 | Listen Server 上对远端客户端角色启用 Mesh 平滑。 | :134-141 |
p.AsyncCharacterMovement | 0 | 实验性的物理线程异步移动;开启后 TickComponent 走完全不同分支(:1692-1721),本文不展开。 | :263-266 |
11.2 复制与平滑(组件 / Actor 属性,不是 CVar)
| 属性 | 默认 / 来源 | 作用 |
|---|---|---|
NetworkSmoothingMode | ExponentialCMC.cpp:719 | 3P 平滑算法。UE 5.8 的 ENetworkSmoothingMode 只有 3 个值(EngineTypes.h:1052-1065):Exponential(默认,指数衰减)、Linear(按服务器时间戳线性插值)、Disabled(直接瞬移)。实现分别在 :8456 / :8522;回放平滑不是第四个枚举值,它走 ReplayLastTransformUpdateTimeStamp 与 p.ReplayLerpAcceleration 另一套机制。 |
MaxSmoothNetUpdateDistNoSmoothNetUpdateDist | 来自 AGameNetworkManager 的 NetworkMaxSmoothUpdateDistance / NetworkNoSmoothUpdateDistanceCMC.cpp:12481-12482 | 单次快照位移过大时限制或清零 MeshTranslationOffset(:8288-8298),避免长距离拉扯。 |
MaxMoveDeltaTime | 0.125 秒 GameNetworkManager.cpp:37 → CMC.cpp:12491 | 单个 move 允许的最大 DeltaTime;客户端与服务器都用它约束,也是合并与时间戳推进的依据。 |
NetUpdateFrequency / MinNetUpdateFrequency | AActor 可编辑属性Actor.h:953-960 | 服务器多久考虑复制一次该 Actor;3P 看到的位置更新节拍 ≈ 1 / NetUpdateFrequency。 |
NetCullDistanceSquared / NetDormancy / bReplicateMovement | Actor.h:898, :948-950 | 相关性裁剪、休眠、是否复制移动——三者决定某台 3P 客户端是否收得到这份 ReplicatedMovement。 |
11.3 误差判定 / 时间漂移
| 变量 / CVar | 默认值 | 作用 |
|---|---|---|
MAXPOSITIONERRORSQUARED | 3.0 | DS 判断"客户端位置是否可接受"的核心阈值,比较式见 GameNetworkManager.cpp:173(LocDiff | LocDiff > MAXPOSITIONERRORSQUARED)。超过即触发纠正。 |
ClientErrorUpdateRateLimit | 0.0 | 两次误差检查之间的最小间隔,用于限制纠正频率(GameNetworkManager.cpp:45, :160)。 |
bMovementTimeDiscrepancyDetection / ...Resolution | false / false | 是否启用客户端/服务器时间漂移检测与自动修正(GameNetworkManager.cpp:48-49),由 ProcessClientTimeStampForTimeDiscrepancy 消费。 |
MovementTimeDiscrepancyMaxTimeMargin / MinTimeMargin / ResolutionRate / DriftAllowance | 0.25 / -0.25 / 1.0 / 0.0 | 漂移容忍窗口、修正速率与允许的漂移量(GameNetworkManager.cpp:50-53)。 |
p.ClientAuthorityThresholdOnBaseChange | 0.0 | 上下移动平台时,允许信任客户端到该距离以内,避免跳 platform 时的突变纠正(CMC.cpp:296-301)。 |
p.MaxFallingCorrectionLeash / ...Buffer | 0.0 / 10.0 | 空中时允许保留的最大位置差及缓冲,减少跳跃离开移动平台时的纠正(CMC.cpp:303-315)。 |
p.NetUseBaseRelativeAcceleration / ...Velocity | 1 / 1 | 动态移动平台上把加速度/速度视为相对 base(CMC.cpp:279-289),与 packed RPC 配合使用。 |
11.4 调试 / 复现流程用的 Cheat CVar
| CVar | 默认 | 怎么用 | 定义位置 |
|---|---|---|---|
p.NetShowCorrections Cheat | 0 | 把客户端纠正位置画出来(红=错误位置,绿=纠正后),配合纠正分支观察。 | :421-427 |
p.NetCorrectionLifetime Cheat | 4.0 s | 纠正可视化保留时长。 | :429-435 |
p.NetForceClientAdjustmentPercent Cheat | 0 | 强制让指定比例的误差检查返回失败,用来稳定复现 ClientAdjustPosition + 回滚重放。 | :442-449 |
p.NetForceClientServerMoveLossPercent / ...Duration Cheat | 0 / 0 | 模拟上行丢包,代码在 ReplicateMoveToServer 的 :9126-9144 生效,用来验证 OldMove 冗余。 | :451-467 |
p.VisualizeMovement / p.NetVisualizeSimulatedCorrections Cheat | 0 / 0 | 移动与模拟端纠正的 world 调试绘制。 | :469-483 |
p.DebugTimeDiscrepancy Cheat | 0 | 1=记录检测日志,2=记录检测+修正日志。 | :485-491 |
p.ClientRecordMovePackedRpcStatsToCsvp.ClientRecordNetCorrectionDistanceToCsv | false | 把 packed RPC 次数/大小、纠正距离写进 CSV,用于量化带宽与纠正频率(需 CSV Profiler)。 | :402-416 |
Net.RepMovement.DrawDebug | 0 | 画出收到的 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。 | 替换数据结构 + 打包路径 |
| ② SavedMove | FSavedMove_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(),纠正时取原子快照。 |
| 派生 PredictionData | FNetworkPredictionData_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-8 | MAXPOSITIONERRORSQUARED=10000.0f、MAXCLIENTUPDATEINTERVAL=0.40、bMovementTimeDiscrepancyDetection/Resolution=true、MovementTimeDiscrepancyMaxTimeMargin=1.0f / MinTimeMargin=-3.0 —— 覆写了原生误差阈值与时间漂移参数(对照第 11 章原生默认 3.0 / false / ±0.25)。 |
DefaultGame.ini:10-14 | ClientNetSendMoveDeltaTime=0.03(Throttled 0.05 / Stationary 0.0833)、ClientNetSendMoveThrottleAtNetSpeed=10000、...OverPlayerCount=50 —— 直接决定主控端上行发包节流。 |
DefaultEngine.ini:1822-1851 | net.Iris.UseIrisReplication=1、net.Iris.UseFrequencyBasedPolling=1、net.Iris.EnableForceNetUpdate=1(注释:英雄轮询钉到 DS tick rate、每帧轮询)、net.PX.* 一系列 Iris 优化开关 —— 决定 3P 收到 ReplicatedMovement 的频率。 |
DefaultEngine.ini:1970-1971 | PackageMapClassPath=/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 端到端函数索引
| 阶段 | 端 | 函数 / 符号 | 位置 |
|---|---|---|---|
| 主控端 | 1P | UCharacterMovementComponent::TickComponent(三端总入口、Role 分流) | CMC.cpp:1663 |
| 1P | ControlledCharacterMove → ReplicateMoveToServer | :6481 / :8968 | |
| 1P | FNetworkPredictionData_Client_Character::UpdateTimeStampAndDeltaTime | :8922 | |
| 1P | FSavedMove_Character::SetMoveFor / CombineWith / PostUpdate / SavedMoves.Push | :9027, :9051, :9092, :9098 | |
| 1P | PerformMovement(本地预测真正移动) | :2790(调用点 :9090) | |
| 1P | CanDelaySendingMove / GetClientNetSendDeltaTime(发送节流) | :13102 / :13121 | |
| 1P | ShouldUsePackedMovementRPCs(决定新旧 RPC 路径) | :1095 | |
| 1P | CallServerMovePacked / ServerMovePacked_ClientSend;旧路径 CallServerMove(DEPRECATED) | :9203 / :9931 / :9258 | |
| 1P | FCharacterNetworkMoveDataContainer::ClientFillNetworkMoveData / FCharacterNetworkMoveData::ClientFillNetworkMoveData | :9795 / :9863 | |
| DS | DS | ServerMovePacked_ServerReceive(收包反序列化)/ ACharacter::ServerMovePacked_Implementation(转发壳)/ ServerMove_HandleMoveData → ServerMove_PerformMovement(Old/Pending/New 三段) | :9949 / Character.cpp:2288 / :9994 / :10033 |
| DS | ServerMove_Implementation(旧 RPC 路径的单个 move 入口;5.8 打包路径改用 ServerMove_PerformMovement:10033) | :10153 | |
| DS | VerifyClientTimeStamp / ProcessClientTimeStampForTimeDiscrepancy | :9442 / :9504 | |
| DS | GetServerMoveDeltaTime / MoveAutonomous → PerformMovement(权威重放) | :10208 / :10730 | |
| DS | ServerMoveHandleClientError / ServerCheckClientError | :10254 / GameNetworkManager.cpp:160 | |
| DS | SendClientAdjustment / ServerSendMoveResponse | :11121 / :10887 | |
| DS | AActor::GatherCurrentMovement / ACharacter::GatherCurrentMovement(写入 ReplicatedMovement) | ActorReplication.cpp:438 / Character.cpp:1734 | |
| DS | FRepMovement 结构(量化等级、Acceleration、bRepPhysics) | Classes\Engine\ReplicatedState.h:118 | |
| DS | AActor::SetReplicatedMovement | Actor.cpp:7216 | |
| 3P | 3P | AActor::OnRep_ReplicatedMovement / ACharacter::OnRep_ReplicatedMovement | ActorReplication.cpp:186 / Character.cpp:1754 |
| 3P | PostNetReceiveVelocity / AActor::PostNetReceiveLocationAndRotation | ActorReplication.cpp:296 / :285 | |
| 3P | ACharacter::PostNetReceiveLocationAndRotation(触发 SmoothCorrection) | Character.cpp:2004 | |
| 3P | SmoothCorrection(累计 MeshTranslationOffset / 时间戳) | CMC.cpp:8245 | |
| 3P | SmoothClientPosition(Exponential / Linear 逐帧衰减) | :8426, :8456, :8522 | |
| 3P | SimulatedTick / SimulateMovement | :1898 / :2208 | |
| 3P | ACharacter::OnRep_RootMotion / SimulatedRootMotionPositionFixup | Character.cpp:1786 / :1821 | |
| 闭环 1P ← DS | 1P | ClientHandleMoveResponse | :11039 |
| 1P | ClientAckGoodMove_Implementation(丢弃已确认 SavedMoves) | :11706 | |
| 1P | ClientAdjustPosition_Implementation(接受权威状态) | :11289 | |
| 1P | ClientUpdatePositionAfterServerUpdate(回滚 + 重放 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 / FCharacterServerMovePackedBits | Packed 上行:把 New/Pending/Old 三段 move 量化打包成位流,NetSerialize 后由 ServerMovePacked 承载。 | 主控端 → DS |
FClientAdjustment / FCharacterMoveResponseDataContainer | Packed 下行响应:Ack(时间戳)或 Correction(位置/速度/朝向/移动模式/时间戳)。 | DS → 主控端 |
FRepMovement | 下行属性复制的载荷:Location / Rotation / LinearVelocity / AngularVelocity + 三个 EVectorQuantization 量化等级 + bRepPhysics / bRepAcceleration + Acceleration。 | DS → 所有客户端 |
FRepRootMotionMontage / FSimulatedRootMotionReplicatedMove | RootMotion 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.h | OnRep 转发、GatherCurrentMovement(Acceleration 复制)、复制属性声明、RootMotion 复制与位置修正。 |
Private\ActorReplication.cpp · Private\Actor.cpp · Classes\GameFramework\Actor.h | OnRep_ReplicatedMovement、PostNetReceive*、GatherCurrentMovement、ReplicatedMovement 属性、NetUpdateFrequency / relevancy / dormancy。 |
Classes\Engine\ReplicatedState.h · Private\Engine\ReplicatedState.cpp · Private\RepMovementNetSerializer.cpp | FRepMovement 结构与(Iris)量化序列化实现。 |
Private\GameNetworkManager.cpp | MAXPOSITIONERRORSQUARED、MaxMoveDeltaTime、TimeDiscrepancy 参数、ServerCheckClientError。 |
Private\PlayerController.cpp | 输入采集、UpdateRotation、NotifyServerReceivedClientData、AcknowledgedPawn。 |
13.4 版本与口径说明
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),不能当作引擎实现引用,本文一律以引擎源码行号为准。