UE5.3 CharacterMovementComponent 网络同步是怎么实现的

分析自 UE 5.3 源码:UE_5.3\Engine\Source\Runtime\Engine

主要文件:

  • Classes/GameFramework/CharacterMovementComponent.h
  • Private/Components/CharacterMovementComponent.cpp(约 500KB,核心战场)
  • Classes/GameFramework/CharacterMovementReplication.h(UE 4.26+ Packed RPC 数据结构)
  • Classes/GameFramework/Character.h(RPC 入口声明在这)

这篇文章目标:讲清楚 CMC 那套"客户端预测 + 服务器权威 + 客户端回滚"是怎么用代码串起来的。按"如果我是 Epic 的程序员,会怎么一步步把它写出来"的思路来讲。

代码片段只保留最能印证思路的几行,防御性代码、log、debug 分支一律省略。


目录

  1. 先别看代码,先想清楚要解决什么问题
  2. 三个角色三条路——CMC 的 Tick 长什么样
  3. 客户端:先动再说,顺便把操作录下来
  4. 服务器:拿到客户端操作,重跑一遍,然后和客户端对答案
  5. 服务器发回来的判卷结果:Ack 还是纠正
  6. 客户端被纠正后怎么办——回滚和重放
  7. 顺带一提:别人家的角色(SimulatedProxy)是怎么同步的
  8. 带宽优化:Move Combining、Dual Move、Old Move
  9. TimeStamp、反作弊、服务器兜底这些细节
  10. 把整条链路串起来看一遍
  11. 如果要自己扩展 CMC 加字段,大概是怎么个套路
  12. 附录:关键源码行号速查表

1. 先别看代码,先想清楚要解决什么问题

要理解 CMC,得先理解它到底在解决什么麻烦事。

网络游戏里角色移动有三个互相矛盾的需求:

  1. 玩家按下 W,角色必须立刻动——不能等服务器回包,不然按键手感一坨屎;
  2. 服务器说了算——位置、碰撞、掉落伤害都得以服务器为准,否则谁都能改内存作弊;
  3. 其他玩家看到你要平滑——不能一卡一卡地瞬移。

这三个需求没法同时满足,只能折中。Epic 选的折中方案就是经典的 CSP + Rollback(客户端预测 + 回滚) 模型,大致思路:

  • 你按 W,本地客户端先自己模拟一遍,角色立刻动——满足需求 1;
  • 同时把"我刚才按了 W、走了 0.016 秒"这个操作记录下来,发给服务器;
  • 服务器按你发的操作重新模拟一遍,得到"服务器认为你应该在哪"——满足需求 2;
  • 服务器把结果发回来:差不多就 Ack(“你算对了”),差太多就发纠正。客户端收到纠正后把角色拉回权威位置,再把后续还没确认的操作重放一遍(Rollback + Replay);
  • 对于别人看到你,走另一条路:属性复制 + 插值平滑——满足需求 3。

接下来几节沿着这个思路一步步拆代码。

1.1 在看代码之前,先把整条链路用大白话过一遍

为了让你后面看代码不至于在细节里迷路,这里先把第 2~7 节要讲的东西一行代码不写地完整描述一遍。把这一段当作"剧情梗概",看完再去逐章对号入座,体感会清晰很多。

(1) 三个角色,三条路

同一个角色的 CMC,在不同机器上跑的根本不是同一段逻辑,这一点是理解整套同步的前提。

把"角色 A 的 CMC"复制三份分别放到三台机器上:

  • 服务器那一份是权威。它每帧根据收到的输入直接跑物理模拟,得到的位置就是"真理",不需要预测、也不需要回滚;
  • A 的本地客户端那一份是预测者。它必须在玩家按下按键的那一瞬间就让角色动起来,所以它会先在本地"假装自己是权威"跑一遍,再把这一帧的输入打包寄给服务器对账;
  • 其他玩家的客户端上那一份是观察者。它既不知道 A 按了什么键,也不需要响应 A 的输入,它只负责把服务器广播下来的"A 当前在哪、朝哪、什么状态"用平滑过的方式画出来。

CMC 每帧 Tick 第一件事就是看自己处于上面哪种角色,然后分流到完全不同的代码路径。这就是为什么第 2 节非要先用一张表把三种角色摆出来——后面所有讨论都建立在"我现在说的是哪一份 CMC"之上。

(2) 客户端:先动,再把这一帧"录"下来

镜头切到玩家本地的那一份 CMC。它每帧要做的事其实可以拆成两步,但这两步在大脑里要拧成一股绳来理解:

第一步,立刻动。CMC 内部那个真正处理物理、碰撞、斜坡、重力、空气阻力的"干活函数"是不管网络的——它只负责"给我输入加速度,我还你新位置"。客户端预测的全部秘密就是:毫不犹豫地在本地先把这个干活函数跑一遍。所以你按 W 那一帧,根本没有等服务器,角色就已经动了。

第二步,把这一帧的所有信息录下来存进一个队列里。录的内容包括玩家这一帧的输入(推杆方向、跳/蹲等按键、时间戳和这帧的时长),也包括动之前的初始状态(位置、速度、朝向、脚下踩的什么、动画播到哪里)和动完之后的结果状态(最后到了哪、速度变成多少)。这一整张"快照"被 push 到一个有序队列里,从老到新排着。

这个队列是整个客户端预测的命门。它的存在有两个目的:一是要把这些操作打包通过 RPC 发给服务器,让服务器能"重跑同样一遍";二是万一服务器反馈说"你算错了",客户端有这份录像才能回到过去某一帧,从那里重新跑一遍。

关于发包,有一个反常识的点要先在脑子里建立起来:录是每帧都录,但发不是每帧都发。CMC 会做节流——上一帧刚发过,这一帧的录像就先扣住不发,等下一帧合并之后再一起寄出去。如果连续好几帧玩家都在做差不多的事(比如按住 W 直线跑),这些帧甚至会被压缩合并成一条发出去,省带宽。

还有一个反常识的点:发出去的这个 RPC 是 unreliable 的,也就是允许丢包。这听上去很不安全,但其实合理——移动包一秒几十个,如果用可靠通道一旦堵了游戏就卡住;丢一两个普通包没关系,因为下一个包里天然带着最新的位置和最新的输入,会自动追上。

只有那些"玩家刚改变方向"的关键操作才需要额外保险,CMC 的做法是每次发包都顺带把最近一个还没被服务器确认的"重要操作"再寄一份,相当于关键帧自带冗余重发。

这一段对应第 3 节和第 8 节。

(3) 服务器:拿到录像,自己再跑一遍,然后判分

服务器收到 RPC 后做的事情非常朴素:用客户端发来的输入,自己跑一遍同样的物理模拟。注意这里是"同一个干活函数"——客户端预测时跑的是它,服务器对账时跑的也是它,两边只有输入不同的话,输出就应该一致。

这就解释了一个常见的坑:为什么 CMC 要求服务器和客户端的物理参数必须严格一致。如果你只在客户端临时加了个移速 buff 而忘了同步给服务器,那就等于两边在用不同的算式做同一道题,答案当然对不上,结果就是玩家会频繁地被服务器拉回去——不是网络坏,是你在制造系统性误差。

跑完之后,服务器手里就有了"它认为正确"的位置;与此同时,客户端在 RPC 里也带上了"它自己算出来的位置"。服务器把这两个位置相减,差距小就判"通过",差距大就判"纠正"。

这个判定结果不会立刻寄回客户端,而是先记在一个待处理槽里——这是个有意为之的设计,让"哪怕这一帧服务器处理了好几条 Move(包括冗余重发的旧 Move、被合并扣住的 Move、最新的 Move),最终也只回一封信"。

这一段对应第 4 节。

(4) 服务器:到了帧末,统一回信

回信的时机不是判完答案马上回,而是等到这一帧的网络层 flush 时统一发一次。这样既能合并多个判定,也方便做整体限流——CMC 默认无论是确认还是纠正,最快每秒只回 10 次。这意味着客户端的录像队列里平时积着五条十条都很正常,不是 bug。

回信本身也是 unreliable 的。原因和上行 RPC 一样:确认丢了下次再来一封更新版的就行;纠正丢了只要客户端还在错误位置上飘着,服务器下一帧继续判错、继续发,本质上是个幂等的重试机制。

这一段对应第 5 节。

