April 30

Игровой ИИ. Utility Based AI

Продолжаю мой цикл по игровому ИИ. В предыдущей части мы продолжили собирать плагин для UE 5.4, в котором реализуем ИИ, основанный на полезности. В этой частим мы добавим возможность дебага нашего ИИ прямо в игре. В этой части также будет много кода, поэтому дублирую ссылку на git с кодом из этой статьи.

GameplayDebugger

В прошлый раз мы добавили логирование для нашей системы, но во время реальной разработки ИИ и отладки игры логи - не самый удобный инструмент отладки. ИИ выполняется постоянно и агентов ИИ могут быть десятки. Для любых ИИ систем и модулей нужен инструмент, который выводит необходимую информацию в реальном времени (и желательно в том же окне, в котором запущена игра).

В Unreal Engine 5 уже существует такой инструмент для отладки - это система GamaplayDebugger. Достаточно навестись на любой объект центром экрана и нажать ‘ (символ одинарной кавычки, он же апостроф) на клавиатуре, и GameplayDebugger попробует обработать этот объект и выведет информацию на экран.

GameplayDebugger разбит на категории, которые можно включать и отключать, используя цифры на NumPad. Поэтому возможно отфильтровать необходимую для отладки информацию.

FGameplayDebuggerCategory_UtilityAI

Давайте заведем новую категорию для нашего ИИ.

#pragma once
#if WITH_GAMEPLAY_DEBUGGER

#include "CoreMinimal.h"
#include "GameplayDebuggerCategory.h"

/**
 * 
 */
class UTILITYAI_API FGameplayDebuggerCategory_UtilityAI : public FGameplayDebuggerCategory
{
public:
	FGameplayDebuggerCategory_UtilityAI();

	static TSharedRef<FGameplayDebuggerCategory> MakeInstance();

	virtual void CollectData(APlayerController* OwnerPC, AActor* DebugActor) override;
	virtual void DrawData(APlayerController* OwnerPC, FGameplayDebuggerCanvasContext& CanvasContext) override;

protected:
	struct FRepData
	{
		FString CompName;
		FString BehaviourDesc;
		FString BlackboardDesc;	

		void Serialize(FArchive& Ar);
	};
	FRepData DataPack;
};
#endif    // WITH_GAMEPLAY_DEBUGGER
#include "Debug/GameplayDebuggerCategory_UtilityAI.h"
#if WITH_GAMEPLAY_DEBUGGER
#include "AIController.h"
#include "AI/UtilityAIBehaviorComponent.h"
#include "BehaviorTree/BlackboardComponent.h"

FGameplayDebuggerCategory_UtilityAI::FGameplayDebuggerCategory_UtilityAI()
{
	SetDataPackReplication<FRepData>(&DataPack);
}

TSharedRef<FGameplayDebuggerCategory> FGameplayDebuggerCategory_UtilityAI::MakeInstance()
{
	return MakeShareable(new FGameplayDebuggerCategory_UtilityAI());
}

void FGameplayDebuggerCategory_UtilityAI::CollectData(APlayerController* OwnerPC, AActor* DebugActor)
{
	APawn* MyPawn = Cast<APawn>(DebugActor);
	AAIController* MyController = MyPawn ? Cast<AAIController>(MyPawn->Controller) : nullptr;
	UUtilityAIBehaviorComponent* BrainComp = Cast<UUtilityAIBehaviorComponent>(GetValid(MyController ? MyController->GetBrainComponent() : nullptr));
	
	if (BrainComp)
	{
		DataPack.CompName = BrainComp->GetName();
		DataPack.BehaviourDesc = BrainComp->GetDebugInfoString();		
		if (BrainComp->GetBlackboardComponent())
		{
			DataPack.BlackboardDesc = BrainComp->GetBlackboardComponent()->GetDebugInfoString(EBlackboardDescription::KeyWithValue);
		}
	}
}

