技术博客

复制带宽优化实战:NetUpdateFrequency、量化与热点定位

第 78 / 100 篇 · 2026-03-17 · ue5 · 网络复制带宽优化性能分析

多人项目的带宽问题几乎从不以"带宽不够"的面目出现——它表现为远端玩家瞬移、命中判定漂移、偶发的整体卡顿。等玩家投诉再查就晚了,正确姿势是把上行带宽当预算管:先算账,再上杠杆,最后用数据验证。这篇把三个杠杆(频率、量化、休眠)各拆一遍,附定位热点的完整流程。

先算账:带宽公式

一个 actor 占用的带宽近似为

$$B_{\text{actor}} = f_{\text{update}} \times S_{\text{update}} \times (1 - p_{\text{dormant}})$$

其中 $f_{\text{update}}$ 是实际复制频率(由 NetUpdateFrequency 和 MinNetUpdateFrequency 上下夹住),$S_{\text{update}}$ 是单次复制的序列化体积,$p_{\text{dormant}}$ 是处于休眠的时间占比。优化就是分别压这三个因子。

经验数字:一条 ReplicatedMovement(开启组件复制时的 transform)序列化后大约 40~70 字节;一个血条组件(一个 float 属性)几字节。100 个高频 actor 在 30Hz 下就是 $100 \times 30 \times 60\text{B} \approx 172\text{KB/s}$——家用宽带上行就满了。频率是第一杠杆,因为所有属性共享一次属性块打包。

杠杆一:NetUpdateFrequency

每个 AActor 有 NetUpdateFrequency(复制间隔下限的倒数)和 MinNetUpdateFrequency(相关性变差时的降频下限)。NetDriver 每个连接每 tick 遍历 relevant 的 channel,检查 NextUpdateTime,到期才调度。把远景 NPC 从 30Hz 降到 10Hz,带宽直接砍 2/3,玩家看不出差别——十米外的人每 100ms 才插值一次,动捕精度远超需求。

两个细节常被忽略:

  • 降频不省 CPU。属性脏检查和 bunch 打包的固定开销还在,只是发得少。GAS 项目里 ASC 属性多的角色,瓶颈经常是这个遍历。
  • 别用 MinNetUpdateFrequency 兜底 NetUpdateFrequency 设得太高的错误。正确做法是先想清楚"这个对象多久变一次值得同步",直接用 SetNetUpdateFrequency 按游戏状态动态切(战斗中 20Hz,站岗 2Hz)。

杠杆二:量化——用精度换比特

浮点属性默认按 32 位发,但游戏里 99% 的数值不需要 IEEE 精度。量化的数学很直白:把范围 $R$ 切成步长 $\Delta$ 的格子,需要的比特数是

$$N = \left\lceil \log_2 \frac{R}{\Delta} \right\rceil$$

血量 0–1000、步长 1:$R/\Delta = 1000$,只要 10 bit,比 float 省 22 bit。引擎内置的量化原语直接覆盖常见场景:

  • FVector_NetQuantize 系列:位置/向量按固定精度压缩(序列化时按值域自适应位宽,SerializePackedVector),角色移动 RPC 和 ReplicatedMovement 默认就走这个。
  • FFloat_NetQuantize / FFloat_NetQuantize8 / FFloat_NetQuantize16:把 float 压成 8/16 位定点,自己声明 UPROPERTY(ReplicatedUsing=...) 的手写属性首选。
  • 枚举、bool 组、uint8:能小则小。一个 bool 数组打包进 bitfield 比 8 个 bool 属性省一个数量级。

量化的红线是语义边界:位置量化误差会进碰撞和命中判定(步长 1cm 在爆头判定里就是大事);血量量化误差会进死亡判定。凡是喂给"离散判决"的数值,$\Delta$ 必须小于判决阈值的 1/2,否则边界情况会随机翻转。这篇的取舍口诀:连续体验类数值大胆量化,离散判决类数值保守量化。

杠杆三:DORM 休眠

dormancy 那篇细讲过状态机,这里只算带宽账:DORM_Awake → DORM_DormantAll 切换后,该 actor 完全退出复制调度(直到被 WakeUp 或接收客户端 RPC)。配合 FlushDormancy 的"睡之前把最终状态发干净",休眠是免费的零带宽。箱子、尸体、建造物——凡是不动的都该睡。上面公式里的 $p_{\text{dormant}}$ 在静态场景里能压到 0.9 以上。

定位:谁花了我的带宽

先 stat net 看总量和 channel 数(连接级:in/out bytes per second、bunches、 actors)。总量超了再用 Network Profiler 出明细:控制台 netprofile enable 跑一段有代表性的战斗,netprofile disable 后在 Saved/Profiling/Network/ 得到 .nprof,用 UnrealFrontend 或 NetProfileAnalyzer 打开,按 actor class 排序看每个类的 bytes/sec——高频低信息量的 class 会立刻浮出来(常见嫌疑:特效 actor 忘了销毁、ASC 属性默认值也在同步、Tick 里每帧改一个 replicate 变量)。

带宽预算的构成与三个杠杆的作用点 总带宽 B = Σ f × S × (1 − p_dormant) stat net 看总量 → netprofile 按 class 拆明细 杠杆一:频率 NetUpdateFrequency / 按游戏状态动态切换 杠杆二:量化 N = ⌈log2(R/Δ)⌉ 位 判决类数值保守量化 杠杆三:休眠 不动的 actor 退出调度 FlushDormancy 再入睡 顺序:先砍频率(无感),再量化(要验证判决阈值),最后休眠(静态场景)

落地顺序与一个反面教训

推荐顺序就是上图从左到右:频率最无感、收益最大;量化要逐属性验证;休眠适合静态世界。反面教训:我见过一个项目反过来做——先给所有属性加量化,结果命中判定用的血量被量化成 4 位,判决翻转导致"明明打死了没死"的 bug 查了一周。杠杆顺序错了,省下的带宽会用调试时间还回去。

最后,stat net 的数字要和真实网络条件一起看:本地回环测出来的带宽没有 UDP 重传和 MTU 分片的损耗,上线前务必在加包(net pktloss/net pktlag 控制台命令)下复测一次。