(5) 客户端:根据回信做两件不同的事

客户端收到回信,分两条岔路:

如果是"通过",事情简单——按时间戳到队列里找到对应的那一条录像,把它和它之前的所有更老的录像全部从队列头部出队。意思是"截至这条 Move 为止,服务器已经认账了,前面的录像可以扔了,不再需要保留"。这就是为什么队列叫做"已执行但还没被 Ack 的"——一旦 Ack 来了,对应的就出队。

如果是"纠正",事情就有意思了。客户端要做的不是"听话地把角色搬到服务器告诉我的那个位置就完了"——那样会出大问题,因为服务器告诉你的位置是它对过去某个时间戳做出的判决,而你这边的玩家在那之后又按了好几帧键、跑了好几格,如果你直接搬过去再不动,画面就会突然倒退好几格,玩家会觉得自己被"打回去"。

所以正确做法分三步走,这是整个 CMC 同步最精彩的一步:

第一步,先承认服务器对那个过去时间戳的判决,把对应的及之前的录像从队列头部出队。此时队列里剩下的,就是"在那个被纠正的时间点之后,玩家又按了哪些键产生的录像",它们还没被服务器确认过。

第二步,把角色瞬移到服务器告诉我们的那个权威位置上。注意这是个瞬时穿越,没有平滑——但因为只是 Capsule 在底层穿越,玩家的视觉模型 Mesh 那一块有专门的平滑机制(后面会讲),所以画面看起来不会突兀。

第三步,不立刻重放,只打个标记说"我这帧需要回放剩下的录像"。真正的回放推迟到下一帧的 Tick 开头执行。这里推迟一帧不是偷懒,而是因为收到 RPC 的上下文做这种重活儿不合适,挪到正规 Tick 时序里更安全。

到了下一帧,CMC 一开 Tick 就发现这个标记被打了,于是按时间顺序把队列里剩下的所有录像重新跑一遍。每跑一条,先把那一帧的初始状态灌回 CMC(位置、速度、底座、动画进度全部还原),再用录像里记的输入跑一次干活函数,最后把这一帧重新算出来的结果回填到那条录像里覆盖旧值。
/另一方面,Replay 不是"慢慢重跑",是"一瞬间算完"。/

跑完之后角色到了哪?到了"服务器判的过去那个权威起点 + 之后所有还没被确认的玩家操作的最新模拟结果"——既权威又跟上了玩家最新的输入。

最重要的一点:如果服务器和客户端原本算出来的就一致(差距没超过阈值),那回放完之后角色的位置就还是你原来预测的位置,玩家根本感觉不到刚才被纠正过;只有当差距大到要发纠正时,玩家才会看到一个小的位置跳跃。

这就是 CMC 名义上叫"客户端预测 + 回滚",但绝大多数时候回滚的视觉成本是零的根本原因。

这一段对应第 6 节。

(6) 别人家的角色:完全是另一套故事

前面五段讲的全是"自己的角色"。但你屏幕上还有其他玩家,CMC 怎么处理它们?

简单:完全不预测。因为你这边根本拿不到其他玩家的输入——他们按了什么键你不知道,没法重跑。所以其他玩家的 CMC 走的是另一条完全不同的路:服务器把它们的位置、速度、朝向、当前移动模式作为普通的复制属性广播下来,客户端拿到这些属性后只做一件事——把角色放到那个位置。

但这里有个明显的矛盾:服务器一秒可能只发 30 次同步包,而客户端可能 120 帧渲染,4 倍的帧率差,每收到一次就 SetLocation 一次的话,画面就是每隔几帧瞬移一次,鬼畜到无法接受。

CMC 的解法很巧妙,也很值得一品——它 /把!!#ff0000 “碰撞用的位置"和"画面看到的位置”!!拆成了两份:Capsule 立刻按服务器同步的位置到位,保证物理判定(碰撞、Overlap、命中检测)算的是权威值;但显示用的 Mesh 不立刻跟过去,而是记下"现在 Mesh 比 Capsule 落后多少",然后每帧用插值把这个偏移量慢慢衰减到零。/

视觉上你看到的就是 Mesh 在平滑地追上 Capsule 的位置,而真正用来判定的位置已经是权威的最新值了。这种"碰撞瞬时同步、视觉平滑追赶"的双轨思路,是 UE 解决"权威感和流畅感矛盾"的标准答案,第 7 节会展开。

(7) 把整张图收拢一下

如果把上面六个步骤拍成一张大图,从一次按键到画面结束,会经过这样一条完整的链路:

玩家按下 W → 客户端 CMC 立刻在本地跑一帧物理模拟,角色画面动了 → 这帧的输入和结果被打包成快照 push 进队列 → 队列里挑出来若干条(可能是合并后的、可能附带冗余的旧重要操作)通过 unreliable RPC 寄给服务器 → 服务器用同样的输入和同一个物理函数自己跑一遍 → 服务器比对客户端报上来的位置,差距小就准备发"通过",差距大就准备发"纠正",结果先存起来 → 这一帧网络 flush 时,服务器把准备好的判定(最快每秒 10 次)通过另一个 unreliable RPC 寄回客户端 → 客户端收到"通过"就把对应的及之前的录像从队列出队;收到"纠正"就先把队列对应位置之前出队、把角色穿越到权威位置、打标记 → 下一帧 Tick 开头根据标记,把队列里剩下的所有录像按时间顺序重新跑一遍 → 跑完之后角色既站在权威位置上又包含了所有最新的玩家输入。

与此同时,服务器还在把这个角色的最新位置作为复制属性广播给其他客户端,其他客户端的那一份 CMC 走完全不同的"被动接收 + 双轨平滑"路径把它画出来。

这就是 CMC 网络同步的全貌。带着这张图去看后面 2~7 节的代码细节,你会发现每一段代码都只是这条主链路上的一个环节,不会再陷在某个函数里出不来。


2. 三个角色三条路——CMC 的 Tick 长什么样

上一节说了三个需求,对应到代码里,每台机器上每个角色都会走其中一条路,具体走哪条取决于它的 Role

2.1 同一个角色在不同机器上走的根本不是同一段代码

理解 CMC 的前提:你看到自己、你看到别人、服务器看到你——这三个位置虽然叫同一个角色,但它们的 CMC 跑的是完全不同的代码路径。

原因很简单,它们各自手里拿到的"牌"不一样——服务器有权威但没本地输入,本地玩家有输入但没权威世界,别人的客户端什么都没有只能被动接收。所以每种身份只能用"自己有什么就用什么"的路子。