void FGameplayDebuggerCategory_UtilityAI::DrawData(APlayerController* OwnerPC,	FGameplayDebuggerCanvasContext& CanvasContext)
{
	if (!DataPack.CompName.IsEmpty())
	{
		CanvasContext.Printf(TEXT("Brain Component: {yellow}%s"), *DataPack.CompName);
		CanvasContext.Print(DataPack.BehaviourDesc);
		TArray<FString> BlackboardLines;
		DataPack.BlackboardDesc.ParseIntoArrayLines(BlackboardLines, true);

		const float SavedDefX = CanvasContext.DefaultX;
		const float SavedPosY = CanvasContext.CursorY;
		CanvasContext.DefaultX = CanvasContext.CursorX = 600.0f;
		CanvasContext.CursorY = CanvasContext.DefaultY;

		for (int32 Idx = 0; Idx < BlackboardLines.Num(); Idx++)
		{
			int32 SeparatorIndex = INDEX_NONE;
			BlackboardLines[Idx].FindChar(TEXT(':'), SeparatorIndex);

			if (SeparatorIndex != INDEX_NONE && Idx)
			{
				FString ColoredLine = BlackboardLines[Idx].Left(SeparatorIndex + 1) + FString(TEXT("{yellow}")) + BlackboardLines[Idx].Mid(SeparatorIndex + 1);
				CanvasContext.Print(ColoredLine);
			}
			else
			{
				CanvasContext.Print(BlackboardLines[Idx]);
			}
		}

		CanvasContext.DefaultX = CanvasContext.CursorX = SavedDefX;
		CanvasContext.CursorY = SavedPosY;
	}	
}

void FGameplayDebuggerCategory_UtilityAI::FRepData::Serialize(FArchive& Ar)
{
	Ar << CompName;
	Ar << BehaviourDesc;
	Ar << BlackboardDesc;
}
#endif    // WITH_GAMEPLAY_DEBUGGER

Эта категория является, копией категории FGameplayDebuggerCategory_BehaviorTree, но в этом случае копирование кода оправдано: во-первых: удобно в дальнейшем иметь отдельные категории, если мы захотим добавить что-то уникальное, во-вторых: для разработчика, который использует наше ИИ, будет удобно и понятно что дебаг вынесен в отдельную категорию, даже если внутри кода это требует копирования.

Теперь необходимо добавить нашу новую категорию в модуль нашего проекта. В частности в файле UtilityAI.cpp

#if WITH_GAMEPLAY_DEBUGGER
#include "Debug/GameplayDebuggerCategory_UtilityAI.h"
#include "GameplayDebugger.h"
#endif
.
.
.
void FUtilityAIModule::StartupModule()
{
#if WITH_GAMEPLAY_DEBUGGER
	IGameplayDebugger& GameplayDebuggerModule = IGameplayDebugger::Get();

	GameplayDebuggerModule.RegisterCategory("UtilityAI",
											IGameplayDebugger::FOnGetCategory::CreateStatic(&FGameplayDebuggerCategory_UtilityAI::MakeInstance),
											EGameplayDebuggerCategoryState::EnabledInGameAndSimulate);
	GameplayDebuggerModule.NotifyCategoriesChanged();

#endif
}

void FUtilityAIModule::ShutdownModule()
{
#if WITH_GAMEPLAY_DEBUGGER
	if (IGameplayDebugger::IsAvailable())
	{
		IGameplayDebugger& GameplayDebuggerModule = IGameplayDebugger::Get();
		GameplayDebuggerModule.UnregisterCategory("UtilityAI");
	}
#endif	
}

Теперь наша категория зарегистрирована в проекте, её можно включать и отключать в дебаге и настройках.

UUtilityAIBehaviorComponent

Для начала перепишем функцию GetDebugInfoString для нашего UUtilityAIBehaviorComponent

virtual FString GetDebugInfoString() const override;

Давайте выведем всю информацию о текущем поведении и текущем действии.

