技术博客

网络复制的属性同步协议:FRepLayout 增量序列化全流程

第 69 / 100 篇 · 2025-12-11 · ue5 · 网络复制Replication源码分析

UE 的属性复制看起来是"标记 Replicated 就自动同步",底下实际是一套带历史状态的增量二进制协议。基于 UE5.3 源码(Engine/Source/Runtime/Engine/Private/RepLayout.cpp、DataChannel.cpp)把服务端一次 tick 里属性从内存到网络的完整路径走一遍。

静态描述:FRepLayout 是怎么建成的

每个类的复制属性在加载时被编译成 FRepLayout(RepLayout.h)。DOREPLIFETIME 宏注册的是 FLifetimeProperty(属性 index + 条件),而 FRepLayout::InitFromObjectClass 会做更多事:

  • 遍历 UClass 的 FProperty 链,把每个 Replicated 属性编成一条 FRepLayoutCmd(记录偏移、类型、影子状态的相对位置);
  • 给每个属性建影子状态(shadow state)模板——FRepState 里按 layout 复制一份对象内存的拷贝,作为"上次发送的值"的基准。

影子状态是整套协议的支点:增量同步靠"当前值和影子值逐字节比"来决定哪些属性脏了。值得注意的比较方式——不是逐个属性调 Equals,而是大量属性走 memcmp 级别的逐字节比较(FRepLayoutCmd 按类型分派,POD 直接比内存块)。这意味着比较成本正比于对象内存大小而不是属性个数,也是 Push Model 出现前大对象复制 CPU 开销的主要来源。

服务端 tick:比较、序列化、打包

每个网络 tick,UNetDriver::ServerReplicateActors 遍历相关 actor,对每个 actor 的每个连接走 UActorChannel::ReplicateActor → FObjectReplicator::ReplicateProperties。核心在 FRepLayout::ReplicateProperties:

// RepLayout.cpp 流程的伪码还原(UE5.3)
for (每条 ChangedProperty)
{
    CompareProperties(当前对象数据, ShadowState);   // 逐 cmd 比较
    差异的 cmd 记录 handle → Changelist
}
SerializeChangedProperties(Changelist, Writer);    // 只序列化差异属性

注意它分两遍:先比较产出一个 changelist(哪些属性变了),再序列化只写变化的属性。序列化时每个属性前写 handle(不是名字——带宽优化的关键,handle 是整形索引,位打包后每个属性头只有几个 bit;如果按属性名字符串标识,一个头就是几十字节,带宽差一个数量级),值按 FRepLayoutCmd 的类型做 quantize 或原生写入。比如 FVector_NetQuantize 在这里被压成定点数。

量化的账可以算一下:原始 FVector 三个 float 共 12 字节;FVector_NetQuantize 把每个分量乘 100 后取整,误差上界 $0.005$ 个 UE 单位(1 单位 = 1 cm,即 0.05 mm 精度),换来的是定点整数可以做变长/固定位数打包。位置类属性的带宽大户身份就是这么被压下去的。自己写结构体复制时,NetSerialize 里做自定义 quantize 是同一套思路:先想清楚业务能容忍的误差界,再决定位数。

写完属性包后,数据进入 FOutBunch 挂到 channel 的发送队列。Bunch 是 UE 网络层的"数据包逻辑单元",带 channel index 和标志位(bOpen/bClose/bReliable 等),UNetConnection 把多个 channel 的 bunch 拼进一个 UDP 包(受 MTU 限制,MAX_PACKET_SIZE 默认分片逻辑在 FNetConnection::SendRawBunch)。

历史状态与丢包重传

UDP 会丢包,而增量协议的天敌就是丢包——客户端收到"第 5 次变更"但丢了"第 4 次",状态就对不上了。先看教科书方案为什么不适用:TCP 式的可靠传输丢包要重传原包,Go-Back-N 更是丢一个回退一串。对状态同步来说这是纯浪费——旧状态往往已经被更新的状态覆盖了,重传第 4 次变更的内容时,第 5 次已经把它改写了一半。

UE 的解法在 FRepChangelistState 和 FSendingRepState:

  • 服务端为每个 (channel, 对象) 维护发送历史环形缓冲(NumHistoryItems,默认几十条),每条历史是一次完整 changelist + 对应的影子快照;
  • Bunch 头里带 ChangelistHistoryIndex,客户端 ack 时回告"我收到过第 N 次";
  • 下次发送时,从客户端确认过的那条历史对应的影子状态开始比较——ack 过的增量全部合并进新的 changelist。

