UE5.8 网络性能优化:从带宽告警到稳定对局

📑 目录

UE5.8 网络性能优化:从带宽告警到稳定对局

1. 周五晚上的告警电话

老陈负责的项目叫《裂痕行动》,一款 16 对 16 的战术射击游戏。去年六月上线,第一个周末玩家数冲到三千,机房监控立刻炸了。服务端单台出口带宽 22 Mbps,CPU 占用 85%,延迟中位数 80 ms,但 P95 飙到 260 ms。玩家论坛里骂声最多的是两句:远处敌人隔半秒才动一下,近战开镜总是慢半拍。

运维把数据包抓下来,老陈和主程小林在会议室看了三小时。Network Profiler 里有一条特别刺眼:每个客户端每秒接收 14 Mbps 的 Replication 数据,其中 60% 来自 200 米外的 Actor。地图本身不大,800 米 × 800 米,可默认复制策略把整局所有玩家、所有投掷物、所有可破坏掩体的状态一起推给每个人。玩家 A 蹲在仓库里,服务端还在告诉他 B 点二楼那盏吊灯有没有被子弹打碎。

那天晚上小林先检查了网络模拟环境。他用控制台命令 net pktlag=120 pktloss=0.5 在本地复现了玩家反馈的延迟和丢包感。延迟和丢包本身不是这次的主要元凶,真正的凶手是流量太大。出口带宽被占满后,即使网络物理延迟不高,队列排队也会让 P95 延迟飙升。这个判断很重要,因为它决定了优化方向:先压流量,再谈预测和补偿。

问题不是引擎不行,是默认配置太慷慨。UE5.8 的多人网络框架给开发者留了足够多的优化手段,只是需要一层一层拧紧。接下来我把这次排查用到的方法按优先级整理出来,希望能帮你在上线前少走点弯路。

2. 先看清楚数据怎么流出服务器

优化之前最好画一张数据流图。服务端每帧把 Actor 状态打包发出去,中间要经过 NetDriver、Replication 调度、属性序列化、RPC 队列几个阶段。客户端收到后再反序列化,应用到本地对象上。

flowchart TD
    subgraph server ["服务端"]
        world["UWorld / Actor 集合"]
        driver["NetDriver"]
        scheduler["Replication 调度器"]
        serializer["属性序列化"]
        sender["网络包发送"]
    end
    world --> scheduler
    scheduler --> driver
    scheduler --> serializer
    serializer --> sender

这张图只画了服务端,因为大部分优化动作都发生在这里。客户端也会做预测和插值,但带宽、CPU、丢包这三座大山主要压在服务器上。优化的核心目标可以写成一个简单的不等式:

i=1nfisicBlimit\sum_{i=1}^{n} f_i \cdot s_i \cdot c \leq B_{\text{limit}}

其中 nn 是相关 Actor 数量,fif_i 是第 ii 个 Actor 的更新频率,sis_i 是单次更新的平均包大小,cc 是连接数,BlimitB_{\text{limit}} 是单台服务器能承受的出口带宽。想把这个式子压下去,无非三条路:减少 nn、降低 fif_i、压缩 sis_i。下面八节分别对应这三条路的不同实现。

实际排查时,我们习惯按下面这个流程走:先抓 Profiler 看瓶颈在哪,再决定动 Cull Distance、Frequency、Dormancy 还是 Replication Graph。流程本身不复杂,难的是每一步都要回到数据上,而不是凭感觉改参数。

flowchart TD
    A["启动 Network Profiler 抓包"] --> B{Out Kbps 是否超标?}
    B -->|"是"| C["按 Actor 排序 Top 带宽消耗"]
    C --> D{问题类型?}
    D -->|"Actor 过多"| E["收紧 Net Cull Distance"]
    D -->|"频率过高"| F["调整 Replication Frequency"]
    D -->|"静止对象"| G["启用 Dormancy"]
    D -->|"规模大"| H["启用 Replication Graph"]
    D -->|"RPC 占比高"| I["优化 Reliable / Multicast"]
    E --> J["复测 Profiler"]
    F --> J
    G --> J
    H --> J
    I --> J
    J --> B

