UE5.8 Replication Graph:百人战场与开放世界的同步优化

📑 目录

UE5.8 Replication Graph:百人战场与开放世界的同步优化

从一场百人混战说起

去年有支小团队做了一款战术竞技原型:地图 8 km × 8 km,单局 100 人,地上散落着弹药、护甲、载具,玩家还能炸开房门、打爆油桶。测试第一天,服务器在 60 人同时在线时就跪了:CPU 占用飙到 95%,带宽直接顶满,客户端普遍 200 ms 以上的网络同步延迟。打开 Profiler 一看,大部分时间耗在 IsNetRelevantFor 和属性复制上——每一帧服务器都要为每个连接判断每个 Actor 是否相关,几十万次距离检测在帧预算里烧得干干净净。

这个项目的症结很典型:虚幻默认的相关性模型是按 Actor 逐连接做距离裁剪。地图上 Actor 越多、玩家越多,开销就以 Na×NcN_a \times N_c 的速度膨胀。更麻烦的是,很多 Actor 的复制频率没有区分:远处的树和脚下的枪被一视同仁地推送,带宽被大量无关信息吃掉。团队需要一种能按空间、按优先级、按连接状态批量裁剪的机制,Replication Graph 就是 UE 给出的答案。

Replication Graph 解决的核心问题

Replication Graph 在 4.20 左右进入引擎,到 UE5.8 已经是大型多人项目的标配。它的核心思想是把“服务器有哪些 Actor”和“每个客户端需要看哪些 Actor”拆成两张图:一张全局图保存所有可复制的 Actor 及其节点归属,另一张按连接维护的视图决定当前帧要发送的内容。

传统模型里,服务器每帧对每个连接调用一次 AActor::IsNetRelevantFor,相当于用双重循环扫描全量 Actor:

for (auto Connection : Connections) {
    for (auto Actor : ReplicatedActors) {
        if (Actor->IsNetRelevantFor(Connection)) {
            Replicate(Actor, Connection);
        }
    }
}

公式化表达,单帧相关性计算开销大致是:

Tclassic=NaNctrelevantT_{\text{classic}} = N_a \cdot N_c \cdot t_{\text{relevant}}

其中 NaN_a 是可复制 Actor 总数,NcN_c 是客户端连接数,trelevantt_{\text{relevant}} 是单次相关性测试的常数开销。当 NaN_a 过万、NcN_c 上百时,这一项就足以让主线程喘不过气。

Replication Graph 改用了空间索引 + 静态节点分配 + 批量收集。Actor 生成时就被分进合适的 Node,之后只在该 Node 内维护脏状态。每帧只需要遍历有变化的节点,并把结果按连接分发。开销不再按乘积关系增长,而是接近可见 Actor 数与连接数的规模。

这种结构也带来了更精细的带宽控制。我们可以在 Actor 类级别设定复制周期帧 pip_i,单帧期望带宽变成:

BoutiRΔipiB_{\text{out}} \approx \sum_{i \in R} \frac{\Delta_i}{p_i}

RR 是当前相关 Actor 集合,Δi\Delta_i 是该 Actor 单条更新的数据量,pip_i 是复制间隔帧数。把远处的静态物体 pip_i 调到 30,玩家和载具保持 1,带宽曲线就会明显平缓。

有人可能会问,Net Dormancy 不也能减少无关更新吗?确实可以,但 Dormancy 是一种消极策略:Actor 沉睡后不再检查相关性,醒来时却要重新走一遍完整流程。对于持续移动、持续被多个连接关注的对象,频繁休眠和唤醒本身就会带来新的开销。Replication Graph 则是积极策略,它事先告诉系统每个 Actor 该进哪个桶、以什么频率复制,相关性判断被内建在 Node 结构里,而不是每帧临时计算。

这种差别在百人战场上会被放大。传统模型里,一个连接每帧要遍历全部可复制的玩家、载具、掉落物、环境物件;Replication Graph 里,连接只访问自己相关的 Node,Grid Node 又只返回视野内的 Cell。遍历量的差距在对象数量上去之后非常可观。

Node 类型:三张基础牌

