技术博客

ReplicationGraph:按空间分桶缓存的相关性计算

第 75 / 100 篇 · 2025-06-02 · ue5 · 网络复制ReplicationGraph大世界

百人对局(BR、大逃杀类)的复制瓶颈不在序列化,在相关性计算:ServerReplicateActors 每 tick 要回答"这 100 个连接各自 relevant 哪些 actor",默认实现是全量遍历所有 actor × 所有连接,复杂度 $O(N_{\text{actor}} \times N_{\text{conn}})$。1000 actor × 100 连接 = 每 tick 10 万次 IsNetRelevantFor 调用。ReplicationGraph 插件(Engine/Plugins/Runtime/ReplicationGraph)把这个问题重构了一遍。基于 UE5.3 源码梳理。

核心思路:把相关性变成缓存

默认模型的浪费在于相邻 tick 的相关性结果几乎不变——玩家移动是连续的,本帧 relevant 的 actor 和上帧 95% 相同,但默认实现每帧重新算。ReplicationGraph 的思路是两层缓存:

  • actor → 哪些节点关心它:actor 注册时按类型/位置分到 graph 节点(UReplicationGraphNode),节点内部维护"我管辖的 actor"列表。
  • 连接 → 从哪些节点收列表:每个连接(UReplicationGraphConnection)维护"我订阅了哪些节点",每 tick 只向订阅的节点要 actor 列表。

相关性计算被空间分桶吸收掉:UReplicationGraphNode_GridSpatialization2D 把世界切成 2D 网格,actor 进格,连接按视锥+距离查格。查格是 $O(\text{覆盖格数})$,比逐 actor 判距离便宜一个数量级,且网格列表可以跨连接共享——同格子的两个玩家查的是同一份 actor 列表。

GridSpatialization2D:actor 进格,连接按位置查格,同格列表跨连接共享 ● actor 按位置入格 连接 P 的位置 连接 P 的 Gather 查 P 周围 3×3 格 → actor 列表 + AlwaysRelevant 节点(全图共享) + Dormancy 节点(休眠 actor) → 去重排序 → ReplicateActor 100 个连接查同一份格列表, 距离判定只算一次

节点类型与分工

ReplicationGraph 不是单一数据结构,是一组节点类的组合,项目里按需搭:

  • GridSpatialization2D:空间分桶主节点,管"距离相关"的 actor。支持动态 actor(每 tick 重分格)和静态 actor(分一次格后休眠)。
  • AlwaysRelevantNode:管 bAlwaysRelevant 的 actor(GameState、PlayerState)。列表全连接共享,每 tick 零计算。
  • DormancyNode:管 dormant actor。actor 休眠时移到这里,Flush 时移回空间节点。
  • AdaptiveReplayNode / AlwaysRelevantStreamingLevelNode:观战回放和流送关卡的专项节点。

UReplicationGraph::RouteAddNetworkActorToNode 决定新 actor 进哪个节点,默认按 GetReplicationGraphNode 的 CDO 配置路由。自定义节点继承 UReplicationGraphNode,实现 GatherActorListsForConnection——这是整个插件的扩展点。

Gather:每 tick 到底做什么

每网络 tick,UReplicationDriver::ServerReplicateActors(ReplicationGraph 模式下的 driver 是 UReplicationGraph)对每个连接调 GatherActorListsForConnection:

  1. 空间节点按连接位置查格,返回本格 actor 列表;
  2. 各专项节点追加自己的列表;
  3. 去重、按优先级排序;
  4. 逐个走 ReplicateActor(后面就是属性同步那套协议,没变)。

成本从 $O(N_a \times N_c)$ 的逐对判断降到 $O(N_c \times N_{\text{格覆盖}})$ + $O(N_a)$ 的分格维护。1000 actor × 100 连接的场景,默认模型每 tick 10 万次距离/视锥判断;ReplicationGraph 下分格维护 1000 次,查格 100 连接 × 9 格 = 900 次,三个数量级的差距。实测 BR 项目里 ServerReplicateActors 的 gather 阶段从 3-4ms 降到 0.3ms 以内。

接入与代价

接入成本不高:.Build.cs 加 ReplicationGraph 模块,创建 UReplicationGraph 子类,在 GameMode 的 InitGame 里 SetReplicationDriverClass:

void AMyGameMode::InitGame(const FString& MapName, const FString& Options, FString& ErrorMessage)
{
    Super::InitGame(MapName, Options, ErrorMessage);
    SetReplicationDriverClass(UMyReplicationGraph::StaticClass());
}

子类的 InitGlobalActorClassSettings 里配置各类 actor 的路由规则。代价要认清楚:

  • 相关性语义变了。默认模型的 IsNetRelevantFor 是每对独立判定的;ReplicationGraph 是"格子里就是 relevant,格子外就不是"。自定义的 relevancy 逻辑(比如隔墙不可见)要么进 ConditionalActorFilter,要么接受粒度变粗。
  • 调试成本上移。默认模型的 bug 是"某个 actor 没同步";ReplicationGraph 的 bug 是"它进错格子了"或"路由规则没配"。ReplicationGraph.Debug 控制台变量族可以可视化格子归属。
  • 小项目负收益。50 个 actor、4 个连接的场景,默认模型每 tick 200 次判断,ReplicationGraph 的分格开销反而更贵。百人对局是拐点。

ReplicationGraph 是 UE 复制体系里"用缓存换计算"思路的集中体现:相关性结果天然是连续的,把逐帧重算改成增量维护,再按空间维度把"哪些 actor 可能 relevant"的答案跨连接共享。理解了它,Iris 那篇里"相关性为什么被彻底重构成 fragment"就顺理成章了。