UE5.8 带宽优化与网络分析

📑 目录

UE5.8 带宽优化与网络分析

1. 上线第一晚的告警

我们负责的多人战术射击项目《哨兵行动》在 UE5.8 上线后,第一个周末就收到了服务器带宽告警。32 人对战地图,单台服务器出口带宽飙到 18 Mbps,峰值丢包率 3%。玩家反馈集中在两点:远处敌人位置偶尔瞬移,近战交火时开枪有延迟。

运维同学把 Network Profiler 抓到的数据发过来。我一看,光是每个客户端每秒接收的 Replication 数据就占了 12 Mbps,其中 70% 来自与当前战局无关的远端 Actor。地图是一张 2km × 2km 的开放世界,交战半径平均只有 80 米,可服务端还在把山另一头载具的详细状态发给每个玩家。

这就是带宽优化的起点。服务端不是故意浪费流量,而是默认配置没有根据玩法收紧。UE5.8 提供了一整套工具链,从裁剪距离、复制频率、休眠机制,到 Network Profiler 和 Unreal Insights,能把 Replication 流量压到合理区间。本文把这次排查和改造过程整理出来,希望给同类项目一个参考。

2. 网络复制在 UE5.8 中的基本结构

理解带宽问题之前,先看一下数据由服务端发往客户端的流动方式。UE5.8 的网络复制可以分成几个层次:World 负责管理所有 Actor;NetDriver 维护连接;每个 Actor 根据 NetUpdateFrequency 决定多久向客户端发送一次属性更新;服务器最后把包序列化后发送出去。

flowchart TD
    subgraph api ["服务端世界"]
        db["Actor 集合"]
        disk1["NetDriver"]
        server["Replication 调度器"]
        disk2["属性序列化"]
        internet["网络包发送"]
    end
    db --> server
    server --> disk2
    disk2 --> internet
    server --> disk1

上面的架构图描述了服务端一侧的主要组件。客户端收到包后由 NetDriver 反序列化,再应用属性变化。我们的问题出在前面三层:太多 Actor 被纳入复制范围,更新频率也没有区分远近。接下来几节会逐一处理。

3. Net Cull Distance:先砍掉看不见的东西

Net Cull Distance 决定了一个 Actor 距离客户端观察者多远之后,服务端就不再向该客户端复制它。UE 默认给很多 Actor 设置了 150 米甚至全图的 Cull Distance。战术射击游戏里,玩家隔着两栋建筑和一条河,不可能看到对岸一把手枪的状态变化,服务端却还在同步它。

我们按 Actor 类型建立了一张 Cull Distance 表:

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

Cull Distance 不是越远越好,也不是一刀切到 0。过短会导致远处爆炸或载具突然出现在视野里,过长又会浪费带宽。我们的策略是:影响战局的保留合理距离,纯装饰性的直接剔除同步。

在 UE5.8 中,可以通过 NetCullDistanceSquared 直接设置平方距离,避免每帧开方。假设视觉距离为 dd,则写入的值为 d2d^2。两个玩家之间的同步距离用平方比较,计算量更小:

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

行内我们经常写判断:如果 Distance2>NetCullDistanceSquared\text{Distance}^2 > \text{NetCullDistanceSquared},就跳过该连接。代码层面可以在 Actor 构造函数里设置:

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

这张表上线后,Replication Actor 数量从平均每个玩家 1400 个降到 380 个,带宽立刻少了 35%。Net Cull Distance 是最低成本的优化手段,前提是你对地图和玩法有清晰分层。

4. Replication Frequency:该快的快,该慢的慢

Net Cull Distance 解决的是同步哪些 Actor。Replication Frequency 解决的是同步得多勤。UE5.8 默认 NetUpdateFrequency = 100 次每秒,对静态箱子每秒发 100 次包显然浪费。

我们的做法是给 Actor 打标签,按动态程度分档:

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

单连接带宽可以粗略估算为:

B=i=1nfisicB = \sum_{i=1}^{n} f_i \cdot s_i \cdot c

其中 BB 是总带宽,fif_i 是第 ii 个 Actor 的更新频率,sis_i 是平均包大小,cc 是连接数。这个公式不是精确模型,但告诉我们一个方向:想降低 BB,要么减少 nn,要么降低 fif_i,要么压缩 sis_i。Net Cull Distance 减少 nn,Replication Frequency 降低 fif_i

UE5.8 有两个关键参数:NetUpdateFrequency 是目标频率,MinNetUpdateFrequency 是保底频率。当属性没有变化时,UE 会自动把更新间隔拉长,但不会低于 MinNetUpdateFrequency。如果某个 Actor 在某段时间内完全没变,还可以进入 Dormancy,后面会讲到。

