UE4

[UE4] Behavior Tree

Honey Badger 2023. 4. 12. 02:42

개요

     게임을 게임답게 재밌게 만드는 요소 중 하나는 AI라고 생각한다. 이 AI를 만드는 방법에는 여러가지가 있는데 그 중에서도 Behavior Tree는 가장 효과적인 AI 구성 방법으로 알려져 있지만, 배움의 난이도가 조금 높다는 것이 단점이다. 언리얼에는 감사하게도 AI를 편리하게 만들 수 있도록 직관적인 인터페이스와 함께 자체적으로 Behavior Tree를 제공한다. 오늘은 언리얼에서 제공하는 Behavior Tree의 사용법을 알아보고 예제를 하나 만들어보고자 한다. 

 

 

 

블랙보드(BlackBoard)

     블랙보드는 Behavior Tree(앞으로 줄여서 BT라고 부르겠다)에게 필요한 정보를 저장하고 공유하는 데 사용되는 데이터 저장소이다. 블랙보드는 각각의 게임 AI 객체에 대해 별도로 유지되며 키-값 쌍으로 데이터가 저장된다. 키는 블랙보드에서 데이터를 참조할 때 사용되는 이름이며, 값은 해당 키에 대한 데이터이다. 

  쉽게 이해하기 위해 예를 들어보겠다. 적AI가 플레이어를 공격할 때, 블랙보드에는 플레이어의 위치, 거리, 체력 등의 정보가 저장될 수 있고, 이 정보를 사용하여 적 AI는 플레이어를 추적하고, 공격 범위에 들어왔을 때 공격을 시작할 수 있다. 

 

 

노드

    BT는 노드의 트리 구조를 사용하여 게임 AI의 행동을 설계한다. 각 노드의 베이스 클래스는 UBTNode이며, 작업, 논리 흐름제어, 데이터 업데이트를 포함하여 BT의 메인 작업들을 수행한다. 노드에는 다양한 유형과 종류가 존재하는데 이제부터 하나하나 알아보자.            

 

  1. 루트노드                

   BT의 시작점 역할을 하는 노드로 트리 내의 고유한 노드이다. 특징으로는 하나의 연결만 가질 수 있으며, 데코레이터 노드나 서비서 노드의 연결은 지원하지 않는다. 루트 노드에는 고유한 속성이 없지만 루트 노드를 선택하면 디테일 패널에서 BT의 속성을 확인할 수 있다. 여기서 BT의 블랙보드 또한 설정할 수 있다. 

 

 

  

  2. 컴포짓 노드(Composite Node)

   분기의 root가 되는 노드로 해당 분기가 어떤 조건에서 실행되는지 기본 규칙을 정의한다. 분기에 대한 항목을 수정하거나 실행 도중 취소하기 위해 데코레이터를 적용할 수 있다. 또한 컴포짓 노드의 자식 노드가 실행되고 있는 경우에만 활성화 되는 서비스노드를 연결할 수도 있다. 컴포짓 노드만이 루트노드에 접근할 수 있다. 컴포짓 노드의 종류는 아래 글을 참고하자. 

https://coding-hell.tistory.com/92

 

[UE4] BehaviorTree - 컴포짓 노드(Composite Node)의 종류

1. Selector 셀렉터 노드는 왼쪽에서 오른쪽방향 순서대로 자식 노드들을 실행시킨다. 자식 노드 중 한명이 성공하면 실행이 중지되며 셀렉터도 성공한 것으로 간주된다. 만약 셀렉터의 모든 자식

coding-hell.tistory.com

 

 

 

 

  3. 테스크 노드(Task Node)

      테스크는 AI를 이동하거나 블랙보드 값을 조정하는 것과 같은 작업(Task)을 "하는(Do)" 노드이다. 그들은 데코레이터나 서비스를 연결할 수 있다. 테스크 노드의 종류는 아래 글을 참고하자. 

https://coding-hell.tistory.com/93

 

