UE中多阶段的自动化资源检查方案

Multi-stage automated resource inspection scheme in UE

在大型项目中,资源的规模非常庞大,涉及的制作团队也非常广泛,场景、角色、UI、动画、特效、蓝图、数据表格等等,随之而来就是资源量和资源规范的管理难以把控。

对于制定的资源规格,美术制作人员难以覆盖100%的情况,会存在不经意间漏掉,大多数情况下都是包内发现问题后再处理,而且对于存量的资源,需要耗费大量的人力处理,难以审查和修复。

基于这种痛点,我之前开发了一个资源扫描工具,可以方便地编辑规则对项目内的资源进行扫描。

近期,我对插件做了全方面地升级,增强了编辑时和自动化检查的能力,本篇文章会介绍如何利用ResScannerUE实现编辑时提交时CI定时或Hook任务Cook时等各个阶段的资源扫描,把错误地资源尽可能地在制作时暴露出来并提示解决,避免包内资源异常。

在插件的具体实现上,也针对扫描速度做了很多优化,尽可能把检查变成一个无感知的行为,文章内会具体介绍。

ResScanner简介

前面已经有两篇文章介绍了它的功能,这里不再详细赘述,简单地介绍一下功能。

ResScannerUE是一个UE的资源扫描工具,可以极为方便地编辑规则,支持纯配置和蓝图脚本规则,99%的情况下不需要编写任何C++代码。对于路径、命名的检查不需要实际加载资源。

支持路径规则、命名规则、属性规则、自定义规则四种类型,可以使用蓝图或者C++扩展规则。并且单个规则支持多个组合条件,支持逻辑操作(与或),对于复杂规则的支持非常好。

对于属性检查规则,基于UE的反射和Detail Customization机制,可以根据所选择的资源类型,列出其所有的属性,根据所选择的属性,构造出该属性类型的控件,对于属性的检测,鼠标勾选即可完成:

深度集成Git,可以基于Git的版本管理扫描,可以支持待提交文件、提交记录间的差异扫描。

具有完善的Commandlet支持,支持配置化、命令行参数的形式集成至ci/cd系统。

支持编辑时的规则扫描,把错误扼杀在摇篮中。

编辑时

大多数的资源问题,一般是以下几种情况造成的:

  1. 正式资产目录中创建了临时资源
  2. 选项忘记勾选、设置
  3. 资源规格不对
  4. 提交了不应该修改的资源

这种情况随着团队规模的扩大,几乎是无法避免的事情。而且对于第5点,资源是二进制形式,无法进行diff,就会存在覆盖的情况,如果出现问题,被覆盖的资源就要回滚或者重复制作,非常地浪费人力。

基于这种痛点,我对ResScannerUE增加了编辑时实时提醒的机制,在保存资源的同时,进行扫描,如果具有不合规的地方,及时提醒:

并且可以检查当前编辑的人对资源是否有修改权限:

插件并不会阻止保存,而是给予提示修改。

在实现上,是监听了UPackage::PackageSavedEvent,当保存资源时会执行该Delegate,从而获取到哪些资源被修改了:

1
UPackage::PackageSavedEvent.AddRaw(this,&FResScannerEditorModule::PackageSaved);

在回调中进行检查:

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
void FResScannerEditorModule::PackageSaved(const FString& PacStr,UObject* PackageSaved)
{
bool bEnable = GetDefault<UResScannerEditorSettings>()->bEnableEditorCheck;
if(bEnable)
{
static UResScannerEditorSettings* ResScannerEditorSettings = GetMutableDefault<UResScannerEditorSettings>();
ScannerProxy->SetScannerConfig(ResScannerEditorSettings->EditorScannerConfig);

FString PackageName = LongPackageNameToPackagePath(FPackageName::FilenameToLongPackageName(PacStr));
FSoftObjectPath ObjectPath(PackageName);
FAssetData AssetData;
if(UAssetManager::Get().GetAssetDataForPath(ObjectPath,AssetData))
{
const auto& ScanResult = ScannerProxy->ScanAssets(TArray<FAssetData>{AssetData});

if(ScanResult.HasValidResult())
{
FString ScanResultStr = ScanResult.SerializeResult(true);
UE_LOG(LogResScannerEditor,Warning,TEXT("\n%s"),*ScanResultStr);
FText DialogText = UKismetTextLibrary::Conv_StringToText(ScanResultStr);
FText DialogTitle = UKismetTextLibrary::Conv_StringToText(TEXT("ResScanner Message"));
FMessageDialog::Open(EAppMsgType::Ok, DialogText,&DialogTitle);
}
}
}
}

因为每次只检查一个资源,而且保存的资源已经被引擎加载,所以扫描速度非常快,几乎没有感知。

启用方式
打开Project Settings-Game-Res Scanner Settings面板,开启bEnableEditorCheck,新建一个FScannerMatchRuleDataTable,并在规则数据表中指定。

这个DataTable中的每一项就是一个单独的规则。

可以根据自己项目中的情况编辑扫描的规则,比如只扫描IMPORTANT类型的规则、并且支持黑白名单的方式指定规则。

