UE资源管理:引擎打包资源分析

UE Resource Management: Engine Packaging Resource Analysis

默认情况下,在UE中打包项目时,会拉起BuildCookRun来执行Compile/Cook/Pak/Stage等一系列流程。在UE中,只有参与Cook的资源才会被打包,但是通常会包含很多预期之外的资源,可能会造成困扰,到底引擎依赖哪些资源?以及该如何管理UE参与打包的资源。

本篇文章从UE打包时分析资源进行Cook的规则入手,研究在打包时究竟会将哪些资源进行Cook,了解这一点对于资源管理很有作用,基于此可以实现自定义的Cook过程,将Cook任务分配至不同的进程乃至机器实现并行化,加速UE的构建过程。

除了uasset这些资源外,打包时还有很多Non-Asset文件,如ini、Shader Library、AssetRegistry、或者项目中添加的脚本文件等等,在之前的文章UE热更新:需求分析与方案设计中有过介绍,UE对于它们的收集并不在Cook阶段(Shader Library和AssetRegistry是在Cook阶段生成),本篇文章暂不作讨论,后续会写一篇专门介绍的文章。

UE参与Cook的资源,从逻辑上大概可以分为以下几个类别:

  1. 项目设置的关键资源,如StartupMap、GameMode、GameInstance、DefaultTouchInterface等,它们是必要资源
  2. 默认打包的几个UI目录
  3. 引擎启动时通过代码加载的资源
  4. 项目设置中配置的Cook资源(包括Directory to Alway CookPrimaryAssetLabel等标记的要进行Cook的资源)
  5. 通过执行FGameDelegates::Get().GetCookModificationDelegate()传递给CookOnTheFlyServer的资源。
  6. 在一定条件下若没有指定资源,则分析项目、插件目录下的资源
  7. 本地化资源(UE支持给不同的Culture使用不同的资源,不过不常用)

UE关于资源的分析流程非常复杂,而且散落在各个地方,还包含了各种条件检测,想要完整地分析出项目依赖的资源,十分不便。这也是不太方便能精确UE到底打包了哪些资源的原因。

基于这些痛点,我准备基于HotPatcher实现一套简洁规范的资源收集和打包流程。究其本质,打包资源,是要将引擎和程序运行时需要用到的必要资源打包进去。这是最根本的需求,UE里的资源配置和检测那么复杂都是为了满足它,但我们可以将其精简,进行统一分析和管理。

CookCommandlet

引擎中提供了UCookCommandlet,来实现资源的Cook,在打包流程中,它由UAT拉起。默认的Cook命令如下:

1
2
3
4
5
6
7
8
9
10
11
12
D:/UnrealEngine/Engine/Engine/Binaries/Win64/UE4Editor-Cmd.exe
D:/UnrealProjects/Blank425/Blank425.uproject
-run=Cook
-TargetPlatform=WindowsNoEditor
-fileopenlog
-unversioned
-abslog=D:/UnrealEngine/Engine/Engine/Programs/AutomationTool/Saved/Cook-2021.12.07-15.47.10.txt
-stdout
-CrashForUAT
-unattended
-NoLogTimes
-UTF8Output

它会执行到UCookCommandlet的main中:

Source\Editor\UnrealEd\Private\Commandlets\CookCommandlet.cpp
1
2
3
4
5
6
7
8
9
10
/* UCommandlet interface
*****************************************************************************/

int32 UCookCommandlet::Main(const FString& CmdLineParams)
{
COOK_STAT(double CookStartTime = FPlatformTime::Seconds());
Params = CmdLineParams;
ParseCommandLine(*Params, Tokens, Switches);
// ...
}

它在经过一些参数检测后会将执行流程传递到CookByTheBook,创建出CookOnTheFlyServer,并调用StartCookByTheBook

引擎打包时的资源都会在CookOnTheFlyServer中被Cook,并生成Shader和AssetRegistry,可以说CookOnTheFlyServer就是UE打包过程中将在编辑器中通用格式的uasset,序列化为平台格式的过程。

资源分析

UE打包时加载资源的思路是:先找到本地uasset文件,将路径转换为PackageName,进行加载,在加载时会把依赖的资源也加载了,然后将其一起Cook。

StartupPackages

CookOnTheFlyServer.cppUCookOnTheFlyServer::Initialize中,将已经加载到内存中的资源添加到了CookByTheBookOptions->StartupPackages中:

CookOnTheFlyServer在后续的流程会将它们添加到Cook列表中,并会处理重定向器。

AllMaps

