Сommandlets: разработка и применение
Коммандлеты удобно использовать для автоматизации процессов обработки большого количества ассетов или проведения определенных операций в уровнях. Например, удаление/переименование на нескольких уровнях большого количества актеров с определенным условием или изменение параметров в актерах определенного типа. Или создание новых ассетов и изменение параметров в уже существующих ассетах.
Мы покажем, как создать коммандлет в плагине. Именно в плагине — потому что есть некоторые моменты, которые нужно учитывать. Коммандлет будет иметь примеры базовых операций над ассетами: создание и изменение, а также работа с актерами на уровне.
Чтобы создать коммандлет, нужно унаследовать свой класс коммандлета от существующего класса UCommandlet. При создании своего коммандлета важно, чтобы название класса заканчивалось на “Commandlet”, в нашем случае — “UModifyAssetsExampleCommandlet”. Если назвать класс “UModifyAssetsExample”, при попытке запуска коммандлета через аргументы запуска такой класс не будет найден с ошибкой “LogInit: Error: ModifyAssetsExampleCommandlet looked like a commandlet, but we could not find the class.”
Аналогичную ошибку “LogInit: Error: ModifyAssetsExampleCommandlet looked like a commandlet, but we could not find the class.” можно увидеть, если коммандлет называется правильно, но плагин отключен.
Запуск коммандлета происходит следующим образом:
[Путь к движку]\Engine\Binaries\Win64\UE4Editor.exe "[Путь к проекту]/[Имя проекта].uproject" -Run=ModifyAssetsExample
Создание
Мы создадим коммандлет “UModifyAssetsExampleCommandlet” в простом плагине, который был создан по шаблону Blank плагина. Поместим исходные файлы в папку Commandlets.
В первую очередь следует переопределить функцию virtual int32 Main(const FString& Params);. Она является точкой входа в коммандлет и несет входные аргументы запуска.
Разбор входных аргументов
В базовом классе коммандлета есть функция ParseCommandLine, которой можно воспользоваться, чтобы разобрать входные аргументы.
TArray<FString> Tokens;
TArray<FString> Switches;
TMap<FString, FString> ParamsMap;
ParseCommandLine(*Params, Tokens, Switches, ParamsMap);
Пример результата разбора строки:
В аргументы запуска мы подали следующую строку:
-run=ModifyAssetsExample -switch0 -switch1 -param=value token0 token1
-run=ModifyAssetsExample — Запуск нашего UModifyAssetsExampleCommandlet
В Tokens попадут элементы: token0 и token1
В Switches попадут элементы, которые начинаются с символа ‘-’, такие как: switch0 и switch1
В ParamsMap попадут ключи со значениями: ключ “run” со значением “ModifyAssetsExample” и ключ “param” со значением “value”.
Операции с уровнями
Порядок операций:
- Выгружаем уже существующий мир, если нужно
- Загружаем уровень *.umap
- Инициализируем мир как мир редактора
- Проводим операции над ассетами
- Проводим сборку мусора
- Сохраняем уровень, если были изменения
- Удаляем уровень и собираем мусор
Создадим уровень с несколькими текстовыми актерами и триггерами.
Для примера поместим несколько (выделенных на скриншоте) текстовых актеров в другой уровень, чтобы было видно, что хоть мы и загружаем основной уровень, но результат выполнения не повлияет на подуровни, потому что мы их не будем загружать.
Снимем флаг bGenerateOverlapEvents с двух триггеров.
Передадим во входные аргументы
-run=ModifyAssetsExample -FixLevels CommandletTests
-FixLevels будет служить меткой, что будем обрабатывать уровень.
CommandletTests — название карты, которую будем обрабатывать. Если нужно обработать несколько карт, следует их передать через пробел, например, CommandletTests Map1 Map2.
Для обработки этого случая используем код:
if (Switches.Contains(TEXT("FixLevels"))) { ProcessLevels(Tokens); }
Функция ProcessLevels выглядит так:
void UModifyAssetsExampleCommandlet::ProcessLevels(TArray<FString> LevelsList) { // Выгружаем уже существующий мир, если нужно UWorld* World = GWorld; if (World != NULL) { World->CleanupWorld(); World->RemoveFromRoot(); } // Итерируем все карты, которые были переданы for (const FString& FileName : LevelsList) { UE_LOG(LogClass, Display, TEXT("Loading %s..."), *FileName); // Загружаем Package уровня *.umap. FileName здесь выглядит как чистое название карты "CommandletTests". После загрузки следует найти мир в этом Package. UPackage* Package = LoadPackage(NULL, *FileName, LOAD_None); World = UWorld::FindWorldInPackage(Package); if (World != nullptr) { // Если нужно, можно присвоить текущий мир в GWorld GWorld = World; // Тип мира должен быть помечен как Editor World->WorldType = EWorldType::Editor; // Следует проинициализировать этот мир. В коммандлете не нужен звук, поэтому запретим звук в этом мире и проинициализируем актеров и компоненты в нем. Также добавим в Root, чтобы при работе с этим миром он не был собран сборщиком мусора. World->AddToRoot(); World->InitWorld(UWorld::InitializationValues().AllowAudioPlayback(false)); World->GetWorldSettings()->PostEditChange(); World->UpdateWorldComponents(true, false); // Делаем наши операции над актерами в уровне ProcessWorld(World); // Чистим уровень, если на нем были удалены актеры GEngine->PerformGarbageCollectionAndCleanupActors(); // Сохраняем карту, если в ходе работы ProcessWorld что-то было помечено как bDirty if (Package->IsDirty()) { // Формируем путь для сохраняемого ассета. Это должен быть относительный путь к ассету с расширением файла. В нашем случае это выглядит так "../../../Projects/Shooter/Content/FirstPersonCPP/Maps/CommandletTests.umap". FString PackageName = FPackageName::LongPackageNameToFilename(Package->GetPathName(), FPackageName::GetMapPackageExtension()); Package->SavePackage(Package, World, RF_Standalone, *PackageName, GLog); } // После всех операций нужно удалить этот мир и собрать мусор принудительно, во избежание накопления лишних объектов, чтобы перейти к следующему или завершить работу. World->CleanupWorld(); World->RemoveFromRoot(); World = GWorld = NULL; } CollectGarbage(RF_NoFlags); } }
В функции ProcessWorld(UWorld *World) можно делать различные операции с загруженным миром. В нашем примере мы найдем все ATextRenderActor и поменяем в них текст, а ATriggerVolume, в которых не установлен флаг bGenerateOverlapEvents, удалим с уровня.
void UModifyAssetsExampleCommandlet::ProcessWorld(class UWorld *World) { for (TActorIterator<AActor> It(World); It; ++It) { AActor* Actor = *It; if(ATextRenderActor *TextActor = Cast<ATextRenderActor>(Actor)) { // Меняем отображаемый текст TextActor->GetTextRender()->SetText(FText::FromString(TextActor->GetName())); } else if (ATriggerVolume *TriggerVolume = Cast<ATriggerVolume>(Actor)) { if (!TriggerVolume->GetBrushComponent()->GetGenerateOverlapEvents()) { // Удаляем актера с уровня TriggerVolume->Destroy(); } } else { continue; } // Отмечаем, что в этом актере есть изменения, чтобы в дальнейшем сохранить его Actor->MarkPackageDirty(); } }
Как видим, текст поменялся только в тех актерах, которые находятся на обрабатываемом уровне, а ненужные триггеры удалены.
Изменение ассетов
Порядок операций:
- С помощью модуля “AssetRegistry” находим нужные ассеты
- Находим нужные ассеты
- Меняем ассеты
- Сохраняем ассеты
Так как коммандлет создан в плагине, при работе с AssetRegistry следует обратить внимание на фазу загрузки модуля.
Рассмотрим случай, когда AssetRegistry модуль используется из функции StartupModule. Следует убедиться, что LoadingPhase модуля происходит не ранее, чем PreLoadingScreen (в *.uplugin файле), так как инициализация модуля AssetRegistry считается завершенной начиная с этой фазы загрузки. Использование AssetRegistry раньше этого этапа невозможно.
В примере мы создали 3 ассета SoundCue.
В параметры запуска будем передавать:
-run=ModifyAssetsExample -FixAssets SoundCue -Root=/Game/FirstPersonCPP
Добавим в функцию Main обработку этого функционала:
else if (Switches.Contains(TEXT("FixAssets"))) { FString *RootDirectory = ParamsMap.Find(TEXT("Root")); if (RootDirectory) { ProcessAssets(Tokens, *RootDirectory); } }
Массив “Tokens” будет содержать названия классов ассетов, которые нужно изменить, а параметр “Root” — указывать корневой путь к ассетам. Это нужно, чтобы не затронуть ассеты движка или других плагинов при изменении. В примере папка с нужными ассетами находится в “UnrealEngine-4.21.1\Projects\Shooter\Content\FirstPersonCPP”.
Но указываться должна в формате “/Game/FirstPersonCPP”.
void UModifyAssetsExampleCommandlet::ProcessAssets(TArray<FString> Classes, FString RootDirectory) { FAssetRegistryModule& AssetRegistryModule = FModuleManager::LoadModuleChecked<FAssetRegistryModule>(AssetRegistryConstants::ModuleName); // Перед началом работы нужно провести поиск ассетов, так как некоторых ассетов может не быть в кэше AssetRegistryModule.Get().SearchAllAssets(true); // Итерируем заданные классы for (FString ClassName : Classes) { // Получаем список ассетов конкретного класса TArray<FAssetData> AssetList; AssetRegistryModule.Get().GetAssetsByClass(*ClassName, AssetList, true); for (FAssetData AssetData : AssetList) { // Отсеиваем ненужные ассеты, которые не входят в папку поиска if (AssetData.ObjectPath.ToString().StartsWith(RootDirectory, ESearchCase::IgnoreCase)) { // Находим уже загруженный или загружаем текущий ассет UObject *AssetInstance = AssetData.GetAsset(); // Изменяем ассет ProcessAsset(AssetInstance); // Сохраняем ассет SaveAsset(AssetInstance); CollectGarbage(RF_NoFlags); } } } }
В случае с ассетами можно не собирать мусор после каждого ассета. Но стоит учитывать, что если обрабатываемых ассетов много, нужно следить за преждевременной сборкой мусора.
Изменим VolumeMultiplayer для SoundCue.
void UModifyAssetsExampleCommandlet::ProcessAsset(class UObject *Asset) { if (USoundCue *CUE = Cast<USoundCue>(Asset)) { CUE->VolumeMultiplier = 0.75f; } else { return; } // Отмечаем, что этот ассет был изменен Asset->MarkPackageDirty(); }
Функция SaveAsset немного отличается от сохранения карты.
bool UModifyAssetsExampleCommandlet::SaveAsset(UObject *AssetInstance) { if (AssetInstance) { // Получаем Package текущего объекта if (UPackage *Package = AssetInstance->GetOutermost()) { // Проверяем менялся ли объект if (AssetInstance->GetOutermost()->IsDirty()) { // Формируем путь аналогично формированию пути в случае с сохранением уровня, но с расширением *.uasset FString PackageName = FPackageName::LongPackageNameToFilename(Package->GetPathName(), FPackageName::GetAssetPackageExtension()); UE_LOG(LogClass, Log, TEXT("Saving asset to: %s..."), *PackageName); if (Package->SavePackage(Package, AssetInstance, RF_Standalone, *PackageName, GLog)) { UE_LOG(LogClass, Log, TEXT("Done.")); return true; } else { UE_LOG(LogClass, Warning, TEXT("Can't save asset!")); return false; } } } } return false; }
В результате выполнения видим, что во всех SoundCue в проекте поменялся VolumeMultiplayer:
Создание новых ассетов
В случае с созданием новых ассетов требуются вспомогательные Factory. Перед созданием ассета определенного типа Texture, Blueprint, SoundCue и т.д. требуется создать Factory. У каждого типа ассетов — своя Factory. После создания ее нужно настроить и только потом с помощью этой Factory создавать новые ассеты.
Порядок операций:
- Создание Factory
- Настройка Factory
- Создание нового ассета
- Изменение его параметров, если нужно
- Сохранение ассета
Добавим в функцию Main обработку этого случая:
-run=ModifyAssetsExample -CreateAssets Blueprint else if (Switches.Contains(TEXT("CreateAssets"))) { CreateAssets(Tokens); }
В массиве “Tokens” будут имена классов ассетов, которые нужно создать.
Создадим 5 экземпляров Blueprint ассета, наследованного от AActor.
void UModifyAssetsExampleCommandlet::CreateAssets(TArray<FString> Classes) { // Итерируем заданные классы for (const FString& AssetClassName : Classes) { UClass *AssetClass = nullptr; // Класс создаваемого ассета (Blueprint, Texture2D, SoundCue и т.д.) UFactory *AssetFactory = nullptr; // Factory для конкретного ассета // Обрабатываем случай создания Blueprint ассета if (AssetClassName.Equals(TEXT("Blueprint"), ESearchCase::IgnoreCase)) { // Задаем класс ассета AssetClass = UBlueprint::StaticClass(); // Создаем Factory и назначаем родительский класс будущего ассета. Это будет простой AActor. UBlueprintFactory* BlueprintFactory = NewObject<UBlueprintFactory>(); BlueprintFactory->ParentClass = AActor::StaticClass(); AssetFactory = BlueprintFactory; // Проверим, возможно ли создание Blueprint ассета, наследованного от этого класса check(FKismetEditorUtilities::CanCreateBlueprintOfClass(BlueprintFactory->ParentClass)); } if (AssetClass != nullptr && AssetFactory != nullptr) { // Загружаем модуль, с помощью которого будут создаваться новые ассеты IAssetTools& AssetTools = FModuleManager::LoadModuleChecked<FAssetToolsModule>("AssetTools").Get(); // Создадим 5 ассетов для примера for (int i = 0; i < 5; i++) { // Создаем новый ассет с именем "CommandletMadeAsset_i" в указанную директорию проекта в формате "/Game/FirstPersonCPP/Blueprints" UObject* NewAsset = AssetTools.CreateAsset(*FString::Printf(TEXT("CommandletMadeAsset_%d"), i), TEXT("/Game/FirstPersonCPP/Blueprints"), AssetClass, AssetFactory, GetFName()); // Здесь можно задать или изменить любые параметры нового ассета // Сохраняем этот ассет на диск SaveAsset(NewAsset); } } CollectGarbage(RF_NoFlags); } }
После выполнения в контенте появились 5 новых Blueprint ассетов, унаследованных от Actor.
Текст: Артем Иванов, tools developer, Fractured Byte