WorldPartition解析

概览

在 UE5 推出了服务于大世界的 World Partition 系统,总的来说主要分几大块:

  1. Grid、Level(Grid Level,非 ULevel)、Cell 的划分
  2. OFPA
  3. Level Instance
  4. Data Layer
  5. HLOD

还有一些也与之相关,例如之前研究过的 LandscapeSpline 也在 WP 版本有特殊的实现方式。

https://docs.unrealengine.com/5.3/zh-CN/world-partition-in-unreal-engine/

以下代码均为 5.3 版本对应代码。

Grid、Level、Cell 的划分

WP 与 UWorld、ULevel 的串连

我们知道,UE中,一个 UWorld 由各个 ULevel 组织而成,每个 ULevel 则拥有一个对应的设置信息记录 AWorldSettings: https://zhuanlan.zhihu.com/p/22924838

AWorldSettings 对应编辑器中的 World Settings 面板,也是保存 UWorldPartition 的容器:

这里使用的是 UE5 新增的 TObjectPtr,方便序列化反序列化,可参考: https://zhuanlan.zhihu.com/p/504115127

我们在编辑器新打开一个 World Partition 地图的时候(File->New Level->Open World),会调用 FEditorFileUtils::LoadMap(*MapPackageFilename, /*bLoadAsTemplate=*/true); 去加载一个大世界模板地图,接着加载 AWorldSettings 的时候就会把对应 WorldPartition 随之加载进来。

当然,既然是 UObject 我们也可以直接 NewObject<UWorldPartition>(WorldSettings);

在 UWorld、ULevel 中则均有 GetWorldPartition 方法,实现均是拿到对应 WorldSettings(UWorld 则是拿对应 PersistentLevel 的 WorldSettings)的 WorldPartition:

对于 UWorld 我们也有 IsPartitionedWorld 方法来判断是否是 WP 版本: bool IsPartitionedWorld() const { return GetWorldPartition() != nullptr; }

因此我们可以这样写:

1
2
3
if (Actor->GetWorld() && Actor->GetWorld()->IsPartitionedWorld())
{
}

UWorldPartition

既然 WP 是保存在 AWorldSettings 中,编辑器下我们就可以调整对应设置:

引擎为其定制了此面板,可参考:Engine\Source\Editor\WorldPartitionEditor\Private\WorldPartition\Customizations\WorldPartitionDetailsCustomization.h

有几处设置需要细讲,我们先回到 UWorldPartition 这个类,他本身继承自 UObject、FActorDescContainerCollection、IWorldPartitionCookPackageGenerator, 也就是说他本身就存放有所有的 ActorDesc,在 UActorDescContainer 的 ActorsByName 中:

1
2
using FNameActorDescMap = TMap<FName, TUniquePtr<FWorldPartitionActorDesc>*>;
FNameActorDescMap ActorsByName;

这里的 ActorDesc 即 FWorldPartitionActorDesc 类型十分重要,他保存有 Guid、ActorPackage、ActorPath 等离线信息(即保存在资产中的信息)以及 AActor的指针 ActorPtr、引用计数等运行时信息(即运行游戏才有的信息)。为后续划分所属 Cell(GenerateStreaming)等功能服务。

我们可以用 wp.Editor.DumpActorDescs hbh_test.csv 指令把他们都 dump 出来,参数是文件路径。

接着回到编辑器面板,对应 UWorldPartition 的:

我们的 RuntimeHash 除非特殊需要自己定制,否则都是走默认的 UWorldPartitionRuntimeSpatialHash,在World Settings中可以修改, 那么接下来的 Preview Grids 以及 Grids 与 Debug Color 等设置则都属于 UWorldPartitionRuntimeSpatialHash:

可以看到他们都被 WITH_EDITORONLY_DATA 包裹,真正运行游戏时,最重要的 Cell Size、Loading Range 等信息会被重新安置给 StreamingGrids:

而这一过程则是在最重要的 GenerateStreaming 函数中完成的:

Grid、Level、Cell 的基础含义

在进入正式分析之前,再简单说一下这三个的意思。

Grid 是棋盘,Cell 是格子,Level 则是分层管理。每一级 Level 都有自己的格子,即自己的 Cell Size,每两级 Level 之间的 Cell Size 是乘 2 的关系, 即格子放大四倍(宽、高都乘上了2)。

Level 0 的 Cell Size 是 WP 面板上填的数值,也是最小的 Cell Size,最大的一级 Level 则会覆盖整个世界,因此我们只需要知道 Level 的 总个数以及面板上自己设定的 Cell Size,就知道了每一级 Level 的 Cell Size,具体函数如下(会在 UWorldPartitionRuntimeSpatialHash 的 GenerateStreaming 函数中调用,后面章节会详细分析):

一个例子如下:

显然为了内存考虑,一个 Level 只会保存含有 Actor 的那些格子,在这个例子中,我们可以看到 Level 0 有 7 个 Cell,Level 4 则有 25 个;一个 Actor 只会被划分到固定一级 Level 的固定一个 Cell 中。

初始化:GenerateStreaming