FString UUtilityAIBehaviorComponent::GetDebugInfoString() const
{
   FString DebugInfo;
   DebugInfo += FString::Printf(TEXT("Archetype: %s\n"), *GetNameSafe(ArchetypeAsset));
   DebugInfo += FString::Printf(TEXT("CurrentBehavior: %s\nCurrent Action Index: %d\n"), *GetNameSafe(CurrentBehavior), CurrentIndex);
   DebugInfo += FString::Printf(TEXT("ActiveAction: %s\n"), *GetNameSafe(ActiveAction));

Также хотелось бы разрешить действиям сообщать свою дополнительную информацию для дебага. Добавим функцию в UUtilityAIBaseAction

virtual void AddDebugInfo(const UUtilityAIBehaviorComponent& OwnerComp, FString& DebugInfo) const {};

и вызовем эту функцию из UUtilityAIBehaviorComponent

if (IsValid(ActiveAction))
{     
   ActiveAction->AddDebugInfo(*this,DebugInfo);
}

Также давайте добавим информацию о том, как мы выбрали текущее поведение. У нас уже есть список структур, хранящих информацию о полезности  для каждого поведения TArray<FUtilityBehaviorWrapper> BehaviorWrappers

for (int Index = 0; Index < BehaviorWrappers.Num(); Index++)
{
   const FUtilityBehaviorWrapper& BehaviorWrapper = BehaviorWrappers[Index];
   if (!ArchetypeAsset->Behaviors.IsValidIndex(Index))
   {
      continue;
   }
   const UUtilityAIBehaviorAsset* Behaviour = ArchetypeAsset->Behaviors[Index];
   DebugInfo += FString::Printf(TEXT("Behaviour %s: Utility: %f \n"), *GetNameSafe(Behaviour), BehaviorWrapper.Utility);
}

Теперь дополним FUtilityBehaviorWrapper информацией о каждом слагаемом итоговой полезности.

USTRUCT()
struct FUtilityBehaviorWrapper
{
.
.
.
   TArray<float> UtilityScore;      
};
void UUtilityAIBehaviorComponent::SelectBehavior()
{
.
.
	TArray<float> UtilityScoreHistory;	
	UE_VLOG(GetOwner(), LogAIUtility, VeryVerbose, TEXT("Start Looking for  new behaviour"));
	for (TObjectPtr<UUtilityAIBehaviorAsset> Behavior : ArchetypeAsset->Behaviors)
	{
#if !UE_BUILD_SHIPPING
		UtilityScoreHistory.Reset();
#endif    // UE_BUILD_SHIPPING	_SHIPPING 
.
.
.
        if (!IsValid(Behavior) ||Behavior->Utilities.IsEmpty() || Behavior->AIActions.IsEmpty())
		{
			UE_VLOG(GetOwner(), LogAIUtility, Error, TEXT("Invalid behaviour returning invalid Utility"));
			BehaviorWrappers.Add({UUtilityAIBaseUtility::INVALID_UTILITY, UtilityScoreHistory});
.
.
.           if (UtilityValue == UUtilityAIBaseUtility::FILTERED_UTILITY)
            {
               UE_VLOG(GetOwner(), LogAIUtility, VeryVerbose, TEXT("Behaviour %s, was filtered by %s"), *Behavior->GetName(), *Utility->GetName());
               TotalUtility = 0.0;
#if !UE_BUILD_SHIPPING
               UtilityScoreHistory.Add(UUtilityAIBaseUtility::FILTERED_UTILITY);
#endif    // UE_BUILD_SHIPPING 
.
.
.
                if (UtilityValue == UUtilityAIBaseUtility::FILTERED_UTILITY)
				{
					UE_VLOG(GetOwner(), LogAIUtility, VeryVerbose, TEXT("Behaviour %s, was filtered by %s"), *Behavior->GetName(), *Utility->GetName());
					TotalUtility = 0.0;
#if !UE_BUILD_SHIPPING
					UtilityScoreHistory.Add(UUtilityAIBaseUtility::FILTERED_UTILITY);
#endif    // UE_BUILD_SHIPPING	
.
.
.
#if !UE_BUILD_SHIPPING
			UtilityScoreHistory.Add(UtilityValue);
#endif    // UE_BUILD_SHIPPING	
		}
.
.
.
BehaviorWrappers.Add({TotalUtility, UtilityScoreHistory});

Поскольку изменение массивов - это операция, связанная с выделением памяти, мы вырезаем нашу дебаг логику из финального билда с помощью макросов, чтобы вспомогательная логика не влияла на производительность
Также добавим отображение информации о слагаемых полезности

        if (IsValid(Behaviour))
		{			
			for (int UtilityIndex = 0; UtilityIndex < BehaviorWrapper.UtilityScore.Num(); UtilityIndex++)
			{
				if (!Behaviour->Utilities.IsValidIndex(Index))
				{
					continue;
				}
				const UUtilityAIBaseUtility* Utility =Behaviour->Utilities[UtilityIndex];
				DebugInfo += FString::Printf(TEXT("    Utility  %s: Score: %f \n"), *GetNameSafe(Utility), BehaviorWrapper.UtilityScore[UtilityIndex]);
			}
		}

UUtilityAIAction_RunEQSQuery и UUtilityAIAction_MoveTo

Добавим дополнительную инфу в наши действия поиска точки и передвижения

void UUtilityAIAction_RunEQSQuery::AddDebugInfo(const UUtilityAIBehaviorComponent& OwnerComp, FString& DebugInfo) const
{
	if (IsValid(EQSRequest.QueryTemplate))
	{
		DebugInfo += FString::Printf(TEXT("Runing EQS : %s\n"), *EQSRequest.QueryTemplate->GetName());	
	}	
}
void UUtilityAIAction_MoveTo::AddDebugInfo(const UUtilityAIBehaviorComponent& OwnerComp, FString& DebugInfo) const
{
	const UBlackboardComponent* MyBlackboard = OwnerComp.GetBlackboardComponent();
	if (IsValid(MyBlackboard))
	{
		FBlackboard::FKey KeyID = MyBlackboard->GetKeyID(BlackboardKey);
		if (KeyID == FBlackboard::InvalidKey)
		{
			return ;	}
		if (MyBlackboard->GetKeyType(KeyID) == UBlackboardKeyType_Object::StaticClass())
		{
			UObject* KeyValue = MyBlackboard->GetValue<UBlackboardKeyType_Object>(KeyID);
			DebugInfo += FString::Printf(TEXT("Move To: %s\n"), *GetNameSafe(KeyValue));	
		}
		else if (MyBlackboard->GetKeyType(KeyID) == UBlackboardKeyType_Vector::StaticClass())
		{
			const FVector TargetLocation = MyBlackboard->GetValue<UBlackboardKeyType_Vector>(KeyID);
			DebugInfo += FString::Printf(TEXT("Move To: %s\n"), *TargetLocation.ToString());
		}
	}
}

Теперь во время тестирования нашего ИИ мы можем прямо в runtime считывать информацию о нашем ИИ.

Важность инструментов отладки.

В заключение хотелось бы объяснить, почему я уделил внимание инструментам дебага. В разработке игр (в частности игрового ИИ) очень много времени уходит на поиск и устранение багов. Современные игры - это огромные сложные системы, в которых сотни подсистем, постоянно взаимодействующих друг с другом. Очень часто баги возникают из-за граничных случаев взаимодействия систем. Система анимации не рассчитана на то, что ИИ не дождется окончания монтажа, ИИ не рассчитан на то, что персонажа могут прервать cutscene и т.д. Невозможно предугадать все ситуации и события, которые могут произойти в игре. Поэтому надо иметь инструменты, которые позволяют быстро и легко понять, что происходит с той или иной системой. Многие команды и разработчики (в том числе опытные) пренебрегают такими инструментами часто из-за объемов игры или иллюзии, что их опыт и знание системы позволят обойтись без таких инструментов. Моя практика показывает, что всегда время, потраченное на разработку инструментов для отладки, окупается десятикратно в моменты починки  багов. Те 2 часа, что мы потратили на дебаг для нашего плагина, превратятся в 10 сэкономленных часов во время создания и настройки наших ИИ. Именно поэтому я уделил внимания на целую отдельную статью о создании дебага для нашего плагина.

В следующий раз мы попробуем собрать простой ИИ, используя наш плагин.