[UE4] BehaviorTree - 테스크 노드(Task Node)의 종류

1. Finish With Result 트리의 실행을 종료하면서, 노드가 지정한 결과값을 반환하는 노드이다. 이 노드는 주로 Behavior Tree가 끝나는 부분에서 사용되며, 이전 노드들이 실행한 결과값에 따라 Behavior Tre

coding-hell.tistory.com

 

 

 

 

4. 데코레이터 (Decorator)

     다른 트리 모델에서는 Conditionals라고도 불리는 데코레이터는 컴포짓 노드 또는 테스크 노드에 연결되어 트리의 분기 또는 단일 노드를 실행할 수 있는지 여부를 정의한다. 데코레이터의 디테일패널을 확인해보면 플로우 컨트롤 카테고리가 존재하는데 이에 대한 자세한 내용은 다음과 같다. 

 

Notify Observer (언제 관찰자가 중단을 요구할지에 관한 옵션)

- On Value Change : 관찰되는 블랙보드 값이 바뀔때마다 노드를 재시작한다.

- On Result Change : 가져온 컨디션의 결과가 바뀌었을 때만 재시작한다. 

 

관찰자 중단(Observer Abort)

- Self : 이 설정을 사용하면 블랙보드 값이 바뀔때 하위 트리가 스스로 중단된다. 

- Lower Priority(낮은 우선순위) : 블랙보드 값이 바뀌면 우선 순위가 낮은 트리가 중단된다. 

- Both : 위의 두가지 중단 유형이 모두 활성화된다. 

 

 

데코레이터의 종류는 아래 글을 참고하자.   

https://coding-hell.tistory.com/94

 

[UE4] BehaviorTree - 데코레이터(Decorator)의 종류

1. Blackboard 블랙보드 노드는 지정된 블랙보드 키에 값이 설정되어 있는지 확인한다. 2. Check Gameplay Tag Condition 이 데코레이터는 게임 플레이 태그를 검사하고 해당 태그가 존재하는지 여부에 따라

coding-hell.tistory.com

 

 

 

 

5. 서비스 노드 (Service Node)

     서비스 노드는 컴포짓 노드에 연결되며 분기가 실행되는 동안 일정한 빈도로 실행된다. 이 노드는 주기적으로 액터의 상태를 확인하고 블랙보드를 업데이트 하는 데 사용된다. 서비스노드의 종류는 아래 글을 참고하자. 

https://coding-hell.tistory.com/95

 

[UE4] BehaviorTree - 서비스 노드(Service Node)의 종류

1. Default Focus Default Focus는 AI 컴트롤러의 포커스를 설정하여 블루프린트 및 코드에서 액터에 엑세스하는 바로 가기를 만든다. AI 컨트롤러의 포커스를 액터로 설정하면 해당 액터의 블랙보드 키

coding-hell.tistory.com

 

 

예시

 

   아무래도 설명만으론 이해가 어렵다고 생각해서 실전으로 적용해보고자 한다. 다음은 내가 언리얼을 처음 접했을 때 배웠던 이득우의 언리얼 C++ 책에서 짰던 BT와 BB다. 

 

우선 비헤이비어 트리의 작동을 간단히 설명해보겠다. 먼저 주기적으로 반경 이내에 플레이어가 있는지 탐지한다.

  - 만약 타겟이 있다면 공격할 수 있는지 판단하고 공격 가능하다면 공격과 플레이어 방향으로의 회전을 동시에 수행한다. 공격이 불가능하다면 감지된 타겟쪽으로 이동한다. 

  - 만약 타겟이 없다면 기다리고 다음 순찰 장소를 탐색하고 그 장소로 이동하는 시퀀스를 수행한다. 

 

<블랙보드>

- SelfActor : Object 타입으로 이 BT를 소유하고 있는 액터 자기자신이다.

- HomePos : Vector 타입으로, 액터의 초기 위치값이다.

- PatrolPos : Vector 타입으로, 순찰할 위치이다.

