技术博客

DORM 休眠机制:让静态 actor 的 channel 闭嘴

第 72 / 100 篇 · 2026-01-12 · ue5 · 网络复制DORM大世界

大世界复制的带宽大头不是动态 actor,而是大量存在但几乎不变的东西:摆好的箱子、装饰物、关卡几何。这类 actor 每 tick 都过一遍比较、序列化、发送,纯属浪费——它的客户端状态五分钟内都不会变。DORM 机制就是给这类 actor 的 channel 装一个开关。基于 UE5.3 源码(Actor.cpp、NetDriver.cpp)梳理。

EDormancy 五档的精确语义

ENetDormancy 是个枚举,但档与档之间的差别比直觉细:

档位 语义
DORM_Never 永不休眠,默认值。每个复制 tick 都全量走。
DORM_Awake 可以休眠,但当前醒着。
DORM_DormantAll 全休眠:本 actor 的所有属性都不复制了。
DORM_DormantPartial 部分休眠:有一小撮属性(按条件白名单)仍然复制。
DORM_Initial 初始即休眠:actor 刚复制完初始状态就立即休眠。

关键区分在 DormantAll 和 DormantPartial:前者一刀切,适合"摆好就永远不动"的纯装饰;后者配合 AActor::GetDormancy 的重写——你可以在特定条件下临时返回 DORM_Awake,让个别属性(比如被交互状态)继续同步,其余时间休眠。5.x 里 DormantPartial 常与 Push Model 搭配:用 IRepChangedPropertyTracker 精确控制哪些属性在休眠期仍可标记 dirty。

DORM_Initial 是最容易用错的档。它跳过了"复制一轮再睡"的步骤——actor 的初始状态复制完立刻休眠。前提是你的 actor 初始状态之后真的没有任何要同步的东西,包括动态生成的 component。用错的表现是:客户端 spawned 出来的 actor 永远缺后续状态。

状态机:谁负责唤醒

dormant 状态下 actor 的 channel 进入"只监听"模式:ServerReplicateActors 的 gather 阶段直接跳过 dormant actor 的比较和序列化,channel 本身还开着(bunch 还能进来),只是不主动发。要让它重新说话,两条路:

DORM 状态机:唤醒靠 Flush,睡下靠 dormancy 标记 AWAKE 每 tick 复制 DORMANT 跳过比较/序列化,channel 保留 GetDormancy 返回 Dormant* FlushNetDormancy() 强制唤醒 Flush 的语义:本 tick 把 actor 标回 awake,走一轮完整比较,把积累的变化发出去 然后按 GetDormancy 决定是重新睡下还是保持醒着 常见误用:每帧 Flush → 退化成 DORM_Never 还白付状态切换开销

显式唤醒:调 AActor::FlushNetDormancy()。这会把 actor 标回 awake,下个复制 tick 走完整一轮比较把积累的变化全发出去,然后按当前 GetDormancy() 的返回值决定是重新睡下还是保持醒着。语义上它是"我现在有一波状态要同步,同步完继续睡"。

隐式唤醒:5.x 里 Push Model 的 MarkPropertyDirty 配合 dormant actor 时,引擎会自动 flush 对应的 channel——因为属性被标记 dirty 就意味着它要同步,和 dormant 语义冲突,引擎选择尊重 dirty 标记。这让 DormantPartial 的实际使用体验好很多:不用手动管唤醒,标 dirty 就行。

带宽收益的量级

账很好算。1000 个静态 actor,每个 10 个复制属性,属性平均 4 字节:醒着时每个 actor 每 tick(NetUpdateFrequency 默认 100)最坏 400 字节带宽;全 dormant 后是 0,只有 Flush 时的尖峰。对 50 个连接的专服,这是 $1000 \times 50 \times 100 \times 400\text{B} = 2\text{GB/s}$ 到接近 0 的差别——当然真实场景 relevancy 会先砍掉远距离的,但中距离"可见但静止"的 actor 全靠 dormancy 兜。

这也是为什么 UE 把 DORM_Initial 设为 AStaticMeshActor 的默认值:摆进关卡的静态网格几乎不需要后续同步。

实操坑位清单

  • FlushNetDormancy 别在 Tick 里无脑调。每帧 Flush 等于 DORM_Never,还白付一轮状态切换。正确姿势是"状态变化事件里 Flush 一次"。
  • 客户端不会自己知道 dormancy 变化。actor 睡下后客户端那边的 OnRep 自然不触发,如果客户端逻辑依赖"它睡了我就能干某件事"(比如离线渲染优化),要自己处理时序。
  • bNetLoadOnClient 和 dormancy 正交:前者管 actor 是否随关卡加载到客户端,后者管加载后的复制行为,别混。
  • 验证 dormancy 是否生效:开 LogNetTraffic 或 stat net,对比 dormancy 全开的场景带宽基线; spike 时看 Flush 频率是不是过高。

DORM 的设计哲学一句话概括:用"状态不变"这个先验换带宽。它和 relevancy(空间维度砍连接)、Push Model(时间维度砍比较)是正交的三层优化,大世界项目三层都要上。