没有通过命令行指定任何地图的情况下会给MapIniSections添加AllMaps


它是DefaultEditor.ini中的Section

DefaultEngine.ini
1
2
3
4
[AllMaps]
+Map=/Game/Maps/Login
+Map=/Game/Maps/LightSpeed
+Map=/Game/Maps/VFXTest

启动之前会全局编译一遍GlobalShader:

通过GRedirectCollector获取资源:

UI

默认情况下,UE会将BaseEditor.iniContentDirectories下的目录添加到Cook列表中:Engine/Config/BaseEditor.ini#L271

引擎中的默认配置如下,也可以修改DefaultEditor.ini添加其他的目录:

BaseEditor.ini
1
2
3
4
5
6
[UI]
; Directories specifying assets needed by Slate UI, assets in these directories are always cooked even if not referenced
+ContentDirectories=/Game/UI
+ContentDirectories=/Game/Widget
+ContentDirectories=/Game/Widgets
+ContentDirectories=/Engine/MobileResources

这些目录下的资源会被打包:CookOnTheFlyServer.cpp#L5519

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
//@todo SLATE: This is a hack to ensure all slate referenced assets get cooked.
// Slate needs to be refactored to properly identify required assets at cook time.
// Simply jamming everything in a given directory into the cook list is error-prone
// on many levels - assets not required getting cooked/shipped; assets not put under
// the correct folder; etc.
if ( !(FilesToCookFlags & ECookByTheBookOptions::NoSlatePackages))
{
TArray<FString> UIContentPaths;
TSet <FName> ContentDirectoryAssets;
if (GConfig->GetArray(TEXT("UI"), TEXT("ContentDirectories"), UIContentPaths, GEditorIni) > 0)
{
for (int32 DirIdx = 0; DirIdx < UIContentPaths.Num(); DirIdx++)
{
FString ContentPath = FPackageName::LongPackageNameToFilename(UIContentPaths[DirIdx]);

TArray<FString> Files;
IFileManager::Get().FindFilesRecursive(Files, *ContentPath, *(FString(TEXT("*")) + FPackageName::GetAssetPackageExtension()), true, false);
for (int32 Index = 0; Index < Files.Num(); Index++)
{
FString StdFile = Files[Index];
FName PackageName = FName(*FPackageName::FilenameToLongPackageName(StdFile));
ContentDirectoryAssets.Add(PackageName);
FPaths::MakeStandardFilename(StdFile);
AddFileToCook( FilesInPath, StdFile);
}
}
}

if (CookByTheBookOptions && CookByTheBookOptions->bGenerateDependenciesForMaps)
{
for (auto& MapDependencyGraph : CookByTheBookOptions->MapDependencyGraphs)
{
MapDependencyGraph.Value.Add(FName(TEXT("ContentDirectoryAssets")), ContentDirectoryAssets);
}
}
}

如果你不想要默认打包时包含/Game/UI目录下的资源,则可以在项目配置文件DefaultEditor.ini中重写:

1
2
3
[UI]
!ContentDirectories=ClearArray
+ContentDirectories=/Engine/MobileResources

Directory to Alway cook

Project Setgings-Directory to Alway cook DirectoriesToAlwaysCook

Maps

DefaultGame.ini
1
2
[/Script/UnrealEd.ProjectPackagingSettings]
+MapsToCook=(FilePath="/Game/HandheldAR/Maps/HandheldARBlankMap")

Cultures

这里代表的并不是多语言,而是针对不同的Culture,可以支持使用不同的资源。Asset Localization

Cultures资源的获取代码:CookOnTheFlyServer.cpp#L6714

从项目设置中读取:

会遍历所有的RootPaths,如/Engine/Game以及插件的资源根目录:

如以下目录下的资源,并会递归子目录:

1
/Game/L10N/en/

DefaultTouchInterface

DefaultTouchInterface是引擎中配置的虚拟摇杆类,它可能不会被其他的资源依赖,但也需要被打包,所以在Cook时会单独获取它:

1
2
3
4
5
6
7
8
9
10
FConfigFile InputIni;
FString InterfaceFile;
FConfigCacheIni::LoadLocalIniFile(InputIni, TEXT("Input"), true);
if (InputIni.GetString(TEXT("/Script/Engine.InputSettings"), TEXT("DefaultTouchInterface"), InterfaceFile))
{
if (InterfaceFile != TEXT("None") && InterfaceFile != TEXT(""))
{
SoftObjectPaths.Emplace(InterfaceFile);
}
}

GetCookModificationDelegate