情形 LocalRole 所在机器 走哪条路
服务器上的你 ROLE_Authority Server 直接跑权威模拟(PerformMovement
你本地看到的自己 ROLE_AutonomousProxy 你这台客户端 预测 + 上报(ReplicateMoveToServer
你看到的别人 ROLE_SimulatedProxy 你这台客户端 属性复制 + 插值平滑(SimulatedTick

2.2 Tick 的顶层分发器:按 Role 分流

CMC 的 TickComponent(第 1457 行)本身不跑物理,它唯一的工作就是按 Role 把这一帧分发到三条路里的一条

这也是"同一个组件能同时服务三种运行时角色"的实现方式——不靠继承也不靠虚函数,就靠一个 Role 字段动态路由。

void UCharacterMovementComponent::TickComponent(float DeltaTime, ...)
{
    if (CharacterOwner->GetLocalRole() > ROLE_SimulatedProxy)
    {
        // 客户端上一帧收到过纠正吗?先补回滚重放
        if (bIsClient && ClientData->bUpdatePosition)
            ClientUpdatePositionAfterServerUpdate();    // ← 第 6 节讲

        ControlledCharacterMove(InputVector, DeltaTime); // ← 主分派点
    }
    else if (LocalRole == ROLE_SimulatedProxy)
    {
        SimulatedTick(DeltaSeconds);                     // ← 第 7 节讲
    }
}

这里有一个细节值得先记住:Replay 不是在收到纠正的那一瞬间做的,而是延迟到下一帧 Tick 开头才做(上面那行 bUpdatePosition 判断就是这个延迟标记的作用)。这样可以保证所有物理模拟都发生在正规的 Tick 时序里,避开 RPC 回调这种不适合做重活儿的上下文。

2.3 再分一次:服务器跑权威,客户端跑预测

上面的路由只把"要跑移动的"和"被动接收的"分开了,还没决定"跑移动"那条路具体是以权威身份跑还是以预测者身份跑。这一步在 ControlledCharacterMove(5981 行)里完成:

void UCharacterMovementComponent::ControlledCharacterMove(const FVector& InputVector, float DeltaSeconds)
{
    // ... 处理输入、计算 Acceleration ...

    if (LocalRole == ROLE_Authority)
        PerformMovement(DeltaSeconds);                   // 服务器:直接跑
    else if (LocalRole == ROLE_AutonomousProxy && IsNetMode(NM_Client))
        ReplicateMoveToServer(DeltaSeconds, Acceleration); // 客户端:预测 + 上报
}

两层分派合起来就是一个嵌套 if/else——服务器直接跑权威物理;客户端跑预测物理再上报

两条路分叉之前那段 处理输入、计算 Acceleration 的省略代码里做了一件重要的事:把玩家的推杆/按键翻译成 Acceleration 向量。正是因为这一步已经把输入加工成了统一的意图向量,下游两条路才能共用同一个 PerformMovement——这也是 SavedMove 里只存 Acceleration 就够用的原因。

Listen Server 插曲:主机服务器上的本地角色 LocalRole = Authority,走 PerformMovement——它本身就是权威,不需要预测,所以 IsNetMode(NM_Client) 这个判断能把它正确排除在客户端预测分支之外。

接下来 3、4、5、6 节就沿着 ReplicateMoveToServer → 服务器处理 → 服务器回包 → 客户端回滚的顺序往下讲。


3. 客户端:先动再说,顺便把操作录下来

回到第 1 节的思路:客户端要立刻响应按键,所以本地得立刻跑一遍移动,但同时要把这次操作记下来——一是得发给服务器,二是万一服务器说"你算错了",还得靠这份记录回滚。

这一节的核心是理解"SavedMove"这个对象——它既是发给服务器的那封信,也是本地用来应对回滚的那份录像。下面的字段和流程,都是围绕这两个身份展开的。

3.1 存操作:FSavedMove_Character 是本"操作录像带"

FSavedMove_Character(头文件 2837 行)是"一次移动的快照",按职责分成三类字段:输入(发给服务器用)、起始状态(本地 Replay 用)、结束状态(给服务器对账用)。

注意这里说的"输入"并不是原始按键,而是被加工后的"意图向量"——CMC 在上一层就已经把"按了 W"翻译成了 Acceleration,所以存储和传输都统一用这个向量。

class FSavedMove_Character
{
    // 【输入是什么】
    FVector Acceleration;
    float   TimeStamp;          // 客户端时间戳,核心字段
    float   DeltaTime;
    uint32  bPressedJump:1;
    uint32  bWantsToCrouch:1;

    // 【Move 开始前的状态】——用来回滚到"起点"
    FVector StartLocation;
    FVector StartVelocity;
    // ...

    // 【Move 做完后的状态】——用来和服务器结果比对
    FVector SavedLocation;
    FVector SavedVelocity;
    // ...

    virtual uint8 GetCompressedFlags() const;   // 把 jump/crouch 等压成 1 字节
};

两个字段特别重要,单独拎出来说:

  • TimeStamp 是整个同步机制的"身份证"——Ack 和纠正都用它作锚点定位 SavedMoves 里的某一条;
  • Acceleration + CompressedFlags 合起来就是真正会发给服务器的"输入",其余字段要么只发一小撮用于对账(如 SavedLocation),要么完全留在本地(Start 开头的那一堆)。

"跳/蹲/自定义移动意图"跨网络传输靠一个字节 CompressedFlags,每一位代表一个意图:

enum CompressedFlags {
    FLAG_JumpPressed   = 0x01,
    FLAG_WantsToCrouch = 0x02,
    FLAG_Custom_0      = 0x10,   // 给项目扩展用
    FLAG_Custom_1      = 0x20,
    // ...
};

这个设计的好处是:扩展新意图不需要加字段、不需要改 RPC 签名

项目要加冲刺、滑铲,就重写 GetCompressedFlags 把自己的 bool 塞到某个 Custom 位,服务器侧 UpdateFromCompressedFlags 解出来——总开销永远是 1 个字节。

3.2 存所有操作:SavedMoves 队列

单条 Move 讲完了,再看"多条 Move 怎么堆在一起"。容器是 FNetworkPredictionData_Client_Character(头文件 3021 行),只有 AutonomousProxy 角色才会持有这个结构:

class FNetworkPredictionData_Client_Character : public FNetworkPredictionData_Client
{
    TArray<FSavedMovePtr> SavedMoves;  // ★ 已执行但还没被 Ack 的队列,从老到新
    TArray<FSavedMovePtr> FreeMoves;   // 对象池:用完的 Move 放这复用
    FSavedMovePtr PendingMove;         // 等着和下一帧合并的那个
    FSavedMovePtr LastAckedMove;       // 服务器最后 Ack 的那个
    uint32 bUpdatePosition:1;          // ★ 刚收到纠正,下帧需要 Replay
};

核心就两个:SavedMoves 是"已执行但还没被 Ack"的队列(客户端的操作录像带);bUpdatePosition 是"下一帧 Tick 开头要 Replay"的标记位——这两个字段合起来决定了纠正发生时客户端怎么重建状态。

其余字段都是为这两个核心服务的辅助设施:FreeMoves 是对象池(避免每帧 new/delete)、PendingMove 是"发包节流"的缓冲位置、LastAckedMove 是判断新 Move 能不能和最近一条合并、或者它是不是"重要 Move"的参考。

3.3 主流程:ReplicateMoveToServer

ReplicateMoveToServer(8375 行)是整个客户端预测的大动脉。这一帧所有和网络同步有关的动作都在这里:

void UCharacterMovementComponent::ReplicateMoveToServer(float DeltaTime, const FVector& NewAcceleration)
{
    // ── [1] 找"最老的、还没 Ack 的重要 Move"作冗余保险(见 8.3)──
    FSavedMovePtr OldMove = ... ;

    // ── [2] 申请新 Move,填充本帧数据 ──
    FSavedMovePtr NewMovePtr = ClientData->CreateSavedMove();
    NewMovePtr->SetMoveFor(CharacterOwner, DeltaTime, NewAcceleration, *ClientData);

    // ── [3] 尝试把上一帧扣住的 PendingMove 和本帧合并 ──
    if (PendingMove && PendingMove->CanCombineWith(NewMovePtr, ...))
        NewMovePtr->CombineWith(PendingMove, ...);

    // ── [4] ★ 本地立即执行移动!这就是"预测"本身 ★ ──
    PerformMovement(NewMovePtr->DeltaTime);
    NewMovePtr->PostUpdate(CharacterOwner, PostUpdate_Record);

    // ── [5] 入队 + 发 RPC ──
    ClientData->SavedMoves.Push(NewMovePtr);

    if (bCanDelayMove && !ClientData->PendingMove.IsValid()) {
        // 距上次发包太近?扣为 PendingMove,下帧再处理
        ClientData->PendingMove = NewMovePtr;
        return;
    }

    CallServerMovePacked(NewMovePtr.Get(), ClientData->PendingMove.Get(), OldMove.Get());
}

这段代码最值得关注的是 [4]PerformMovement 是 CMC 干实事的函数(处理物理、碰撞、斜坡、重力……),它不管网络,输入加速度进去、输出角色新位置出来。客户端预测的本质就是在本地先无脑跑一遍它,玩家视觉上角色已经动了。紧接着的 [5] 才是"顺便告诉服务器我刚才干了啥"。

其他几步都是在服务于这个核心:[1][3] 是带宽优化(丢包冗余、合并同类帧,第 8 节细讲);[2] 负责从对象池申请 Move 并填输入;[5] 的节流判断决定这一帧是立刻发还是扣为 PendingMove 等下一帧。

函数名叫"Replicate 给服务器",但重心其实是"先跑本地、顺便上报" ——这个顺序就是客户端预测的灵魂。

3.4 打包发射:CallServerMovePacked

UE 4.26 之前这里有 ServerMove / ServerMoveDual / ServerMoveNoBase / ServerMoveOld 等五六个不同签名的 RPC,每种参数组合一个——扩展起来非常痛苦,加一个字段要改所有 RPC 签名。

4.26 之后重构成一个 Packed RPC,所有字段打成一个变长 bit stream,加字段不用改 RPC 签名:

void UCharacterMovementComponent::CallServerMovePacked(
    const FSavedMove_Character* NewMove, const FSavedMove_Character* PendingMove, const FSavedMove_Character* OldMove)
{
    // 把 3 个 Move 填进数据容器
    MoveDataContainer.ClientFillNetworkMoveData(NewMove, PendingMove, OldMove);

    // 序列化成 bit stream
    MoveDataContainer.Serialize(*this, ServerMoveBitWriter, PackageMap);

    // 拷进 PackedBits,发 RPC
    FMemory::Memcpy(PackedBits.DataBits.GetData(), ServerMoveBitWriter.GetData(), ...);
    ServerMovePacked_ClientSend(PackedBits);
}

项目要给移动 RPC 塞自定义字段(冲刺耐力、锁定目标等),扩展点就在 ClientFillNetworkMoveData——继承 FCharacterNetworkMoveData(Container) 重写即可,整条 RPC 路径不用动。

实现细节:ServerMoveBitWriter 是 CMC 的成员变量不是每次 new 的;PackedBits 是函数内 static 局部变量——都为了避免每帧分配内存。

RPC 声明在 Character.h 第 270 行(注意声明在 ACharacter 不是 UCharacterMovementComponent,为的是少序列化一个 Component 路径,省带宽):

UFUNCTION(unreliable, server, WithValidation)
void ServerMovePacked(const FCharacterServerMovePackedBits& PackedBits);

unreliable!移动包一秒几十个,用 reliable 会塞爆可靠通道;丢一个包也没事——下次 ServerMove 天然带最新位置,客户端自动追上。

真正担心丢的"重要 Move"靠 OldMove 冗余重发兜底(第 8.3 节)。


4. 服务器:拿到客户端操作,重跑一遍,然后和客户端对答案

上一节客户端把 Move 发出去了,镜头切到服务器。服务器要干两件事

  1. 重放一遍——用客户端传来的输入跑一次 PerformMovement,得到"权威位置";
  2. 和客户端对答案——客户端在包里也带了自己算的位置,服务器比一下差多少。

理解这一节的核心是:服务器信任的是"输入"、不是"客户端算出来的位置"

客户端报上来的位置只是附带的"我猜的答案",服务器会拿自己的重跑结果去覆盖它——这是 CMC 反作弊的根本思路,也让"改客户端内存把自己瞬移过去"这种作弊从原理上就无效。

4.1 收包 & 分派

调用链很直:

ACharacter::ServerMovePacked_Implementation  (RPC 入口)
  → CMC::ServerMovePacked_ServerReceive       (9313 行,反序列化)
  → CMC::ServerMove_HandleMoveData            (9360 行,分派三种 Move)
  → CMC::ServerMove_PerformMovement           (9399 行,真正干活)

ServerMove_HandleMoveData 的逻辑一句话概括:按时间顺序依次处理 OldMove → PendingMove → NewMove

void UCharacterMovementComponent::ServerMove_HandleMoveData(const FCharacterNetworkMoveDataContainer& Container)
{
    if (Container.bHasOldMove)      ServerMove_PerformMovement(*Container.GetOldMoveData());
    if (Container.bHasPendingMove)  ServerMove_PerformMovement(*Container.GetPendingMoveData());
    /* always */                    ServerMove_PerformMovement(*Container.GetNewMoveData());
}

顺序不能乱,因为每条 Move 的起点是上一条的终点——乱序执行就得不到一致结果。

OldMove 是冗余重发、PendingMove 是上一帧被扣住的那一条、NewMove 才是这一帧最新的。

4.2 重放:ServerMove_PerformMovement

这是"服务器版的 PerformMovement 包装器"。核心逻辑 3 步:

void UCharacterMovementComponent::ServerMove_PerformMovement(const FCharacterNetworkMoveData& MoveData)
{
    // [1] 校验客户端时间戳(防重放、防乱序)
    if (!VerifyClientTimeStamp(MoveData.TimeStamp, *ServerData))
        return;

    // [2] ★ 用客户端给的输入跑一次模拟(和客户端跑的是同一个 PerformMovement)★
    const float DeltaTime = ServerData->GetServerMoveDeltaTime(MoveData.TimeStamp, ...);
    MoveAutonomous(MoveData.TimeStamp, DeltaTime, MoveData.CompressedMoveFlags, MoveData.Acceleration);

    // [3] 只对最新的 NewMove 做误差检测
    if (MoveData.NetworkMoveType == ENetworkMoveType::NewMove)
        ServerMoveHandleClientError(MoveData.TimeStamp, DeltaTime, MoveData.Acceleration, MoveData.Location, ...);
}

这段代码最关键的是 [2] —— MoveAutonomous 内部最终调的 PerformMovement 和客户端跑的是同一个函数。这就是为什么 CMC 要求服务器和客户端物理参数完全一致——同样的输入才能得到同样的输出;如果你在客户端临时改了 MaxWalkSpeed 没同步到服务器,就会频繁触发纠正(这类 bug 在 GAS 项目里很常见,原因是改属性的路径没走复制)。

另外 [1] 的时间戳校验和 [2] 里 GetServerMoveDeltaTime 对 DeltaTime 的限幅是两道反作弊闸门——防伪造旧包重放、防伪造"这一帧持续了 10 秒"来瞬移。

[3] 只对 NewMove 做对账是因为 OldMove 和 PendingMove 只是把状态推进到正确时刻的过渡,不代表"当前",没必要拿它们判错。

跑完之后 UpdatedComponent->GetComponentLocation() 就是服务器权威位置

4.3 对答案:ServerMoveHandleClientError

现在服务器手里有两个位置:服务器算的UpdatedComponent 的位置) vs 客户端算的MoveData.Location)。减一减看差距(9604 行):

void UCharacterMovementComponent::ServerMoveHandleClientError(
    float ClientTimeStamp, float DeltaTime, const FVector& Accel, const FVector& ClientLoc, ...)
{
    // ... 节流、处理 Base 相对坐标、空中容忍等 ...

    if (ServerCheckClientError(...)) {
        // ★ 差得太远 → 填"纠正" ★
        ServerData->PendingAdjustment.NewLoc       = UpdatedComponent->GetComponentLocation();
        ServerData->PendingAdjustment.NewVel       = Velocity;
        ServerData->PendingAdjustment.TimeStamp    = ClientTimeStamp;
        ServerData->PendingAdjustment.bAckGoodMove = false;        // ← "这是纠正"
    }
    else {
        // ★ 差得不多 → 填"Ack" ★
        ServerData->PendingAdjustment.TimeStamp    = ClientTimeStamp;
        ServerData->PendingAdjustment.bAckGoodMove = true;         // ← "这只是 Ack"
    }
}

注意这里只是把结果填到 PendingAdjustment 里,没立刻发——因为想合并:同一帧即使服务器处理了好几个 Move(dual move、old move 都会调),最终也只发一次响应。真正发送在下一节。

两个分支的写入内容有差别:纠正分支要填 NewLocNewVel 让客户端能拉回去;Ack 分支只填 TimeStamp(语义只是"截至这个时间戳你都算对了",不需要带位置)。

空中容忍:UE5 还加了 bDeferServerCorrectionsWhenFalling 这类参数,用来解决"起跳瞬间误差放大导致频繁纠正"的痛点。本质是扩大空中的误差阈值换取视觉流畅,具体调参可以不深究。

判错依据ServerCheckClientError(9914 行)主要看位置差是否超过 AGameNetworkManager::ExceedsAllowablePositionError 的阈值。CVar p.NetForceClientAdjustmentPercent 可以按百分比强制触发纠正(压测用)。


5. 服务器发回来的判卷结果:Ack 还是纠正

上一节服务器把 Ack/纠正信息填到了 PendingAdjustment 里,还没发。这一节讲什么时候发、怎么发

核心要理解:服务器的回包节奏远比客户端的发包节奏稀疏——客户端可能每秒产生几十条 Move,但服务器最多每秒回 10 次判定。这是 CMC 一个很克制的设计,下面一步步看它怎么做到的。

5.1 发送入口在哪

反直觉的地方是——判卷的 ServerMoveHandleClientError 并不负责发送,真正的发送入口挂在网络层的 TickFlush 上:

UNetDriver::TickFlush
  → APlayerController::SendClientAdjustment       (PlayerController.cpp:1667)
  → CMC::SendClientAdjustment                     (CMC.cpp:10395,真正干活)

这样设计的好处:服务器一帧内就算收到好几个 ServerMove,也只发一次响应——因为 PendingAdjustment 每次被覆盖,到帧末才读一次。

5.2 SendClientAdjustment 干了啥

void UCharacterMovementComponent::SendClientAdjustment()
{
    if (ServerData->PendingAdjustment.TimeStamp <= 0.f) return;

    if (ServerData->PendingAdjustment.bAckGoodMove) {
        // Ack 分支:两次 Ack 之间至少隔 NetworkMinTimeBetweenClientAckGoodMoves (0.10s)
        if (CurrentTime - ServerLastClientGoodMoveAckTime > NetworkMinTimeBetweenClientAckGoodMoves)
            ServerSendMoveResponse(ServerData->PendingAdjustment);
    }
    else {
        // 纠正分支:大纠正优先发(等待间隔更短)
        if (CurrentTime - ServerLastClientAdjustmentTime > AdjustmentTimeThreshold)
            ServerSendMoveResponse(ServerData->PendingAdjustment);
    }

    ServerData->PendingAdjustment.TimeStamp = 0;
}

这段代码唯一的核心动作就是限流 + 清槽位

bAckGoodMove 分两条独立的限流规则(Ack 和纠正可以独立调节率),距上次发送超过阈值才真的发包。

值得注意的是函数末尾无论有没有发出去都会把 TimeStamp 清零——因为下一帧服务器会重新判定、重新填,旧的判定没有保留价值,被限流扣掉就直接丢弃。这是整个机制对丢包不敏感的来源:每帧都在重新判,丢一次没关系。

两个默认值(CMC.cpp:583-584):

  • NetworkMinTimeBetweenClientAckGoodMoves = 0.10f
  • NetworkMinTimeBetweenClientAdjustments = 0.10f

也就是说服务器最快每秒给客户端发 10 次 Ack 或纠正——不是每次 ServerMove 都回包。

这条 10Hz 的上限还有一个重要副作用:客户端那边的 SavedMoves 队列稳态长度就是 5~10 条左右(60fps 客户端、10Hz Ack),这是正常现象不是 bug。

5.3 发送路径

ServerSendMoveResponse 和客户端发 Move 的打包套路完全对称:

ServerSendMoveResponse(PendingAdjustment)
  → 填充 FCharacterMoveResponseDataContainer
  → 序列化成 FCharacterMoveResponsePackedBits
  → ACharacter::ClientMoveResponsePacked    ← UFUNCTION(unreliable, client)

又是 unreliable!不怕丢:Ack 丢了客户端 SavedMoves 多留一会儿没事,下次 Ack 来再清;纠正丢了只要客户端还在错误位置,服务器下一帧继续判错、继续发

本质是个幂等重试,完全不需要应用层再实现一套"重传 + 确认"的可靠机制。


6. 客户端被纠正后怎么办——回滚和重放

服务器把纠正包发出来了,轮到客户端收尾。这一节讲收到纠正后角色怎么优雅地回到正确位置

这一节是整条链路里最精妙的部分,也是"客户端预测 + 回滚"这个模型名字里"回滚"两个字的真正含义。

核心思路其实很容易想歪——一般人第一反应是"服务器告诉我该在哪,我就搬过去呗",但这样做玩家立刻就能感觉到自己被"拉回了过去",手感会变得非常屎。

CMC 的做法更巧妙:把角色搬回服务器说的那个过去位置,然后再把你从那以后按的键全部重新跑一遍,让你又回到"现在应该在的地方"——玩家绝大多数情况下根本感觉不到被纠正过。

6.1 收包分派

客户端收到服务器回包的第一件事只是做一个最朴素的判断——“你是来告诉我我对了,还是来告诉我我错了?”。这两种情况走完全不同的处理路径:

void UCharacterMovementComponent::ClientHandleMoveResponse(const FCharacterMoveResponseDataContainer& MoveResponse)
{
    if (MoveResponse.IsGoodMove())
        ClientAckGoodMove_Implementation(MoveResponse.ClientAdjustment.TimeStamp);
    else
        ClientAdjustPosition_Implementation(...);   // 最常见的情况
}

这里的关键信息是——客户端没有"质疑"或"校验"服务器的机会。它只有两条路可走:要么老老实实出队,要么老老实实被拉回去。

这是 CMC 反作弊模型的下半场:上半场是"服务器不信客户端的位置",下半场是"客户端无权拒绝服务器的纠正"。两者合起来保证了服务器说了算。

6.2 简单分支:收到 Ack

Ack 分支的逻辑简单到一句话就能说完——按 TimeStamp 在 SavedMoves 里找到对应位置,把队头到那一条之间的所有 Move 全部扔掉。意思是"截至这个时间戳的所有操作,服务器都认账了,对应的录像可以销毁了":

void UCharacterMovementComponent::ClientAckGoodMove_Implementation(float TimeStamp)
{
    int32 MoveIndex = ClientData->GetSavedMoveIndex(TimeStamp);
    if (MoveIndex != INDEX_NONE)
        ClientData->AckMove(MoveIndex, *this);
}

实际的"扔掉"动作在 AckMove(11782 行)里,本质就是 SavedMoves.RemoveAt(0, AckedMoveIndex + 1)——把队头开始的一段一次性出队。

为什么一次能扔这么多?因为 Ack 是累积语义的——服务器说"截至 TimeStamp = T 为止你都算对了",那 T 之前的更老的 Move 自然也都算对了,没必要逐条确认。

这和 5.2 节讲的"服务器最快每秒只回 10 次"对应得上:客户端 60fps 产生 Move、服务器 10Hz 回 Ack,每次 Ack 平均能顺带确认 6 条 Move,完全够用。

6.3 复杂分支:收到纠正

这才是 CMC 最精彩的一步。收到纠正后真正要做的事情看起来只有 3 行,但每一行背后的取舍都很讲究:

void UCharacterMovementComponent::ClientAdjustPosition_Implementation(
    float TimeStamp, FVector NewLocation, FVector NewVelocity, ...)
{
    // ── Step 1:先 Ack 该时间戳(等于"承认服务器版本")──
    //   此时 SavedMoves 里只剩 TimeStamp 之后还没确认的 Move
    int32 MoveIndex = ClientData->GetSavedMoveIndex(TimeStamp);
    if (MoveIndex == INDEX_NONE) return;
    ClientData->AckMove(MoveIndex, *this);

    // ── Step 2:角色"瞬移"到服务器告知的权威位置 ──
    UpdatedComponent->SetWorldLocation(NewLocation, false, nullptr, ETeleportType::TeleportPhysics);
    Velocity = NewVelocity;

    // ── Step 3:只打标记,告诉下一帧 Tick"我需要重放" ──
    ClientData->bUpdatePosition = true;
}

Step 1 先当成 Ack 处理是理解这步的关键。很多人第一次看代码会奇怪——“服务器明明是来告状的,你怎么还先把它当成 Ack?”——这里所谓的"Ack"不是"我对了"的意思,而是**“我承认服务器版本到此为止”**。

结果就是队列里 TimeStamp 之前的 Move 被一次性扔掉(因为那些服务器都已经用自己的版本重新定性过了),队列里剩下的全是"时间戳在纠正点之后、还没来得及被服务器判定"的那批 Move——这批 Move 正是等会儿要重跑的东西。

Step 2 把角色瞬移到服务器给的权威位置。这是个冷酷但必须做的动作,没有任何平滑——因为接下来 Step 3 标记的 Replay 会从这个位置再跑一遍玩家最新的输入,如果这一步做平滑,相当于 Replay 的起点还在平滑过程中,会越搞越乱。

这里不平滑不代表玩家会看到瞬移——Mesh 那边有独立的平滑机制(虽然主要是给 SimulatedProxy 用的,自己被纠正时通常不会触发或触发极小的位移),而且大多数时候 Replay 跑完之后位置又回到了原来预测的点,根本没位移可言。

Step 3 是一个反直觉的设计:不立刻重放,只打个标记 bUpdatePosition = true,真正的 Replay 要等到下一帧 Tick 开头才做。

为什么非要拖一帧?因为这个函数是 RPC 回调触发的,此时的执行上下文不是正规的 Tick 时序——在这里直接跑物理模拟可能打乱帧内的其他系统顺序(动画、动画蓝图、其他 Actor 的 Tick 等)。把它延后到 Tick 开头做,整个 Replay 就能作为一个"干净的 Tick 子流程"运行,和正常 Tick 的时序规则完全一致。

一句话总结这一步:“先清账 → 瞬移到过去 → 等下一帧再把过去到现在补回来”——先承认服务器对过去那一点的判决,再等下一帧把这个过去点重新演进到"现在"。

6.4 重放:ClientUpdatePositionAfterServerUpdate

上一步只打了个标记,真正的 Replay 在下一帧 TickComponent 一开头就执行(2.2 节讲过)。这个函数(8081 行)的逻辑可以浓缩成一句话——“把 SavedMoves 里剩下的那些 Move 按时间顺序,原封不动地再跑一遍”

bool UCharacterMovementComponent::ClientUpdatePositionAfterServerUpdate()
{
    if (!ClientData->bUpdatePosition || ClientData->SavedMoves.Num() == 0) return false;
    ClientData->bUpdatePosition = false;

    CharacterOwner->bClientUpdating = true;     // ← 标记"现在在 Replay 模式"

    // ★ 核心:按时间顺序重放所有未 Ack 的 Move ★
    for (auto& Move : ClientData->SavedMoves)
    {
        Move->PrepMoveFor(CharacterOwner);       // 把 SavedMove 的状态灌回 CMC
        MoveAutonomous(Move->TimeStamp, Move->DeltaTime,
                       Move->GetCompressedFlags(), Move->Acceleration);
        Move->PostUpdate(CharacterOwner, PostUpdate_Replay);
    }

    CharacterOwner->bClientUpdating = false;
    return ClientData->SavedMoves.Num() > 0;
}

这段代码就是整个 CMC 网络同步的灵魂。用大白话把它说清楚:

循环里每跑一条 Move 都在做同一件事——让 CMC 把这一条 Move 当成是"现在正在发生的输入"再执行一次

PrepMoveFor 负责把这条 Move 的起始状态(位置、速度、底座、动画进度)灌回 CMC,等于"把场记板调回这一帧开跑之前的样子";然后 MoveAutonomous 用这条 Move 记录的输入(Acceleration + CompressedFlags)跑一次物理模拟,算出新的位置;最后 PostUpdate(PostUpdate_Replay) 把新算出来的结果回填到这条 Move 上,覆盖之前那一次预测的结果。

下一条 Move 开始时,状态就自动承接上这条的新结果,依次推进到队尾。

中间那个 bClientUpdating = true 的标记很有意思——它是给整个 UE 系统的一个全局信号,意思是**“我现在是在重跑过去的 Move、不是在处理新的一帧”**。

其他系统(比如相机 Shake、声音播放、粒子发射)看到这个标记就知道不该对这些事件再响应一次,否则一次 Replay 跑 10 条 Move 就会叠加出 10 份震屏和音效,玩家会觉得很怪。

整个 Replay 做完之后,再回到第 1 节那个思路对一下:

  1. Step 2 的 SetWorldLocation 把角色拉回服务器对"某个过去时间点"的判决位置
  2. 本函数的循环从那个时间点开始,把客户端在此之后做的所有还没被确认的操作重新跑一遍
  3. 跑完之后角色位置就是"服务器判的起点 + 你后续所有操作的模拟结果"——既权威又最新

这里有一个关键的直觉:如果服务器和客户端物理参数完全一致(3.3 和 4.2 节反复强调过这点),那么用同样的输入从同样的起点跑出来的结果应该是一样的。

也就是说,绝大多数时候 Replay 完角色位置还是你原来预测的那个位置,玩家根本感觉不到刚刚发生过纠正

只有当两端状态真的不一致时(通常是客户端被作弊软件改过参数、或者网络抖动太大导致两端对同一输入的解释有细微偏差),玩家才会看到一个小的视觉跳跃。

这就是为什么 CMC 名义上叫"客户端预测 + 回滚",但回滚的视觉成本在 99% 的情况下是零——"回滚"听起来很吓人,实际用起来非常丝滑。

前 6 节把"自己的角色"整条链路讲完了,下面第 7 节切到"别人的角色",第 8~9 节补齐优化和细节。


7. 顺带一提:别人家的角色(SimulatedProxy)是怎么同步的

前六节都在讲"自己的角色"(AutonomousProxy)怎么和服务器对账。但你屏幕上还有其他玩家的角色,它们不用预测——因为你根本没有它们的输入。

这一节的核心是理解"为什么别人的角色走完全不同的路"。原因只有一个——你这台客户端压根拿不到别人按了什么键,没有输入就没法预测、也没法 Replay。

所以别人的角色在你这边完全是个"被动接收者":服务器把它们的最新状态广播过来,你这边老老实实显示出来就行。这一节要讲的就是"状态怎么广播过来"和"怎么让它看上去不是一卡一卡的"。

7.1 数据不是靠 RPC 发的,是靠属性复制

AutonomousProxy 走自定义 RPC,但 SimulatedProxy 走的是普通的属性复制——也就是服务器上的 AActor/ACharacter 身上挂着几个加了 UPROPERTY(Replicated) 的字段,UE 的网络层在 TickFlush 时自动把有变化的字段打包广播给所有观察这个角色的客户端:

  • AActor::ReplicatedMovementFRepMovement 结构):Location / Rotation / Velocity;
  • ACharacter::ReplicatedBasedMovement:角色站在动态平台上时存相对平台的位置;
  • ACharacter::ReplicatedMovementMode:当前是 Walk / Fall / Swim 等。

