Relevancy 与带宽预算
「100 人同屏,服务器带宽怎么办?」——这是 UE 网络最硬核的规模问题。答案不是「优化一下」,而是一套可量化的账:哪些 Actor 要发给谁(Relevancy)、多久发一次(频率与休眠)、每客户端多少带宽(预算算式)、判定本身扛不扛得住(ReplicationGraph)。本篇把这套账算给你看。
一句话结论
Relevancy 决定「谁收谁」,频率/优先级/Dormancy 决定「发多勤」,带宽 ≈ 每Actor字节 × 相关Actor数 × 频率;默认判定 O(连接×Actor) 不可扩展,靠 ReplicationGraph/Iris 破。
场景问题
打个比方:这像给全城送报纸。100 户人家,如果每户都要收到另外 99 户的动态,那是 1 万份稿件——O(N²),印刷厂先破产。所以第一件事不是「把纸印薄点」(压缩),而是只送该送的:你隔着三条街看不见的人家的动态,根本不用送给你(Relevancy)。
第二件事是送多勤:重要的邻居每天一送(高
NetUpdateFrequency),远处的每周一送(NetCullDistance外或降频),常年没动静的干脆停送、有变化再恢复(Dormancy)。第三件事是算账:每户每天收多少字节 = 每份稿件字节 × 该送几份 × 送多少次。这笔账要能算出来,才能回答「100 人房间要多少带宽」。
最后一件容易被忽略:分拣本身也要钱。就算最后只送 20 份,如果每天都要把 100 户 × 100 户挨个比一遍「该不该送」,光分拣就压垮了印刷厂——ReplicationGraph 就是预先画好片区图,按片区直接取,不再每次全城比对。
服务器上行带宽 = 每个客户端的下行 × 客户端数。一个 100 人房间,如果每人都要收到其他 99 人的完整状态,带宽是 O(N²) 增长——100 人就是 ~1 万条 Actor 关系。所以第一步不是压缩,而是砍掉不必要的复制:你看不见的敌人根本不该发给你。这就是 Relevancy。之后才是「发多勤、怎么排、判定本身多贵」。
实现方案
1. Relevancy 判定链:谁收谁
AActor::IsNetRelevantFor(Viewer, ...) 决定某 Actor 是否要复制给某连接。判定按优先级(前面的覆盖后面的):
关键开关:
bAlwaysRelevant:永远发给所有人(GameState、全局管理器)。慎用,绕过所有裁剪。bOnlyRelevantToOwner:只发给 owner(PlayerController 默认如此)。NetCullDistanceSquared:距离剔除半径(平方,省开方)。超出就不相关。大世界最重要的裁剪手段。- 视线/最近可见:进一步剔除被遮挡的。
2. 节流:发多勤
相关不等于每帧发,频率由这些控制:
NetUpdateFrequency:每秒最多考虑复制几次(默认 100)。高频对象(玩家)设高,静态对象设低(如 2~5)。MinNetUpdateFrequency:没变化时降到的最低频率(默认 2),配合自适应。net.UseAdaptiveNetUpdateFrequency:开启后,没变化的 Actor 自动降频,省 CPU 和带宽。NetPriority:饱和时的排序权重。高优先级先发,低的可能被饿死(见 NetDriver/Channel/Bunch 的饥饿)。- Dormancy(休眠):几乎不变的 Actor 可休眠,彻底退出复制考虑:
DORM_Awake:正常复制。DORM_DormantAll:对所有连接休眠,不再比较不再发。DORM_Initial:一出生就休眠(如静态道具,发完初始状态就不管了)。- 需要改动时调
FlushNetDormancy()临时唤醒发一次。休眠是大世界省 CPU 的利器——一堆摆设道具发完初始值就休眠。
3. 带宽预算算式
把账算出来(数字均为数量级估算,非实测):
每客户端下行 ≈ Σ(每个相关 Actor 每次序列化字节) × 复制频率
服务器上行 ≈ 每客户端下行 × 客户端数
举例(示意量级):
- 一个玩家角色每次复制约 50~100 字节(位置+动作+属性,经 NetQuantize 压缩后)。
- 100 人房间,每人平均相关 30 个其他玩家,频率 20Hz:
- 每客户端下行 ≈ 30 × 80 字节 × 20 ≈ 48 KB/s
- 服务器上行 ≈ 48 KB/s × 100 ≈ 4.8 MB/s(单房间!)
MaxClientRate默认约 15000 B/s 量级——上面这个已经远超默认,必须靠 Relevancy 把「相关 30 人」压下去、靠 NetQuantize 把每 Actor 字节压下去、靠降频把 20Hz 压下去。
这套算式和 极限发压工具 估算「一台机器能扛多少长连接」是同一种数量级思维——先算账,再优化。
4. ReplicationGraph:判定本身的可扩展性
默认 Relevancy 判定是 O(连接 × Actor):每帧对每个连接遍历每个 Actor 问「相关吗」。1000 Actor × 100 连接 = 每帧 10 万次判定,判定本身就吃满 CPU——这才是大房间的真瓶颈,不是带宽。
ReplicationGraph 把「谁和谁相关」预先组织成图节点,避免每次全量遍历:
GridSpatialization2D节点:把世界切成网格,Actor 按格子登记。一个连接只需查它所在格子及邻近格子,把 O(Actor) 降到 O(附近 Actor)。AlwaysRelevant节点:全局相关对象(GameState)集中管理,一次算好给所有人。AlwaysRelevantForConnection节点:每连接固定相关的(自己的 Pawn/PC),直接挂连接上。
接入代价:要为项目写自定义 ReplicationGraph 类,把 Actor 分类到合适节点,改造成本不低。适合明确要做大房间(100+)的项目。
一句话记住这几组容易混的概念:
| 一组 | 差别一句话 | 类比 |
|---|---|---|
| Relevancy vs Dormancy | Relevancy 是「你不该收这个 Actor」(看谁);Dormancy 是「这个 Actor 没变化,谁都别发」(看有没有变) | 不在你的送报片区 vs 这家根本没出新稿 |
NetUpdateFrequency vs NetPriority | 频率定「一秒最多考虑几次」;优先级定「带宽不够时谁先上车」 | 一周送几次 vs 车装不下时先送谁 |
| 带宽瓶颈 vs CPU 瓶颈 | 带宽是「线路塞不下」;判定 CPU 是「分拣算不完」——大房间里后者往往先炸 | 邮车不够 vs 分拣员不够 |
| ReplicationGraph vs Iris | RepGraph 优化「谁和谁相关」的判定;Iris 重构整个复制序列化路径 | 重画片区图 vs 整个印刷厂换设备(见 Iris) |
优化顺序也就出来了:先砍相关数(Relevancy/Dormancy)→ 再降频率 → 再压每 Actor 字节(NetQuantize/FastArray)→ 判定 CPU 撑不住才上 ReplicationGraph/Iris。反过来先上 RepGraph 是把最贵的改造放在最前面。
5. 剖析工具:定位吃带宽的 Actor
排查「谁在吃带宽」的步骤:
stat net:控制台看总体收发字节、包数、每秒复制的 Actor 数——先看总量是否异常。stat NetDormancy:看有多少 Actor 该休眠没休眠。- NetworkProfiler:启动加
-networkprofiler=true生成.nprof,用 NetworkProfiler 工具打开,按 Actor/属性排序看谁占的字节最多——这是定位大户的主力工具。 - CSV Profiler:
csvprofile start/stop导出时间序列,看带宽随时间/场景的变化。 net.DumpRelevantActors:打印某帧对某连接相关的 Actor 列表——确认「不该相关的是不是错误地相关了」。
典型排查链:stat net 发现上行超标 → NetworkProfiler 看哪个 Actor 类占字节最多 → 检查它的 NetUpdateFrequency 是否过高、是否该休眠、NetCullDistance 是否过大、属性是否该上 NetQuantize/COND_。内核侧的收发优化(epoll、零拷贝、多队列网卡)见 全栈极限低延迟,那是另一层的账。
为什么这么做
先裁剪再压缩:Relevancy 是数量级的节省(不发 = 0 字节),压缩只是常数倍。100 人房间靠 NetCullDistance 把「相关 99 人」砍到「相关 20 人」,比把每 Actor 从 100 字节压到 50 字节收益大得多。
频率/休眠分层:不同 Actor 变化频率天差地别——玩家每帧动、道具永远不动。统一高频是浪费,按需分频 + 休眠让 CPU/带宽花在真正变化的东西上。
ReplicationGraph 破 O(连接×Actor):带宽能靠裁剪压下来,但判定次数压不下来——只要还是「每连接遍历每 Actor」,Actor 一多 CPU 就崩。空间网格把判定局部化,是大房间唯一的扩展路径(或上 Iris)。
为什么别的选择不行
- 全
bAlwaysRelevant:绕过所有裁剪,带宽 O(N²) 爆炸,100 人必崩。 - 统一高
NetUpdateFrequency:静态道具也每秒考虑 100 次,CPU 浪费在没变化的对象上。 - 不用 ReplicationGraph 硬扛大房间:判定 O(连接×Actor) 每帧十万次,GameThread 直接打满,带宽再省也没用。
- 凭感觉优化不测量:吃带宽的往往是意料之外的 Actor(某个忘了降频的特效管理器)。不用 NetworkProfiler 定位就是瞎调。
沉淀结论
规模问题的账:Relevancy 决定谁收谁(bAlwaysRelevant/bOnlyRelevantToOwner/NetCullDistanceSquared/视线),频率/优先级/Dormancy 决定发多勤。带宽 ≈ 每Actor字节 × 相关Actor数 × 频率,服务器上行再 × 客户端数。默认判定 O(连接×Actor) 不可扩展,大房间靠 ReplicationGraph(空间网格)或 Iris。定位大户用 NetworkProfiler。
记忆口诀
谁收谁:AlwaysRelevant 全发 / OnlyRelevantToOwner 只给主 / NetCullDistance 距离剔除 / 视线遮挡剔除
发多勤:NetUpdateFrequency 频率 / 自适应降频 / NetPriority 排序防饥饿 / Dormancy 休眠退出复制
算账:每Actor字节 × 相关数 × 频率 = 每客户端下行 / × 客户端数 = 服务器上行
扩展:默认 O(连接×Actor) / ReplicationGraph 空间网格局部化 / Iris 全局脏状态
剖析:stat net 看总量 / NetworkProfiler 定位大户 / DumpRelevantActors 查相关性
内容来源
综合整理。主要参考:Epic 官方文档 Network Relevancy、Actor Dormancy、Replication Graph、UE 源码 ReplicationGraph 插件、Epic 官方演讲《Replication Graph》(Fortnite 大规模实践)。数字为引擎默认值或业界数量级估算,非实测。
相关专题:饥饿与带宽配额的底层见 NetDriver/Channel/Bunch;属性字节压缩(NetQuantize/COND_)见 属性复制;下一代复制系统见 UE5 Iris;单机承载压测方法见 极限发压工具;内核侧收发优化见 全栈极限低延迟。
自测:合上资料能说清楚吗?
100 人房间服务器带宽怎么算?怎么压下来?
参考答案
每客户端下行 ≈ 每相关 Actor 字节 × 相关 Actor 数 × 复制频率;服务器上行 ≈ 每客户端下行 × 客户端数(O(N²) 增长)。三个方向压:① Relevancy(NetCullDistanceSquared 距离剔除)把「相关 Actor 数」砍下来——收益最大;② NetQuantize/COND_ 把「每 Actor 字节」压下来;③ 降频 + Dormancy 把「频率」压下来。
大房间真正的瓶颈是带宽还是 CPU?为什么?
参考答案
往往是 CPU(Relevancy 判定)。默认判定是 O(连接×Actor):每帧对每个连接遍历每个 Actor 问是否相关,1000 Actor × 100 连接 = 每帧 10 万次判定,GameThread 打满。带宽可以靠裁剪压,但判定次数压不下来。解法是 ReplicationGraph 用空间网格把判定局部化,或上 Iris。
Dormancy 三态是什么?什么时候用?
参考答案
DORM_Awake(正常复制)、DORM_DormantAll(对所有连接休眠,退出复制比较)、DORM_Initial(出生即休眠,发完初始状态就不管)。几乎不变的 Actor(摆设道具、静态建筑)用休眠彻底退出复制考虑,省 CPU。需要改动时调 FlushNetDormancy() 临时唤醒发一次。
怎么定位是哪个 Actor 最吃带宽?
参考答案
stat net 先看总收发是否异常 → 启动加 -networkprofiler=true 生成 .nprof,用 NetworkProfiler 工具按 Actor/属性排序看谁占字节最多 → 用 net.DumpRelevantActors 确认相关性是否错误。找到大户后检查它的 NetUpdateFrequency、是否该休眠、NetCullDistance、属性是否该压缩。