Replication Graph 不直接管理单个 Actor,而是通过 Node 分组。UE5.8 里常用的基础 Node 有三类,对应三种相关性语义。

FAlwaysRelevantNode

这个节点里的 Actor 对所有连接始终相关。适合 GameState、全局天气管理器、时间轴控制者这类每个人都必须知道的对象。加入这个节点要慎重,因为任何一条属性变化都会广播给全部客户端。

FConnectionAlwaysRelevantNode

只对特定连接相关的 Actor 放这里,典型代表是 PlayerController 和玩家自己拥有的 Character。服务器为每个连接维护一个独立实例,其他连接不会收到这些 Actor 的更新。把玩家自身的 HUD 状态、背包 UI 数据放在这里能避免向周围人泄露不必要信息。

FGridSpatialization2D

这是大世界性能提升的主力。它把世界按二维网格切分,每个 Cell 保存落入其中的 Actor。连接只收集自己视野附近的 Cell,从而跳过整张地图的遍历。树木、岩石、房屋、掉落的装备、静止的载具都适合塞进这个节点。

flowchart TD
    subgraph server ["Server"]
        repgraph["Replication Graph"]
        always["FAlwaysRelevantNode"]
        conn["FConnectionAlwaysRelevantNode"]
        grid["FGridSpatialization2D"]
    end
    subgraph clientA ["Client A"]
        viewA["本地视图"]
    end
    subgraph clientB ["Client B"]
        viewB["本地视图"]
    end
    repgraph --> always
    repgraph --> conn
    repgraph --> grid
    conn --> viewA
    conn --> viewB
    grid --> viewA
    grid --> viewB

架构上,Replication Graph 在服务器初始化时创建这些 Node,并把 Actor 按规则分配到不同桶里。Node 之间互不影响,各自维护内部列表,这让多线程收集和脏状态管理变得清晰。

Actor 分配到 Node

分配发生在 Actor 首次进入复制系统时。项目通常会继承 UReplicationGraph,重写 InitGlobalGraphNodesInitGlobalActorClassSettings。下面这段代码展示了一个百人战场的典型配置:

void UBRReplicationGraph::InitGlobalGraphNodes()
{
    AlwaysRelevantNode = CreateNewNode<FAlwaysRelevantNode>();
    AddGlobalNode(AlwaysRelevantNode);

    ConnectionAlwaysRelevantNode = CreateNewNode<FConnectionAlwaysRelevantNode>();
    AddConnectionNode(ConnectionAlwaysRelevantNode);

    GridSpatializationNode = CreateNewNode<FGridSpatialization2D>();
    GridSpatializationNode->CellSize = 10000.0f;
    AddGlobalNode(GridSpatializationNode);
}

void UBRReplicationGraph::InitGlobalActorClassSettings()
{
    FClassReplicationInfo CharInfo;
    CharInfo.ReplicationPeriodFrame = 1;
    CharInfo.CullDistanceSquared = 100000000.0f;
    AddClassReplicationInfo(APlayerCharacter::StaticClass(), CharInfo);

    FClassReplicationInfo PickupInfo;
    PickupInfo.ReplicationPeriodFrame = 5;
    PickupInfo.CullDistanceSquared = 16000000.0f;
    AddClassReplicationInfo(APickupItem::StaticClass(), PickupInfo);
}

关键配置项有三项:CullDistanceSquared 决定多远之外不再复制;ReplicationPeriodFrame 决定隔多少帧发送一次;DistancePriorityScale 用于在带宽紧张时调整优先级。Actor 生成后,Replication Graph 会根据这些类级别配置把它塞进对应 Node。如果某个 Actor 在运行时规则变了,也可以通过委托 GetReplicationGraphInfo 动态重定向。

flowchart TD
    A["Actor 加入复制系统"] --> B{是否 AlwaysRelevant}
    B -->|"是"| C[FAlwaysRelevantNode]
    B -->|"否"| D{是否仅对拥有连接可见}
    D -->|"是"| E[FConnectionAlwaysRelevantNode]
    D -->|"否"| F{是否需要空间裁剪}
    F -->|"大世界对象"| G[FGridSpatialization2D]
    F -->|"特殊规则"| H["自定义 Node"]

