UE默认的资源管理较为复杂,默认情况下是根据在ProjectSetting
里配置的地图、目录、PrimaryAsset的配置,以及对一些条件的组合检测来执行资源打包过程的。并且,UE的Cook是根据运行时的动态加载来实时添加资源打到包中的,这导致打包的资源进包过程几乎等同于黑盒。
博客中介绍默认的进包资源规则及基础包拆分的文章:
本篇文章提供了一种新的思路,利用HotPatcher的精确Cook和打包机制,实现了一个HotChunker的Mod,能够对引擎的非侵入式,直接复用UE默认打包过程、简单清晰地进行拆分基础包。本篇文会具体介绍使用方式以及实现原理。
默认打包的问题
前面提到了,UE打包参数和配置较为复杂,以及一些无法处理的问题:
- 无法直接地预估有哪些资源进包以及最终进包的大小
- 打包的配置可以零散分布在各种地方,难以管理
- 所有的Non-uasset资源全在0号pak中(也就是必须进基础包)
- 无法根据pak的拆分来拆分Shaderlibrary(可能会非常大)
- 无法根据pak的拆分来拆分AssetRegistry
- 精确地控制某个pak规则的忽略资源
- 为所有平台共用一套PakFilter规则
在资源管理的角度来说,希望能够清晰、并且精确地控制资源打包规则,默认情况下UE提供的无法做到这些。
干净的基础包
在文章UE 资源管理:引擎打包资源分析中,我分析了引擎打包时的资源分析规则。
除了资源之外,还有很多Non-uasset的文件,如:Slate的图片、Ini、ShaderLib/AssetRegistry,以及项目中配置的DirectoriesToAlwaysStageAsUFS
路径。
常见的情况是,新建了一个工程,添加了很多资源,什么打包规则都没配置,执行打包,会把非常多的资源给打包进来。
对于新手很友好,但想要精细管理的时候,这种默认进包方式就有比较大的负担。
首先,第一个问题是:哪些是引擎启动的必要文件,必须要位于基础包中?
我大概总结为以下几种:
- SlateResources、Ini
- Global ShaderLib
- AssetRegistry
- 配置的Startup的地图、以及依赖到的蓝图GameMode、GameInstance等Gameplay框架资源
- 移动端的自定义摇杆资源
- 一些可能用到的基础UI资源
在UE默认打包的过程中,会对这些资源、文件进行分析以及进包。但我们如果想要打包出一个干净的基础包,就需要把这些必要资源的过程独立出来,让引擎去分析,额外的非必要资源就由业务自己控制。
第二个问题:如何排除非必要资源?
在文章引擎打包资源分析#AssetManager中,记录了CookOnTheFlyServer中的这样一种条件检测:
如果命令行没有显式指定任何资源、以及从 FGameDelegates::Get().GetCookModificationDelegate()
、UAssetManager::Get().Modify
都没有获取到任何资源,则添加插件、项目所有的资源。
执行条件为:
-
DefaultEditor.ini
中的AlwaysCookMaps
为空,AllMaps
为空 - 项目设置中
List of maps to include in a packaged build
为空 - 项目设置中
DirectoriesToAlwaysCook
为空 -
FGameDelegates::Get().GetCookModificationDelegate()
获取到的资源为空 -
UAssetManager::Get().ModifyCook
获取到的资源为空
这意味着,不配置任何资源也无法使基础包变小,反而会变大。如果想要避免包含所有的资源进包,就要从上面的方式中喂给CookOnTheFly一些资源,让它不要去添加非标记的工程资源。
我选择的方式是重载AssetManager
并重写ModifyCook
函数:
1 | void UHotChunkerAssetManager::ModifyCook(TArray<FName>& PackagesToCook, TArray<FName>& PackagesToNeverCook) |
然后在项目设置中指定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 | void FHotChunkerEditorModule::StartupModule() |
在监听的回调过程中,可以做想要做的事情了。
但要检测是否是在CookCommandlet中执行,因为Editor的模块,在引擎启动时都会执行,我们只需要它在CookCommandlet中生效,所以要做检测:
1 | bool CommandletHelper::IsCookCommandlet() |
通过这种方式,就能实现非侵入式地监听Cook完成的时机,进行自定义的打包行为。
自动化Stage
前一小结,讲到了如何利用Hook Commandlet的方式,在Cook完成之后,拉起HotPatcher进行打包。
在执行完毕CookCommandlet之后,打包的状态如下:
- 基础资源已经被Cook,但未打包成Pak
- 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而言,可以具有多种类型,一般情况下会用到Runtime
、Editor
、Developer
等日常开发用法。但UE还提供了一种Program
的类型,可以让引擎中的一些Program启动时去加载。
以HotChunker的.uplugin
为例:
1 | { |
通过指定SupportedPrograms
,标记该插件可以被Program启动时加载,Modules中的ProgramAllowList
用于控制,该模块能够被哪个程序加载。
这意味着,能在不变动引擎的情况下,让UnrealPak去加载我们工程中的一个特定模块,来做我们想做的事情。
注意,引擎提供这个机制,本质上是想要能够非侵入式地实现拓展压缩算法,本文介绍的这种用法,是一种合理的使用方式。
当然要求是启动Program的过程中,要传递项目路径,而UAT拉起UnrealPak时,就是这么传递的。
在打包时,UAT拉起UnrealPak执行的命令:
1 | 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 |
这样我们就可以在Module的StartupModule
或者ShutdownModule
中自定义的操作了:
1 | void FHotChunkerModule::StartupModule() |
注意,需要在运行时检测,是否是以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默认的打包,就能实现完整的流程。