这是 WP 最重要的函数之一,在 UWorldPartition::OnBeginPlay 中我们会调用此函数,来完成对所有 Actor 的划分,决定归属于哪一级 Level 的哪一个 Cell 中,以及 哪些 Actor 会打到一个 Cluster 里面(根据引用关系来)。这样划分好后,我们加载的时候,就只需要根据 Loading Range 来判断覆盖了哪些 Cell,然后去 SpatialHash 中拿到 加载对应 Cell 中的 Actor 所在的 Cluster. 例如 A 引用了 B,而此时 A 所处的 Cell 被覆盖,而 B 所处的 Cell 未被覆盖,那么加载 A 时,由于有引用关系,我们也会把 B 给加载出来, 对于 B 所处 Cell 的其他 Actor 则不予理会。

我们会调用到 UWorldPartition::GenerateContainerStreaming 中,此时分为三步:

  1. Dump state log

    这一步我们会把WP相关信息都dump出来,和指令 wp.Editor.DumpStreamingGenerationLog 是一样的效果,我们放在后面的 Debug 一节重点讲。当然这一步是做好 dump 的准备工作,在后面的2、3都会执行相关dump操作。

  2. Preparation Phase

    这一步我们会创建所有的 Container,这里的 Container 即 UActorDescContainer,我们会递归完善所有的 ActorDesc 信息、ActorDescView 信息、Cluster 信息, 打进 Cluster 的相关算法都在 GenerateObjectsClusters 函数中:

  3. Generate streaming

    这一步首先我们会创建 Streaming 所需的 Policy,然后根据 Policy 去完成 Actor 的划分(归属于哪一级 Level 的哪一个 Cell)。Policy 在代码中写死的为 UWorldPartitionStreamingPolicy。

Actor 的划分 —— 归属于哪一级 Level 的哪一个 Cell

我们重点来看最后这步:根据 Policy 去完成 Actor 的划分。我们最终会调用到 UWorldPartitionRuntimeSpatialHash 的 GenerateStreaming 函数(如果是默认的 UWorldPartitionRuntimeSpatialHash 的话):

在进入 UWorldPartitionRuntimeSpatialHash 的 GenerateStreaming 函数之前,我们首先需要看上图圈出来的 GetStreamingGenerationContext 函数,这一步我们会创建出一个 FStreamingGenerationContext, 在里面我们会把 Preparation Phase 创立的 Cluster 给写到 ActorSetInstances 中,具体可看 FStreamingGenerationContext 的构造函数。

接着进入 UWorldPartitionRuntimeSpatialHash 的 GenerateStreaming 函数,这里我们会根据 Policy 以及刚刚创立的 StreamingGenerationContext 完成最终 Actor 的划分。大体上又分几个步骤:

  1. 提取面板设置的 Grid 信息

    首先是 UWorldPartition 一节所述,会先提取面板设置的 Grid 信息

  2. 把 ActorSetInstance 写到中间变量 GridActorSetInstances 中

    这一步即包含我们的 Cluster 信息。

  3. 根据 WP 面板设置,完成 Actor 的划分

    重点看 RuntimeSpatialHashGridHelper.cpp 中的 GetPartitionedActors 函数,接下来会详细分析。

  4. 完成最终 FSpatialHashStreamingGrid 的构建

    对应 CreateStreamingGrid 函数。至此初始化完毕。

GetPartitionedActors

在这里我们又会分几步进行:

3.1. FSquare2DGridHelper

关键点是这几个配置:

这是 5.3 才给出的选项,前两个一般勾选 Disabled ,是为了解决老版本的问题:

这里保存 Cell 的时候也是仅考虑上面有 Actor 的情形,我们也可以手动调用这个函数来完成一些事情,例如调查有几个 Cell 的小工具:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
if (UWorldPartition* WorldPartition = WorldSettings->GetWorldPartition())
{
    UWorldPartition::FGenerateStreamingParams Params;
    UWorldPartition::FGenerateStreamingContext Context;
    WorldPartition->GenerateStreaming(Params, Context);

    UWorldPartitionRuntimeHash* RuntimeHash = WorldPartition->RuntimeHash;
    if (nullptr == RuntimeHash) { continue; }

    UWorldPartitionRuntimeSpatialHash* RuntimeSpatialHash = Cast<UWorldPartitionRuntimeSpatialHash>(RuntimeHash);
    if (nullptr == RuntimeSpatialHash) { continue; }

    int32 CellNum = 0;
    int32 TotalActorsCount = 0;
    RuntimeSpatialHash->ForEachStreamingCells([&](const UWorldPartitionRuntimeCell* RuntimeCell)
    {
        CellNum++;
        TotalActorsCount += RuntimeCell->GetActorCount();
        return true;
    });
}

这里的 ForEachStreamingCells 方法返回 false 的时候会直接阻断,只有返回 true 的时候才会继续遍历下一个 Cell。

运行时:Level Streaming 的加载

我们的加载主要是通过 UWorldPartitionLevelStreamingDynamic 完成,他本身就继承自 ULevelStreamingDynamic:

我们在运行时会更新 World 中的各个 Subsystem,调用 UpdateStreamingState:

其中和 WP 相关的主要是 UWorldPartitionSubsystem,在他的 UpdateStreamingState 函数中,我们会根据 StreamingPolicy 拿到当前需要加载的 Cell, 再通过 UWorldPartitionStreamingPolicy::SetCellStateToActivated 函数去 Load 这些 Cell:

这里每个 Cell 会对应一个 UWorldPartitionLevelStreamingDynamic,

因而加载的时候也是最终调用到 UWorldPartitionLevelStreamingDynamic::Activate

OFPA

ExternalActorsExternalObjects

对于 ExternalObjects,他主要保存的是 Outliner 的文件夹对象,对应 UE 的 UActorFolder 类。在构造时会调用 FLevelActorFoldersHelper::AddActorFolder 添加到 ULevel 中, 可以看到对应 ULevel 里面的属性是标记为 UPROPERTY(Transient) 不保存的:

对于 ExternalActors,他保存的是关卡中的Actor对象,实际上会对Actor调用 SetPackageExternal(true),最后给 Object 设置上 RF_HasExternalPackage 标记:

而在序列化时,我们保存的是 UPackage,对于标记了 RF_HasExternalPackage 的 Actor,他的 GetPackage 方法最终会得到 ExternalPackage:

这段代码的逻辑是,我们会不断拿 ExternalPackage,如果 OuterPrivate 非空且标记了 RF_HasExternalPackage,就会拿到一份 ExternalPackage,如果 OuterPrivate 非空但没标记上 RF_HasExternalPackage,拿到的就是 nullptr; 而如果拿到 nullptr,则会继续追踪 Outer(OuterPrivate),直到没有 Outer,表示到达最顶层,从而拿到最顶层 UPackage

在 FPackagePath 类中有方法:

1
2
3
4
5
6
7
8
9
const TCHAR* FPackagePath::GetExternalActorsFolderName()
{
	return TEXT("__ExternalActors__");
}

const TCHAR* FPackagePath::GetExternalObjectsFolderName()
{
	return TEXT("__ExternalObjects__");
}

编辑器下Actor的加载

对于 ExternalObjects,会在 ULevel::PostLoad 中调用 FExternalPackageHelper::LoadObjectsFromExternalPackages 来加载:

实际上也就是根据 Level 名字拿到对应 __ExternalObjects__ 路径。例如 Content/HbhTest/NewMap.umap 就会拿到 Content/__ExternalObjects__/HbhTest/NewMap 这个路径从而直接加载里头的所有 Package:

对于 ExternalActors 也类似,只是挪到了 ULevel::OnLevelLoaded 里面初始化 WorldPartition 的阶段,最后会调用到 UActorDescContainer::Initialize 来加载所有对应 __ExternalActors__ 路径下的 Actor:

这些 Actor 信息在编辑器情况下最后会被缓存到 WorldPartition 的 EditorHash 中,最后可以根据 XYZ 以及对应 Level 级别得到所有的 Actor:

引用关系

在前一节中我们说过,加载的时候是根据路径来判断的,Map 对应的资产并不会存有 __ExternalObjects__ 与 __ExternalActors__ 的信息。但是当我们使用 Reference Viewer 进行查看的 时候,会发现大世界 Map 对应的 Dependencies 有所有的 __ExternalActors__,而 Referencers 既有 __ExternalActors__ 又有 __ExternalObjects__

这是因为 UE 的 Reference Viewer 也对大世界关卡特殊处理了,在打开ue编辑器的时候,他会对所有资产做一个缓存,存到 FAssetRegistryState 的 CachedAssets 中,我们每添加一个依赖,就会对应的 添加上引用:

因此对于一个大世界 map,实际上是对 __ExternalObjects__ 的这些资产添加上了 map 的依赖,从而让 map 的引用有了 __ExternalObjects__ 里的东西。

而对于 map 本身的依赖项,ue 会进行特殊处理:

和上一节加载的手法类似,从而让 map 的 dependencies 有了这些 __ExternalActors__

只特殊处理 ExternalActors 的原因思考

通过 Cook-打包逻辑 我们知道引擎 Cook 时会把依赖的文件一同进行 Cook,而在游戏中我们是不需要这些文件夹信息的,因此也就无需 Cook ExternalObjects,所以只特殊处理 ExternalActors 就够了。

Debug

Demp大世界场景信息

1
wp.Editor.DumpStreamingGenerationLog

5.3版本可以使用这个命令,输出一次WP的信息(也可以运行一次游戏或者cook的时候),输出结果会在 Saved\Logs\WorldPartition 里。

Grid调试

https://docs.unrealengine.com/5.3/zh-CN/world-partition-in-unreal-engine/

获取OFPA的Actor的信息

右键可以获取对应的本地路径:

想获取guid,我测试下来可以这样做:

  1. 视口点击想确定的actor
  2. 切换到python命令行
  3. 运行指令 print(unreal.get_editor_subsystem(unreal.EditorActorSubsystem).get_selected_level_actors()[0].get_editor_property("actor_guid").to_string())

参考

https://zhuanlan.zhihu.com/p/675514420

https://zhuanlan.zhihu.com/p/610772419