开发者容易忽略的一点是:Node 分配只看初始规则,运行时位置变化由 Node 内部处理。Grid Spatialization Node 会在 Actor 移动时自动把它从一个 Cell 挪到另一个 Cell,不需要手动重新分配。

Grid Spatialization

FGridSpatialization2D 把世界沿 X-Y 平面切成等大的正方形 Cell。Cell 边长由 CellSize 指定,通常取 5 m 到 20 m。Actor 的世界位置 (x,y)(x, y) 落到的 Cell 坐标为:

cx=xS,cy=ySc_x = \left\lfloor \frac{x}{S} \right\rfloor, \quad c_y = \left\lfloor \frac{y}{S} \right\rfloor

SS 是 CellSize。每个 Cell 内部再按 Actor 的 ReplicationPeriodFrame 分桶,这样收集时就能一次性拿到某个区域内所有待复制的 Actor。

收集阶段,服务器用连接对应的 ViewTarget 位置计算视野覆盖的 Cell 范围。若 Net Cull Distance 为 DD,则涉及的 Cell 是一个以玩家为中心、边长约 2D/S2 \lceil D / S \rceil 的方块。连接只遍历这些 Cell,而不是全图。

这种结构天然支持动态对象。Actor 移动时,Grid Node 会把它从旧 Cell 的列表里移除,再插入新 Cell。由于每个 Cell 只保存少量 Actor,插入和删除都是常数级别的轻量操作。如果项目还用了 World Partition 或 Level Streaming,可以把流送块边界和 Grid Cell 对齐,进一步减少跨边界对象的抖动。

graph LR
    P1["玩家 A 视野"]
    P2["玩家 B 视野"]
    C1["Cell 1,2"]
    C2["Cell 5,6"]
    C3["Cell 2,3"]
    P1 --> C1
    P1 --> C3
    P2 --> C2
    C1 --> A1["树木/补给箱"]
    C2 --> A2["载具"]
    C3 --> A3["武器"]

Cell 尺寸是个需要反复权衡的参数。设得太小,Actor 频繁跨 Cell,维护开销上升;设得太大,一次收集会带回大量无关 Actor,浪费带宽。百人战场里,10 m 的 Cell 往往是个比较稳妥的起点,后续再根据地图密度和玩家密度微调。

另外,Grid Spatialization 并不只处理静态对象。高速飞行的炮弹、疾驰的载具也会被放进对应 Cell,只不过这类对象复制周期短、移动快,会占据更多跨 Cell 更新。把它们的 CullDistance 适当收紧,能减少周边 Cell 的扰动。

调试 Grid 时有一个实用技巧:打开 net.RepGraph.DrawDebug 后,服务器会把每个 Cell 的边界和当前连接收集到的 Actor 用线框画出来。如果看到某个玩家周围一大片 Cell 都被标红,说明 CullDistance 设得过大;如果 Actor 在 Cell 边界附近频繁闪烁,说明 CellSize 可能太小。这种可视化反馈比纯看数据更直观,也能帮助策划理解玩家实际能感知到的世界范围。

复制频率分层

Replication Graph 让“按优先级复制”变得简单。每个 FClassReplicationInfo 里的 ReplicationPeriodFrame 表示隔多少帧复制一次。以 50 Hz 服务器帧率为例:

  • 玩家角色:1 帧一次,20ms20 ms 间隔。
  • 附近载具:2 帧一次,40ms40 ms 间隔。
  • 掉落物品:5 帧一次,100ms100 ms 间隔。
  • 远处建筑细节:30 帧一次,600ms600 ms 间隔。

这样就把 Actor 天然分成了几个复制层。Priority 再通过 DistancePriorityScale 做距离加权:越近的 Actor 越容易被优先发送,越远的越容易被推迟或丢弃。带宽公式可以进一步写成:

BoutiRΔiwi(d)piB_{\text{out}} \approx \sum_{i \in R} \frac{\Delta_i \cdot w_i(d)}{p_i}

wi(d)w_i(d) 是距离权重函数,通常随 d2d^2 衰减。当网络拥堵时,服务器会从低优先级、远距离的 Actor 开始丢包或延后,保证玩家眼前的对象始终流畅。