3. Replication Frequency:给 Actor 分档

UE5.8 默认 NetUpdateFrequency = 100,意思是每秒最多复制 100 次。这个默认值对玩家角色合理,对远处静止的木箱就是灾难。我们的做法是给 Actor 按动态程度分档,而不是在蓝图里一个一个改。

老陈和小林把项目里的可复制对象分了四档:

  • 高频:玩家角色、飞行中的子弹、高速载具,30 Hz 到 60 Hz。
  • 中频:开门状态、陷阱触发、远处玩家,5 Hz 到 10 Hz。
  • 低频:任务进度、仓库库存、环境破坏结果,1 Hz 到 2 Hz。
  • 事件驱动:血量变化、死亡事件,只在属性变化时复制。

分组依据是玩家对延迟的敏感度。玩家自己控制的角色必须响应快,远处一个箱子的盖子是开着还是关着,晚半秒没人会注意。代码里在构造函数里设置频率最稳,因为蓝图里改来改去容易漏。我们项目还做了一张 Excel 表,列出每类 Actor 的 NetUpdateFrequency、MinNetUpdateFrequency 和 NetCullDistance,代码评审时逐项核对。

AExplosiveBarrel::AExplosiveBarrel()
{
    NetUpdateFrequency = 5.0f;
    MinNetUpdateFrequency = 1.0f;
}

NetUpdateFrequency 是目标上限,MinNetUpdateFrequency 是保底。UE 会在属性没有变化时自动拉长间隔,但不会低于保底值。别把这俩设成一样,否则失去动态调节空间。我们项目里最常见的错误是只改 NetUpdateFrequency,不改 MinNetUpdateFrequency,结果远处对象还是每秒被强制复制一次。

4. Net Cull Distance:看不见就别同步

Cull Distance 解决的是 nn 的问题。UE 默认给很多 Actor 设置了 150 米甚至全图的同步距离。地图再大,玩家视野也有限,超出范围的细节对当前客户端毫无意义。

《裂痕行动》里我们按玩法重新定了一张表:

Actor 类型旧距离新距离理由
玩家角色全图500 米超出后只保留队伍标记
普通武器150 米80 米地面武器过远无意义
投掷物全图300 米抛物线远端仍需可见
载具800 米400 米移动快,保留中距离
可破坏掩体200 米120 米破坏事件只影响附近
环境小物件100 米0 米花瓶纸袋不做网络同步

距离比较用平方值,可以省掉每帧开方。视觉距离 dd 对应的写入值是 d2d^2

NetCullDistanceSquared=d2\text{NetCullDistanceSquared} = d^2

行内判断常常这样写:当 Distance2>NetCullDistanceSquared\text{Distance}^2 > \text{NetCullDistanceSquared} 时跳过该连接。代码层面:

AWeaponPickup::AWeaponPickup()
{
    NetCullDistanceSquared = FMath::Square(8000.0f); // 80 米
    NetUpdateFrequency = 10.0f;
    MinNetUpdateFrequency = 2.0f;
}

这张表上线后,每个客户端平均相关的 Actor 数量从 1600 降到 420,Replication 流量少了 40%。Cull Distance 是最快见效的优化,前提是你清楚地图里哪些东西真的需要被远处玩家知道。

5. Dormancy:让静止的对象睡着

有些 Actor 不是一直活跃。比如仓库里的补给箱,被玩家打开一次后就长期处于打开状态。默认情况下 UE 还是会每帧检查它是否需要复制,这就是浪费。Dormancy 的作用是让这些对象进入休眠,不再占用每帧的 Replication 开销。