- Target : Object 타입으로 추격, 공격할 타겟을 의미한다.

 

 

 

1번, 2번 노드 :

  셀렉터 노드에 커스텀 서비스 노드인 Detact가 붙어있다. Detact의 내용은 다음과 같다. 주기적인 Detact를 통해 블랙보드의 Target값이 설정되는데 이 값에 따라 3,4번 노드를 실행할지 12, 13번 노드를 실행할지 갈린다. 

 

#include "AI/BT/BTService_Detect.h"
#include "AI/MyAIController.h"
#include "Character/Player/MyPlayer.h"
#include "BehaviorTree/BlackboardComponent.h"
#include "DrawDebugHelpers.h"

UBTService_Detect::UBTService_Detect()
{
	NodeName = TEXT("Detect"); //노드 이름 설정.
	Interval = 1.0f; // 실행 빈도 설정.
} 

//빈도마다 실행되는 함수.
void UBTService_Detect::TickNode(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory, float DeltaSeconds)
{
	Super::TickNode(OwnerComp, NodeMemory, DeltaSeconds); //부모의 함수 먼저 실행.

	APawn* ControllingPawn = OwnerComp.GetAIOwner()->GetPawn(); //이 BT를 소유하고 있는 폰을 가져와 지역변수에 저장.
	if (nullptr == ControllingPawn) return; // 만약 가져오는데 실패했다면 return.

	UWorld* World = ControllingPawn->GetWorld(); //폰이 존재하는 월드를 지역 변수에 저장.
	if (nullptr == World) return;//월드가 유효하지 않다면 return.

	FVector Center = ControllingPawn->GetActorLocation(); //폰의 위치를 Vector값 지역 변수에 저장.
	float DetectRadius = 600.0f; //물체 감지 반경을 6미터로 설정.

	TArray<FOverlapResult> OverlapResults; //오버랩 결과를 저장할 배열 선언.
	FCollisionQueryParams CollisionQueryParam(NAME_None, false, ControllingPawn); //탐색 방법에 대한 설정 값을 모아둔 구조체. 
	/*
	* 첫번째 인자 (TraceTag) : Trace 디버깅을 위한 추가 정보 또는 필터링을 제공하는 데 사용되는 태그(예: Collision Analyzer)
	* 두번째 인자 (bTraceComplex) : 복잡한 충돌에 대해 추적해야 하는지 여부.
	* 세번째 인자 (IgnoreActor) : Trace하는 동안 무시해야 하는 엑터.
	*/

	bool bResult = World->OverlapMultiByChannel(//트레이스 채널을 통해 오버랩 여부를 가리는 함수.
		OverlapResults, //오버랩된 액터들을 저장할 배열.
		Center, // 탐색을 시작할 위치.
		FQuat::Identity, // 탐색에 사용할 도형의 회전값.
		ECollisionChannel::ECC_GameTraceChannel2, // 물리 감지에 사용할 콜리전 채널 정보.
		FCollisionShape::MakeSphere(DetectRadius), // 탐색에 사용할 기본 도형 정보. (구체, 캡슐, 박스 등) 
		CollisionQueryParam //탐색 방법에 대한 설정 값을 모아둔 구조체.
	);
	
	//만약 감지된 액터가 있다면,
	if (bResult)
	{	
		for (auto const& OverlapResult : OverlapResults) // 오버랩된 액터 배열 요소를 하나하나 꺼내보기.
		{
			AMyPlayer* MyPlayer = Cast<AMyPlayer>(OverlapResult.GetActor()); // 오버랩된 액터를 플레이어로 캐스팅.
			if (MyPlayer) // 만약 플레이어가 맞다면,
			{
				OwnerComp.GetBlackboardComponent()->SetValueAsObject(AMyAIController::TargetKey, MyPlayer); //해당 플레이어를 블랙보드의 TargetKey값에 저장.
				//인자 : (구를 그릴 공간, 그릴 위치, 구의 반지름, 조각수, 구의 색깔, 영구적인지 여부, 구의 수명)
				DrawDebugSphere(World, Center, DetectRadius, 16, FColor::Green, false, 0.2f); // 구모양의 디버그 라인을 그린다. 색은 초록색.
				//인자 : (선을 그릴 공간, 그리기 시작할 위치, 마지막 위치, 선의 색깔, 영구적인지 여부, 선의 수명)
				DrawDebugLine(World, ControllingPawn->GetActorLocation(), MyPlayer->GetActorLocation(), FColor::Blue, false, 0.2f); // 적에서 플레이어 사이에 파란색 디버그 라인을 그린다. 
				return;
			}
		}
	}

	//감지된 액터가 없다면, 
	DrawDebugSphere(World, Center, DetectRadius, 16, FColor::Red, false, 0.2f); //위와 같은 구모양의 디버그라인을 그리지만 색은 빨간색이다.
	OwnerComp.GetBlackboardComponent()->SetValueAsObject(AMyAIController::TargetKey, nullptr); //블랙보드의 타겟값 nullptr로 초기화. 
}

 

 

 