提交时

在资源的提交阶段的检查,主要利用了版本控制软件提供的HOOK机制,如Git提供的Pre-Commit hook,Ugit提供了钩子等等。本质上就是可以在提交时执行一个脚本,拉起来我们的资源扫描功能,并在触发规则时,禁止提交。

这部分实现,我在文章基于 ResScannerUE 的资源检查自动化实践中有详细介绍。

CI/CD

可以把资源扫描集成到CI/CD系统中执行定时或Hook触发的扫描:

利用插件的Commandlet机制,可以通过导出的配置文件、命令行参数替换等方式实现。

1
UE4Editor.exe Project.uproject -run="ResScanner" -config="Full_Scan_Config.json" -gitChecker.bGitCheck=true -gitchecker.beginCommitHash=HEAD~ -gitchecker.endCommitHash=HEAD

扫描完毕之后,可以通过企业微信机器人等方式,发送群通知,并@相关提交人处理。

我提供了一个完整的实现脚本,详情可见:企业微信提醒

打包时

在引擎中的资源,并不是都会完全进包的,如果只想要分析当前进包的资源中的规则检查,插件也提供了支持。

只要启用,在Cook阶段会自动执行,并在Cook完毕之后生成检测报告,默认存储在Saved/ResScanner/Cooking.txt中。

整个实现是非侵入式的,只需要集成插件,无需变动引擎。

启用方式

Project Settings-Game-ResScanner Settings中启用bEnableCookingCheck,并配置Cook时扫描的规则即可:

扫描优化

资源扫描,本质上就是拉起来UE的UE4Editor-cmd进程,去执行Commandlet,耗时方面还是有些敏感,因为需要完整地拉起引擎。

所以要针对Commandlet的启动做些优化,避免引擎启动太久导致的流程卡顿。

首先要分析启动引擎时,各个模块的启动耗时,也就是每个模块的StartupModule的耗时。引擎中默认没有添加能够分析全部Modules的Profiling tag,可以在FModuleDescriptor::LoadModulesForPhase以及FModuleManager::LoadModule中添加Profiling Tag进行检查:

Runtime\Projects\Private\ModuleDescriptor.cpp
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
void FModuleDescriptor::LoadModulesForPhase(ELoadingPhase::Type LoadingPhase, const TArray<FModuleDescriptor>& Modules, TMap<FName, EModuleLoadResult>& ModuleLoadErrors)
{
FScopedSlowTask SlowTask(Modules.Num());
for (int Idx = 0; Idx < Modules.Num(); Idx++)
{
SlowTask.EnterProgressFrame(1);
const FModuleDescriptor& Descriptor = Modules[Idx];

// Don't need to do anything if this module is already loaded
if (!FModuleManager::Get().IsModuleLoaded(Descriptor.Name))
{
if (LoadingPhase == Descriptor.LoadingPhase && Descriptor.IsLoadedInCurrentConfiguration())
{
// @todo plugin: DLL search problems. Plugins that statically depend on other modules within this plugin may not be found? Need to test this.

// NOTE: Loading this module may cause other modules to become loaded, both in the engine or game, or other modules
// that are part of this project or plugin. That's totally fine.
FScopedNamedEventStatic LoadModuleEvent(FColor::Red,*Descriptor.Name.ToString());
EModuleLoadResult FailureReason;
IModuleInterface* ModuleInterface = FModuleManager::Get().LoadModuleWithFailureReason(Descriptor.Name, FailureReason);
if (ModuleInterface == nullptr)
{
// The module failed to load. Note this in the ModuleLoadErrors list.
ModuleLoadErrors.Add(Descriptor.Name, FailureReason);
}
}
}
}
}

FModuleManager::LoadModule

Runtime\Core\Private\Modules\ModuleManager.cpp
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
IModuleInterface* FModuleManager::LoadModule( const FName InModuleName )
{
FScopedNamedEventStatic LoadModuleEvent(FColor::Red,*InModuleName.ToString());
// We allow an already loaded module to be returned in other threads to simplify
// parallel processing scenarios but they must have been loaded from the main thread beforehand.
if(!IsInGameThread())
{
return GetModule(InModuleName);
}

EModuleLoadResult FailureReason;
IModuleInterface* Result = LoadModuleWithFailureReason(InModuleName, FailureReason );

// This should return a valid pointer only if and only if the module is loaded
checkSlow((Result != nullptr) == IsModuleLoaded(InModuleName));

return Result;
}

AkAudio

如果项目继承了WWise音频功能,它的AkAudio模块启动会非常耗时(取决于项目的资源量),经过分析发现,它会在StartupModule中去扫描整个工程Content下的文件,并扫描AssetRegistry数据,这部分非常耗时,只扫描文件就会有几十秒的耗时:

AkAudioModule.cpp
1
2
3
4
5
6
7
#if WITH_EDITOR
TArray<FString> paths;
IFileManager::Get().FindFilesRecursive(paths, *FPaths::ProjectContentDir(), TEXT("InitBank.uasset"), true, false);

