老周第一次把项目跑在 PS5 上时,画面卡了整整十七秒。
那是个开放世界原型,山脉、村庄、地下城全塞在一个 Persistent Level 里。美术很开心,因为所有东西都能同时看见。策划很开心,因为传送门随便摆。只有老周不开心。十七秒的卡顿足以让任何玩家按下退出键。
小林凑过来说,要不我们把地图拆一拆?老周点头,拆是肯定要拆的,问题是拆完之后怎么让它在正确的时间出现。
这就是 Level Streaming 要解决的命题。
1. Level Streaming 基础
Unreal Engine 把一个项目的世界抽象成 World。World 下面挂着一个 Persistent Level,也就是主关卡,它永远不会被卸载。其余的 Sub Level 可以按需加载或卸载,整个过程就是 Level Streaming。
你可以把它理解成剧场的后台。Persistent Level 是舞台本身,灯光和地板始终在那里。Sub Level 是布景,幕布拉开时换上去,幕布落下时撤下来。观众永远看不见后台里堆了多少东西。
flowchart TD
subgraph ue ["Unreal Engine World"]
persistent["Persistent Level"]
sub_a["Sub Level A"]
sub_b["Sub Level B"]
sub_c["Sub Level C"]
end
persistent --> sub_a
persistent --> sub_b
persistent --> sub_c每个 Sub Level 在 Content Browser 里都是一个独立的 .umap 资产。打开 Levels 面板,右键把已有关卡 Add Existing 进来,或者 New Level 创建一个再挂上去。挂上去之后,这个 Sub Level 就和 Persistent Level 建立了父子关系。
流送有三种状态:
- Unloaded:关卡完全不在内存里,只占一个引用。
- Loaded:关卡资产已经加载到内存,但还没有生成到 World 中。
- Visible:关卡已经生成,Actor 开始 ticking,玩家看得见。
从 Unloaded 到 Loaded,再从 Loaded 到 Visible,是两个独立步骤。UE5.8 允许你先把关卡数据读进来,等玩家走到门口再让它现身。这个设计对无缝加载至关重要。
小林在编辑器里试过,把一个 Loaded 状态的关卡切到 Visible 只需要几十毫秒,因为磁盘读取那一步已经提前做完了。真正耗时的是从硬盘往内存搬数据,以及后续生成成百上千个 Actor。理解这两步的差异,才能决定预加载要预多远、阻塞加载要阻塞多久。
内存预算是流送的直接驱动力。假设 Persistent Level 占 MB,第 个 Sub Level 占 MB,同时在线的关卡集合为 ,则当前世界占用的总内存可以写成:
如果 超过平台的纹理池和物理内存上限,卡顿、崩溃、纹理串流降级就会接踵而至。老周后来在白板上画了一条红线,上面写:同时在线的关卡加起来不能超过 4.2 GB。
2. Blueprint / C++ 流送
老周先让小林用 Blueprint 做原型。Blueprint 里有两个节点最常用:Load Stream Level 和 Unload Stream Level。把 Level 的 FName 传进去,再勾上 Make Visible After Load,关卡就会在加载完成后自动显示。
flowchart TD
A["玩家触发传送门"] --> B{目标关卡是否已加载}
B -->|"是"| C["调用 Unload Stream Level"]
B -->|"否"| D["调用 Load Stream Level"]
D --> E["等待 Completed 委托"]
E --> F["关卡进入 Visible 状态"]
C --> G["关卡进入 Unloaded 状态"]Blueprint 的好处是快。策划改个触发条件,连重新编译都不用。坏处是不够细。你想控制加载优先级、阻塞加载、异步回调的时机,节点上的那几个勾选框就不够用了。
于是老周把核心路径迁到了 C++。UGameplayStatics::LoadStreamLevel 和 UnloadStreamLevel 本质上是封装,ULevelStreamingDynamic 和 UWorld 的接口才更底层。
下面这段代码演示了如何异步加载一个 Sub Level,并在回调里做后续操作:
#include "Engine/LevelStreamingDynamic.h"
#include "Kismet/GameplayStatics.h"
void AMyWorldManager::StreamLevelByName(const FString& LevelPath)
{
FLatentActionInfo LatentInfo;
LatentInfo.CallbackTarget = this;
LatentInfo.ExecutionFunction = FName("OnLevelLoaded");
LatentInfo.Linkage = 0;
LatentInfo.UUID = FMath::Rand();
UGameplayStatics::LoadStreamLevel(
this,
FName(*LevelPath),
true, // bMakeVisibleAfterLoad
true, // bShouldBlockOnLoad
LatentInfo
);
}
void AMyWorldManager::OnLevelLoaded()
{
UE_LOG(LogTemp, Log, TEXT("关卡加载完成,可以安全生成玩家或 NPC"));
}bShouldBlockOnLoad 这个参数很关键。如果设置为 true,当前线程会停下来等关卡加载完。适合传送门、副本切换这种必须等加载完成的场景。如果设置为 false,加载在后台跑,不会阻塞游戏线程,适合大世界漫游时的预加载。
老周的经验是:不要把阻塞加载和异步加载混在同一帧里。玩家冲进一个新区域时,前台正在播动画,后台用异步预加载;等他真正跨过边界线,再用一次轻量的阻塞加载确保那一两秒的可见性没有空洞。
Blueprint 节点还有一个不太直观的细节:Load Stream Level 的 Completed 委托在关卡进入 Visible 之后才会触发,而不是 Loaded。如果你只是想预加载到内存,应该调用 C++ 中 ULevelStreaming 的 SetShouldBeLoaded 接口,把 Visible 的切换留到更晚。
3. Soft Object Path 与 Async Load
UE5.8 里,关卡的引用方式有两种:硬引用和软引用。硬引用会在打包和加载时把依赖链全部拉进来,适合 Persistent Level 内部必不可少的资源。软引用只记录一个路径字符串,不会自动加载,适合 Sub Level 这种按需出现的资源。
FSoftObjectPath 就是软引用的载体。它的值长这样:/Game/Maps/Dungeon_01.Dungeon_01。你可以把它存在 Data Asset 里,也可以存在 Game Instance 的表格中。运行时再用 FStreamableManager 异步请求加载。
sequenceDiagram
participant Player as "玩家角色"
participant GM as GameMode
participant SM as StreamableManager
participant Asset as "Sub Level 资产"
Player->>GM: 接近新区域
GM->>SM: RequestAsyncLoad(SoftObjectPath)
SM->>Asset: 开始异步读取
SM-->>GM: 返回 Handle
Asset-->>SM: 加载完成
SM-->>GM: 触发 Completed 委托
GM->>Asset: 调用 LoadStreamLevel
Asset-->>Player: 关卡 VisibleFStreamableManager 的请求方式分两种:RequestAsyncLoad 和 RequestSyncLoad。同步加载会阻塞当前线程,异步加载不会。实际项目中,老周更推荐用异步加载配合优先级:
FStreamableManager& Streamable = UAssetManager::GetStreamableManager();
FSoftObjectPath LevelPath(TEXT("/Game/Maps/Dungeon_01.Dungeon_01"));
TSharedPtr<FStreamableHandle> Handle = Streamable.RequestAsyncLoad(
LevelPath,
FStreamableDelegate::CreateUObject(this, &AMyWorldManager::OnLevelAsyncLoaded)
);回调触发后,关卡资产已经在内存里,再去调用 LoadStreamLevel 时速度会快很多。因为磁盘 IO 已经提前做完了,剩下的只是 World 初始化和 Actor 生成。
这套预加载机制非常适合副本门口。玩家站在副本入口看说明文字的三秒钟,足够把地下城关卡读到内存。等他点击确认,真正的 LoadStreamLevel 几乎瞬间完成 Visible 切换。
这里有个小林踩过的坑:Soft Object Path 在编辑器里和打包后解析路径的方式不完全一致。如果路径里带了 _C 后缀或者引用了蓝图生成的关卡,最好在打包版本上再测一遍。老周的补救办法是用 FSoftObjectPath::FixupForPIE 和 FixupForPIEInstance 在运行前做一次规范化。
4. Level Streaming Volume
手动写代码控制每个关卡的加载很精确,但也繁琐。UE5.8 提供了 Level Streaming Volume,一种放在关卡里的体积触发器。玩家摄像机进入体积时自动加载关联的 Sub Level,离开体积时自动卸载。
它的工作方式很直接。打开 Levels 面板,选中一个 Sub Level,点击 Level Details 里的 Streaming Volumes,然后把场景里的 Volume 加进去。Volume 的 Streaming Usage 有三种模式:
- SVB Loading:只负责加载,不负责卸载。适合希望关卡驻留更久的场景。
- SVB Loading and Visibility:加载后立刻可见。最常用于室外到室内的切换。
- SVB Blocking:玩家进入体积时阻塞直到关卡加载完成。适合必须保证连续性的边界。
graph LR
A["Persistent Level 山脉"] -->|"Streaming Volume"| B["Sub Level 村庄"]
B -->|"Streaming Volume"| C["Sub Level 酒馆"]
C -->|"Streaming Volume"| D["Sub Level 地下室"]
D -->|"完成副本"| B老周把村庄外围放了一圈 Loading and Visibility Volume。玩家从山道走过来时,村庄在远处渐渐加载;等真正跨过村口那座石桥,酒馆的内部关卡也开始预热。整个过程没有黑屏,只有视距上的自然过渡。
Volume 的缺点是依赖玩家位置。如果玩家用传送或者快速旅行,摄像机可能跳过 Volume,导致目标关卡没被触发。老周的处理方式是:传送逻辑里再补一次手动的 LoadStreamLevel,并在回调完成后再把玩家 Pawn 放下去。Volume 只覆盖正常移动路径。
另外,Volume 的判断基于玩家 Camera 的位置,而不是 Pawn 的根节点。第三人称游戏里,Camera 可能离角色好几米远。小林曾经在角色已经进屋、Camera 还在门外的情况下触发过多次加载和卸载,帧率像过山车。把 Volume 往外扩一点,或者改用角色位置做二次校验,可以缓解这个问题。
Volume 的卸载延迟由 Min Time Between Volume Updates 控制,默认值是 0.5 秒。如果你的关卡很大,玩家高速移动,可以适当调大这个值,避免 Volume 边界处来回横跳导致频繁加载卸载。
5. 加载屏与无缝旅行
Level Streaming 的最高境界是玩家感觉不到加载的存在。但有时候你必须加载,比如从主菜单进开放世界,或者从大地图进入副本。这时候有两种选择:加载屏和无缝旅行。
加载屏最简单。调用 UGameplayStatics::OpenLevel 或 OpenLevelBySoftObjectPtr,UE 会暂停当前世界,把目标关卡加载进来,再切过去。玩家看见的是一张静态图加一个进度条。实现成本低,但体验有割裂感。
无缝旅行对应的是 ServerTravel 和 ClientTravel。它的核心思想是:在旧 World 还在渲染的同时,后台创建新 World 并加载目标关卡,完成后再把玩家无缝迁移过去。网络游戏中尤其重要,因为所有客户端需要同步到达新地图。
flowchart LR
A["当前关卡渲染中"] --> B["后台创建新 World"]
B --> C["异步加载目标关卡"]
C --> D["迁移玩家控制器"]
D --> E["销毁旧 World"]
E --> F["新关卡继续渲染"]老周在副本入口用了无缝旅行。玩家点击进入副本后,镜头开始播放一段进门动画。动画播放到第三秒时,服务器已经在后台把副本关卡加载好了。动画结束,玩家角色被传送到副本出生点,旧关卡被卸载。整个流程没有加载屏,只有一段看起来像过场的黑场过渡。
无缝旅行需要处理好 Game Mode 和 Player State 的迁移。AGameModeBase::ProcessServerTravel 会触发 SeamlessTravel 流程,期间 GameMode::PostSeamlessTravel 和 PlayerController::ReceiveSeamlessTravel 会被调用。如果你在这些回调里做初始化,要注意旧 World 的 Actor 可能还没完全销毁。
加载屏和无缝旅行的选择没有绝对优劣。单机 Demo 用加载屏更稳,网络副本用无缝旅行更顺。老周的判断标准很简单:如果玩家需要等待超过五秒,就给他看点东西;如果能在三秒内完成,就尽量做成无缝。
无缝旅行还有一个隐藏成本:内存中会同时存在旧 World 和新 World 一段时间。如果两个关卡都很大,峰值内存可能直接翻倍。老周在 PS5 上监控过,迁移瞬间内存从 3.1 GB 涨到 5.8 GB,好在持续时间不到两秒。主机平台的内存预算要按峰值留余量,不能按稳态算。
6. 生命周期回调
Sub Level 加载和卸载过程中,UE5.8 提供了一系列生命周期事件。掌握这些事件的触发时机,才能避免在关卡还没准备好时就访问其中的 Actor。
最常见的是 AActor::BeginPlay 和 EndPlay。它们在每个 Actor 被生成到世界或从世界移除时触发。但如果你想在关卡整体加载完成时做一些事情,比如初始化区域任务、生成动态 NPC、播放环境音效,用单个 Actor 的 BeginPlay 就不够可靠。
ULevelStreaming 自身提供了委托:
OnLevelLoaded:关卡资产加载完成,但 Actor 可能还没生成完。OnLevelShown:关卡已经 Visible,Actor 全部生成完毕,可以安全交互。OnLevelHidden:关卡被隐藏,但还在内存里。OnLevelUnloaded:关卡完全卸载,内存释放。
老周写了一个 ALevelCoordinator,专门监听这些委托。当某个 Sub Level 的 OnLevelShown 触发时,它才去查询该关卡内的任务触发器,并把任务状态同步给客户端。这比在每个任务 Actor 的 BeginPlay 里各自发消息要可控得多。
if (ULevelStreaming* Streaming = FindLevelStreamingForName(LevelName))
{
Streaming->OnLevelShown.AddDynamic(this, &ALevelCoordinator::HandleLevelShown);
Streaming->OnLevelHidden.AddDynamic(this, &ALevelCoordinator::HandleLevelHidden);
}
void ALevelCoordinator::HandleLevelShown()
{
// 此时关卡内所有 Actor 都已生成
}小林问过,能不能在 OnLevelLoaded 里直接 FindActor?答案是可以,但不建议。Loaded 状态意味着资产就绪,但 World 的 spawning 过程可能还在进行。如果你此时遍历 Actor 列表,可能会拿到 nullptr 或者不完整的对象。等 OnLevelShown 再动手更稳妥。
卸载侧同理。OnLevelHidden 触发时,Actor 还在内存里,只是不再 ticking 和渲染。如果你需要保存玩家在该区域的状态,这是最后的机会。OnLevelUnloaded 之后再访问该区域 Actor,就会直接崩溃。
UE5.8 还提供了一个全局委托 FWorldDelegates::LevelAddedToWorld 和 LevelRemovedFromWorld,适合在 Game Instance 或 Subsystem 里做统一监听。老周把任务系统的区域注册逻辑放在这里,比在每个 Sub Level 里放监听器更容易维护。
7. 与 World Partition 的关系
UE5 引入 World Partition 之后,很多人问老周:既然 World Partition 已经把大世界自动分块了,那 Level Streaming 还有用吗?
答案是:两者不是替代关系,而是不同层级的工具。
World Partition 解决的是单个大关卡内部的流送。它把一张巨大的地图按网格划分,根据玩家位置和流送距离决定哪些 Cell 加载、哪些 Cell 卸载。你不用手动拆关卡,只要打开 World Partition,编辑器会自动帮你生成 Grid。
Level Streaming 解决的是多个关卡之间的加载关系。它把地图拆成独立的 .umap,由你决定什么时候加载、加载后是否可见、是否阻塞。
老周的项目同时用了两者。山脉和草原是一张巨大的 World Partition 关卡,由引擎按 Grid 自动管理。村庄、酒馆、地下城则是独立的 Sub Level,通过 Level Streaming 控制。这样策划可以在大世界地图里自由摆放村庄入口,美术又不用把所有室内细节都塞进同一张地图。
flowchart TD
subgraph world ["World"]
persistent["Persistent Level"]
wp["World Partition 大地图"]
village["Sub Level 村庄"]
dungeon["Sub Level 地下城"]
end
subgraph wp_internal ["World Partition 内部"]
cell_a["Cell A"]
cell_b["Cell B"]
cell_c["Cell C"]
end
persistent --> wp
persistent --> village
persistent --> dungeon
wp --> cell_a
wp --> cell_b
wp --> cell_cWorld Partition 的加载单位是 Cell,距离阈值由 Runtime Grid 和 HLOD 设置决定。Level Streaming 的加载单位是整个关卡,触发条件由你控制。两者叠加时,要注意别让 World Partition 卸载你正在 Level Streaming 加载的关卡。老周的做法是:把需要 Level Streaming 的 Sub Level 放到 World Partition 关卡的内部,但关闭该 Sub Level 的自动流送,完全由代码接管。
调试这两种流送时,stat levels 和 stat streaming 是两个最常用的控制台命令。前者显示当前加载了哪些关卡以及它们的状态,后者显示流送线程的 IO 和内存统计。老周习惯在屏幕上开一个小窗口,实时看这些数字,比事后翻日志直观得多。
8. 多人游戏中的关卡流送
多人游戏的关卡流送比单机复杂一个数量级。因为服务器和每个客户端都可能加载不同的关卡集合,而游戏逻辑又要求大家对某些 Actor 达成一致。
在 Listen Server 或 Dedicated Server 架构下,服务器通常加载全部必要的关卡。客户端则只加载自己附近可见的关卡。这意味着服务器上存在的 Actor,客户端上未必存在。如果你用 RPC 给客户端发送一个只存在于服务器某关卡的 Actor 引用,客户端会解析失败。
老周的处理原则有三条:
第一,关卡加载策略由服务器决定,客户端只负责执行。服务器根据玩家位置计算应该加载哪些 Sub Level,然后通过 RPC 或 replicated 变量通知客户端。不要让客户端自己决定加载什么。
第二,所有需要在网络间同步的 Actor,必须放在双方都已经加载的关卡里。如果你要同步一个宝箱状态,确保宝箱所在关卡在服务器和该客户端上都是 Visible。
第三,玩家 Pawn 的生成位置必须选在已经加载并 Visible 的关卡。否则 APlayerController::ClientTravel 或 RestartPlayer 会把玩家放到虚空里。
无缝旅行在多人游戏里几乎是必选项。APlayerController::ClientTravel 和 AGameModeBase::ProcessServerTravel 会协调服务器和所有客户端一起迁移。迁移过程中,服务器先 travel 到新地图,然后依次把客户端拉过去。每个客户端在本地做异步加载,加载完成后再通知服务器。服务器等所有人都到齐后,统一开始新一局。
这个过程中最麻烦的是 replication graph 和 net cull distance。老周建议在关卡迁移前先把旧的 replication 数据清理掉,避免客户端在新地图里还收到旧地图 Actor 的更新。可以在 GameMode::PostSeamlessTravel 里重置 Replication Driver。
延迟也是多人流送的大敌。假设服务器在日本,客户端在欧洲,异步加载完成的时间可能相差数秒。老周加了一个缓冲状态:服务器收到所有客户端都加载完成的确认后,再统一让玩家可以移动。等待期间用一段通用的进入动画或过渡画面遮住差异。
判断客户端加载完成不能只靠 OnLevelShown。老周让客户端在本地回调触发后,再向服务器发送一个可靠的 RPC 确认。服务器收集到全部确认,才设置 bCanMove = true。这个简单的状态机避免了性能差的客户端还没准备好就被迫开始游戏。
结语
老周后来把项目重新跑在 PS5 上,卡顿从十七秒降到了一点二秒。大部分时间里,玩家只是在山路上走几步,远处的村庄就悄悄出现了。
Level Streaming 不是魔法。它只是把大问题切成了小问题,让每个小问题在合适的时机出现。拆关卡的粒度、加载的时机、阻塞与异步的平衡、服务器与客户端的同步,这些细节决定了大世界是流畅还是破碎。
UE5.8 给了足够多的工具。剩下的,就是开发者愿不愿意坐下来,把每一块拼图放到正确的位置。