用属性复制而不是 RPC 的原因也很朴素——RPC 适合"离散的事件",属性复制适合"连续的状态"

别人的角色位置是一个一直在变的连续量,每帧都发变化才有意义,UE 的属性复制系统天然就是按"定期检查哪些字段变了、有变化才发"的节奏工作的,和这个需求完美契合。

另外属性复制还自带"相关性裁剪"——离你很远的其他玩家服务器根本不会给你发,这是 RPC 做不到的。

数据到达客户端后会触发 OnRep_ReplicatedMovement / OnRep_ReplicatedBasedMovement 这类回调,然后统一交给 SimulatedTick(1677 行)消费。

SimulatedTick 的职责就是"把收到的新状态应用到角色身上"——注意它完全不调 PerformMovement,不跑物理、不处理碰撞、不产生 SavedMove。它最主要的动作就是下面这个"双位置平滑"。

7.2 怎么避免瞬移感——Mesh Offset 双位置平滑

这里得先把问题讲清楚——帧率差导致的瞬移感

服务器通常以 30Hz 或更低的频率广播 SimulatedProxy 的状态(因为几十个玩家每个都要广播给所有其他玩家,带宽撑不住太高频),但客户端自己是 60 甚至 120fps 渲染。也就是说,客户端每渲染 4 帧才收到一次新位置更新——如果收到就直接 SetLocation,画面上看到的就是别人每隔几帧"啪"地瞬移一下,像幻灯片一样。