3번, 4번 노드 :

  셀렉터 노드에 블랙보드 데코레이터가 붙어있다. 블랙보드의 Target값이 있으면 노드가 실행된다. 

 

 

5번, 6번 노드 :

   Simple Parallel 컴포짓 노드에 커스텀 데코레이터 "IsInAttackRange"가 붙어있다. 

 

 

9번, 10번 노드 :

   시퀀스 컴포짓 노드에 위와 마찬가지로 커스텀 데코레이터"IsInAttackRange"가 붙어있다. 

커스텀 데코레이터 "IsInAttackRange"의 내용은 다음과 같다. 

 

#include "BTDecorator_IsInAttackRange.h"
#include "AI/MyAIController.h"
#include "Character/Player/MyPlayer.h"
#include "BehaviorTree/BlackboardComponent.h"

UBTDecorator_IsInAttackRange::UBTDecorator_IsInAttackRange()
{
	NodeName = TEXT("CanAttack"); // 노드 이름 설정.
}

bool UBTDecorator_IsInAttackRange::CalculateRawConditionValue(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory) const
{
	bool bResult = Super::CalculateRawConditionValue(OwnerComp, NodeMemory); //지역변수 선언후 부모의 함수실행값 저장.

	auto ControllingPawn = OwnerComp.GetAIOwner()->GetPawn(); // 주인 폰 받아오기.
	if (nullptr == ControllingPawn) return false; //예외처리.

	auto Target = Cast<AMyPlayer>(OwnerComp.GetBlackboardComponent()->GetValueAsObject(AMyAIController::TargetKey)); // 블랙보드의 타겟값 저장.
	if (nullptr == Target) return false; // 예외처리.

	bResult = (Target->GetDistanceTo(ControllingPawn) <= 200.0f); // 타겟과 주인폰사이에 거리를 재서 2미터보다 가까우면 true를 결과값에 저장.
	return bResult; // 결과값 반환.
}

 

 

 

 

7번 노드 :

   위의 커스텀 데코레이터에서 타겟과 BT를 소유한 폰 거리가 2미터 이내일 경우 Attack 이라는 커스텀 테스크 노드를 실행한다. Attack 커스텀 테스크의 내용은 다음과 같다. 

 

#include "AI/BT/BTTask_Attack.h"
#include "AI/MyAIController.h"
#include "Character/Monster/MyMonster.h"

UBTTask_Attack::UBTTask_Attack()
{
	bNotifyTick = true; // 이 값이 true이면 TickTask() 함수가 호출됩니다.
	IsAttacking = false; // 멤버변수로 선언된 공격중인지 판단하는 변수 false 초기화.
}

