概览
在 UE5 推出了服务于大世界的 World Partition 系统,总的来说主要分几大块:
- Grid、Level(Grid Level,非 ULevel)、Cell 的划分
- OFPA
- Level Instance
- Data Layer
- 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; }
因此我们可以这样写:
|
|
UWorldPartition
既然 WP 是保存在 AWorldSettings 中,编辑器下我们就可以调整对应设置:
引擎为其定制了此面板,可参考:Engine\Source\Editor\WorldPartitionEditor\Private\WorldPartition\Customizations\WorldPartitionDetailsCustomization.h
有几处设置需要细讲,我们先回到 UWorldPartition 这个类,他本身继承自 UObject、FActorDescContainerCollection、IWorldPartitionCookPackageGenerator, 也就是说他本身就存放有所有的 ActorDesc,在 UActorDescContainer 的 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 中,此时分为三步:
Dump state log
这一步我们会把WP相关信息都dump出来,和指令
wp.Editor.DumpStreamingGenerationLog
是一样的效果,我们放在后面的 Debug 一节重点讲。当然这一步是做好 dump 的准备工作,在后面的2、3都会执行相关dump操作。Preparation Phase
这一步我们会创建所有的 Container,这里的 Container 即 UActorDescContainer,我们会递归完善所有的 ActorDesc 信息、ActorDescView 信息、Cluster 信息, 打进 Cluster 的相关算法都在 GenerateObjectsClusters 函数中:
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 的划分。大体上又分几个步骤:
提取面板设置的 Grid 信息
首先是 UWorldPartition 一节所述,会先提取面板设置的 Grid 信息
把 ActorSetInstance 写到中间变量 GridActorSetInstances 中
这一步即包含我们的 Cluster 信息。
根据 WP 面板设置,完成 Actor 的划分
重点看 RuntimeSpatialHashGridHelper.cpp 中的 GetPartitionedActors 函数,接下来会详细分析。
完成最终 FSpatialHashStreamingGrid 的构建
对应 CreateStreamingGrid 函数。至此初始化完毕。
GetPartitionedActors
在这里我们又会分几步进行:
3.1. FSquare2DGridHelper
关键点是这几个配置:
这是 5.3 才给出的选项,前两个一般勾选 Disabled ,是为了解决老版本的问题:
这里保存 Cell 的时候也是仅考虑上面有 Actor 的情形,我们也可以手动调用这个函数来完成一些事情,例如调查有几个 Cell 的小工具:
|
|
这里的 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
ExternalActors 与 ExternalObjects
对于 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 类中有方法:
|
|
编辑器下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大世界场景信息
|
|
5.3版本可以使用这个命令,输出一次WP的信息(也可以运行一次游戏或者cook的时候),输出结果会在 Saved\Logs\WorldPartition 里。
Grid调试
https://docs.unrealengine.com/5.3/zh-CN/world-partition-in-unreal-engine/
获取OFPA的Actor的信息
右键可以获取对应的本地路径:
想获取guid,我测试下来可以这样做:
- 视口点击想确定的actor
- 切换到python命令行
- 运行指令
print(unreal.get_editor_subsystem(unreal.EditorActorSubsystem).get_selected_level_actors()[0].get_editor_property("actor_guid").to_string())