做流送大世界时最容易搞混的问题:"子关卡的 Actor Tick 是谁在驱动?"翻完 UE5.3 源码(Engine/Source/Runtime/Engine/Private/World.cpp、Level.cpp)可以一句话回答:UWorld 是唯一的调度者,ULevel 只是装 Actor 的容器。
UWorld 持有的东西
看 UWorld(Engine/Public/Engine/World.h)的成员,和关卡相关的核心就三个:
// World.h(UE5.3)
UPROPERTY()
TObjectPtr<ULevel> PersistentLevel; // 主关卡,WorldSettings 在这
UPROPERTY()
TArray<TObjectPtr<ULevel>> Levels; // 所有已加载关卡,含 PersistentLevel
UPROPERTY()
TArray<TObjectPtr<ULevelStreaming>> StreamingLevels; // 流送描述符,不是关卡本身
注意第三行:StreamingLevels 存的是 ULevelStreaming 对象——加载策略的描述符(包名、是否常驻、流送触发方式),真正的 ULevel 对象加载完成后会塞进 Levels 数组,和 PersistentLevel 平起平坐。这两层经常被混为一谈,排查流送问题时先分清自己手里拿的是描述符还是实体。
UWorld::InitWorld 里还会建出 GameState、AI 系统、导航等一堆"世界级"子系统,这些都不属于任何 Level——这就是关键设计:Tick、物理、导航的驱动者是世界,不是关卡。所以流送关卡的 Actor 和主关卡的 Actor 跑在同一个 Tick 循环里,区别只在于它们分别躺在哪个 ULevel::Actors 数组。
ULevel:接近裸数据的容器
ULevel(Level.h)的核心成员:
AActor** Actors/TObjectPtr<AActor>* ActorsForGC:Actor 数组;FURL URL:这个关卡包的标识;TObjectPtr<UWorld> OwningWorld:反向指针,指向持有本关卡的世界。
每个 Level 有自己的 ActorList 增删逻辑(ULevel::AddActor / RemoveActor),Actor spawn 时通过 Outer 链 GetWorld() 找到世界,再挂到对应 Level 的数组里。GetWorld() 的实现就是沿 Outer 链往上找第一个 UWorld——Actor → Level → World,所以 Actor 永远能通过 Outer 找到自己的 Level 和 World,不需要全局查表。
PersistentLevel 之所以特殊,是因为它持有 WorldSettings(AWorldSettings,重力、GameMode 类、KillZ 等都在这)和世界的 Model(BSP 遗留)。WorldSettings 每个世界只有一份,在 PersistentLevel 上,子关卡里再放 WorldSettings 会被忽略——这是"改子关卡设置不生效"的高发原因。
流送关卡的两个阶段
ULevelStreaming 把"加载"和"可见"拆成了两个独立决策,对应状态机里两组状态(LevelStreaming.h 的 ELevelStreamingState):
// LevelStreaming.h(UE5.3)
enum class ELevelStreamingState : uint8
{
Removed, // 什么都没加载
Unloaded, // 包已知道,实体未加载
Loading, // 异步加载中
LoadedNotVisible, // 已在内存,尚未挂进世界
LoadedVisible, // 已 AddToWorld,Actor 在跑
};
拆开的理由很实际:可见性切换是一帧内要完成的体验问题(走 Stream 触发点就该立刻看到),而磁盘 IO 是几十上百毫秒的物理事实。把两件事耦合,每次想显示关卡都得现场等加载,卡帧无法避免;拆开之后,策略层可以先让关卡进入 LoadedNotVisible(数据驻留、Actor 未激活),等真正需要显示时 AddToWorld 只是一次数组和场景的挂载操作,毫秒级完成。这就是为什么状态机中间要留 LoadedNotVisible 这个缓冲态。
挂载的实际执行在 UWorld::AddToWorld(World.cpp):把 Level 加进 Levels 数组 → 逐个 AActor::BeginPlay → 通知导航系统/场景添加 primitive。卸载走 RemoveFromWorld 逆序。整个过程中 UWorld 的 Tick 循环没停过,只是数组里的元素换了一批。
多世界共存时怎么找到"那个"世界
一个进程里同时活着多个 UWorld:游戏世界、编辑器 preview 世界、PIE 的每个实例各一个。Actor 找到正确世界靠的是 Outer 链,而业务代码随手拿 GWorld 是跨世界串线的头号来源——GWorld 只保证"某个世界",不保证"你要的那个"。线程上下文同理:只有 GameThread 的 GWorld 才有确定含义。流送关卡的调试里,"我改了 Actor 怎么没反应"有一半是改到了 preview 世界的同名 Actor 上。
顺带一提 WorldComposition 的遗产:UE5 主推 World Partition,把世界切成 grid cell 由系统托管流送,不再需要手动摆 ULevelStreaming 描述符。但底层的 Levels 数组 + OwningWorld 模型没变,理解经典流送的状态机再看 World Partition 的 HLOD/Cell 流送,只是策略层换了实现。
排查清单
- 子关卡里改 WorldSettings 不生效:WorldSettings 只认 PersistentLevel 那份。
- 流送关卡 Actor 不 Tick:先看它进没进
UWorld::Levels,再看 Actor 自己的SetActorTickEnabled——容器对了不代表开关开了。 - 卸载后内存不降:
ULevelStreaming::SetShouldBeVisible(false)只到LoadedNotVisible,要SetShouldBeLoaded(false)才会走卸载;GPU 侧的 primitive 场景代理另有异步剔除延迟。 - 拿不准 Actor 属于谁:
GetLevel()看容器,GetWorld()看调度者,两个都要对上预期再往下查。