UE 的解法很聪明,也很值得细品——把"碰撞用的位置"和"眼睛看到的位置"拆成两个对象独立处理

  • Capsule(角色的碰撞胶囊)收到新位置就立刻按权威位置到位——反正碰撞、Overlap、命中检测这些逻辑层面的事情只看碰撞体,玩家眼睛又看不到 Capsule;
  • Mesh(角色的骨骼网格,也就是玩家真正看到的那个模型)不立刻跟过去,而是记下"我现在离 Capsule 差多远"这个 Offset,然后每帧把这个 Offset 慢慢衰减到零。

这样一来碰撞判定始终是瞬时跟上权威位置的(保证了网络同步的正确性),但玩家眼睛看到的那个 Mesh 是平滑地朝着 Capsule 滑过去的(保证了视觉流畅)。两个互相矛盾的需求就这么被分摊到两个独立的对象上了。

实现(SmoothClientPosition_Interpolate,7869 行):

// 收到新位置时:Capsule 立刻到位
UpdatedComponent->SetWorldLocation(ServerLoc);

// 同时记下 Mesh 相对 Capsule 的当前视觉偏移
ClientData->MeshTranslationOffset = OldMeshLoc - NewCapsuleLoc;

// 每帧衰减这个 Offset 到 0(视觉上 Mesh 慢慢追上 Capsule)
ClientData->MeshTranslationOffset *= (1.f - DeltaSeconds / SmoothLocationTime);

