一种灵活与非侵入式的基础包拆分方案

A Flexible and Non-Intrusive Basic Package Splitting Scheme

UE默认的资源管理较为复杂,默认情况下是根据在ProjectSetting里配置的地图、目录、PrimaryAsset的配置,以及对一些条件的组合检测来执行资源打包过程的。并且,UE的Cook是根据运行时的动态加载来实时添加资源打到包中的,这导致打包的资源进包过程几乎等同于黑盒。

博客中介绍默认的进包资源规则基础包拆分的文章:

本篇文章提供了一种新的思路,利用HotPatcher的精确Cook和打包机制,实现了一个HotChunker的Mod,能够对引擎的非侵入式,直接复用UE默认打包过程简单清晰地进行拆分基础包。本篇文会具体介绍使用方式以及实现原理。

默认打包的问题

前面提到了,UE打包参数和配置较为复杂,以及一些无法处理的问题:

  1. 无法直接地预估有哪些资源进包以及最终进包的大小
  2. 打包的配置可以零散分布在各种地方,难以管理
  3. 所有的Non-uasset资源全在0号pak中(也就是必须进基础包)
  4. 无法根据pak的拆分来拆分Shaderlibrary(可能会非常大)
  5. 无法根据pak的拆分来拆分AssetRegistry
  6. 精确地控制某个pak规则的忽略资源
  7. 为所有平台共用一套PakFilter规则

在资源管理的角度来说,希望能够清晰、并且精确地控制资源打包规则,默认情况下UE提供的无法做到这些。

干净的基础包

在文章UE 资源管理:引擎打包资源分析中,我分析了引擎打包时的资源分析规则。
除了资源之外,还有很多Non-uasset的文件,如:Slate的图片、Ini、ShaderLib/AssetRegistry,以及项目中配置的DirectoriesToAlwaysStageAsUFS路径。

常见的情况是,新建了一个工程,添加了很多资源,什么打包规则都没配置,执行打包,会把非常多的资源给打包进来。

对于新手很友好,但想要精细管理的时候,这种默认进包方式就有比较大的负担。

首先,第一个问题是:哪些是引擎启动的必要文件,必须要位于基础包中?
我大概总结为以下几种:

  1. SlateResources、Ini
  2. Global ShaderLib
  3. AssetRegistry
  4. 配置的Startup的地图、以及依赖到的蓝图GameMode、GameInstance等Gameplay框架资源
  5. 移动端的自定义摇杆资源
  6. 一些可能用到的基础UI资源

在UE默认打包的过程中,会对这些资源、文件进行分析以及进包。但我们如果想要打包出一个干净的基础包,就需要把这些必要资源的过程独立出来,让引擎去分析,额外的非必要资源就由业务自己控制。

第二个问题:如何排除非必要资源?

在文章引擎打包资源分析#AssetManager中,记录了CookOnTheFlyServer中的这样一种条件检测:

如果命令行没有显式指定任何资源、以及从 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 获取到的资源为空

这意味着,不配置任何资源也无法使基础包变小,反而会变大。如果想要避免包含所有的资源进包,就要从上面的方式中喂给CookOnTheFly一些资源,让它不要去添加非标记的工程资源。

我选择的方式是重载AssetManager并重写ModifyCook函数:

1
2
3
4
5
void UHotChunkerAssetManager::ModifyCook(TArray<FName>& PackagesToCook, TArray<FName>& PackagesToNeverCook)  
{
FName DefaultAsset = TEXT("/Engine/EngineMaterials/DefaultDiffuse");
PackagesToCook.Add(DefaultAsset);
}

然后在项目设置中指定AssetManager的Classes为UHotChunkerAssetManager

再次执行CookCommandlet可以看到这个条件不再满足:

前面讲到的是Cook阶段的资源控制,在Cook之后还要控制资源进入Pak。

在4.27中,会默认把当前打包任务中Cooked下的资源全进包:

如果想要打出一个干净的包,就要控制真正进入0号Pak的文件,也就是要控制只有必要的文件才存储在[PROJECTDIR]/Saved/Cooked

我给HotPatcher新加了一个功能,可以自定义指定Cook时保存的路径:

对Cooked的无干扰,从而实现基础包拆分包的分离。

介入CookCommandlet

默认情况下,我们无法直接介入到UE的打包过程。

UE的打包是通过拉起UAT执行BuildCookRun实现的,它是一个独立的程序,相当于是一个任务调度器,拉起多个进程执行不同阶段地任务,用于统筹整个打包过程,并且可以控制跳过某些阶段。

这就造成一个问题,没有办法很方便地介入到这个过程中去,因为UAT独立与我们的业务代码。

但如果需要修改引擎,那就很难受了,不是一种通用的解决方案。遇事不决就改引擎,太过无脑,不是我的风格,所以我研究了一种非侵入式的方案。

通过上面的图可以看到,UAT执行的过程中是会拉起CookCommandlet去执行Cook的,虽然我们没有办法直接介入到UAT的流程,但是可以想一些奇技淫巧,介入到CookCommandlet的过程。

