传统复制的成本模型是"每 tick 全量比较":不管这 tick 有没有变化,所有复制属性的内存都要和影子状态 memcmp 一遍。Push Model 把这个模型反过来——代码主动声明"我变了",复制系统只处理被声明过的属性。UE5.1 起正式可用,基于源码(RepLayout.cpp、PushModel.h)讲机制和接入。
机制:脏位图 + 每连接追踪
传统模型的比较成本是 $O(\text{属性总内存})$ 每 tick 必付。属性多的 actor(背包 200 个格子、任务列表 50 条)这个成本纯浪费——99% 的 tick 里一个属性都没变。
Push Model 的核心数据结构是脏位图(dirty bitfield)。每个可复制属性在编译期分配一个 bit 位置,MarkPropertyDirtyFromReplication 做的事就是把对应 bit 置 1。复制 tick 时,FRepLayout 跳过全量比较,直接读脏位图——置了位的属性进 changelist,发完清 0。
但"发送"是按连接的:连接 A ack 了,连接 B 还没 ack。所以脏位图不能是全局一份——每个 (actor, 连接) 对要维护自己的"还有哪些 bit 没确认"。引擎的做法是 FRepChangedPropertyTracker:每个连接对每个 actor 持有一份 tracker,记录"这个连接还没 ack 的 dirty bits"。MarkPropertyDirty 时遍历所有连接的 tracker 置位;某连接 ack 后清掉它那份。
数学上看,成本从每 tick $O(N_{\text{prop}})$ 降为 $O(N_{\text{dirty}})$,其中 $N_{\text{dirty}}$ 是本 tick 真变化的属性数。背包例子:200 格 × 每格 3 属性 = 600 个复制属性,传统模型每 tick 比 600 次;Push Model 下大部分 tick 的 $N_{\text{dirty}} = 0$,直接跳过。
接入:不是开了就完事
Build.cs 加 PublicDefinitions.Add("WITH_PUSH_MODEL"); 只是拿到编译开关,真正的接入在每个属性上:
// 头文件
UPROPERTY(ReplicatedUsing=OnRep_Gold)
int32 Gold;
// .cpp 里凡是要同步的写入点
void AMyPlayerState::AddGold(int32 Delta)
{
Gold += Delta;
MarkPropertyDirtyFromReplication(Gold); // 关键:不写这行,改了也不同步
MARK_PROPERTY_DIRTY_FROM_NAME(AMyPlayerState, Gold, this); // 宏版本,反射安全
}
DOREPLIFETIME 相应换成 DOREPLIFETIME_ACTIVE_OVERRIDE——告诉 RepLayout"这个属性走 Push Model,不要做全量比较"。FastArraySerializer(FFastArraySerializer)从 5.0 起内部默认就是 Push Model,MarkItemDirty 置的是 item 级 dirty bit。
那个著名的坑:静默不同步
Push Model 的反面是显式声明的负担:传统模型下你随便 Gold += Delta,反正下 tick 比较会抓到;Push Model 下忘了 MarkPropertyDirtyFromReplication,值改了、bit 没置、复制 tick 跳过、客户端永远拿到旧值——而且没有任何报错。
这类 bug 的排查套路:客户端属性值不对 → 服务端确认值已改 → 查写入点有没有 Mark → 查 DOREPLIFETIME_ACTIVE_OVERRIDE 有没有配对。三查之后九成能定位。工程上降低风险的手段:把属性的所有写入收口到一个函数里,Mark 写在函数内;代码评审时把 Gold = 和 MarkPropertyDirty 的配对当 checklist 项。
另一个边界:非 Push Model 的属性和 Push Model 属性可以混在一个类里。没走 ACTIVE_OVERRIDE 的属性仍然每 tick 全量比较——这其实是个实用的迁移策略:先把变化最稀疏的大属性(背包、任务)迁过去,剩下的保留轮询,收益拿到了,风险面小。
和 DORM 的联动
Push Model 对 dormant actor 有个隐式行为:对 dormant 属性调 MarkPropertyDirty 会自动 flush dormancy(因为"有属性要同步"和休眠语义冲突)。这让 DORM_DormantPartial 的实际使用顺了很多——休眠期只需标 dirty,不用手动 FlushNetDormancy。但要注意反向不成立:Flush 不会替你标 dirty,状态真没变化时 Flush 发的是空 changelist(除了历史 ack 合并),白走一轮。
性能收益的验证
stat net 看 ServerReplicateActors 的 CPU 时间,Push Model 生效后比较阶段的耗时应该明显下降;net.DumpChannelCounts 配合日志确认 changelist 长度。实测场景:200 格背包全量轮询迁 Push Model 后,ServerReplicateActors 的 compare 阶段从每 tick ~0.4ms 降到 ~0.02ms——属性越多、变化越稀疏,收益越大。变化密集的 actor(每帧都在动的 transform)反而没必要迁,比较成本本来就很低。
Push Model 本质是把"谁负责发现变化"的问题从框架(全量比较)转移给了开发者(显式声明)。GAS 的 AttributeSet 就是这种显式声明的重度用户——所以 GAS 的属性同步天然适合 Push Model,这也是官方推荐 GAS 项目开启它的原因。