//테스크 실행 함수.
EBTNodeResult::Type UBTTask_Attack::ExecuteTask(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory)
{
	EBTNodeResult::Type Result = Super::ExecuteTask(OwnerComp, NodeMemory); //부모의 함수 먼저 실행 후 결과값 저장.

	auto MyMonster = Cast<AMyMonster>(OwnerComp.GetAIOwner()->GetPawn()); // BT 소유주 불러오기.
	if (nullptr == MyMonster) return EBTNodeResult::Failed; // 예외처리

	MyMonster->Attack(); // 위에서 불러온 소유주의 공격함수 호출.
	IsAttacking = true; // 공격중을 true로 설정.

	return EBTNodeResult::InProgress; // 테스크가 진행중임을 return.
}

//테스크 Tick 함수.
void UBTTask_Attack::TickTask(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory, float DeltaSeconds)
{
	Super::TickTask(OwnerComp, NodeMemory, DeltaSeconds);
	if (!IsAttacking) //공격중이 아니라면 테스크 종료.
	{
		FinishLatentTask(OwnerComp, EBTNodeResult::Succeeded);//태스크 종료함수. 테스크 성공 반환. 
	}
}

 

 

 

 

8번 노드 :

   Simple Parallel 노드(6번)에 메인 테스크 노드인 Attack노드(7번)과 함께 서브로 붙어있는 커스텀 노드인 Turn이다. 위의 문서를 꼼꼼히 봤다면 눈치챘겠지만 언리얼에서 제공해주는 테스크노드 중 "Rotate to face BB entry"라는 노드가 동일한 기능을 하므로 굳이 귀찮다면 만들 필요는 없다. 그래도 공부를 위해 살펴보자. Turn 커스텀 테스크 노드의 내용은 다음과 같다. 

#include "AI/BT/BTTask_TurnToTarget.h"
#include "AI/MyAIController.h"
#include "Character/Player/MyPlayer.h"
#include "Character/Monster/MyMonster.h"
#include "BehaviorTree/BlackboardComponent.h"

UBTTask_TurnToTarget::UBTTask_TurnToTarget()
{
	NodeName = TEXT("Turn"); // 노드 기본 이름 설정.
}

//테스크를 실행하는 함수.
EBTNodeResult::Type UBTTask_TurnToTarget::ExecuteTask(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory)
{
	EBTNodeResult::Type Result = Super::ExecuteTask(OwnerComp, NodeMemory); //부모의 테스트 결과 Result에 저장.

	auto MyMonster = Cast<AMyMonster>(OwnerComp.GetAIOwner()->GetPawn()); // BT 소유주 찾아 저장.
	if (nullptr == MyMonster) return EBTNodeResult::Failed; // 예외처리.

	auto Target = Cast<AMyPlayer>(OwnerComp.GetBlackboardComponent()->GetValueAsObject(AMyAIController::TargetKey)); // 감지된 타겟 찾아 저장.
	if (nullptr == Target) return EBTNodeResult::Failed; // 예외처리.

	FVector LookVector = Target->GetActorLocation() - MyMonster->GetActorLocation(); //타겟의 위치벡터에서 자신의 위치벡터를 뺀 값을 바라봐야하는 방향 벡터로 저장한다.
	LookVector.Z = 0.0f; // Z값은 높이와 관련된 값이므로 빼준다. 고개과 관련된 블랜드스페이스나 에임오프셋이 있다면 설정해주기.
	FRotator TargetRot = FRotationMatrix::MakeFromX(LookVector).Rotator(); //위에서 계산한 벡터의 Rotator를 받아와 저장한다.
	MyMonster->SetActorRotation(FMath::RInterpTo(MyMonster->GetActorRotation(), TargetRot, GetWorld()->GetDeltaSeconds(), 2.0f));// 자신의 Rotation값을 보간을 통해 설정해준다.
	// RInterpTo 인자 (현재값, 목표값, 프레임 수행에 걸리는 시간, 보간 속도)

	return EBTNodeResult::Succeeded; //테스크 성공을 알린다. 
}

 

 

