April 16

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

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

Логирование

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

UTILITYAI_API DECLARE_LOG_CATEGORY_EXTERN(LogAIUtility, Display, All);

DEFINE_LOG_CATEGORY(LogAIUtility);

Для начала, давайте логировать ошибки и странные состояния при поиске нового поведения в UUtilityAIBehaviorComponent::SelectBehavior()

if (!IsValid(ArchetypeAsset) || !IsValid(GetAIOwner()))
{
	UE_VLOG(GetOwner(), LogAIUtility, Error, TEXT("Invalid ArchetypeAsset"));

.
.
.	
if (!IsValid(Behavior) ||Behavior->Utilities.IsEmpty() || Behavior->AIActions.IsEmpty())
{
	UE_VLOG(GetOwner(), LogAIUtility, Error, TEXT("Invalid behaviour returning invalid Utility"));
	
.
.
.
if (!IsValid(Utility))
{
	UE_VLOG(GetOwner(), LogAIUtility, Error, TEXT("Invalid Utility returning %s"), *Behavior->GetName());

Мы используем VisLogger, который позволяет нам удобно разбивать логи на акторы и кадры. Подробнее про VisLogger можно почитать тут .

Теперь, когда мы прологировали ошибки, давайте добавим дополнительной информации в логи при обычной работе ИИ, когда никаких ошибок нет.

UE_VLOG(GetOwner(), LogAIUtility, VeryVerbose, TEXT("Start Looking for  new behaviour"));
.
.
.
if ((Utility->GetUtilityType() &  EUtilityType::Filter)  != 0)
{
	if (UtilityValue == UUtilityAIBaseUtility::FILTERED_UTILITY)
	{
		UE_VLOG(GetOwner(), LogAIUtility, VeryVerbose, TEXT("Behaviour %s, was filtered by %s"), *Behavior->GetName(), *Utility->GetName());
.
.
.
UE_VLOG(GetOwner(), LogAIUtility, VeryVerbose, TEXT("Behaviour %s, utility  %s : %2.f"), *Behavior->GetName(), *Utility->GetName(), UtilityValue);
.
.
.
UE_VLOG(GetOwner(), LogAIUtility, VeryVerbose, TEXT("Behaviour %s, TotalUtility   %2.f"), *Behavior->GetName(), TotalUtility);
.
.
.
UE_VLOG(GetOwner(), LogAIUtility, VeryVerbose, TEXT("Behaviour %s, Normalized totalUtility   %2.f"), *Behavior->GetName(), TotalUtility);
.
.
.
UE_VLOG(GetOwner(), LogAIUtility, VeryVerbose, TEXT("Found suitable %d behaviour "), IndexInRange.Num());

Теперь  с  помощью лога мы можем отслеживать, что происходит с нашей ИИ.

Это все еще далеко до удобного дебаггера, но уже позволит нам поймать и исправить часть багов

По аналогии добавим логи в UUtilityAIBehaviorComponent::StartBehavior, UUtilityAIBehaviorComponent::StartAction, UUtilityAIBehaviorComponent::OnActionFinished и остальные функции нашего компонента. Я не буду приводить тут все логи, которые были добавлены, но все изменения доступны на git.

Остановка поведения.

Теперь давайте реализуем логику остановки нашего действия и поведения.

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

UENUM()
enum class EUtilityAIActionStatus : uint8
{
.
.
.
	Interrupted,//utility action was interrupted
};

Также добавим enum, который описывает причину остановки.

UENUM()
enum class EUtilityAIActionStopReason : uint8
{
	StopAI,//AI is shutting down
	Replanning,// We decided to replan our action 
};

После чего добавим в UUtilityAIBaseAction функцию остановки действия

UCLASS(EditInlineNew, Abstract)
class UUtilityAIBaseAction : public UObject, public IGameplayTaskOwnerInterface
{
.
.
.
public:
    void StopAction(const FUtilityAIActionContext& Context, EUtilityAIActionStopReason Status);
.
.
.
protected:
	//Clean up function in case we interrupt our action  doesn't trigger in regular flow of actions
	virtual void StopAction_Internal(const FUtilityAIActionContext& Context, EUtilityAIActionStopReason Status);
void UUtilityAIBaseAction::StopAction(const FUtilityAIActionContext& Context, EUtilityAIActionStopReason Reason)
{
	StopAction_Internal(Context, Reason);
	if (bOwnsGameplayTasks && Context.Controller)
	{
		UGameplayTasksComponent* GTComp = Context.Controller->GetGameplayTasksComponent();
		if (GTComp)
		{
			GTComp->EndAllResourceConsumingTasksOwnedBy(*this);
		}
	}
}

void UUtilityAIBaseAction::StopAction_Internal(const FUtilityAIActionContext& Context, EUtilityAIActionStopReason Status)
{
	FinishedDelegate.Execute(EUtilityAIActionStatus::Interrupted);	
}

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

Каждое действие само будет принимать решение, что делать в случае прерывания. Например UUtilityAIAction_MoveTo останавливает внутреннюю UAITask_MoveTo

void UUtilityAIAction_MoveTo::StopAction_Internal(const FUtilityAIActionContext& Context,
	EUtilityAIActionStopReason Status)
{
	bObserverCanFinishTask = false;
	if (UAITask_MoveTo* MoveTask = Task.Get())
	{
		MoveTask->ExternalCancel();
	}
	Super::StopAction_Internal(Context, Status);
}

Теперь, когда у нас есть остановка действия, мы можем реализовать недостающие функции UBrainComponent в  UUtilityAIBehaviorComponent

void UUtilityAIBehaviorComponent::StopLogic(const FString& Reason)
{
	Super::StopLogic(Reason);

	if (IsValid(ActiveAction))
	{
		const FUtilityAIActionContext AIActionContext{GetAIOwner()->GetPawn(), GetAIOwner(), this };
		ActiveAction->StopAction(AIActionContext, EUtilityAIActionStopReason::StopAI);
		ActiveAction = nullptr;
	}
	CurrentBehavior = nullptr;
	CurrentIndex = 0;
}

void UUtilityAIBehaviorComponent::RestartLogic()
{
	Super::RestartLogic();
	if (IsValid(ActiveAction))
	{
		const FUtilityAIActionContext AIActionContext{GetAIOwner()->GetPawn(), GetAIOwner(), this };
		ActiveAction->StopAction(AIActionContext, EUtilityAIActionStopReason::StopAI);
		ActiveAction = nullptr;
	}
	CurrentBehavior = nullptr;
	CurrentIndex = 0;
	SelectBehavior();
}

Также мы теперь можем позволять внешним системам запрашивать поиск нового поведения.

class UTILITYAI_API UUtilityAIBehaviorComponent : public UBrainComponent
{
public:
	.
	.
	.
	UFUNCTION(BlueprintCallable, Category = "AI|Logic")
	void RestartBehaviourSelection();
void UUtilityAIBehaviorComponent::RestartBehaviourSelection()
{
	if (IsValid(ActiveAction))
	{
		const FUtilityAIActionContext AIActionContext{GetAIOwner()->GetPawn(), GetAIOwner(), this };
		ActiveAction->StopAction(AIActionContext, EUtilityAIActionStopReason::Replanning);
		ActiveAction = nullptr;
	}
	CurrentBehavior = nullptr;
	CurrentIndex = 0;
	SelectBehavior();
}

Также стоит добавить обработку нового статуса EUtilityAIActionStatus  в UUtilityAIBehaviorComponent::StartAction и в UUtilityAIBehaviorComponent::OnActionFinished

void UUtilityAIBehaviorComponent::OnActionFinished(const EUtilityAIActionStatus& Status)
{
.
.
.
	case EUtilityAIActionStatus::Interrupted:
		UE_VLOG(GetOwner(), LogAIUtility, VeryVerbose, TEXT("Behaviour %s, action %s was interrupted"), *CurrentBehavior->GetName(), *ActiveAction->GetName());
		break;
	default: ;
	}
}

void UUtilityAIBehaviorComponent::StartAction(int Index)
{
.
.
.
	case EUtilityAIActionStatus::Interrupted:
		UE_VLOG(GetOwner(), LogAIUtility, Error, TEXT("Behaviour %s, action %s bad status: Interrupted"), *CurrentBehavior->GetName(), *ActiveAction->GetName());
		break;
	default: ;
	}
}

Теперь у нас реализован полный цикл ИИ в фреймворке UE5, и есть возможность перезапускать поиск оптимального поведения.

AIMessage

Одна из удобных систем взаимодействия между системами ИИ Unreal - это AIMessage. Это простая система, которая пересылает FAIMessage между разными компонентами ИИ. Сами сообщения представляют из себя простую структуру.

struct FAIMessage
{
	enum EStatus
	{
		Failure,
		Success,
	};

	/** type of message */
	FName MessageName;

	/** message source */
	FWeakObjectPtr Sender;

	/** message param: ID */
	FAIRequestID RequestID;

	/** message param: status */
	TEnumAsByte<EStatus> Status;

	/** message param: custom flags */
	uint8 MessageFlags;
}

Поскольку тип сообщения определяется через строчку FName, добавлять новые сообщения можно просто заводя новые строчки при отправлении сообщения, без каких-либо изменений в движке.

В базовой версии UE5 доступны следующие сообщения

const FName UBrainComponent::AIMessage_MoveFinished = TEXT("MoveFinished");
const FName UBrainComponent::AIMessage_RepathFailed = TEXT("RepathFailed");
const FName UBrainComponent::AIMessage_QueryFinished = TEXT("QueryFinished");

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

class UUtilityAIBaseAction : public UObject, public IGameplayTaskOwnerInterface
{
.
.
.
public:
	void ReceivedMessage(UBrainComponent* BrainComponent, const FAIMessage& Message);
protected:
	//Call back for Unreal AI message
	virtual void OnReceivedMessage(const FUtilityAIActionContext& AIActionContext, const FAIMessage& Message);
	//Unreal AI message support functions
	void WaitForMessage(const FUtilityAIActionContext& Context, FName MessageType);
	void StopWaitingForMessages(const FUtilityAIActionContext& Context);

Теперь определим и реализуем функции нашего UUtilityAIBehaviorComponent  для работы  с сообщениями.

class UTILITYAI_API UUtilityAIBehaviorComponent : public UBrainComponent
{
	.
	.
	.
public:
	void RegisterMessageObserver(UUtilityAIBaseAction* UtilityAIBaseAction, FName Name);
	void UnRegisterMessageObserver(UUtilityAIBaseAction* UtilityAIBaseAction);
	FUtilityAIActionContext CreateContextFor(UUtilityAIBaseAction* UtilityAIBaseAction);

protected: 
	//For future if we want few action run in parallel 
	TMultiMap<TWeakObjectPtr<UUtilityAIBaseAction>, FAIMessageObserverHandle> ActionMessageObservers;
void UUtilityAIBehaviorComponent::RegisterMessageObserver(UUtilityAIBaseAction* UtilityAIBaseAction, FName MessageType)
{
	if (!IsValid(UtilityAIBaseAction))
	{
		UE_VLOG(GetOwner(), LogAIUtility, Error, TEXT("Null action in RegisterMessageObserver"));
		return;
	}
	
	ActionMessageObservers.Add(TWeakObjectPtr<UUtilityAIBaseAction>(UtilityAIBaseAction),
		FAIMessageObserver::Create(this, MessageType, FOnAIMessage::CreateUObject(UtilityAIBaseAction, &UUtilityAIBaseAction::ReceivedMessage))
		);

	UE_VLOG(GetOwner(), LogAIUtility, Log, TEXT("Message[%s] observer added for %s"),
		*MessageType.ToString(),  *UtilityAIBaseAction->GetName());
}

void UUtilityAIBehaviorComponent::UnRegisterMessageObserver(UUtilityAIBaseAction* UtilityAIBaseAction)
{
	if (const int32 NumRemoved = ActionMessageObservers.Remove(TWeakObjectPtr<UUtilityAIBaseAction>(UtilityAIBaseAction)))
	{
		UE_VLOG(GetOwner(), LogAIUtility, Log, TEXT("Message observers removed for action [%s] (num:%d)"),
			*UtilityAIBaseAction->GetName(), NumRemoved);
	}
}

FUtilityAIActionContext UUtilityAIBehaviorComponent::CreateContextFor(UUtilityAIBaseAction* UtilityAIBaseAction)
{
	return FUtilityAIActionContext{GetAIOwner()->GetPawn(), GetAIOwner(), this };
}

Мы также добавили функцию CreateContextFor , чтобы наши действия при получении сообщения могли получить актуальный контекст для работы.

Теперь мы можем реализовать функции в UUtilityAIBaseAction

void UUtilityAIBaseAction::ReceivedMessage(UBrainComponent* BrainComponent, const FAIMessage& Message)
{
	UUtilityAIBehaviorComponent* OwnerComp = static_cast<UUtilityAIBehaviorComponent*>(BrainComponent);
	check(OwnerComp);

	const FUtilityAIActionContext AIActionContext = OwnerComp->CreateContextFor(this);
	OnReceivedMessage(AIActionContext, Message);
}

void UUtilityAIBaseAction::OnReceivedMessage(const FUtilityAIActionContext& AIActionContext, const FAIMessage& Message)
{	
}

void UUtilityAIBaseAction::WaitForMessage(const FUtilityAIActionContext& Context, FName MessageType) 
{
	if (IsValid(Context.OwnerComponent))
	{
		// messages delegates should be called on node instances (if they exists)
		Context.OwnerComponent->RegisterMessageObserver(this, MessageType);
	}	
}

void UUtilityAIBaseAction::StopWaitingForMessages(const FUtilityAIActionContext& Context)
{
	if (IsValid(Context.OwnerComponent))
	{
		// messages delegates should be called on node instances (if they exists)
		Context.OwnerComponent->UnRegisterMessageObserver(this);
	}	
}

Примером использования сообщений будет является действие UUtilityAIAction_MoveTo,  где мы подписываемся на сообщения на старте действия.

EUtilityAIActionStatus UUtilityAIAction_MoveTo::RunAction_Internal(const FUtilityAIActionContext& Context)
{
.
.
.
WaitForMessage(Context, UBrainComponent::AIMessage_RepathFailed);

Отписываемся при завершении действия

void UUtilityAIAction_MoveTo::OnGameplayTaskDeactivated(UGameplayTask& InTask)
{
.
.
.
OnFinishMoveTo(BehaviorComp->CreateContextFor(this));
.
.
.
}
void UUtilityAIAction_MoveTo::OnFinishMoveTo(const FUtilityAIActionContext& AIActionContext)
{
	StopWaitingForMessages(AIActionContext);
}

И реагируем на сообщения в переопределенной функции

void UUtilityAIAction_MoveTo::OnReceivedMessage(const FUtilityAIActionContext& AIActionContext,
	const FAIMessage& Message)
{
	if (Message.MessageName == UBrainComponent::AIMessage_RepathFailed)
	{
		OnFinishMoveTo(AIActionContext);
		FinishedDelegate.Execute(EUtilityAIActionStatus::Failed);
	}
	Super::OnReceivedMessage(AIActionContext, Message);
}

Нормализация полезности

В прошлый раз мы не сделали ещё одну полезную вещь - нормализацию полезности. Давайте исправим это. Дополним наш базовый класс UUtilityAIBaseUtility несколькими функциями.

class UTILITYAI_API UUtilityAIBaseUtility : public UObject
{
.
.
.
protected:
	virtual float GetUtilityValueNormal(const struct FUtilityAIUtilityContext& Context) const;	
	virtual float GetUtilityValue(const struct FUtilityAIUtilityContext& Context) const;
	virtual float GetUtilityValueMax(const struct FUtilityAIUtilityContext& Context) const;

Также поменяем название основной функции для получение полезности

class UTILITYAI_API UUtilityAIBaseUtility : public UObject
{
.
.
.
public:
	float GetUtility(const struct FUtilityAIUtilityContext& Context) const;

Поскольку мы её уже используем в UUtilityAIBehaviorComponent , мы также поменяем вызов этой функции в UUtilityAIBehaviorComponent::SelectBehavior

также добавим несколько удобных констант

class UTILITYAI_API UUtilityAIBaseUtility : public UObject
{
.
.
.
	static float INVALID_UTILITY;
	static float FILTERED_UTILITY;

Теперь реализуем наши функции.

float UUtilityAIBaseUtility::INVALID_UTILITY = -1.0f;
float UUtilityAIBaseUtility::FILTERED_UTILITY = 0.0f;

float UUtilityAIBaseUtility::GetUtilityValue(const FUtilityAIUtilityContext& Context) const
{
	return INVALID_UTILITY;
}

float UUtilityAIBaseUtility::GetUtilityValueMax(const FUtilityAIUtilityContext& Context) const
{
	return 1.0f;
}

float UUtilityAIBaseUtility::GetUtilityValueNormal(const FUtilityAIUtilityContext& Context) const
{
	return GetUtilityValue(Context)/ GetUtilityValueMax(Context);
}

float UUtilityAIBaseUtility::GetUtility(const FUtilityAIUtilityContext& Context) const
{
	const float UtilityValue = GetUtilityValueNormal(Context);
	if (IsValid(UtilityModifier) && UtilityValue >= 0.0f)
	{
		return UtilityModifier->ModifyUtility(UtilityValue);
	}
	return UtilityValue;
}

После таких изменений нам надо поменять наш Blueprint класс UUtilityAIUtility_Blueprint , чтобы он соответствовал новой логике. Мы хотим позволить пользователю нашего плагина удобно переопределять функции полезности без необходимости генерировать дополнительный код. Добавим проверку на то, что в Blueprint переопределена логика нормализации. Если нет, то мы используем стандартную логику в UUtilityAIBaseUtility .

/**
 * Utility Class for Blueprint implementation want to keep it separate with Native cause of performance
 */
UCLASS(Blueprintable, BlueprintType, EditInlineNew)
class UTILITYAI_API UUtilityAIUtility_Blueprint : public UUtilityAIBaseUtility
{
	GENERATED_BODY()
public:
	UUtilityAIUtility_Blueprint();
	virtual float GetUtilityValueNormal(const FUtilityAIUtilityContext& Context) const override final;
protected:
	virtual float GetUtilityValue(const FUtilityAIUtilityContext& Context) const override final;
	virtual float GetUtilityValueMax(const FUtilityAIUtilityContext& Context) const override final;
	UFUNCTION(BlueprintImplementableEvent, DisplayName="GetUtilityValueNormal")
	float BP_GetUtilityValueNormal(const FUtilityAIUtilityContext& Context) const;
	UFUNCTION(BlueprintImplementableEvent, DisplayName="GetUtilityValue")
	float BP_GetUtilityValue(const FUtilityAIUtilityContext& Context) const;
	UFUNCTION(BlueprintImplementableEvent, DisplayName="GetUtilityValueMax")
	float BP_GetUtilityValueMax(const FUtilityAIUtilityContext& Context) const;
private:
	bool bHasBlueprintNormal;
};
UUtilityAIUtility_Blueprint::UUtilityAIUtility_Blueprint()
{
	auto ImplementedInBlueprint = [](const UFunction* Func) -> bool
	{
		return Func && ensure(Func->GetOuter())
			&& Func->GetOuter()->IsA(UBlueprintGeneratedClass::StaticClass());
	};
	
	{
		static FName FuncName = FName(TEXT("BP_GetUtilityValueNormal"));
		UFunction* ActivateFunction = GetClass()->FindFunctionByName(FuncName);
		bHasBlueprintNormal = ImplementedInBlueprint(ActivateFunction);
	}
}

float UUtilityAIUtility_Blueprint::GetUtilityValueNormal(const FUtilityAIUtilityContext& Context) const
{
	//if BP Has it's onw normalization logic let it run otherwise run default
	if (bHasBlueprintNormal)
	{
		return BP_GetUtilityValueNormal(Context);
	}
	return Super::GetUtilityValueNormal(Context);
}

float UUtilityAIUtility_Blueprint::GetUtilityValue(const FUtilityAIUtilityContext& Context) const
{
	return BP_GetUtilityValue(Context);
}

float UUtilityAIUtility_Blueprint::GetUtilityValueMax(const FUtilityAIUtilityContext& Context) const
{
	return BP_GetUtilityValueMax(Context);
}

Модификатор полезности

Как обсуждалось раньше, очень удобно иметь возможность модифицировать полезность с помощью некой кривой или модификатора. Давайте добавим такую возможность в наш код.

Добавим класс

UCLASS(EditInlineNew)
class UTILITYAI_API UUtilityAIBaseUtilityModifier : public UObject
{
	GENERATED_BODY()
public:
	UUtilityAIBaseUtilityModifier();
	float ModifyUtility(float CurrentValue) const;
	
protected:
	float ModifyUtility_Inner(float CurrentValue) const;

	UFUNCTION(BlueprintImplementableEvent, DisplayName="ModifyUtility")
	float BP_ModifyUtility(float CurrentValue) const;
	UPROPERTY(EditAnywhere)
	FRuntimeFloatCurve Curve;
private:
	bool bHasModifyUtility;
};
UUtilityAIBaseUtilityModifier::UUtilityAIBaseUtilityModifier()
{
	auto ImplementedInBlueprint = [](const UFunction* Func) -> bool
	{
		return Func && ensure(Func->GetOuter())
			&& Func->GetOuter()->IsA(UBlueprintGeneratedClass::StaticClass());
	};
	
	{
		static FName FuncName = FName(TEXT("BP_ModifyUtility"));
		UFunction* ActivateFunction = GetClass()->FindFunctionByName(FuncName);
		bHasModifyUtility = ImplementedInBlueprint(ActivateFunction);
	}
}

float UUtilityAIBaseUtilityModifier::ModifyUtility(float CurrentValue) const
{
	if (bHasModifyUtility)
	{
		return BP_ModifyUtility(CurrentValue); // Call the Blueprint function if it's implemented.
	}
	return ModifyUtility_Inner(CurrentValue);
}

float UUtilityAIBaseUtilityModifier::ModifyUtility_Inner(float CurrentValue) const
{
	if (Curve.GetRichCurveConst())
	{
		return  Curve.GetRichCurveConst()->Eval(CurrentValue);
	}
	return CurrentValue;
}

Класс позволяет нам определить кривую для модификации полезности, или вызывать переопределенную функцию из Blueprint.

Теперь давайте модифицируем наш класс UUtilityAIBaseUtility

class UTILITYAI_API UUtilityAIBaseUtility : public UObject
{
.
.
.
protected:
	UPROPERTY(EditAnywhere, Instanced)
	TObjectPtr<class UUtilityAIBaseUtilityModifier> UtilityModifier;
float UUtilityAIBaseUtility::GetUtility(const FUtilityAIUtilityContext& Context) const
{
	const float UtilityValue = GetUtilityValueNormal(Context);
	if (IsValid(UtilityModifier) && UtilityValue >= 0.0f)
	{
		return UtilityModifier->ModifyUtility(UtilityValue);
	}
	return UtilityValue;
}

Теперь любая функция полезности может определить модификатор, который повлияет на финальное значение полезности.

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