实际调参时,DistancePriorityScale 和 ReplicationPeriodFrame 要配合看。比如把玩家角色的 Scale 设为 1.0,附近载具 0.8,补给箱 0.5,远处建筑碎片 0.1。这样即使所有对象都在相关集合里,服务器也会按优先级排队发送,低优先级对象在带宽吃紧时自然被推迟。策划可以据此建立一张清晰的优先级表,程序再根据压测数据微调。

这种分层对传统裁剪模型很难做。传统方式下,Actor 要么复制要么不复制,频率控制靠 Sleep/Dormancy,粒度粗且容易出错。Replication Graph 把频率作为一等公民,让策划和程序可以一起制定玩家最该看到什么的规则表。

客户端相关性

每帧 Replication Graph 会执行一次 PrepareForReplication,然后为每个连接构建待复制列表。流程大致如下:先收集该连接在 FConnectionAlwaysRelevantNode 中的私有 Actor,再收集 FAlwaysRelevantNode 中的全局 Actor,最后根据 ViewTarget 位置从 Grid Node 中取出可见 Cell 里的 Actor。

sequenceDiagram
    participant S as Server
    participant RG as ReplicationGraph
    participant CN as "Connection Node"
    participant GN as "Grid Node"
    participant C as Client
    S->>RG: PrepareForReplication
    RG->>CN: 收集连接私有 Actor
    CN-->>RG: PlayerController / 自有角色
    RG->>GN: 按 ViewTarget 位置取可见 Cell
    GN-->>RG: 空间相关 Actor 集合
    RG->>C: 序列化属性并发送
    C-->>S: ACK 与属性确认

这个过程中还有两个重要概念:Dormancy 和 ForceNetUpdate。

Dormancy 让长时间没变化的 Actor 进入休眠,复制系统不再检查它,直到 FlushNetDormancy 被触发。仓库门、未被打扰的补给箱就很适合休眠。ForceNetUpdate 则允许关键时刻立即打破周期限制,比如玩家开枪、车辆爆炸时主动标记为脏,让下一帧就发送。

相关性不是一成不变的。玩家上车后,载具从 Grid Node 里的一般对象变成该连接的“拥有对象”,此时需要把它在节点间的归属处理清楚。UE5.8 提供了 SetReplicationGraphCategorySetNetworkMoveType 等接口来应对这些动态转移。

这些接口的使用要慎重。频繁在 Node 之间移动 Actor 会抵消 Replication Graph 的批量收集优势。理想情况下,Actor 的类别在生成时就确定,运行期只做少数几次明确的切换。如果业务逻辑要求高频切换,可以先评估是不是分类本身不够细,而不是把所有变化都交给运行时处理。

调试与优化

Replication Graph 把逻辑拆成了 Node,也带来了新的调试维度。最常用的入口是控制台命令:

  • stat gamestat net 先看总体 CPU 与带宽。
  • net.RepGraph.PrintGraphStats 输出每个 Node 的 Actor 数量、收集耗时。
  • showdebug replicationgraph 在游戏视口里绘制每个连接收集到的 Actor 和 Cell 边界。
  • net.RepGraph.DrawDebug 在服务器或客户端显示 Grid 线和 Node 状态。

通过这些工具,可以很快定位哪个 Node 在烧钱。常见优化方向包括:

  1. 收紧类级别的 CullDistance,让远处对象根本不进收集列表。
  2. 把不常变化的 Actor ReplicationPeriodFrame 调大。
  3. 调整 Grid CellSize,使其与玩家密度和对象密度匹配。
  4. 对大量重复对象使用 FastSharedPathSharedReplication,减少序列化冗余。
  5. 配合 Network Prediction 把玩家自身输入预测和服务器复制解耦,降低对低延迟的依赖。

这些方向可以组合使用。例如先通过 PrintGraphStats 看到 Grid Node 占用了大头,再尝试把 CellSize 从 5 m 提到 10 m,同时把远距离对象的 CullDistance 收紧 20%。改完后跑一局 100 人测试,看 stat net 里的 Out KBytes 和 CPU 时间是否同步下降。如果带宽下来了但 CPU 没变化,说明 Grid 收集本身不是瓶颈,可能要去看属性序列化或 Property Replication 的消耗。

