Игровой ИИ. 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 сэкономленных часов во время создания и настройки наших ИИ. Именно поэтому я уделил внимания на целую отдельную статью о создании дебага для нашего плагина.
В следующий раз мы попробуем собрать простой ИИ, используя наш плагин.