CookCommandlet是一个Commandlet,执行方式为:

1
UE4Editor-cmd.exe PROJECT.uproject -run=CookCommandlet

注意,它本质上是通过引擎启动项目,去执行一个命令行任务。这意味着,启动它时会加载我们项目中的插件。

利用这一点我们就能实现非侵入式介入CookCommandlet的过程。

其次,我们可以在CookCommandlet的什么阶段做事情,对于本文的需求而言,希望在Cook完成之后,拉起来HotPatcher执行拆分打包。

那么在什么时机合适呢?

在之前的文章中介绍了,UE的Cook是根据运行时的加载到内存中的资源动态Cook进包的,所以,我们不能在Cook完成之前就拉起我们自定义的打包任务,不然会导致进基础包。

只能在Cook完成之后才可以,但引擎没有暴露出Cook完成之后的回调,所以又要想一个其他的方案。

CookCommandlet本质上也是启动引擎,我们可以利用引擎暴露的CoreDelegates时机去做选择,通过阅读代码,发现比较合适的时机是OnEnginePreExit,因为这个阶段Cook已经完毕,并且引擎还没有开始退出,保持了完整的引擎启动状态,在这个阶段去做想做的事情,可以利用完整地引擎功能,而不会出现有些模块被卸载的情况。

但要注意的是,这个回调在4.25中才存在,之前的引擎版本中没有。

首先是创建一个插件,创建一个Editor的模块,在StartupModule中监听FCoreDelegates::OnEnginePreExit

1
2
3
4
5
6
7
void FHotChunkerEditorModule::StartupModule()  
{

#if ENGINE_MAJOR_VERSION > 4 || (ENGINE_MAJOR_VERSION == 4 && ENGINE_MINOR_VERSION > 24)
FCoreDelegates::OnEnginePreExit.AddRaw(this,&FHotChunkerEditorModule::OnPreEngineExit_Commandlet);
#endif
}

在监听的回调过程中,可以做想要做的事情了。
但要检测是否是在CookCommandlet中执行,因为Editor的模块,在引擎启动时都会执行,我们只需要它在CookCommandlet中生效,所以要做检测:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
bool CommandletHelper::IsCookCommandlet()  
{
bool bIsCookCommandlet = false;

if(::IsRunningCommandlet())
{ FString CommandletName;
bool bIsCommandlet = CommandletHelper::GetCommandletArg(TEXT("-run="),CommandletName); //FParse::Value(FCommandLine::Get(), TEXT("-run="), CommandletName);
if(bIsCommandlet && !CommandletName.IsEmpty())
{ bIsCookCommandlet = CommandletName.Equals(TEXT("cook"),ESearchCase::IgnoreCase);
} } return bIsCookCommandlet;
}
void FHotChunkerEditorModule::OnPreEngineExit_Commandlet()
{
if(CommandletHelper::IsCookCommandlet())
{
// do something
}
}

通过这种方式,就能实现非侵入式地监听Cook完成的时机,进行自定义的打包行为。

自动化Stage

前一小结,讲到了如何利用Hook Commandlet的方式,在Cook完成之后,拉起HotPatcher进行打包。
在执行完毕CookCommandlet之后,打包的状态如下:

  1. 基础资源已经被Cook,但未打包成Pak
  2. HotChunker拆分的资源已经被Cook,并被打包成了Pak,暂存在临时目录中。

CookCommandlet默认是没有打包Pak的行为的,把CookCommandlet的结果打包成Pak是UAT拉起UnrealPak做的事情。

在CookCommandlet中,我们打包出来的Pak,并没有直接存储在Saved/StagedBuilds目录下,因为UAT里后续创建Pak的过程,会清掉StagedBuilds下的目录,如果在清掉之前拷贝过去,其实没什么意义,会被删除。

所以,要找到一个稍微靠后的时机,不会把我们创建的Pak清理掉的方法。

看一下这张图:

引擎中的代码(Scripts/CopyBuildToStagingDirectory.Automation.cs):

它是在UAT拉起UnrealPak之前去做的清理,然后直接把UnrealPak的产出生成到干净的StagedBuilds目录里。

所以我们要在UnrealPak及之后的时机才能做事情。那么如何不改动引擎实现?

这里需要插入一些UE模块加载的科普。
对于UE的Module而言,可以具有多种类型,一般情况下会用到RuntimeEditorDeveloper等日常开发用法。但UE还提供了一种Program的类型,可以让引擎中的一些Program启动时去加载。
以HotChunker的.uplugin为例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
{   
"FriendlyName": "HotChunker",
"CreatedBy": "lipengzha",
"CreatedByURL": "https://imzlp.com/",
"CanContainContent": false,
"SupportedPrograms": [ "UnrealPak" ],
"Plugins": [
{ "Name": "HotPatcher",
"Enabled": true
}
],
"Modules": [
{ "Name": "HotChunker",
"Type": "Program",
"LoadingPhase": "PostConfigInit",
"ProgramAllowList": [ "UnrealPak" ]
}
]
}

