UE插件与工具开发:Commandlet

UE plug-in and tool development:Commandlet

在使用UnrealEngine开发工具时,有相当一部分的情况是对资源处理和数据导出需求,这些任务是需要频繁且自动化执行的,通常会把它们集成到CI/CD系统中。

在具体的实现中,就要利用UE的Commandlet机制,用命令行的形式去驱动引擎,做自定的行为。

以我开发的插件中支持的Commandlet功能为例:

  1. HotPatcher:导出基础包信息、打包补丁
  2. ResScannerUE:变动资源的增量扫描
  3. HotChunker:独立打包Chunk
  4. libZSTD:训练Shader字典
  5. ExportNavMesh:导出NavMesh数据

Commandlet能够使它们比较方便地集成到CI/CD中,实现自动化。

本篇文章中,我将会主要介绍UE的Commandlet机制,并分析它的实现原理,以及提供一些开发技巧、我在开发过程中的一些思考等。

同时,这也是我UE插件与工具开发系列的第二篇文章,后续会持续更新,敬请期待。

什么是Commandlet

UE的Commandlet是一种可以用命令行地方式去驱动引擎的机制,如传统的cli程序:

arg_printer.cpp
1
2
3
4
5
int main(int argc,char* agrv[]){
for (int i = 0; i < argc; i++){
cout << argv[i] << endl;
}
}

则可以通过命令行的形式调用,并传递参数:

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类:

ResScannerCommandlet.h
1
2
3
4
5
6
7
8
9
10
11
12
13
14
#pragma once  
#include "Commandlets/Commandlet.h"
#include "ResScannerCommandlet.generated.h"

DECLARE_LOG_CATEGORY_EXTERN(LogResScannerCommandlet, All, All);

UCLASS()
class RESSCANNER_API UResScannerCommandlet :public UCommandlet
{
GENERATED_BODY()

public:
virtual int32 Main(const FString& Params)override;
};

其中的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
2
// Token -- XXXXCommandlet
CommandletClass = FindObject<UClass>(ANY_PACKAGE,*Token,false);

拿到UClass之后,创建一个实例,然后调用它的Main函数:

Runtime\Launch\Private\LaunchEngineLoop.cpp
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
UCommandlet* Commandlet = NewObject<UCommandlet>(GetTransientPackage(), CommandletClass);
check(Commandlet);
Commandlet->AddToRoot();

// Execute the commandlet.
double CommandletExecutionStartTime = FPlatformTime::Seconds();

// Commandlets don't always handle -run= properly in the commandline so we'll provide them
// with a custom version that doesn't have it.
Commandlet->ParseParms( CommandletCommandLine );
#ifSTATS
// We have to close the scope, otherwise we will end with broken stats.
CycleCount_AfterStats.StopAndResetStatId();
#endif // STATS
FStats::TickCommandletStats();
int32 ErrorLevel = Commandlet->Main( CommandletCommandLine );
FStats::TickCommandletStats();

RequestEngineExit(FString::Printf(TEXT("Commandlet %s finished execution (result %d)"), *Commandlet->GetName(), ErrorLevel));

注意,是启动了所有模块之后才会进入到Commandlet的Main中,意味着模块的StartupModule在Main之前。

引擎执行Commandlet Main函数的调用栈:

上下文

检测Commandlet

根据上面的内容,需要记住以下两个关键点:

  1. Commandlet位于Editor模块内
  2. Commandlet进入到Main之后就是完整的引擎环境

因为Commandlet是位于Editor模块内的,有可能会调用到模块内的其他函数,为了区分Editor启动还是Commandlet启动,可以用以下函数进行检测:

1
2
3
4
if(::IsRunningCommandlet())
{
// do something
}

接收参数

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,就像一个普通的函数,所有代码执行完毕之后,离开当前的作用域,就请求退出引擎了。

Runtime\Launch\Private\LaunchEngineLoop.cpp
1
2
3
int32 ErrorLevel = Commandlet->Main( CommandletCommandLine );  
// ...
RequestEngineExit(FString::Printf(TEXT("Commandlet %s finished execution (result %d)"), *Commandlet->GetName(), ErrorLevel));

但是有些需求,需要在Commandlet中驱动Tick去执行逻辑,如HotPatcher中的分帧Cook,避免在一帧内处理过多的资源,都需要在完整的引擎Tick环境内执行。

基于这种需要,我封装了一个在Commandlet中驱动Tick的函数:

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
37
void CommandletHelper::MainTick(TFunction<bool()> IsRequestExit)  
{
GIsRunning = true;

FDateTime LastConnectionTime = FDateTime::UtcNow();

while (GIsRunning &&
// !IsRequestingExit() &&
!IsRequestExit())
{ GEngine->UpdateTimeAndHandleMaxTickRate();
GEngine->Tick(FApp::GetDeltaTime(), false);

// update task graph
FTaskGraphInterface::Get().ProcessThreadUntilIdle(ENamedThreads::GameThread);

// execute deferred commands
for (int32 DeferredCommandsIndex=0; DeferredCommandsIndex<GEngine->DeferredCommands.Num(); DeferredCommandsIndex++)
{ GEngine->Exec( GWorld, *GEngine->DeferredCommands[DeferredCommandsIndex], *GLog);
}
GEngine->DeferredCommands.Empty();

// flush log
GLog->FlushThreadedLogs();

#if PLATFORM_WINDOWS
if (ComWrapperShutdownEvent->Wait(0))
{ RequestEngineExit();
}#endif
}
//@todo abstract properly or delete
#if PLATFORM_WINDOWS
FPlatformProcess::ReturnSynchEventToPool(ComWrapperShutdownEvent);
ComWrapperShutdownEvent = nullptr;
#endif

GIsRunning = false;
}

