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)(各状态都可回退,卸载走异步)
三条关键性质:
- 迁移不由时间驱动,由"目标态"驱动。
SetShouldBeLoaded/SetShouldBeVisible只是改两个 bool 目标位,真正的迁移每帧由UWorld::UpdateLevelStreaming统一结算。这一层"意图与执行分离"让 N 个触发源(流送体积、蓝图调用、Grid repaint)可以同时改目标,结算器在一帧内做一次去重决策。 - Visibility 是廉价的,Loading 是昂贵的。LoadedNotVisible → LoadedVisible 走
AddToWorld(挂载 + BeginPlay,毫秒级);反向RemoveFromWorld同价。而 Unloaded → Loaded 要走异步包加载,百毫秒起步。预热策略"先 SetShouldBeLoaded(true) 等 LoadedNotVisible 再显示"就是靠这条不对称性。 - 每个状态都有合法的回退边。任何时刻 SetShouldBeLoaded(false) 都会逐级回退,且卸载也是异步的(不能假设这帧 RemoveFromWorld 就执行完)——黑屏切换的常见原因就是卸载目标位置恰在可见状态,回退链要等好几帧。
结算循环:UpdateLevelStreaming
每帧 UWorld::Tick 调 UpdateLevelStreaming(World.cpp),它的工作分三段:
- 收集目标:遍历
StreamingLevels,对每个ULevelStreaming调UpdateStreamingState的判定(蓝图可重载ShouldBeLoaded/ShouldBeVisible),汇总出"想变成什么状态"; - 执行迁移:按上表的合法边推进。Loading 状态的推进在
FlushLevelStreaming或异步回调里完成——包的加载走LoadPackageAsync,完成后通过回调把LoadedLevel挂到描述符上; - 处理世界侧挂载:LoadedNotVisible → LoadedVisible 实际执行
UWorld::AddToWorld,触发 ActorBeginPlay;可见性撤销时先 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在服务器照常执行,别让客户端和服务器的目标位由不同逻辑驱动,否则相关性复制会对不上。