下面这张图展示了我们是如何根据距离和重要性决定更新频率的。

flowchart TD
    A["Actor 进入复制列表"] --> B{距离是否小于
Net Cull Distance?} B -->|"否"| C["跳过该连接"] B -->|"是"| D{是否玩家控制角色?} D -->|"是"| E["60 Hz, 全属性"] D -->|"否"| F{是否移动载具?} F -->|"是"| G["30 Hz, 位置和旋转"] F -->|"否"| H{是否在交战半径内?} H -->|"是"| I["20 Hz, 关键属性"] H -->|"否"| J["5 Hz, 位置和生命状态"]

这个流程图去掉了很多分支判断,只保留我们最常用的几条路径。实际项目中不必为了复杂而复杂,能让团队一眼看懂最重要。

5. Dormancy:让静止的 Actor 闭嘴

有些 Actor 创建后就很少变化,比如占点模式里的旗帜、解谜关卡里的机关。它们虽然还在 Cull Distance 内,也设置了较低更新频率,但只要活着,服务端每隔一段时间还是会检查是否有属性变化。量大了以后,这种检查本身也会消耗 CPU 和少量带宽。

Dormancy 机制允许 Actor 进入休眠状态。休眠期间,服务端不会主动复制它,除非满足某个唤醒条件。这个机制和 Net Cull Distance 不同:Cull 是按距离把 Actor 移出客户端视野,Dormancy 是 Actor 还在视野里但暂时不需要更新。UE5.8 提供了几种 Dormancy 模式:

  • DORM_Never:不休眠,保持默认行为。
  • DORM_Awake:当前清醒,正常工作。
  • DORM_DormantAll:对所有客户端休眠。
  • DORM_DormantPartial:对部分客户端休眠,对另一些保持清醒。
  • DORM_Initial:初始状态,等待第一次复制。

我们的旗帜 Actor 采用 DORM_DormantAll。当占领进度变化时,调用 FlushNetDormancy() 强制唤醒并发送一次更新,随后再次休眠。代码很短:

void ACaptureFlag::SetCaptureProgress(float NewProgress)
{
    if (!FMath::IsNearlyEqual(NewProgress, CaptureProgress))
    {
        CaptureProgress = NewProgress;
        FlushNetDormancy();
        ForceNetUpdate();
    }
}

注意 FlushNetDormancy() 只是唤醒,真正触发复制还需要属性变化或手动 ForceNetUpdate()。如果只唤醒但属性没变,包不会发出去。

对于占领点这种所有玩家共同关注的对象,我们用 DORM_DormantAll。对于玩家自己建造但别人看不到的掩体,则用 DORM_DormantPartial,只向拥有者保持清醒,向其他玩家休眠。Partial Dormancy 在多人在线建造玩法里特别有用,能避免每个玩家的小改动都广播给全图。

Dormancy 的另一面是客户端预测。旗帜被占领时,客户端不能干等下一次复制,否则进度条会卡顿。我们在 UI 层做了本地插值,收到服务器确认后再对齐真实值。这样玩家看到的是平滑推进,实际网络流量只发生在关键点。

6. Network Profiler:找到带宽大头

优化之前必须测量。UE 自带的 Network Profiler 是定位带宽问题的首选工具。我们在开发包启动参数里加上 -tracehost=127.0.0.1-trace=net,然后在编辑器里打开 Session Frontend,连接目标进程,开始录制。

Network Profiler 主界面会列出所有复制的 Actor 类型,按每秒字节数排序。我们当时的 Top 3 是:

  1. 玩家角色状态:4.2 Mbps
  2. 物理驱动的可破坏箱子:2.1 Mbps
  3. 粒子系统同步:1.8 Mbps
sequenceDiagram
    participant 运维 as "运维同学"
    participant 客户端 as "测试客户端"
    participant 服务端 as "游戏服务端"
    participant Profiler as "Network Profiler"

    运维 ->> 客户端: 启动并连接服务器
    运维 ->> 服务端: 添加 -trace=net 参数
    运维 ->> Profiler: 打开 Session Frontend
    服务端 ->> Profiler: 实时发送网络事件流
    客户端 ->> 服务端: 开始一局 32 人对战
    运维 ->> Profiler: 录制 120 秒
    Profiler ->> 运维: 显示 Actor 级带宽排行
    运维 ->> 开发: 导出 .nprof 文件并标注 Top 3

