在使用UnrealEngine开发工具时,有相当一部分的情况是对资源处理和数据导出需求,这些任务是需要频繁且自动化执行的,通常会把它们集成到CI/CD系统中。
在具体的实现中,就要利用UE的Commandlet
机制,用命令行的形式去驱动引擎,做自定的行为。
以我开发的插件中支持的Commandlet功能为例:
- HotPatcher:导出基础包信息、打包补丁
- ResScannerUE:变动资源的增量扫描
- HotChunker:独立打包Chunk
- libZSTD:训练Shader字典
- ExportNavMesh:导出NavMesh数据
Commandlet能够使它们比较方便地集成到CI/CD中,实现自动化。
本篇文章中,我将会主要介绍UE的Commandlet机制,并分析它的实现原理,以及提供一些开发技巧、我在开发过程中的一些思考等。
同时,这也是我UE插件与工具开发系列的第二篇文章,后续会持续更新,敬请期待。
什么是Commandlet
UE的Commandlet是一种可以用命令行地方式去驱动引擎的机制,如传统的cli程序:
1 | int main(int argc,char* agrv[]){ |
则可以通过命令行的形式调用,并传递参数:
1 | ./arg_printer -test1 -test2 |
Commandlet也是提供的这样的调用形式。不过,需要调用的程序是引擎,并且要传递uproject文件路径、其他的参数等。
以Cook的Commandlet为例:
1 | UE4Editor-cmd.exe D:\Client\FGame.uproject -run=cook |
通过这样的形式启动命令行引擎,不会启动Editor,而是执行所定义的行为。
创建Commandlet
为项目或插件创建Commandlet,通常会在模块类型为Editor
的Module中添加,因为执行Commandlet的逻辑,很少会涉及到运行时,都是在引擎环境下执行的。
创建自定义的Commandlet,需要创建一个继承自UCommandlet
的UObject类:
1 |
|
其中的Main
函数,就是当通过-run=
命令启动该Commandlet时,会执行到的逻辑,就像前面纯C++的main函数一样。
不过,需要注意的是,因为Commandlet其实是完整的引擎环境,所以执行时会拉起已注册模块的启动,相当于有很多的前置逻辑需要执行,等执行到Main
函数时,就是完整的引擎状态了。
可以在这个函数之内做自定义的操作,此时它具有完整的引擎环境,可以根据自己的需要进行数据导出或资源处理。
运行Commandlet
前面已经提到了,运行一个Commandlet需要用以下的命令形式:
1 | UE4Editor-cmd.exe PROJECT_NAME.uproject -run=CMDLET_NAME |
启动项目中的Commandlet时,必须要指定项目路径,因为要去加载工程及插件中的模块。
参数-run=
,指定的是要执行的Commandlet的名字,这个名字和前面创建的继承自UCommandlet
类名字直接相关:如UResScannerCommandlet
它的Commandlet的名字就为ResScanner
,规则就是去掉头部的U
以及尾部的Commandlet
。
引擎内接收到-run=
的参数,会去查找UClass,并会自动拼接Commandlet
后缀:
1 | // Token -- XXXXCommandlet |
拿到UClass之后,创建一个实例,然后调用它的Main函数:
1 | UCommandlet* Commandlet = NewObject<UCommandlet>(GetTransientPackage(), CommandletClass); |
注意,是启动了所有模块之后才会进入到Commandlet的Main中,意味着模块的
StartupModule
在Main之前。
引擎执行Commandlet Main函数的调用栈:
上下文
检测Commandlet
根据上面的内容,需要记住以下两个关键点:
- Commandlet位于Editor模块内
- Commandlet进入到Main之后就是完整的引擎环境
因为Commandlet是位于Editor模块内的,有可能会调用到模块内的其他函数,为了区分Editor启动还是Commandlet启动,可以用以下函数进行检测:
1 | if(::IsRunningCommandlet()) |
接收参数
Commandlet函数的原型是:
1 | int32 Main(const FString& Params) |
传递进来的是不包含引擎路径和项目路径的参数。
如,运行命令:
1 | UE4Editor-cmd.exe G:\Client\FGame.uproject -skipcompile -run=HotChunker -TargetPlatform=IOS |
则接收到的参数为:
1 | -skipcompile -run=HotChunker -TargetPlatform=IOS |
可以通过解析该命令行参数,在Commandlet执行特殊行为。以HotPatcher为例,可以通过-config=
指定配置文件,在Commandlet中就会读取并根据配置文件执行。
驱动Tick
Commandlet默认是一个独立过程行为,不会驱动引擎Tick,就像一个普通的函数,所有代码执行完毕之后,离开当前的作用域,就请求退出引擎了。
1 | int32 ErrorLevel = Commandlet->Main( CommandletCommandLine ); |
但是有些需求,需要在Commandlet中驱动Tick去执行逻辑,如HotPatcher中的分帧Cook,避免在一帧内处理过多的资源,都需要在完整的引擎Tick环境内执行。
基于这种需要,我封装了一个在Commandlet中驱动Tick的函数:
1 | void CommandletHelper::MainTick(TFunction<bool()> IsRequestExit) |
在Main函数中调用,并传递一个callback,用于在每次Tick是请求时候要退出Tick逻辑:
1 | if(IsRunningCommandlet()) |
在HotChunker中我实现了分帧打包Chunk的机制,就利用了这个特性。
注意,在旧版本引擎中,在commandlet中驱动引擎有bug,会crash在FSlateApplication中,因为Commandlet,虽然是完整的引擎环境,但不包含Editor相关的,而我们在Commandlet中驱动引擎后,会触发FSlateApplication的执行,导致空访问。
修复FSlateApplication的BUG
在Commandlet情况下,不会初始化FSlateApplication,所以在用到的模块中要进行检测。
1 | Engine/Source/Editor/UnrealEd/Private/EditorEngine.cpp |
1 | void FAssetThumbnailPool::Tick( float DeltaTime ) |
1 | bool UEditorEngine::AreAllWindowsHidden() const |
修改之后,就能够正常在Commandlet中驱动引擎Tick了。
加速启动
前面提到了,UE启动Commandlet本质上也是会把引擎和项目中依赖的模块都加载,并执行StartupModule
的,但是有些情况下,我们不需要用到一些功能,而他们又比较耗时,可以按需把它们禁用。
可以分析启动引擎时各个模块的启动耗时,也就是每个模块的StartupModule
的耗时,然后把耗时较久的模块加上从命令行检测参数,进而动态开关。
这部分内容,我在文章UE中多阶段的自动化资源检查方案中,详细描述了如何分析引擎中模块加载的耗时,并针对耗时较久的LiveCoding
/AssetRegistry
写了优化策略。
以ResScanner的资源扫描为例,Commandlet引擎整体的启动耗时可以降低到20s左右,资源扫描过程可以降低到20s以内,在git hook这种场景下,提交时去执行Commandlet的耗时感知就不明显了。
介入到某个Cmdlet流程
以Cook为例,它是拉起来了一个CookCommandlet去执行。默认情况下,引擎并没有给我们留口子,去介入到Cook的各个阶段,如果想要在打包的过程中去做一些操作,就需要自己操纵UAT把构建过程拆开,自己维护各个流程。
但这么做的代价比较高,考虑以下这些需求:
- 在Cook完毕之后去训练Shader的字典,并把用字典压缩的ShaderLibrary替换原始的。
在默认情况下,要实现它,就需要把打包过程在Cook之后停掉,处理完自定义流程之后,再进入的UnrealPak阶段,接着执行。
整个过程都比较的繁琐,如果我们想要在Cook之后自动执行,还可以用另一种方式,介入到Commandlet的执行进程中。
根据前文的介绍我们知道,Commandlet在执行中,也是会执行模块的StartupModule
及ShutdownModule
的,而我们想要介入到Cmdlet中执行,就是要利用这个特性。
同样以Cook之后,让引擎拉起我们的自定义处理流程为例,可以拆解这个需求:
- 确定是否运行在CookCommandlet中
- 在Cook执行完毕后,引擎退出之前的时机执行
关于第一点,我们可以解析启动的命令行参数,并检查-run=
的Token是不是Cook
,就能确定是否是在CookCommandlet
中:
1 | bool CommandletHelper::GetCommandletArg(const FString& Token,FString& OutValue) |
第二点,我们可以在引擎启动模块的StartupModule中注册OnPreEngineExit
的回调,之所以不用ShutdownModule
,则是因为,执行到ShutdownModule时,引擎已经进入的退出状态,并且已经关闭了不少Module,此时就不是完整的引擎状态了,在这个流程内执行会出现各种问题。
1 | void FlibZSTDEditorModule::StartupModule() |
在引擎请求退出后,就会自动触发这个回调,可以在其中做检测并执行:
1 | void FlibZSTDEditorModule::OnPreEngineExit_Commandlet() |
注意,这个回调不管是Editor还是Commandlet启动引擎,都会在退出引擎时触发,所以要根据需要检测,是否在正确的环境中执行。
通过这种方式,可以介入到任意一个Commandlet中去,并且不需要修改引擎。也不仅仅只是引擎退出时这一种时机,把Module的LoadingPhase
结合起来使用,可以实现很多种插入流程的口子,放飞想象力。
在我前面的文章一种灵活与非侵入式的基础包拆分方案和资源管理:重塑 UE 的包拆分方案中,都有有介绍到的HotChunker
也是利用这种方式实现的,在CookOnTheFlyServer执行完毕之后,自动地拉起HotChunker进行Chunk的打包,并且对引擎的非侵入式:
结语
本篇文章研究了Commandlet的创建、运行、环境检测、驱动引擎Tick以及分析耗时和启动加速、介入到Cmdlet流程的内容。
Commandlet也是在工具开发过程中,被大量运用的一种技术,合理地运用能够方便地集成到CI/CD系统中去实现自动化,降低人为干预,提升效率。