这套机制的本质是把 ARQ 的停等语义改造成了流水线语义:丢包不重传旧包,而是把丢失的变更合并进下一次增量重算基线。变更天然聚合、不阻塞、不重复,代价是每个 (channel, 对象) 对要存几十份影子快照的内存,以及环形缓冲被高丢包率冲掉时退化为全量发送。实现细节在 FRepLayout::SendProperties 里选基线的逻辑:LastAckedHistory 找不到(历史环形缓冲被冲掉)时 fallback 到全量发送。所以网络极差时你会看到复制流量突然上涨——全量兜底被触发了。

历史环形缓冲:ack 定基线,丢失的变更并入下一次增量 服务端发送历史(FSendingRepState,每连接每对象一份) H0 快照+变更 H1 H2 ✗ 丢包 H3 ✓ ack H4 ... 客户端 ack 到 H3 下一包基线 = H3 影子快照 changelist = diff(当前对象, H3) → H2 丢失的变更自动并入 环形缓冲被冲掉(高丢包 + 高频变更)→ 找不到 ack 基线 → 退化全量发送 net pktloss=10 下复制流量的尖峰就是这个兜底在干活

客户端侧 FRepLayout::ReceiveProperties 反过来:按 handle 找到 cmd,反序列化写进对象内存,然后按 LifetimeProperty 查这个属性有没有 RepNotifyFunc,有就调 CallRepNotifies——这就是 OnRep_Xxx 的触发点。所以 OnRep 只在属性真的随 bunch 到达时才触发,本地 Set 不会触发,这个行为差异写预测代码时必须刻进肌肉记忆。

RepNotify 的"比较语义"坑

服务端在比较阶段对 RepNotify 属性有特殊处理:如果值没变但被标了 dirty(比如主动 MarkPropertyDirty),5.x 里可以选择仍发送(REPNOTIFY_Always),客户端会收到 OnRep 调用。默认 REPNOTIFY_OnChanged 则是客户端也做比较,值没变不触发。实际项目里"服务端 Set 了相同值,客户端 OnRep 没跑"导致状态机卡住,九成是这里。

条件复制与 Push Model

条件(COND_OwnerOnly 等)在比较之前过滤:FRepLayout::FilterChangeList 按连接的 GetConnectionRepCondition 把不满足条件的属性剔掉。Owner 判定走 AActor::GetNetConnection 的归属链。

传统模型是每 tick 全量比较(polling),5.1+ 的 Push Model(WithPushModel)反过来了:代码主动调 MarkPropertyDirtyFromReplication 时才把这个属性登记进 dirty 集,tick 时只处理登记过的。成本模型从 $O(\text{属性总内存})$ 每 tick 必付,变成 $O(\text{本 tick 真变化的属性})$。对属性多但变化稀疏的 actor(背包、任务列表),CPU 收益实测很明显——比较阶段的逐 cmd 遍历被砍成了只遍历 dirty set。代价是忘了 Mark 的属性会静默不同步,这类 bug 不崩不报警,只表现为客户端偶尔看到旧值,排查时要先怀疑 Push Model 标记遗漏。GAS 的属性同步就是 Push Model 的重度用户。

优先级、relevancy 与这套协议的配合

属性同步只解决"怎么编包",解决"给谁发、多久发一次"的是上层调度:AActor::GetNetPriority(按距离和 NetUpdateFrequency 算分)决定 tick 内谁先复制,IsNetRelevantFor 决定这个连接要不要发。两者都在 changelist 比较之前发生——不相关的 actor 连比较都不做,所以大世界省 CPU 的第一手段是 dormancy + relevancy,而不是去优化序列化本身。ReplicationGraph 把 relevancy 从每连接全量遍历改成空间分桶缓存,百人对局必上。

看协议的实操

  • 抓包分析不用 net.DebugBunch(太吵),推荐 PacketHandler 层抓 UDP 后自己按 bunch 格式解,或者干脆开 LogNetTraffic。
  • 想验证增量行为:故意丢包(net pktloss=10)观察流量尖峰,就是全量兜底在干活。
  • 自定义结构体复制走 TStructOpsTypeTraits 的 NetSerialize,可以在里面做自己的 quantize——FRepMovement 就是这套机制的教科书案例。

这套协议设计里最值得学的是"用历史环形缓冲把 ARQ 的停等语义变成流水线语义":丢包不阻塞、不重复,变更自动聚合。理解了它,Iris(5.3 实验的新复制系统)里那些 fragmentation 和 state 协议的变化读起来会顺很多。