这张序列图记录了我们当晚的真实排查流程。Network Profiler 的好处是粒度细到每个 Actor 类型、每个属性、每个 RPC。点击某一行,能看到它占总带宽的百分比,以及主要消耗在哪几个属性上。

物理箱子占 2.1 Mbps 是我们之前没想到的。原因是关卡设计师把大量装饰性木箱勾选了 Replicate Movement,玩家开枪打飞时,每个箱子的刚体状态都在同步。解决办法分两步:一是把装饰箱改为客户端物理模拟,只在被命中时发一次 RPC 通知;二是减少物理 Actor 数量,把十几个小箱子合并成一个破坏组。

Network Profiler 还有一个隐藏技巧:按连接过滤。不同玩家的视角不同,带宽分布也不同。 spectating 玩家的带宽通常最高,因为他能同时看到多个交战区域。我们只分析普通玩家连接,避免被观战视角带偏。

另一个细节是看属性列。我们曾有段时间怀疑角色带宽过高,点开后发现 40% 来自一个已经弃用的 AnimState 结构体,里面还包含了一堆不再使用的索引。删除这个属性后,角色包大小从 180 字节降到 110 字节。Network Profiler 的粒度足以暴露这种历史遗留问题。

7. 带宽预算与压缩

经过前面几步,单连接带宽从 12 Mbps 降到 6 Mbps。但运维要求我们控制在 4 Mbps 以内,因为海外节点还要过普通家用宽带。于是我们引入带宽预算机制。

预算分配思路是:先给每类数据封顶,再预留 20% 缓冲。公式如下:

Btotal=Bplayer+Bvehicle+Benvironment+Brpc+BbufferB_{\text{total}} = B_{\text{player}} + B_{\text{vehicle}} + B_{\text{environment}} + B_{\text{rpc}} + B_{\text{buffer}}

我们把总目标设为 Btotal=4 MbpsB_{\text{total}} = 4\ \text{Mbps},缓冲占 0.8 Mbps,剩下 3.2 Mbps 分给各类数据。玩家状态 1.5 Mbps,载具 0.6 Mbps,环境 0.5 Mbps,RPC 和控制指令 0.6 Mbps。

有了预算,就可以反推每个 Actor 的允许更新频率。假设某个载具 Actor 平均包大小 s=120 Bytess = 120\ \text{Bytes},分配给它的预算是 b=6 Kbpsb = 6\ \text{Kbps},那么最大频率:

fmax=b8s61449606.4 Hzf_{\max} = \frac{b}{8 \cdot s} \approx \frac{6144}{960} \approx 6.4\ \text{Hz}

行内我们也会用这个思路快速估算:bb 除以 8s8s 得到每秒最多多少次。结果不是整数时向下取整,再设置 NetUpdateFrequency

压缩方面,UE5.8 默认会对浮点属性做一定压缩,但仍有优化空间。例如角色血量 0 到 100 用 uint8 就够了,没必要用 float。位置同步如果精度要求不高,可以把坐标缩放系数从 100 降到 10。我们还在项目设置里启用了 net.UseFixedBruteForceReplication 相关选项的精简版本,把同一帧内多个小属性合并到一个 Bunch 里发送。

下面这张图展示了带宽、频率、包大小和 Actor 数量之间的关系。

graph LR
    A["Actor 数量 n"] --> B["总带宽 B"]
    C["更新频率 f"] --> B
    D["包大小 s"] --> B
    E["连接数 c"] --> B
    F["Net Cull Distance"] --> A
    G["Replication Frequency"] --> C
    H["属性压缩"] --> D
    I["服务器分片"] --> E

8. Unreal Insights:不只是渲染和 CPU

Unreal Insights 在 UE5.8 里继续扩展了网络分析能力。我们在客户端和服务端都启动 Insights:

UE5Editor-Cmd.exe Project.uproject -game -tracehost=127.0.0.1 -trace=net,cpu,memory

抓取到的 trace 文件用 Unreal Insights 打开,切换到 Networking 标签页,可以看到帧级别的 Outgoing Replication 时间、序列化耗时、每个 Channel 的等待时间。这些信息比 Network Profiler 更偏性能剖面,能发现复制本身是否卡了主线程。

我们遇到过一个典型问题:服务端主线程每 10 帧会卡顿 8 毫秒,玩家感觉子弹判定延迟。Insights 显示卡顿来自一个自定义的 GetLifetimeReplicatedProps 实现,里面每次都在遍历一个动态数组。修复方式是把条件判断提前,只在必要时重建属性列表。8 毫秒卡顿消失,服务端帧时间从 22 毫秒降到 14 毫秒。