FAssetRegistryModule& AssetRegistryModule = FModuleManager::LoadModuleChecked<FAssetRegistryModule>(TEXT("AssetRegistry"));
AssetRegistryModule.Get().ScanFilesSynchronous(paths, false);
#endif

这两部操作异常耗时,但对于Commandlet来说这两步并不需要,可以检测::IsRunningCommandlet屏蔽。

需注意:在EBP模式下的Wwise中,必须要检查InitBank在AssetRegistry是否存在,否则会再创建一个出来,会弹窗提示。
解决这个问题的办法就是在忽略整体扫描的同时,只为InitBank资源生成Registry数据。

1
2
3
4
5
FString PackageFilename;  
if(FPackageName::FindPackageFileWithoutExtension(FPackageName::LongPackageNameToFilename(TEXT("/Game/WwiseAudio/InitBank")), PackageFilename))
{
AssetRegistryModule.Get().ScanModifiedAssetFiles(TArray<FString>{PackageFilename});
}

LiveCoding

如果项目开启了LiveCoding,它的启动耗时比较可观:

但Commandlet完全不需要LiveCoding相关的支持,如果直接关掉,又会对日常的开发造成影响。

所以,我实现了一个方法,可以在Commandlet运行时,动态地关闭LiveCoding,这样就算EditorSetting中LiveCoding始终开启,也可以在执行Commandlet是关闭它。

方法就是在优先级较高的Module(Default)的StartupModule中把GEditorPreProjectIni中的配置改了:

1
2
3
4
if(IsRunningCommandlet())
{
GConfig->SetBool(TEXT("/Script/LiveCoding.LiveCodingSettings"),TEXT("bEnabled"),false,GEditorPerProjectIni);
}

这样等LiveCoding模块启动时,就会认为是关闭的,不会再执行那些耗时操作了。

Asset Registry

在引擎启动时,会通过AssetManager扫描PrimaryAsset,如果没有Cache过,从资源中重建AssetRegistry数据的过程也非常耗时。


对于资源扫描而言,其实大多数情况下(尤其是本地执行)并不需要完整的AssetRegistry数据,可以关闭SearchAllAssetsScanPrimaryAssetTypesFromConfig的扫描,在资源检查时,按需地生成对应资源的AssetRegistry数据即可。

无需修改引擎,只需要继承一个UAssetManager的类,重写ScanPrimaryAssetTypesFromConfig接口:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// .h
UCLASS()
class HOTPATCHERRUNTIME_API UHotPatcherAssetManager : public UAssetManager
{
GENERATED_BODY()
public:
virtual void ScanPrimaryAssetTypesFromConfig() override;
};
// .cpp
void UHotPatcherAssetManager::ScanPrimaryAssetTypesFromConfig()
{
SCOPED_NAMED_EVENT_TEXT("ScanPrimaryAssetTypesFromConfig",FColor::Red);
static const bool bNoScanPrimaryAsset = FParse::Param(FCommandLine::Get(), TEXT("NoScanPrimaryAsset"));
if(bNoScanPrimaryAsset)
{
UE_LOG(LogHotPatcher,Display,TEXT("Skip ScanPrimaryAssetTypesFromConfig"));
return;
}
Super::ScanPrimaryAssetTypesFromConfig();
}

在执行Commandlet时就可通过-NoScanPrimaryAsset参数关闭AssetRegistry的扫描了。并且,尽量不要在Commandlet代码中执行SearchAllAssets操作,也比较耗时。

但在运行中,需要访问AssetRegistry数据时,还是需要针对性地去扫描所需要的Package的AssetRegistry数据,插件中已经支持。

效果

经过优化之后,在引擎、Client、Content都放到SSD的情况下,引擎整体的启动耗时可以降低到20s左右,资源扫描过程可以降低到20s以内,提交时感知不明显。

在HDD中,引擎启动和扫描耗时都要慢一倍以上,就是IO瓶颈了,尽量把所有资源都放到固态中。

更新日志

2023.03.30 Update

  • 支持渐进式扫描
  • 优化Cook时的扫描实现,每个资源在Cook时的单独扫描,降低加载耗时
  • 优化引用关系扫描效率,增加依赖关系、派生类缓存
    优化前
    优化后
  • 优化扫描过程,避免执行不必要的规则
  • 优化扫描结果的序列化阶段,支持非CookOnTheFly的模式
  • 优化Cook时Warnning/Error级别Log的追踪方式,并去重

结语

本篇文章介绍了利用ResScannerUE实现,在资源各个提交阶段进行检查的实现和优化方式,能够覆盖实际开发中的绝大部分情况,尽可能地让错误提前暴露,提前解决。

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

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

本文标题:UE中多阶段的自动化资源检查方案
文章作者:查利鹏
发布时间:2022年08月23日 11时30分
本文字数:本文一共有3.9k字
原始链接:https://imzlp.com/posts/22655/
许可协议: CC BY-NC-SA 4.0
文章禁止全文转载,摘要转发请保留原文链接及作者信息,谢谢!
您的捐赠将鼓励我继续创作!