UE5.8 提供了几种休眠模式:

  • DORM_Never:不休眠,默认行为。
  • DORM_Awake:可以进入休眠,但当前清醒。
  • DORM_DormantAll:对所有连接休眠。
  • DORM_DormantPartial:对部分连接休眠。
  • DORM_Initial:初始即休眠,直到被显式唤醒。

用法很简单,在 Actor 里调用:

void AStaticLootBox::OnOpened()
{
    SetReplicates(true);
    SetActorHiddenInGame(false);
    SetNetDormancy(DORM_DormantAll);
}

关键要记住:一旦 Actor 进入 Dormancy,属性变化不会再自动同步。如果箱子里又刷新了一批物资,必须显式调用 FlushNetDormancy() 把它唤醒,否则客户端永远看不到变化。这个坑我们踩过两次,第一次是刷新逻辑忘了唤醒,第二次是唤醒后又没让它重新入睡。

Dormancy 和 Cull Distance 的区别在于,Cull Distance 是按空间剔除,Dormancy 是按状态剔除。两者可以叠加。远处静止的箱子先被 Cull 掉,近处长期不变的箱子再进入休眠,这才是完整的节省策略。

有个细节容易忽略:进入 Dormancy 的 Actor 仍然存在于服务端,仍然消耗内存和服务器 CPU,只是不再向客户端复制。如果你的场景里有大量一次性生成的临时对象,比如弹壳、弹痕,正确的做法是设置较短的生命周期,而不是指望 Dormancy 解决所有问题。Dormancy 适合生命周期长但状态变化少的对象,比如建筑、门、箱子、固定装置。

6. Replication Graph:百人战场的标准答案

当玩家数和 Actor 数都上去之后,仅靠 Cull Distance 和 Frequency 已经不够。传统模型里,服务器每帧为每个连接遍历所有可复制 Actor,判断 IsNetRelevantFor,开销是 Na×NcN_a \times N_c 级别。100 个玩家、5000 个 Actor,每帧就是 50 万次相关性测试。

Replication Graph 把这个问题从乘积降到近似求和。它的思路是预先把 Actor 分到不同 Node 里:

graph TD
    A["所有可复制 Actor"] --> B{是否全局相关?}
    B -->|"是"| C[FAlwaysRelevantNode]
    B -->|"否"| D{是否仅对拥有者相关?}
    D -->|"是"| E[FConnectionAlwaysRelevantNode]
    D -->|"否"| F{是否有固定空间位置?}
    F -->|"是"| G[FGridSpatialization2D]
    F -->|"否"| H["自定义 Node"]

三种基础 Node 各司其职。FAlwaysRelevantNode 放 GameState、全局天气、时间轴;FConnectionAlwaysRelevantNode 放 PlayerController 和玩家自己的 Character;FGridSpatialization2D 把世界按二维网格切分,连接只收集视野附近的 Cell。树木、岩石、掉落装备、静止载具都适合塞进 Grid Node。

每帧 Replication Graph 不再遍历全量 Actor,而是遍历有变化的 Node,并把结果按连接分发。开销从:

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

变成接近可见 Actor 数与连接数的规模。百人战场里这个差距会被放大几十倍。

配置 Replication Graph 需要继承 UReplicationGraph,重写 InitGlobalGraphNodesInitGlobalActorClassSettings。核心代码不超过五十行:

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

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

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

开启 Replication Graph 后,《裂痕行动》服务端 CPU 占用从 85% 降到 55%,相关性测试不再是瓶颈。 Replication Graph 不是小项目的必需品,但只要你打算做超过 50 人的对战,就应该提前把它纳入架构。

7. RPC 频率与可靠性:别把所有调用都设成 Reliable

RPC 分三种:Server、Client、NetMulticast。每种又可以设 Reliable 或 Unreliable。Reliable RPC 会被引擎保证到达,丢包会重发,代价是额外的确认包和缓存。Unreliable RPC 发出去就不管,丢了就丢了,带宽更省。

