13/06/2019

С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”.

Операции с уровнями

Порядок операций:

  1. Выгружаем уже существующий мир, если нужно
  2. Загружаем уровень *.umap
  3. Инициализируем мир как мир редактора
  4. Проводим операции над ассетами
  5. Проводим сборку мусора
  6. Сохраняем уровень, если были изменения
  7. Удаляем уровень и собираем мусор

Создадим уровень с несколькими текстовыми актерами и триггерами.

Для примера поместим несколько (выделенных на скриншоте) текстовых актеров в другой уровень, чтобы было видно, что хоть мы и загружаем основной уровень, но результат выполнения не повлияет на подуровни, потому что мы их не будем загружать.

Снимем флаг 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();
	}
}

Как видим, текст поменялся только в тех актерах, которые находятся на обрабатываемом уровне, а ненужные триггеры удалены.

Изменение ассетов

Порядок операций:

  1. С помощью модуля “AssetRegistry” находим нужные ассеты
  2. Находим нужные ассеты
  3. Меняем ассеты
  4. Сохраняем ассеты

Так как коммандлет создан в плагине, при работе с AssetRegistry следует обратить внимание на фазу загрузки модуля.

Рассмотрим случай, когда AssetRegistry модуль используется из функции StartupModule. Следует убедиться, что LoadingPhase модуля происходит не ранее, чем PreLoadingScreen (в *.uplugin файле), так как инициализация модуля AssetRegistry считается завершенной начиная с этой фазы загрузки. Использование AssetRegistry раньше этого этапа невозможно.

В примере мы создали 3 ассета SoundCue.

Будем менять VolumeMultiplier во всех этих ассетах на 0.5f.

В параметры запуска будем передавать:

-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 создавать новые ассеты.

Порядок операций:

  1. Создание Factory
  2. Настройка Factory
  3. Создание нового ассета
  4. Изменение его параметров, если нужно
  5. Сохранение ассета

Добавим в функцию 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

Recent Posts

Как в НИУ ВШЭ воспитывают новых Кодзим

11/07/2019
Реально ли попасть в геймдев, отучившись 4 года и получив диплом? Чему предстоит обучаться? Какие подводные камни? Что говорят студенты и преподаватели?

От юриста до разработчика игр при финансовой поддержке Unreal Engine

08/07/2019
Ник Пирс поделился замечательной историей о том, как сменить скучную профессию и стать разработчиком игр. 

Создание ИИ сверхскоростных автомобилей с помощью Unreal Engine

04/07/2019
Ведущий программист Dark Future: Blood Red States рассказывает об изящном решении для создания ИИ правдоподобных противников.