Insights 还能和 Network Profiler 的数据对齐。Profiler 告诉你哪个 Actor 占带宽,Insights 告诉你序列化它花了多少 CPU。两者结合,才能判断是高频率导致的网络问题,还是复杂属性导致的 CPU 问题。

我们常用的 trace 组合是 -trace=net,cpu,memory。net 负责网络事件,cpu 看主线程耗时,memory 用来检查序列化过程中有没有临时分配过多。三者一起开对性能有一定开销,建议只在调试专用服务器或本地多开测试时使用,正式对局不要长期开启。

9. Actor 优先级:先把有限的带宽给重要对象

当服务端带宽吃紧时,UE 会根据 Actor 的 Net Priority 决定谁先被复制。默认所有 Actor 的优先级都是 1.0。我们可以根据玩法调整:玩家角色 2.5,敌方玩家 3.0,任务目标 2.0,普通环境 0.3。

优先级影响的是复制调度顺序,不是复制与否。优先级高的 Actor 会更频繁地获得 Channel 资源,优先级低的会被推迟。计算公式大致是:

p=NetPriorityLastUpdateTimeDistanceFactorp = \frac{\text{NetPriority}}{\text{LastUpdateTime} \cdot \text{DistanceFactor}}

pp 越大,越优先复制。LastUpdateTime 是上次更新到现在的时间,DistanceFactor 通常与距离相关。近处敌人优先级自然更高,远处木箱优先级自然更低。

我们给不同类别设置了不同的 NetPriority

Actor 类型Net Priority
玩家角色2.5
敌方玩家3.0
任务目标2.0
载具1.5
环境破坏0.8
装饰物件0.3

调整优先级时要注意相对关系。如果所有 Actor 都调成 3.0,等于都没调。优先级的价值在于区分,而不是绝对数值。

10. 优化检查清单

最后把这次改造中沉淀下来的检查项列出来。每次新增玩法或地图时,我们都会过一遍。

检查项工具目标
为每类 Actor 设置合理的 Net Cull Distance关卡编辑器 + 运行时统计远处无关 Actor 不复制
按动态程度设置 NetUpdateFrequency 和 MinNetUpdateFrequencyNetwork Profiler静态 Actor 不抢高频资源
对变化少的 Actor 启用 Dormancy代码审查降低空闲检查开销
用 Network Profiler 抓 5 分钟对局Network ProfilerTop 10 Actor 占总带宽不超过 60%
建立并维护带宽预算表Excel / 内部文档单连接目标 ≤ 4 Mbps
审查浮点属性精度,能用整型就用整型代码审查减少每个包大小
用 Unreal Insights 检查序列化耗时Unreal Insights主线程无异常卡顿
调整 Net Priority,保证关键对象优先运行时统计玩家和任务目标优先复制
上线前做 spectating 和普通玩家对比测试本地多开观战视角不污染优化结论
保留 20% 带宽缓冲,应对突发流量带宽预算表峰值不触发丢包

这张清单不是一次性工作。随着地图扩大、玩家数增加、玩法变复杂,Cull Distance 和 Frequency 需要反复调整。建议把 Network Profiler 抓取加入 CI 的自动化性能测试,每次提交后都跑一次小地图对战,生成带宽基线报告。

11. 最终效果与持续改进

《哨兵行动》经过这轮优化后,32 人对战单连接带宽稳定在 3.8 Mbps,峰值不超过 4.5 Mbps。这个结果是多轮测量和调整后的产物,不是一次性改出来的。服务器出口带宽从 18 Mbps 降到 9 Mbps,丢包率从 3% 降到 0.4%。玩家反馈的远端瞬移和开枪延迟问题基本消失。

UE5.8 的 Replication 系统非常灵活,灵活也意味着默认值偏向通用场景,不会为你的玩法量身定制。带宽优化没有银弹,核心思路就是三句话:看不见的不发,不动的少发,重要的先发。剩下的工作交给 Network Profiler 和 Unreal Insights 去验证。

这次改造也让我意识到,网络优化不能只做一次。每次新增武器、载具或地图时,都要重新评估 Cull Distance 和更新频率。把优化检查清单纳入版本发布流程,比事后再救火要省钱得多。团队里最好指定一位网络负责人,专门维护带宽预算表和 Profiler 基线。

如果你的项目也遇到了类似问题,建议从 Net Cull Distance 开始改起,这是收益最明显的一步。改完再用 Network Profiler 看 Top 带宽消耗,按图索骥调整 Replication Frequency 和 Dormancy。最后把预算和优先级固化成流程,避免每次靠个人经验救火。