很多开发者习惯性把关键调用设成 Reliable,生怕丢包导致状态不一致。但 Reliable RPC 有一个隐藏风险:如果客户端网络抖动,大量 Reliable RPC 会在服务端积压,形成所谓的可靠队列膨胀。表现形式就是延迟突然暴涨,过几秒又恢复正常。

下面这张图展示了 Reliable RPC 在丢包场景下的重发机制:

sequenceDiagram
    participant C as "客户端"
    participant S as "服务端"
    participant Q as "可靠消息队列"

    C->>S: Reliable RPC A
    Note over C,S: 包 A 丢失
    Q->>S: 未收到确认
    S->>C: 重发 RPC A
    C-->>S: ACK A
    C->>S: Reliable RPC B
    C-->>S: ACK B

老陈定的 RPC 规则是:

  • Reliable 只用于状态变化必须送达的调用,比如开枪请求、技能释放、死亡判定。
  • Unreliable 用于高频更新,比如枪口朝向、脚步声、特效触发。
  • 周期性状态尽量走属性复制,而不是 RPC。
  • NetMulticast 要特别克制,每个连接都会收到一份,滥用就是带宽炸弹。

代码里设置 Reliability 的方式:

UFUNCTION(Server, Reliable)
void ServerFireWeapon(const FVector& AimLocation);

UFUNCTION(NetMulticast, Unreliable)
void MulticastPlayMuzzleFlash();

枪口火光用 Unreliable 多播,丢了就丢了,下一枪又会发。开枪请求用 Reliable Server,确保服务器收到。这个分界要清楚,不能图省事全设 Reliable。

8. 带宽压缩:在序列化层再挤一点

Replication 数据主要由三部分组成:属性更新、RPC 参数、对象引用。UE5.8 提供了几种压缩和精简手段。

首先是属性本身的精度。位置向量默认用 32 位浮点同步,但很多对象不需要那么高精度。比如远处玩家的位置,用 16 位定点数足够。我们可以在属性复制函数里手动压缩:

void UMyHealthComponent::GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const
{
    DOREPLIFETIME_CONDITION(UMyHealthComponent, Health, COND_OwnerOnly);
    DOREPLIFETIME_CONDITION(UMyHealthComponent, MaxHealth, COND_InitialOnly);
}

COND_OwnerOnly 让血量只对拥有者同步,敌人不需要知道你的具体血量。COND_InitialOnly 让 MaxHealth 只在对象创建时同步一次,后续不再发送。

其次是枚举和整数尽量用位宽更小的类型。uint8int32 省 3 个字节,在 32 人对战里积少成多。布尔值打包到 uint8 的位域里,八个布尔才占一个字节。

最后是对象引用。UE 默认会同步对象路径,长路径名很占带宽。如果对象数量固定,可以用 NetGUID 压缩,或者把引用换成轻量 ID,再在客户端查表还原。这条优化收益很高,但实现起来也最麻烦,建议先把前面几条做完再考虑。

《裂痕行动》里我们还做了一件事:把所有只读配置从属性复制里拆出来,改用 Data Table 或 Curve Table 在本地加载。比如武器伤害、射程、后坐力曲线,这些值对所有客户端都一样,没必要每次生成武器都同步一遍。只在创建时同步武器 ID,客户端自己查表拿到完整参数。这一项改动让武器系统的 Replication 开销又少了 15% 左右。

另外,对于位置同步,可以考虑把 Vector 拆成相对坐标或量化值。UE5.8 的 FVector_NetQuantizeFVector_NetQuantizeNormal 就是为此设计的,它们用 16 位定点数存储常见范围的位置和方向,精度足够大多数多人游戏使用。如果一个对象不需要毫米级精度,就别用完整浮点同步。

9. Network Profiler:用数据说话