这三行代码的工作流程用大白话说就是:Capsule 瞬移过去的同时,量一下 Mesh 现在离它多远,把这段距离记住;后面每帧都把这段距离乘上一个小于 1 的系数让它慢慢变小,直到 Mesh 和 Capsule 完全对齐为止

渲染时 Mesh 的实际绘制位置 = Capsule 位置 + MeshTranslationOffset,所以 Mesh 是"滞后但平滑地"跟着 Capsule 走。

Capsule 立刻到位保证碰撞/Overlap 算的是权威位置;Mesh 带 Offset 滑过去保证视觉流畅——这俩需求就这么分开满足了。

这个设计不只用在看别人,你自己的角色被纠正时其实也能用同样的机制——Capsule 瞬移到纠正位置、Mesh 带偏移滑过去,避免视觉上的硬跳。

枚举 ENetworkSmoothingModeEngineTypes.h:925只有 3 种

模式 行为
Disabled 不平滑,Mesh 跟着瞬移
Linear 线性插值,固定时间归零
Exponential 指数衰减,离目标越远衰减越快(默认值,视觉最好)

为什么 Exponential 是默认值也很好理解——线性插值在"刚收到新包"那一刻衰减最慢,一旦包到得晚一点 Mesh 会明显落在后面。

而指数衰减"距离越远衰减越快",刚收到新位置时追得最急,越接近目标时越慢,符合人眼对"自然移动"的预期。