绑定代理,可以传递给CookCommandlet,要进行Cook的文件:

1
2
3
// allow the game to fill out the asset registry, as well as get a list of objects to always cook
TArray<FString> FilesInPathStrings;
FGameDelegates::Get().GetCookModificationDelegate().ExecuteIfBound(FilesInPathStrings);

注意,传递过来的需要是uasset文件的绝对路径,并不是/Game/xxx等资源路径。

AssetManager

通过UAssetManager::Get().ModifyCook函数,来访问PrimaryAssetTypeInfo(在项目设置中的配置、PrimaryAssetLabelId等)。

这两个则是新建项目里默认添加的,ModifyCook里会扫描所有的PrimaryAssetId资源,将其中指定的资源添加至PackageToCook,注意只有这个资源。

如果命令行没有显式指定任何资源、以及从FGameDelegates::Get().GetCookModificationDelegate()UAssetManager::Get().Modify都没有获取到任何资源,则添加插件、项目所有的资源:

执行条件为:

  1. DefaultEditor.ini中的AlwaysCookMaps为空,AllMaps为空
  2. 项目设置中List of maps to include in a packaged build为空
  3. 项目设置中DirectoriesToAlwaysCook为空
  4. FGameDelegates::Get().GetCookModificationDelegate()获取到的资源为空
  5. UAssetManager::Get().ModifyCook获取到的资源为空

则会通过NormailizePackageNames获取/Engine/Game以及所有启用插件的umap、uasset:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
// If no packages were explicitly added by command line or game callback, add all maps
if (FilesInPath.Num() == InitialPackages.Num() || bCookAll)
{
TArray<FString> Tokens;
Tokens.Empty(2);
Tokens.Add(FString("*") + FPackageName::GetAssetPackageExtension());
Tokens.Add(FString("*") + FPackageName::GetMapPackageExtension());

uint8 PackageFilter = NORMALIZE_DefaultFlags | NORMALIZE_ExcludeEnginePackages | NORMALIZE_ExcludeLocalizedPackages;
if (bMapsOnly)
{
PackageFilter |= NORMALIZE_ExcludeContentPackages;
}

if (bNoDev)
{
PackageFilter |= NORMALIZE_ExcludeDeveloperPackages;
}

// assume the first token is the map wildcard/pathname
TArray<FString> Unused;
for (int32 TokenIndex = 0; TokenIndex < Tokens.Num(); TokenIndex++)
{
TArray<FString> TokenFiles;
if (!NormalizePackageNames(Unused, TokenFiles, Tokens[TokenIndex], PackageFilter))
{
UE_LOG(LogCook, Display, TEXT("No packages found for parameter %i: '%s'"), TokenIndex, *Tokens[TokenIndex]);
continue;
}

for (int32 TokenFileIndex = 0; TokenFileIndex < TokenFiles.Num(); ++TokenFileIndex)
{
AddFileToCook(FilesInPath, TokenFiles[TokenFileIndex]);
}
}
}