所有优化都要能验证。UE5.8 自带的 Network Profiler 是排查网络性能问题的首选工具。启动方式是在命令行加 -NetTrace=1,或者用控制台命令 net.LogSnapshot 抓取一段时间的数据。

Network Profiler 主要看四个指标:

  • Out Kbps:每个连接的出口带宽。
  • Actor Count:当前复制的 Actor 数量。
  • Property Bytes:属性更新占用的字节数。
  • RPC Bytes:RPC 调用占用的字节数。

《裂痕行动》第一次抓包时,Out Kbps 曲线在交火阶段呈尖刺状。小林把尖刺放大,发现是某个武器配件的 MulticastPlayFireEffect 每次开火都发送了完整的特效参数,包括材质引用和粒子系统路径。改成在客户端本地查找资源后,单次开火流量从 1.2 KB 降到 80 字节。

另一个常见问题是某个 Actor 的 Replication 频率被调得太高。Profiler 里能看到每个 Actor 每秒发送多少字节,找出 Top 10 逐个优化,比盲目改配置有效得多。我们建议每个里程碑版本都跑一次 Profiler,把网络预算当成和帧率一样重要的指标来管。

除了 Network Profiler,UE5.8 的 Unreal Insights 也能配合看网络相关的时间线。我们常用 stat net 在运行时查看每个连接的流量和丢包情况。net showrelevantactors 可以在视口里显示当前客户端相关的 Actor 数量,调整 Cull Distance 时非常直观。Profiler 告诉你哪里花了带宽,视口调试告诉你哪些 Actor 不该出现在视野里,两者结合才能定位根因。

10. 优化检查清单

把这次改造总结成一份可直接执行的清单。上线前逐条核对,能避开大部分网络性能坑:

  • [ ] 为每类 Actor 设置合理的 Net Cull Distance,纯装饰性对象设成 0。
  • [ ] 按动态程度分档设置 NetUpdateFrequency 和 MinNetUpdateFrequency。
  • [ ] 长期静止的对象启用 Dormancy,并在状态变化时正确唤醒。
  • [ ] 超过 50 人对战的项目启用 Replication Graph。
  • [ ] 区分 Reliable 和 Unreliable RPC,高频调用避免 Reliable。
  • [ ] 属性复制加条件限制,如 COND_OwnerOnly、COND_InitialOnly。
  • [ ] 定期用 Network Profiler 抓包,定位 Top 带宽消耗 Actor。
  • [ ] 在模拟高延迟、丢包环境下做压力测试,验证优化效果。

这份清单没有高深理论,但执行到位就能把带宽和 CPU 占用压到可控范围。

11. 上线一个月后的数据

《裂痕行动》把上面八项全部实施后,服务端出口带宽从 22 Mbps 降到 6.5 Mbps,CPU 占用从 85% 降到 45%,P95 延迟从 260 ms 降到 95 ms。玩家投诉里关于瞬移和开枪延迟的帖子少了 80%。

这些数据不是炫耀,而是说明网络优化有明确的投入产出比。UE5.8 已经把工具链做得足够完整,剩下的就是根据项目特点一层层拧紧。别等带宽告警响了才开始动手,最好在原型阶段就把 Cull Distance 和 Frequency 分档写进规范。

网络性能优化最怕走两个极端。一种是完全不管,默认配置用到上线,结果玩家骂声一片;另一种是过度优化,把所有对象都压到很低频率,导致远处敌人瞬移、开门状态延迟。好的优化是在体验和成本之间找平衡,每个参数都要在真实网络环境下测过再定。

老陈现在每次开会都强调一句话:网络性能不是测试阶段才补的补丁,是设计阶段就要做的选择。把这句话送给正在做多人项目的你。优化这件事,功夫在平时。每一个参数、每一行条件、每一次 Profiler 抓包,都是在为上线后的稳定对局打基础。愿你也能少接几个凌晨告警电话,玩家体验稳如老狗。