在Main函数中调用,并传递一个callback,用于在每次Tick是请求时候要退出Tick逻辑:

1
2
3
4
5
6
7
8
9
if(IsRunningCommandlet())  
{
CommandletHelper::MainTick([&]()->bool
{
bool bIsFinished = true;
// state control
return bIsFinished;
});
}

在HotChunker中我实现了分帧打包Chunk的机制,就利用了这个特性。

注意,在旧版本引擎中,在commandlet中驱动引擎有bug,会crash在FSlateApplication中,因为Commandlet,虽然是完整的引擎环境,但不包含Editor相关的,而我们在Commandlet中驱动引擎后,会触发FSlateApplication的执行,导致空访问。

修复FSlateApplication的BUG

在Commandlet情况下,不会初始化FSlateApplication,所以在用到的模块中要进行检测。

1
2
Engine/Source/Editor/UnrealEd/Private/EditorEngine.cpp
Engine/Source/Editor/UnrealEd/Private/AssetThumbnail.cpp

UnrealEd/Private/EditorEngine.cpp
1
2
3
4
5
6
7
8
9
10
void FAssetThumbnailPool::Tick( float DeltaTime )
{
// ++[lipengzha] fix commandlet tick crash
if(!FSlateApplication::IsInitialized())
{
return;
}
// --[lipengzha]
// ...
}

UnrealEd/Private/AssetThumbnail.cpp
1
2
3
4
5
6
7
8
9
10
bool UEditorEngine::AreAllWindowsHidden() const
{
// ++[lipengzha] fix commandlet tick crash
if(!FSlateApplication::IsInitialized())
{
return false;
}
// --[lipengzha]
// ...
}

修改之后,就能够正常在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在执行中,也是会执行模块的StartupModuleShutdownModule的,而我们想要介入到Cmdlet中执行,就是要利用这个特性。

同样以Cook之后,让引擎拉起我们的自定义处理流程为例,可以拆解这个需求:

  1. 确定是否运行在CookCommandlet中
  2. 在Cook执行完毕后,引擎退出之前的时机执行

关于第一点,我们可以解析启动的命令行参数,并检查-run=的Token是不是Cook,就能确定是否是在CookCommandlet中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
bool CommandletHelper::GetCommandletArg(const FString& Token,FString& OutValue)  
{
OutValue.Empty();
FString Value;
bool bHasToken = FParse::Value(FCommandLine::Get(), *Token, Value);
if(bHasToken && !Value.IsEmpty())
{ OutValue = Value;
} return bHasToken && !OutValue.IsEmpty();
}
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;
}

第二点,我们可以在引擎启动模块的StartupModule中注册OnPreEngineExit的回调,之所以不用ShutdownModule,则是因为,执行到ShutdownModule时,引擎已经进入的退出状态,并且已经关闭了不少Module,此时就不是完整的引擎状态了,在这个流程内执行会出现各种问题。

1
2
3
4
5
6
void FlibZSTDEditorModule::StartupModule()  
{
#if ENGINE_MAJOR_VERSION > 4 || (ENGINE_MAJOR_VERSION == 4 && ENGINE_MINOR_VERSION > 24)
FCoreDelegates::OnEnginePreExit.AddRaw(this,&FlibZSTDEditorModule::OnPreEngineExit_Commandlet);
#endif
}

在引擎请求退出后,就会自动触发这个回调,可以在其中做检测并执行:

1
2
3
4
5
6
7
8
9
10
11
12
void FlibZSTDEditorModule::OnPreEngineExit_Commandlet()  
{
FCoreUObjectDelegates::PackageCreatedForLoad.Clear();

#if ENGINE_MAJOR_VERSION > 4
FScopeRAII ScopeRAII;
#endif
if(CommandletHelper::IsCookCommandlet())
{
// do something
}
}

注意,这个回调不管是Editor还是Commandlet启动引擎,都会在退出引擎时触发,所以要根据需要检测,是否在正确的环境中执行。

通过这种方式,可以介入到任意一个Commandlet中去,并且不需要修改引擎。也不仅仅只是引擎退出时这一种时机,把Module的LoadingPhase结合起来使用,可以实现很多种插入流程的口子,放飞想象力。

在我前面的文章一种灵活与非侵入式的基础包拆分方案资源管理:重塑 UE 的包拆分方案中,都有有介绍到的HotChunker也是利用这种方式实现的,在CookOnTheFlyServer执行完毕之后,自动地拉起HotChunker进行Chunk的打包,并且对引擎的非侵入式:

结语

本篇文章研究了Commandlet的创建、运行、环境检测、驱动引擎Tick以及分析耗时和启动加速、介入到Cmdlet流程的内容。

Commandlet也是在工具开发过程中,被大量运用的一种技术,合理地运用能够方便地集成到CI/CD系统中去实现自动化,降低人为干预,提升效率。

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

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

本文标题:UE插件与工具开发:Commandlet
文章作者:查利鹏
发布时间:2023年03月23日 13时46分
本文字数:本文一共有3.9k字
原始链接:https://imzlp.com/posts/27475/
许可协议: CC BY-NC-SA 4.0
文章禁止全文转载,摘要转发请保留原文链接及作者信息,谢谢!
您的捐赠将鼓励我继续创作!