通过指定SupportedPrograms,标记该插件可以被Program启动时加载,Modules中的ProgramAllowList用于控制,该模块能够被哪个程序加载。

这意味着,能在不变动引擎的情况下,让UnrealPak去加载我们工程中的一个特定模块,来做我们想做的事情。

注意,引擎提供这个机制,本质上是想要能够非侵入式地实现拓展压缩算法,本文介绍的这种用法,是一种合理的使用方式。

当然要求是启动Program的过程中,要传递项目路径,而UAT拉起UnrealPak时,就是这么传递的。
在打包时,UAT拉起UnrealPak执行的命令:

1
2
3
E:\UnrealProjects\BlankExample\BlankExample.uproject E:\UnrealProjects\BlankExample\Saved\StagedBuilds\WindowsNoEditor\BlankExample\Content\Paks\BlankExample-WindowsNoEditor.pak -create=D:\UnrealEngine\Source\UE_4.27.2\Engine\Programs\AutomationTool\Saved\Logs\PakList_BlankExample-WindowsNoEditor.txt -cryptokeys
=E:\UnrealProjects\BlankExample\Saved\Cooked\WindowsNoEditor\BlankExample\Metadata\Crypto.json -secondaryOrder=E:\UnrealProjects\BlankExample\Build\WindowsNoEditor\FileOpenOrder\CookerOpenOrder.log -patchpaddingalign=2048 -platform=Windows -compressionformats=Oodle -compressmethod=Kraken -compresslevel=3 -multiprocess -abslog=D:\UnrealEngine\Source\UE_4.27.
2\Engine\Programs\AutomationTool\Saved\Logs\UnrealPak-BlankExample-WindowsNoEditor-2022.10.22-13.58.14.txt -compressionblocksize=256KB

这样我们就可以在Module的StartupModule或者ShutdownModule中自定义的操作了:

1
2
3
4
5
void FHotChunkerModule::StartupModule()  
{
bool bIsRunningProgram = FPlatformProperties::IsProgram();
UE_LOG(LogHotChunker,Display,TEXT("HotChunker StartupModule,is Program %s"), bIsRunningProgram ? TEXT("TRUE"):TEXT("FALSE"));
}

注意,需要在运行时检测,是否是以Program启动的模块,用于区分是正常的引擎启动还是Program启动。可以用FPlatformProperties::IsProgram()进行判断。

此时,再通过UAT打包工程,在拉起UnrealPak时会启动项目Module,可以在UAT拉起UnrealPak的Log中看到:

1
LogHotChunker: Display: HotChunker StartupModule,is Program TRUE

在这个Module中,把CookCommandlet中我们打包出来的Pak从临时目录拷贝到真正进包的StagedBuilds目录中即可。

通用的Pak过滤规则

在文章UE 热更新:拆分基础包中,我介绍了UE给Android提供的Pak不进Obb的方法,并提供了IOS的等价实现。

但缺点也是要改动引擎才能实现,不是一个非侵入式的完全跨平台的通用实现。

基于本篇文章的拆分方案,可以在UnrealPak拉起HotChunker执行Copy to StagedBuilds时进行过滤,避免进入基础包。

只需要一份配置,对所有的平台都是通用的。支持通配符,方便指定忽略规则:

Chunk的配置

新的拆分方式,和UE的PrimaryAssetLabel类似,可以创建一个HotPatcherPrimaryLabel资源进行配置,与HotPatcher插件中Chunk的配置一致,可以指定目录、资源、是否进行依赖分析、非资源文件的配置,强制忽略哪些目录等等,都可以灵活配置。

并且可以在项目设置中修改打包Chunk使用的Pak的配置模板,完全复用HotPatcher的配置:

可以控制生成Pak的命名规则等等。

编辑器的支持

我为HotPatcherPrimaryLabel资源支持了右键菜单支持:

可以直接右键打包当前的配置,也可以将其添加到HotPatcher的Chunk配置中。

并且可以直接通过引用视图,查看当前Pak包含的资源:

包内Pak预览

以Android为例,使用本方案的拆包方式将某个Pak打到包内:

具有更清晰和更灵活的优势。

结语

本篇文章介绍了UE默认拆分包的缺点,以及利用HotPatcher进行非侵入式拆分基础包的方式和实现。
利用了很多引擎隐藏的特性,基于Commandlet Hook和Module for Program的方式实现,避免了对引擎的修改。
本文的方案已经完全实现,后续会作为HotPatcher的一个Mod发布,只需要简单地配置,然后执行UE默认的打包,就能实现完整的流程。

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

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

本文标题:一种灵活与非侵入式的基础包拆分方案
文章作者:查利鹏
发布时间:2022年10月23日 15时20分
本文字数:本文一共有5k字
原始链接:https://imzlp.com/posts/24350/
许可协议: CC BY-NC-SA 4.0
文章禁止全文转载,摘要转发请保留原文链接及作者信息,谢谢!
您的捐赠将鼓励我继续创作!