11번 노드 :

   공격범위에 아직 들어가지 못했으면 목표값으로 이동하는 노드이다. 언리얼에서 제공해주는 테스크 노드인 MoveTo를 사용했다. 

 

 

 

12번, 13번 노드 :

   시퀀스 노드에 블랙보드 데코레이터가 붙어있다. 블랙보드의 Target값이 없으면 노드가 실행된다. 시퀀스는 총 3단계로 나뉘어져있다. 대기, 다음 순찰 목표위치 찾기, 그곳으로 이동하기.

 

 

14번 노드 : 

   언리얼에서 제공해주는 테스크 노드인 Wait이다. 지정된 대기 시간이 완료될 때까지 트리가 이 노드에서 대기하도록 하는 노드이다.

 

 

 

15번 노드:

   커스텀 테스크 노드인 FindPatrolPos이다. 다음 순찰 장소를 탐색하는 기능을 담당하는 노드이며 내용은 다음과 같다.

#include "AI/BT/BTTask_FindPatrolPos.h"
#include "AI/MyAIController.h"
#include "BehaviorTree/BlackboardComponent.h"
#include "NavigationSystem.h"


UBTTask_FindPatrolPos::UBTTask_FindPatrolPos()
{
	NodeName = TEXT("FindPatrolPos"); //노드 기본 이름 지정.
}


//BehaviorTree는 Task를 실행할 때 Task Class의 ExecuteTask라는 멤버 함수를 실행한다. 이 함수는 Aborted, Failed, Succeeded, InProgress 이렇게 네가지값을 반환한다. 
EBTNodeResult::Type UBTTask_FindPatrolPos::ExecuteTask(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory)
{
	EBTNodeResult::Type Result = Super::ExecuteTask(OwnerComp, NodeMemory);//부모 테스크 먼저 시행하고 결과값에 저장.

	auto ControllingPawn = OwnerComp.GetAIOwner()->GetPawn(); // BT소유주 불러와 변수에 저장.
	if (nullptr == ControllingPawn) return EBTNodeResult::Failed; // 예외처리.

	UNavigationSystemV1* NavSystem = UNavigationSystemV1::GetNavigationSystem(ControllingPawn->GetWorld()); // 월드의 네이게이션 시스템 불러와 저장.
	if (nullptr == NavSystem) return EBTNodeResult::Failed; // 예외처리.

	FVector Origin = OwnerComp.GetBlackboardComponent()->GetValueAsVector(AMyAIController::HomePosKey);//블랙보드의 HomPosKey값을 변수에 저장.
	FNavLocation NextPatrol; // 다음 순찰할 위치 변수 선언.

	bool myTest = NavSystem->GetRandomPointInNavigableRadius(Origin, 500.0f, NextPatrol); // 초기 위치로부터 반경 5미터 내에 랜덤한 위치를 순찰할 위치로 저장해준다.
	if (myTest) //위의 작업이 성공했다면,
	{
		OwnerComp.GetBlackboardComponent()->SetValueAsVector(AMyAIController::PatrolPosKey, NextPatrol.Location);//블랙보드의 순찰할 위치 값에 저장해준다.
		return EBTNodeResult::Succeeded; // 테스크노드의 성공을 반환한다.
	}
	return EBTNodeResult::Failed; // 실패를 반환한다. 
}

 

 

 

16번 노드 :

   11번 노드와 동일한 언리얼에서 제공해주는 MoveTo 테스크 노드이다. 

 

 

 

 

 

마무리

 

   처음 BT를 공부할 땐 무작정 따라하기 바빠서 제대로 작동원리를 이해하지 못하고 있었는데 정리하고 나니 이렇게 편리한 시스템을 제공해주는 언리얼 똑똑이들에게 다시한번 감사하게 되었다. 이제 교수님께서 말씀하셨던 정말 처절한 전투를 구현해보려한다. 최근에 엘든링에서 보스들을 잡으며 연구했던 지식을 토대로 열심히 만들어봐야겠다. 모두들 화이팅!!