UE5.8 World、Level 与 Streaming:大地图不是一次加载出来的
引子:十六平方公里的噩梦
阿杰第一次打开项目里的 OpenWorld_Main 关卡时,编辑器假死了三分半钟。等界面终于反应过来,他看见右下角内存占用稳稳停在 31 GB。小美在旁边啃着饼干说:「这才 16 平方公里,后面还要做 64 平方公里呢。」
他们俩正在做一款开放世界 RPG。策划案里写着草原、雪原、沙漠、地下城、浮空岛,还有各种副本。最开始大家图省事,把所有地形、建筑、植被、任务点全塞进一个关卡。刚开始还好,地图小、物件少,直到有一天美术导入了一片树林,编辑器开始每隔十分钟崩溃一次。
阿杰用了整个下午排查,发现问题很单纯:关卡太大,一次性加载进内存的资源太多。显卡和 CPU 都没问题,瓶颈在内存和磁盘 IO。即使最终玩家机器配置很高,也不可能把整片大陆同时塞进显存。
小美问:「那电影和 3A 游戏怎么做大地图的?」
阿杰打开 UE5.8 的文档,指着 Level Streaming 说:「一次只加载玩家看得到的地方。」
这就是我们今天要聊的主题:World、Level 与 Streaming。
一、World Context 与 Persistent Level
在 UE 里,World 是一个运行时的容器。玩家真正进入游戏时,引擎会创建一个 UWorld 实例。这个实例里必须有一个 Persistent Level,也就是主关卡。它不会被卸载,会一直存在到 World 销毁。
可以这么简单理解:
- World 是整个游戏场景的运行时表示;
- Persistent Level 是 World 的根基,出生点、主光照、世界设置、Game Mode 逻辑都放在这里;
- Sub-Level 是附加到 Persistent Level 上的子关卡,可以按需加载和卸载。
如果做一个副本切换的游戏,Persistent Level 可能只是一个空壳,只负责管理全局逻辑和玩家状态。每次进副本,就动态加载对应的 Sub-Level,离开时再卸载。这样做的好处是内存里常驻的数据很少,切换副本时不需要销毁整个 World。
architecture-beta
group world(cloud)[UWorld]
service pl(database)[Persistent Level] in world
service sl1(server)[Sub-Level A] in world
service sl2(server)[Sub-Level B] in world
service sl3(server)[Sub-Level C] in world
pl:L --> R:sl1
pl:L --> R:sl2
pl:L --> R:sl3Persistent Level 里放的 Actor 会全局存在。所以不要把海量静态物件塞进去。Terrain、天空盒、全局光照、GameMode 相关的 Actor 可以放在这里。树木、岩石、房屋、敌人这些应该拆到 Sub-Level 里。
有一点新手容易踩坑:World Context Object。蓝图里很多节点需要传入 World Context,本质上就是告诉引擎「我在哪个 UWorld 里干活」。在单进程游戏里通常不用关心,因为只有一个 World。但在编辑器工具、独立服务器、PIE(Play In Editor)多窗口、异步加载场景时,World Context 就变得非常关键。拿错 Context,你可能会把 Actor 生成到编辑器场景里,而不是游戏场景里。
二、Level 与 Sub-Level 的关系
一个 Level 文件就是一组 Actor 的集合。Sub-Level 并不是低人一级的 Level,它和 Persistent Level 在文件格式上没有本质区别,只是运行时地位不同。Persistent Level 是根,Sub-Level 是被挂载的叶子。
在 UE5.8 的 Content Browser 里右键新建 Level,然后把 Level 拖到 Persistent Level 的 Levels 窗口里,就变成了 Sub-Level。Levels 窗口里可以看到所有已挂载的 Sub-Level,右键可以设置当前编辑的 Level、加载或卸载、改变流送方式。
流送方式有两种:
- Blueprint:通过蓝图或 C++ 代码手动控制加载和卸载;
- Always Loaded:启动时自动加载,并且不会被卸载。
Always Loaded 的 Sub-Level 本质上和 Persistent Level 差别不大,只是文件拆开了。真正体现 Streaming 价值的是 Blueprint 方式的 Sub-Level。
flowchart TD
A[Persistent Level] --> B[Always Loaded Sub-Level]
A --> C[Blueprint Streamed Sub-Level 1]
A --> D[Blueprint Streamed Sub-Level 2]
A --> E[Blueprint Streamed Sub-Level 3]
C --> F[按需加载/卸载]
D --> F
E --> F拆分时有一个常用策略:按空间划分。比如把 16 平方公里的地图切成 2x2 公里一块,每一块一个 Sub-Level。玩家走到 A 区时加载 A,离开 A 足够远时卸载 A。这样内存里同时存在的资源就控制在一个可接受的范围。
但按空间划分不是唯一选择。也可以按玩法划分:主城的建筑一个 Sub-Level,地下城一个 Sub-Level,副本一个 Sub-Level。甚至可以把同一片区域按 LOD 拆分,近处高精度模型一个 Level,远处低精度模型一个 Level。
拆得越细,流送粒度越灵活,但管理成本也越高。每个 Sub-Level 在运行时都有自己的 Level Script Actor,加载时会触发 BeginPlay,卸载时会触发 EndPlay。如果拆分太碎,Actor 之间的跨 Level 引用会变得麻烦。
三、Level Streaming Volume 与 Blueprint 流送
UE 提供了两种最常用的流送触发方式:Level Streaming Volume 和 Blueprint 手动控制。
3.1 Level Streaming Volume
Level Streaming Volume 是一个特殊的体积。玩家摄像机进入体积时,关联的 Sub-Level 开始加载;离开体积并超过一定时间或距离后,Sub-Level 卸载。它最适合做开放世界的大地图分块。
用法很简单:在关卡里放一个 Streaming Volume,选中它,在 Details 面板里指定要控制的 Sub-Level。可以设置加载优先级、是否应该阻塞加载、延迟卸载时间等参数。
阿杰的项目里,每块 2x2 公里的地形配一个 Streaming Volume。Volume 比地形本身大一点,形成重叠区域。这样玩家走到边界时,下一块已经提前加载好了,不会看到空气墙或空白。
重叠区域的大小很重要。太小了,玩家跑太快会冲进未加载区域;太大了,同时存在的 Sub-Level 太多,失去流送意义。一般来说,根据玩家移动速度和加载耗时来计算比较靠谱。
假设玩家最大移动速度是 ,从触发加载到 Level 完全可用需要 ,那么安全重叠距离至少为:
其中 是安全系数,通常取 到 。如果 ,,,那么 。阿杰最后把重叠区域设成 80 米,跑测时再也没出现过穿帮。
3.2 Blueprint 流送
Level Streaming Volume 方便,但不够灵活。如果加载时机和玩家位置无关,比如副本切换、任务触发、剧情演出,就需要用 Blueprint 或 C++ 手动控制。
蓝图里最常用的节点是 Load Stream Level 和 Unload Stream Level。Load 的时候可以指定 Completed 回调,加载完成后触发。也可以勾选 Should Block on Load,让加载阻塞当前线程直到完成。阻塞加载简单,但会造成卡顿,适合加载界面或切换副本时短暂的过渡。
sequenceDiagram
participant P as Player
participant B as Level Blueprint
participant S as Streaming Subsystem
participant L as Sub-Level
P->>B: 触发副本入口
B->>S: Load Stream Level (Async)
S->>S: 发起异步加载
Note over S: 显示加载界面
S->>L: 资源就绪
L->>L: Initialize / BeginPlay
S-->>B: Completed 回调
B->>P: 传送玩家到新关卡
Note over P: 隐藏加载界面非阻塞加载更适合大世界。玩家边跑边加载,主线程不被卡住。代价是需要处理加载完成前的中间状态,比如临时用低模地形或雾效遮挡远处。
四、Async Load 与加载界面
大地图流送的核心是异步加载。UE 的 FStreamingLevelPayload 和 ULevelStreaming 会把资源加载任务丢到异步线程,主线程继续跑游戏循环。等资源准备好后,再在一帧的合适时机把 Level 挂到 World 上。
4.1 异步加载的基本流程
在 C++ 里,可以直接用 UGameplayStatics::LoadStreamLevel 的蓝图层,也可以更底层地操作 ULevelStreamingDynamic。下面是一段典型用法:
void AMyGameMode::LoadDungeonLevel(const FString& LevelName)
{
FName LevelFName(*LevelName);
bool bSuccess = false;
ULevelStreamingDynamic* StreamingLevel = ULevelStreamingDynamic::LoadLevelInstance(
this,
LevelName,
FVector::ZeroVector,
FRotator::ZeroRotator,
bSuccess
);
if (bSuccess)
{
StreamingLevel->OnLevelLoaded.AddDynamic(
this,
&AMyGameMode::OnDungeonLevelLoaded
);
}
}
void AMyGameMode::OnDungeonLevelLoaded()
{
// 副本加载完成,通知 UI 关闭加载界面
HideLoadingScreen();
}这段代码创建了一个动态流送关卡实例,并注册加载完成回调。实际项目中,通常会把关卡路径、目标位置、加载优先级等参数做成配置表,避免硬编码。
4.2 加载界面的实现
加载界面不是一张静态图那么简单。好的加载界面应该:
- 在发起加载请求前显示;
- 挡住输入,防止玩家误操作;
- 显示进度条或提示文字;
- 等 Sub-Level 的 BeginPlay 执行完再隐藏。
如果只等 Load Stream Level 的 Completed 回调,可能会发现玩家传送到新位置后,地形已经加载但 NPC 还没初始化完成。因为 Completed 回调只代表 Level 文件加载完成,不代表里面所有 Actor 都完成了 BeginPlay。
更稳妥的做法是:在 Completed 回调里再等一帧或两帧,或者给关键 Actor 发一个自定义事件,等所有关键 Actor 都汇报「准备就绪」后再关闭加载界面。
阿杰的做法是:加载完成回调触发后,遍历副本里的关键 Manager Actor,调用它们的 IsReady 接口。全部返回 true 才关闭加载界面。这样虽然多等几百毫秒,但玩家进入副本时不会看到缺 NPC 或缺任务点的尴尬场面。
4.3 内存预算与粒度
流送不只是为了让加载变快,更是为了控制内存。如果同时加载的 Sub-Level 太多,即使每个 Level 都不大,加起来也会压垮内存。项目早期就要定一个内存预算,并反推每个 Sub-Level 能承受的资源量。
假设目标平台的可用内存为 ,操作系统和引擎自身占用 ,Persistent Level 常驻内存为 ,那么可用于流送的内存约为:
如果同时存在 个 Sub-Level,每个 Level 的平均内存占用不应超过 。这个公式看起来很粗糙,但在立项阶段足够用来和策划、美术对齐目标。
五、Streaming Level 的生命周期回调
每个 Sub-Level 有自己的生命周期。理解这些回调,才能在正确时机做正确的事。
5.1 Level Script Actor
每个 Level 都有一个 Level Script Actor,相当于这个 Level 的专属蓝图。它会在 Level 加载时创建,卸载时销毁。Level Script Actor 里可以写 BeginPlay、EndPlay、Tick 等逻辑。
但要注意:Level Script Actor 不能跨 Level 引用 Actor。如果 Sub-Level A 的 Level Script 想调用 Sub-Level B 里的 Actor,需要通过 Game Instance、Game Mode 或事件总线来中转。
5.2 关键回调
- BeginPlay:Level 被激活后调用。此时 Level 里的 Actor 已经初始化,可以安全地和其他 Actor 交互。
- EndPlay:Level 被卸载前调用。适合做清理工作,比如注销委托、保存临时数据、停止音效。
- OnLevelShown / OnLevelHidden:Level 可见或隐藏时触发。加载完成后 Level 默认是隐藏的,调用
Set Should Be Visible后才会显示。
graph LR
A[Loading] --> B[Loaded]
B --> C[Visible]
C --> D[Hidden]
D --> E[Unloaded]
B --> E
C --> ELevel 加载完成后默认是不可见的,这给了开发者一个缓冲窗口。可以先把下一关加载好,等时机成熟再切过去显示。阿杰的副本切换就用这个机制:玩家还在旧副本时,新副本已经在后台加载并初始化,点击确认后一瞬间把旧副本隐藏、新副本显示,玩家几乎感觉不到加载过程。
5.3 委托和事件
ULevelStreaming 提供了多个委托:
OnLevelLoadedOnLevelUnloadedOnLevelShownOnLevelHidden
在 C++ 里可以直接绑定这些委托。蓝图里则通过 Load Stream Level 节点的输出引脚获取回调。
有一点需要小心:委托可能在 Level 已经加载或卸载后才被绑定。如果先加载再绑定,回调不会触发。所以要在发起加载请求之前就绑定好委托。
六、World Composition 与 World Partition 简介
UE 为大世界提供了两种高层方案:World Composition 和 World Partition。
6.1 World Composition
World Composition 是 UE4 时代就有的方案。它把多个 Level 按坐标拼成一张大地图,支持 Layer 和 Streaming Distance。每个 Sub-Level 可以设置距离范围,玩家靠近时加载,远离时卸载。
它的优点是直观,缺点是需要手动管理大量 Level 文件,而且不支持多人在同一张超大地图上高效协作。World Composition 在 UE5 里仍然可用,但已经不是推荐方案。
6.2 World Partition
World Partition 是 UE5 引入的新系统,也是 UE5.8 里做大世界的主流选择。它把整个地图当成一个单一关卡,但底层自动把空间划分成 Cell。开发者不用再手动切 Sub-Level,只需要把 Actor 放到场景里,World Partition 会按 Cell 自动流送。
architecture-beta
group wp(cloud)[World Partition]
service grid(database)[空间网格]
service cell1(server)[Cell 1]
service cell2(server)[Cell 2]
service cell3(server)[Cell 3]
service cell4(server)[Cell 4]
grid:L --> R:cell1
grid:L --> R:cell2
grid:L --> R:cell3
grid:L --> R:cell4World Partition 的关键概念:
- Cell:空间网格中的一个单元,包含该区域的 Actor 和资源;
- Runtime Grid:决定 Cell 如何被流送,比如按距离、按 HLOD、按 Data Layer;
- Data Layer:可以把 Actor 分组,按逻辑开关。比如白天加载某组建筑,夜晚加载另一组;
- HLOD:层次化 LOD,把远处 Cell 合并成低模代理,减少绘制调用。
UE5.8 对 World Partition 做了很多优化,比如更快的烘焙、更小的包体、更稳定的 Cell 流送。如果项目地图超过几平方公里,建议直接上 World Partition,而不是手动切 Sub-Level。
不过 World Partition 也有学习成本。编辑器里的操作习惯和旧版本不同,比如 Actor 的加载范围、Data Layer 的开关、HLOD 的生成都需要专门配置。对于小型项目或线性关卡,传统 Level Streaming 反而更简单。
七、多人游戏中的关卡流送同步
多人游戏的关卡流送比单机复杂一个数量级。所有客户端必须对流送状态达成一致,否则玩家 A 看到的地形玩家 B 看不到,就会出大问题。
7.1 服务器主导
在 UE 的联网模型里,服务器是权威。哪些 Sub-Level 加载、哪些卸载,由服务器决定。服务器加载一个 Sub-Level 后,会同步给相关客户端。客户端收到同步消息后,在本地加载对应 Level。
不是所有客户端都需要加载所有 Sub-Level。服务器会根据玩家位置决定每个客户端需要哪些 Level。一个玩家在东边,他只需要东边相关 Cell;另一个玩家在西边,服务器不会强行让他加载西边的 Level。
sequenceDiagram
participant C1 as Client A
participant S as Server
participant C2 as Client B
C1->>S: 玩家 A 向东移动
S->>S: 判断 A 进入 Cell X
S->>C1: 同步:加载 Cell X
C1->>C1: 本地加载 Cell X
Note over S: 玩家 B 距离 Cell X 很远
S--xC2: 不发送加载指令7.2 Replication 与关卡边界
Actor 的 Replication 默认只在它所在的 Level 加载到客户端后才会生效。如果一个 replicated Actor 在尚未加载到客户端的 Sub-Level 里,客户端看不到它。这既是好事也是坏事。
好处是:服务器不用把全地图所有 Actor 同步给所有客户端,减轻了带宽压力。坏处是:如果玩家快速穿越关卡边界,可能出现 Actor 闪现或延迟出现。
为了解决这个问题,可以在关卡边界设置缓冲区域,提前让客户端开始加载下一关。缓冲距离同样可以用前面提到的公式估算。多人在线游戏里,还要考虑网络延迟 ,把总预留时间写成:
其中 是网络抖动缓冲,通常取 到 秒。如果 ,,,那么 。对应的重叠距离就是 。
7.3 独立服务器与地图配置
Dedicated Server 通常不需要加载所有视觉资源。UE 提供了 Server Only 和 Client Only 的 Actor 标记,以及 World Partition 里的 Server Streaming 配置。合理配置后,服务器只加载碰撞、导航、玩法相关的数据,显存和内存占用会小很多。
副本切换在多人游戏里也要谨慎处理。如果所有玩家一起进副本,可以直接服务器加载副本 Level,把玩家传进去。如果部分玩家进副本、部分玩家留在大世界,就需要用 Level Instance 或无缝 travel。UE 的 ServerTravel 和 Seamless Travel 就是处理这种场景的工具。
7.4 调试多人流送
多人关卡流送出问题时的常见现象是:客户端看到的地形和服务器不一致,玩家走着走着突然掉下去,或者 NPC 只出现在服务器视角。排查这类问题可以从几个方面入手。
首先打开控制台命令 stat levels,观察每个客户端加载了哪些 Level。服务器和客户端的列表应该基本一致,至少玩家所在区域要一致。如果服务器加载了某个 Cell 但客户端没加载,检查网络同步距离和客户端的流送范围设置。
其次检查 Actor 的 Net Cull Distance。即使 Level 加载了,如果 Actor 的同步距离太小,远处客户端也看不到。对于大地图,通常会把重要玩法 Actor 的同步距离设得比较大,纯视觉 Actor 的同步距离设小一点。
最后看日志。UE 的 LogLevel 和 LogNet 类别会输出 Level 加载和同步的详细信息。遇到问题时把这两个日志级别调低,通常能找到原因。
结语:从一个大关卡到一套系统
阿杰和小美花了两周时间,把原来的 OpenWorld_Main 拆成了一个 Persistent Level 加二十多个 Sub-Level。草原、雪原、沙漠、主城、地下城各自独立加载。配合 Streaming Volume 和 Blueprint 手动流送,内存峰值从 31 GB 降到了 9 GB,编辑器加载时间从三分半钟变成二十秒。
后来他们又把项目迁移到 World Partition,自动化的 Cell 流送让他们不用再手动维护 Sub-Level 的边界。多人测试时,服务器按玩家位置只同步附近 Cell,带宽也降了下来。
World、Level 和 Streaming 不是三个孤立的概念,而是一套组合拳。理解它们的关系,才能做出真正跑得动的大地图。大地图不是一次加载出来的,而是一块一块、按需组装出来的。