技术博客

UWorld 与 Level:PersistentLevel/StreamingLevel 的组织

第 23 / 100 篇 · 2025-06-13 · ue5 · GameFrameworkUWorld源码分析

做流送大世界时最容易搞混的问题:"子关卡的 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 与 ULevel 的持有关系:一个世界,多个容器 UWorld Tick / 物理 / 导航的唯一驱动者 PersistentLevel(ULevel) 持 WorldSettings,必有且仅有一个 StreamingLevels(ULevelStreaming 数组) 加载策略描述符,不是关卡实体 异步加载完成 ULevel(流送关卡实体) 加入 UWorld::Levels,与主关卡平级 ULevel::Actors(每个容器各自持有) Actor 靠 Outer 链 GetWorld() 找世界 Levels 数组 调度看 World,归属看 Level:流送只换容器,不换调度者

多世界共存时怎么找到"那个"世界

一个进程里同时活着多个 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() 看调度者,两个都要对上预期再往下查。