8. 带宽优化:Move Combining、Dual Move、Old Move

第 3 节讲过客户端每帧都产生一个 Move,但并不是每帧都发——因为带宽撑不住。这一节把散落的三个优化机制归到一起讲。

8.1 Move Combining(合并)

场景:玩家按住 W 直线跑,连续 10 帧 Move 长得几乎一样。发 10 个浪费,不如合成一个。

CanCombineWith 决定能不能合并,主要判据:

  • 两个 Move 加速度方向点积AccelDotThresholdCombine(方向接近);
  • MaxSpeed、MovementMode、Crouch 状态一致;
  • bForceNoCombine 没被设(跳跃/落地会置这个强制不合并)。

合并动作:把 DeltaTime 加起来,把起点回退到早的 Move 的起点,重新模拟一次——就像这两帧从没分开过

8.2 PendingMove 与 Dual Move

ReplicateMoveToServer 最后判断"这帧要不要发",节流逻辑:

if (SecondsSinceLastMoveSent < NetMoveDeltaSeconds) {
    ClientData->PendingMove = NewMovePtr;   // 扣住,不发
    return;
}

下一帧两种情况:

  • 能合并:走 Combining,只发一个合并后的 Move;
  • 不能合并(比如突然改方向):PendingMove 不能丢,于是把它和新 Move 一起打包(容器里 bHasPendingMove = true)——这就是 “Dual Move”。

服务器收到后按时间顺序先跑 PendingMove 再跑 NewMove(4.1 节的调用顺序)。

8.3 Old Move:丢包兜底

ServerMove RPC 是 unreliable,会丢包。如果刚改变方向的那一帧 Move 丢了,服务器会继续按旧方向模拟,误差越积越大,最后玩家看到一个大纠正——体验就是"被拉回去"。

解决:IsImportantMove 判断某个 Move 是否"重要",判据就是加速度变化是否大

bool IsImportantMove(const FSavedMovePtr& LastAckedMove) const
{
    // 和上次 Ack 的加速度差异大 = 玩家做了重大输入变化 = 重要 Move
    return (AccelDelta.SizeSquared() > FMath::Square(AccelMagThreshold)) ||
           (FVector::DotProduct(AccelNormal, LastAckedMove->AccelNormal) < AccelDotThreshold);
}

ReplicateMoveToServer 会找到最老的未 Ack 重要 Move,塞进每个包一起发。相当于给重要 Move 加了冗余重发,上一个包丢了下一个包里还在。

为什么不重发所有 Move?因为普通 Move 丢了没事(下一帧的新 Move 隐含覆盖)——只有关键转向时刻丢包才会累积大误差。

8.4 Packed RPC:扩展友好

4.26 之前每种组合一个 RPC,参数列表巨长。4.26+ 重构成一个 Packed RPC,所有东西塞进一个 FCharacterServerMovePackedBits(就是个 TBitArray)。

现在要扩展移动属性,一个 RPC 都不用加,只要继承 FCharacterNetworkMoveData(Container) 加字段、自定义 Serialize,在 CMC 初始化时 SetNetworkMoveDataContainer 替换容器即可(第 11 节有例子)。

CVar p.NetUsePackedMovementRPCs 默认 1 = 新路径。旧路径被 DEPRECATED_CHARACTER_MOVEMENT_RPC 宏标记但保留着,方便老项目渐进迁移。


9. TimeStamp、反作弊、服务器兜底这些细节

这些不是主流程但少了会出问题。

9.1 TimeStamp 是啥

CurrentTimeStamp 是个 float,代表"客户端已模拟到游戏内第几秒"。每帧 += DeltaTime。客户端把它写进 Move 发给服务器用于:

  • 判断包是不是重复/过期 → 丢弃;
  • 算 DeltaTime(这次减上次);
  • Ack 时原样回发,客户端据此在 SavedMoves 里找到对应 Move。

9.2 TimeStamp 重置

float 精度有限,跑久了丢小数位。所以定义了阈值:

// CMC.cpp:675
MinTimeBetweenTimeStampResets = 4.f * 60.f;   // 4 分钟

到 4 分钟客户端把 TimeStamp 重置回 0。服务器的 VerifyClientTimeStamp 看到"新 TimeStamp 远小于旧 TimeStamp"会识别这是合法重置。

9.3 DeltaTime 钳位防加速挂

作弊者可能发"走了 10 秒"这种 DeltaTime 来加速。服务器钳位:

// GameNetworkManager.cpp:34
MaxMoveDeltaTime = 0.125f;   // 125ms

客户端发再大也只按 125ms 模拟一次——加速挂硬截断

9.4 Time Discrepancy:累积时间差

单次被钳位还不够,作弊者可以每次发"刚好没超 125ms 的包"但发得比正常快。所以服务器还维护累积时间戳:

// 每次接受 Move 时
ServerData->ServerAccumulatedClientTimeStamp += DeltaTime;
// 同时服务器知道自己真实经过了多少时间
// 两者差值持续增大 → 触发 OnTimeDiscrepancyDetected 回调

项目可以重写 OnTimeDiscrepancyDetected(CMC.h:2408)来踢人或降速。阈值由 AGameNetworkManager::MovementTimeDiscrepancy* 配置。

9.5 ForcePositionUpdate:客户端断线兜底

反过来:客户端不发包怎么办?服务器如果啥也不做角色会冻住。ForcePositionUpdate(8198 行)会在服务器 Tick 发现很久没收到包时自己替它模拟一下,用最近的 Acceleration 推进位置。等客户端恢复会产生一次大纠正同步回来。


10. 把整条链路串起来看一遍

前面九节拆开讲了,这节合起来看一眼全局。

10.1 正常流(客户端预测正确)

【Client 帧 N】
  TickComponent
    └─ ControlledCharacterMove
       └─ ReplicateMoveToServer
          ├─ [本地] PerformMovement          ★立刻移动★
          ├─ SavedMoves.Push
          └─ CallServerMovePacked
               └─【unreliable RPC】── ServerMovePacked ──→ 服务器

【Server 帧 M】
  ServerMovePacked_Implementation
    └─ ServerMove_HandleMoveData
       └─ ServerMove_PerformMovement
          ├─ MoveAutonomous → PerformMovement   ★服务器重放★
          └─ ServerMoveHandleClientError
             └─ 差距小 → PendingAdjustment.bAckGoodMove = true

【Server TickFlush(同帧帧末)】
  PC::SendClientAdjustment
    └─ CMC::SendClientAdjustment → ServerSendMoveResponse
         └─【unreliable RPC】── ClientMoveResponsePacked ──→ 客户端

【Client 帧 K】
  ClientMoveResponsePacked_Implementation
    └─ ClientHandleMoveResponse
       └─ IsGoodMove == true → ClientAckGoodMove_Implementation
          └─ AckMove → SavedMoves 队头清理

