技术博客

LevelStreaming 源码:ULevelStreaming 的加载状态机

第 28 / 100 篇 · 2025-07-18 · ue5 · 流送UWorld源码分析

ULevelStreaming 的状态机在文档里只有一行枚举,但"按了流送触发点却黑屏两秒"这类问题要定位,就得知道每一跳是谁发的、跳不过去卡在哪。这篇把状态迁移的驱动方和时序翻清楚。源码:Engine/Source/Runtime/Engine/Private/LevelStreaming.cpp(UE5.3)。

状态与迁移矩阵

状态枚举上一篇列过(ELevelStreamingState),这里看迁移边。引擎用一张静态表描述合法迁移(FLevelStreamingStateContext / UWorld::UpdateLevelStreaming 一带):

                SetShouldBeLoaded     异步加载完成      SetShouldBeVisible
Removed ────────→ Unloaded ──────────→ Loading ───────→ LoadedNotVisible ──────→ LoadedVisible
   ↑                │                       │                  │                        │
   │                └───────────────────────┴──────────────────┴──── SetShouldBeLoaded(false)
   └──────── SetShouldBeLoaded(false)(各状态都可回退,卸载走异步)

三条关键性质:

  1. 迁移不由时间驱动,由"目标态"驱动。SetShouldBeLoaded/SetShouldBeVisible 只是改两个 bool 目标位,真正的迁移每帧由 UWorld::UpdateLevelStreaming 统一结算。这一层"意图与执行分离"让 N 个触发源(流送体积、蓝图调用、Grid repaint)可以同时改目标,结算器在一帧内做一次去重决策。
  2. Visibility 是廉价的,Loading 是昂贵的。LoadedNotVisible → LoadedVisible 走 AddToWorld(挂载 + BeginPlay,毫秒级);反向 RemoveFromWorld 同价。而 Unloaded → Loaded 要走异步包加载,百毫秒起步。预热策略"先 SetShouldBeLoaded(true) 等 LoadedNotVisible 再显示"就是靠这条不对称性。
  3. 每个状态都有合法的回退边。任何时刻 SetShouldBeLoaded(false) 都会逐级回退,且卸载也是异步的(不能假设这帧 RemoveFromWorld 就执行完)——黑屏切换的常见原因就是卸载目标位置恰在可见状态,回退链要等好几帧。

结算循环:UpdateLevelStreaming

每帧 UWorld::Tick 调 UpdateLevelStreaming(World.cpp),它的工作分三段:

  1. 收集目标:遍历 StreamingLevels,对每个 ULevelStreaming 调 UpdateStreamingState 的判定(蓝图可重载 ShouldBeLoaded/ShouldBeVisible),汇总出"想变成什么状态";
  2. 执行迁移:按上表的合法边推进。Loading 状态的推进在 FlushLevelStreaming 或异步回调里完成——包的加载走 LoadPackageAsync,完成后通过回调把 LoadedLevel 挂到描述符上;
  3. 处理世界侧挂载:LoadedNotVisible → LoadedVisible 实际执行 UWorld::AddToWorld,触发 Actor BeginPlay;可见性撤销时先 RemoveFromWorld 再视情况卸载包。

异步完成这一跳的挂接在 ULevelStreaming::AsyncLevelLoadComplete:把加载出来的 ULevel 指针存进 LoadedLevel,状态在下一帧结算时从 Loading 跳到 LoadedNotVisible。注意 BeginPlay 不在这里发生——加载完成≠进世界,中间隔着一次 AddToWorld。日志排查时要区分"包加载耗时"(AsyncLoad 阶段)和"进世界耗时"(AddToWorld 阶段),log LogStreaming 和 stat streaming 分别看。

触发方式是怎么接进来的

经典方案有三种触发源,全都收敛到那两个 bool 目标位:

  • StreamingVolume:玩家 Pawn 进入体积 → ULevelStreamingVolume::UpdateStreaming 改目标。性能陷阱:默认每帧每体积扫重叠,大世界里体积多了要开 bEditorPreOnly 或换 Grid repaint;
  • 蓝图/代码手动:SetShouldBeLoaded / LoadLevelInstance(后者是同步封装,游戏线程阻塞,别在大世界主路径用);
  • Grid Repaint(WorldComposition):按玩家位置网格化刷新目标。

WorldComposition 的核心就是这个 Grid repaint + 距离分层(Cinematic/Gameplay/LOD)。UE5 的 World Partition 把它换成了引擎托管的 cell 流送:对开发者来说目标位由 UWorldPartition 系统直接计算,不再需要手动摆 ULevelStreaming 描述符——但底下仍是"改目标 → UpdateLevelStreaming 结算"这一套,状态机本身没有变。

实战排障

  • 触发后不显示:先打 stat streaming 看包是否还在 Loading(IO 问题),再看是不是停在了 LoadedNotVisible(目标位没置上——蓝图里 SetShouldBeVisible 之前要先 SetShouldBeLoaded(true),它不会自动连带)。
  • 切换卡顿:AddToWorld 的 BeginPlay 风暴。预热没用时,把关卡里"BeginPlay 重活"(大批 SpawnActor、同步资产加载)挪到异步阶段,或用 bDisableDistanceStreaming 配合手动流送窗口。
  • 卸载后内存不释放:描述符还停在 LoadedNotVisible(只藏了可见性,包还在),确认 SetShouldBeLoaded(false)。
  • Dedicated Server 流送:服务器默认不渲染,但 Actor 逻辑要跑,所以服务器同样需要流送策略一致——UpdateLevelStreaming 在服务器照常执行,别让客户端和服务器的目标位由不同逻辑驱动,否则相关性复制会对不上。

ULevelStreaming 状态机:目标位驱动,每帧结算 Removed 初始 Unloaded 已知包名 Loading 异步 IO LoadedNotVisible 驻留未挂载 LoadedVisible Actor 激活 加载 异步 完成 挂载 SetShouldBeLoaded(false):逐级异步回退,多帧完成,不可假设即时 每帧结算:UpdateLevelStreaming 收集目标位 → 查迁移表 → 执行 触发源(Volume / 蓝图 / Grid)只改目标,不做迁移;BeginPlay 发生在挂载边,不在加载完成边