Unreal Insights 也能捕获 Replication Graph 的 trace,看到每帧 GatherActorListsForConnection 的耗时分布。优化时切记先在 Review 环境压测,再同步到生产。

大规模多人场景实战

回到那款百人战术竞技原型。团队引入 Replication Graph 后做了下面几件事:

把 GameState、环圈控制器、全局天气放进 FAlwaysRelevantNode;把 PlayerController 和玩家自有 Character 放进 FConnectionAlwaysRelevantNode;其余所有地形、植被、掉落装备、载具、可破坏物全部交给 FGridSpatialization2D。地图按 10 m 网格切分,loot 在未交互前标记为 Dormant。

他们为不同类设了差异化的复制周期:玩家和载具 1 帧,投掷物 1 帧但 CullDistance 较小,补给和尸体 5 帧,远处建筑碎片 30 帧。近距离武器后坐力、血量变化走 ForceNetUpdate,保证手感。

上线后服务器 CPU 下降了约 40%,出口带宽从峰值 1.2 Gbps 降到 600 Mbps 左右,百人同局延迟稳定在 50 ms 以内。更重要的是,策划可以大胆往地图上放更多可互动物件,而不用每次都被程序喊停。

开放世界项目也适用这套思路。把地形流送块和 Grid Cell 对齐,静态植被塞进低频率 Spatialized Node,动态 NPC 按兴趣区域分桶。城市场景里,高楼可以切分成多个 Cell,避免一个巨大建筑被所有玩家同时收集。这些做法在战术竞技、开放世界生存、大型 PvE 副本里都能复用,核心都是把复制对象按相关性和优先级分桶。

实战里还会遇到一些边界情况。比如载具在高速行驶时拥有权切换,原连接的 ConnectionAlwaysRelevantNode 要及时把载具移除,Grid Node 要立刻接管。又比如空投补给从飞机投下,短时间内被很多玩家关注,可以临时提高它的 ReplicationPeriodFrame 并触发 ForceNetUpdate,落到地面后再降回低频。这些动态变化都需要在代码里显式处理,不能指望系统自动猜到意图。

另外,Replication Graph 的优化收益和地图设计强相关。如果所有玩家都挤在一个小房间里,Grid Spatialization 的优势会被削弱,因为大家看到的 Cell 高度重叠。这时更值得关注的是对象总量、复制属性和物理同步频率。反过来,在开阔地图或大地形项目中,Grid Node 就是核心收益来源。

Replication Graph 不是银弹。它要求项目在早期就规划好 Actor 分类和优先级规则,调试阶段也要投入时间看 Node 分布。但一旦规则跑顺,百人战场、开放世界、大规模 PvE 都会从中拿到扎实收益。

结语

Replication Graph 把虚幻的网络复制从“逐 Actor 逐连接扫描”改造成“空间索引 + 节点分层 + 连接视图”的组合结构。面对 UE5.8 下百人战场和开放世界大量 Actor 的同步压力,这套机制能把 CPU 和带宽开销控制在线性可扩展的范围内。理解 FAlwaysRelevantNode、FConnectionAlwaysRelevantNode 和 FGridSpatialization2D 的语义,合理设置 CullDistance 与 ReplicationPeriodFrame,并配合调试工具持续调优,是做大规模多人游戏网络同步的必修课。

对于刚开始接入的团队,建议先从小地图、少玩家的场景跑通基础配置,再把 Grid CellSize、复制周期、CullDistance 三组参数作为调优主轴。不要一次性覆盖所有 Actor,而是先把玩家、载具、掉落物这三类核心对象接入 Replication Graph,验证效果后再逐步扩展。这样既能控制风险,也能让团队慢慢熟悉这套以 Node 为中心的设计思路。

长期维护时,建议把 Replication Graph 的类级别配置导出成数据表格或外部配置文件,策划可以在不改代码的情况下调整复制周期和距离。这样做还能减少版本更新中的编译依赖,让网络和玩法团队并行工作。定期回放压测数据,检查新增 Actor 是否被正确分类,也是防止性能回退的有效手段。