但默认添加了过滤器,会排除引擎目录的资源(/Engine)以及本地化目录下的资源(/*/L10N/)。

其实就是会添加项目、插件、除了L10N目录下的所有uasset和umap。

收集打包的内容主要在UCookOnTheFlyServer::CollectFilesToCook这个函数中:CookOnTheFlyServer.cpp#L5200中。

DefaultInput.ini
1
2
[/Script/Engine.InputSettings]
DefaultTouchInterface=/Engine/MobileResources/HUD/LeftVirtualJoystickOnly.LeftVirtualJoystickOnly

SkipEditorContent

在项目设置中可以配置,在Cook时忽略Editor相关的资源:

在CookOnTheFlyServer中会忽略/Engine/Editor*/Editor/VREditor*中的资源:

1
2
3
4
5
6
7
8
// don't save Editor resources from the Engine if the target doesn't have editoronly data
if (IsCookFlagSet(ECookInitializationFlags::SkipEditorContent) &&
(PackagePathName.StartsWith(TEXT("/Engine/Editor")) || PackagePathName.StartsWith(TEXT("/Engine/VREditor"))) &&
!Target->HasEditorOnlyData())
{
Result = ESavePackageResult::ContainsEditorOnlyData;
bCookPackage = false;
}

Cook时的依赖加载

虽然上面列出了引擎打包时将会包含的资源,但是,它们还不是全部,因为它们都还是单个的资源或者单个的目录,并没有包含资源的依赖关系。所以,在UE进行Cook时还会进行实质上的依赖分析。

考虑以下两个问题:

  1. 如果一个地图中放了一个C++实现的Actor,而在它的构造函数代码中有加载某个资源,怎么将其打包呢?
1
2
3
4
5
6
7
AMyActor::AMyActor()
{
// Set this actor to call Tick() every frame. You can turn this off to improve performance if you don't need it.
PrimaryActorTick.bCanEverTick = true;

UTexture2D* Texture2D = LoadObject<UTexture2D>(nullptr, TEXT("/Game/TextureResources/T_ImportTexture.T_ImportTexture"));
}

将它放置在场景中,不会有任何依赖:

  1. 没有在AssetRegistry引用关系中的资源依赖,如何打包?如AnimSequenceBoneCompressionSettingsCruveCompressionSettings配置,并不在依赖关系中,从AsssetRegistry获取动画序列的依赖时找不到它们。

但是它们在uasset的ImportTable中有记录:

从Cooked的uasset中也可以看到:

既然这样,为什么不能从Asset的ImportTable里直接扫描依赖呢?

这是因为访问ImportTable需要真正加载资源,当资源量非常大时,这是非常耗费硬件资源和时间的,而从AssetRegistry访问依赖关系则无需将资源加载到内存里,所以速度非常快。

那么如何解决这些问题呢?首先先从UE默认的实现机制讲起。要明确两个观点:

  1. 引擎启动时会创建CDO,会执行类的构造函数
  2. UE的资源在加载时会将它依赖的资源也给加载了

所以,基于这个思路,UE在Cook时实现了一个方案,监听所有创建的UObject,将其添加至Cook列表中,确保在C++的构造函数中加载的资源和通过ImportTable加载的依赖资源也能被Cook。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
struct FPackageTracker : public FUObjectArray::FUObjectCreateListener, public FUObjectArray::FUObjectDeleteListener
{
FPackageTracker()
{
for (TObjectIterator<UPackage> It; It; ++It)
{
UPackage* Package = *It;

if (Package->GetOuter() == nullptr)
{
LoadedPackages.Add(Package);
}
}
GUObjectArray.AddUObjectDeleteListener(this);
GUObjectArray.AddUObjectCreateListener(this);
}
~FPackageTracker()
{
GUObjectArray.RemoveUObjectDeleteListener(this);
GUObjectArray.RemoveUObjectCreateListener(this);
}

virtual void NotifyUObjectCreated(const class UObjectBase *Object, int32 Index) override
{
// ...
}

virtual void NotifyUObjectDeleted(const class UObjectBase *Object, int32 Index) override
{
// ...
}
};

所以,基于相同的思路,我们只需要通过AssetRegistry获取资源的依赖关系,存储一个粗略的资源列表,然后在Cook时监听UObject的创建,若不在扫描的资源列表中,就将其添加至Cook队列,从而实现完整的资源打包过程。

未追踪ImportTable的Cook结果对比(左侧为UE默认的Cook结果,右侧为自定义的Cook):
未追踪ImportTable的Cook结果对比

追踪ImportTable的Cook结果对比(左侧为UE默认的Cook结果):
追踪了ImportTable的结果对比

可以看到,追踪了ImportTable的Cook的资源与UE默认Cook的一致。

注意事项

如果自己有在引擎中注册一些可配置项,利用FSoftObjectPath和在UObject上标记存储config的形式。
会导致被标记的资源在Cook过程中被带进来,应该避免之间由ini标记FSoftObjectPath资源的情况,可以创建类似PrimaryAssetLabel形式的资源,用与作为资源管理标记。

总结

本篇文章分析了引擎默认打包的资源的分析流程,能够将UE的资源分析从黑盒转为确定性的过程,通过本文的分析结果可以实现出通过依赖分析获取打包资源粗略列表,通过Cook时监听UObject的创建流程,可以分析出不在依赖列表中,但实际又依赖的资源,实现与UE默认的Cook资源保持一致。

分析出UE的打包资源分析流程,可以实现替换UE默认的Cook流程,实现多进程,甚至跨机器的Cook任务分配,可以极大地提升UE的打包效率,后续我会为HotPatcher实现MultiCook的机制。

全文完,若有不足之处请评论指正。

微信扫描二维码,关注我的公众号。

本文标题:UE资源管理:引擎打包资源分析
文章作者:查利鹏
发布时间:2021/12/31 14:33
本文字数:5k 字
原始链接:https://imzlp.com/posts/22570/
许可协议: CC BY-NC-SA 4.0
文章禁止全文转载,摘要转发请保留原文链接及作者信息,谢谢!
您的捐赠将鼓励我继续创作!