整个过程玩家完全感觉不到网络的存在——本地立刻动,服务器默默认可,队列默默清理。

10.2 纠正流(客户端预测错误)

【Server】 ServerMoveHandleClientError
          └─ 差距超阈值 → PendingAdjustment.bAckGoodMove = false(含权威 NewLoc/NewVel)

【Server TickFlush】 → ClientMoveResponsePacked

【Client 收到纠正】
  ClientAdjustPosition_Implementation
    ├─ AckMove(TimeStamp)                  // 清掉对应 Move 及之前的
    ├─ SetWorldLocation(NewLoc)            ★瞬移到权威位置★
    ├─ Velocity = NewVel
    └─ bUpdatePosition = true              ★只打标记★

【Client 下一帧 Tick 开头】
  ClientUpdatePositionAfterServerUpdate     ★真正 Replay★
    └─ for move in SavedMoves:
         MoveAutonomous(move.TimeStamp, move.DeltaTime, move.GetCompressedFlags(), move.Acceleration)

三句话总结:

  1. 正常时:客户端自己跑 → 发包 → 服务器认可 → 清队列;
  2. 错了时:客户端自己跑 → 发包 → 服务器判错 → 客户端瞬移到权威位置 → 重放未确认 Move;
  3. 别人看你时ReplicatedMovement 属性同步 + Mesh Offset 平滑,和上面两套不沾边。

11. 如果要自己扩展 CMC 加字段,大概是怎么个套路

11.1 只加布尔意图(比如"冲刺")——最省事,零额外带宽

// 1) SavedMove 子类加字段 + 塞进 Custom Flag
class FSavedMove_MyChar : public FSavedMove_Character
{
    uint8 bSavedWantsToSprint : 1;

    virtual uint8 GetCompressedFlags() const override
    {
        uint8 Flags = Super::GetCompressedFlags();
        if (bSavedWantsToSprint) Flags |= FLAG_Custom_0;       // ★
        return Flags;
    }

    virtual void SetMoveFor(ACharacter* C, ...) override      // 本地执行前记录状态
    {
        Super::SetMoveFor(C, ...);
        bSavedWantsToSprint = MyCMC->bWantsToSprint;
    }

    virtual void PrepMoveFor(ACharacter* C) override           // Replay 前灌回 CMC
    {
        Super::PrepMoveFor(C);
        MyCMC->bWantsToSprint = bSavedWantsToSprint;
    }
};

// 2) CMC 子类:服务器侧从 Flag 解回意图
class UMyCMC : public UCharacterMovementComponent
{
    uint8 bWantsToSprint : 1;

    virtual void UpdateFromCompressedFlags(uint8 Flags) override
    {
        Super::UpdateFromCompressedFlags(Flags);
        bWantsToSprint = (Flags & FSavedMove_Character::FLAG_Custom_0) != 0;   // ★
    }
};

关键三处:GetCompressedFlags 编码 → UpdateFromCompressedFlags 解码 → SetMoveFor/PrepMoveFor 记录/灌回状态。其他都是样板代码。

11.2 加连续值字段(比如"剩余体力")

一个布尔就一个 bit,但 float 装不下 Flag,要扩展 Packed 容器:

// 1) FCharacterNetworkMoveData 子类加字段 + 自定义 Serialize
struct FMyNetworkMoveData : public FCharacterNetworkMoveData
{
    float Stamina;

    virtual bool Serialize(UCharacterMovementComponent& CMC, FArchive& Ar, UPackageMap* PM, ENetworkMoveType T) override
    {
        Super::Serialize(CMC, Ar, PM, T);
        Ar << Stamina;                                         // ★
        return !Ar.IsError();
    }
};

// 2) 在 CMC 构造里替换默认容器
UMyCMC::UMyCMC() { SetNetworkMoveDataContainer(MyMoveDataContainer); }

要服务器回发字段(纠正时告诉客户端正确 Stamina)对称扩展 FCharacterMoveResponseDataContainer 即可。

11.3 扩展 CMC 的三个层次总结

扩展需求 要做的事
加 1~4 个布尔意图 重写 GetCompressedFlags / UpdateFromCompressedFlags,一个 bit 搞定
连续值/向量字段 继承 FCharacterNetworkMoveData(Container),自定义 Serialize
服务器要回发新字段 同上再扩展 FCharacterMoveResponseDataContainer

12. 附录:关键源码行号速查表

12.1 CharacterMovementComponent.cpp

功能 行号
TickComponent(按 Role 分派) 1457
SimulatedTick 1677
SmoothClientPosition_Interpolate(Mesh Offset 衰减) 7869
ClientUpdatePositionAfterServerUpdate(★ Replay 入口) 8081
ForcePositionUpdate(服务器兜底) 8198
ReplicateMoveToServer(★ 客户端预测主入口) 8375
CallServerMovePacked 8604
ControlledCharacterMove(Authority/Autonomous 分派) 5981
ServerMovePacked_ServerReceive 9313
ServerMove_HandleMoveData 9360
ServerMove_PerformMovement(★ 服务器权威重放) 9399
ServerMoveHandleClientError(★ 误差检测) 9604
ServerCheckClientError 9914
ClientHandleMoveResponse 10315
SendClientAdjustment(★ 服务器回包主入口) 10395
ClientAdjustPosition_Implementation(★ 客户端应用纠正) 10551
ClientAckGoodMove_Implementation 10911
FNetworkPredictionData_Client_Character::AckMove 11782
NetworkMinTimeBetweenClientAckGoodMoves = 0.10f 583
MinTimeBetweenTimeStampResets = 4.f * 60.f(4 分钟) 675
NetworkSmoothingMode = Exponential(默认) 580

12.2 CharacterMovementComponent.h

功能 行号
ServerMoveBitWriter 成员变量 2627
FSavedMove_Character 类定义 2837
CompressedFlags 枚举 2981
FNetworkPredictionData_Client_Character 定义 3021
SavedMoves / FreeMoves / PendingMove / LastAckedMove 3042~3045
OnTimeDiscrepancyDetected 2408

12.3 CharacterMovementReplication.h

功能 行号
FCharacterNetworkMoveData(★ 客户端→服务器数据) 93
FCharacterNetworkMoveDataContainer 163
FCharacterServerMovePackedBits 230
FClientAdjustment(★ 服务器→客户端纠正数据) 251
FCharacterMoveResponseDataContainer 293

12.4 其他

功能 文件 行号
ACharacter::ServerMovePacked RPC 声明 Character.h 270
ACharacter::ClientMoveResponsePacked RPC 声明 Character.h 279
ENetworkSmoothingMode(3 种模式) EngineTypes.h 925
APlayerController::SendClientAdjustment PlayerController.cpp 1667
MaxMoveDeltaTime = 0.125f GameNetworkManager.cpp 34

12.5 常用 CVar 速查

CVar 默认 作用
p.NetUsePackedMovementRPCs 1 用 4.26+ Packed RPC 路径
p.NetEnableMoveCombining 1 启用 Move Combining
p.NetShowCorrections 0 屏幕可视化纠正(红/绿胶囊)
p.NetForceClientAdjustmentPercent 0 按百分比强制纠正(压测)
p.NetForceClientServerMoveLossPercent 0 人为丢包率(测试)

写在最后:与 GAS 客户端预测的对照

CMC 的套路其实就是 Client-Side Prediction + Rollback 经典范式,GAS 也是同思路的另一种实现。对照一下:

维度 CMC GAS
预测粒度 每 Tick 一次移动模拟 每次 Ability 激活
操作记录 FSavedMove_Character FPredictionKey + 回调委托
时间标识 float TimeStamp uint16 PredictionKey
回滚方式 瞬移回权威位置 + Replay SavedMoves Undo 预测的 Cue / Effect / Attribute
数据通路 专用 ServerMovePacked RPC Ability RPC + AttributeSet 属性复制

共同核心理念

  1. 客户端预测的本地状态要记下来;
  2. 服务器权威执行,决定哪些预测是对的;
  3. 通过"Ack + Replay"或"Undo + Re-apply"把客户端状态拉回与服务器一致。

本仓库 docs/UE_DS_GAS_客户端预测与回滚.md 对 GAS 侧有更详细分析。


版本:v1.2(精简代码版,只留核心片段)
对应引擎:Unreal Engine 5.3
最后更新:2026-05-02

Logo

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

更多推荐