[UE5] 적 AI, NavMesh, BT와 BB

재석 블로그·2025년 2월 18일

AI 이동의 기초

  • 경로 탐색(Pathfinding)
    목표 지점까지 이동경로 계산하는 알고리즘 혹은 기법
    주로 ai가 장애물을 피해 효율적인 경로를 찾을 때 사용
  • NavMesh
    게임 월드에서 이동할 수 있는 영역을 메시로 표현한 것. AI가 이 위에서 경로 탐색을 수행


    사진의 초록색 영역이 AI 컨트롤러가 이동할 수 있는 범위. Nav Mesh Bounds Volume 을 레벨에 생성 후 p를 누르면 확인할 수 있다.
  • AIController
    게임속 폰이나 캐릭터와 상호작용하여 행동하는 AI 주체.
    AI의 의사결정 로직을 담고 Behavior Tree 나 perception 등을 관리

C++로 기초적인 순찰 경로 짜기

1.캐릭터 클래스의 적 개체 생성

//Enemy.h
#pragma once

#include "CoreMinimal.h"
#include "GameFramework/Character.h"
#include "Enemy.generated.h"

UCLASS()
class TEMP_THIRD_API AEnemy : public ACharacter
{
	GENERATED_BODY()

public:
	AEnemy();

	UPROPERTY(EditInstanceOnly, BlueprintReadWrite, Category = "AI")
	TArray<AActor*> PatrolPoints;
};
//Enemy.h
//Enemy.cpp

#include "Enemy.h"
#include "EnemyAIController.h"

AEnemy::AEnemy()
{
	AIControllerClass = AEnemyAIController::StaticClass();
	AutoPossessAI = EAutoPossessAI::PlacedInWorldOrSpawned;
}
//Enemy.cpp
  • 순찰 포인트를 담을 TArray 생성. 월드 내에 배치할 Target PointAActor이다.
  • 플레이어 캐릭터를 만들 때 했던 것처럼
    생성자에서 캐릭터-컨트롤러 연결, 배치되거나 스폰되면 자동빙의로 설정해준다.
  • 실제 게임에서 쓰일 때는 이 적 캐릭터가 가질 멤버들의 설정도 필요하다. (체력, 공격 메소드같은 것들)

2.AIController 클래스 생성

//EnemyAIController.h
UCLASS()
class TEMP_THIRD_API AEnemyAIController : public AAIController
{
	GENERATED_BODY()

public:
	virtual void OnPossess(APawn* InPawn) override;
	virtual void BeginPlay() override;
	
    // 이동 완료시 호출되는 함수
    virtual void OnMoveCompleted(
    FAIRequestID RequestID, 
    const FPathFollowingResult& Result) override; 
    
protected:
	// 반복 순찰을 위한 인덱스
	int32 CurrentPatrolPointIndex = 0; 
	void MoveToCurrentPatrolPoint();
};
//EnemyAIController.h
//EnemyAIController.cpp
void AEnemyAIController::OnPossess(APawn* InPawn)
{
	Super::OnPossess(InPawn);
	//빙의 시 초기화 작업	
}

void AEnemyAIController::BeginPlay()
{
	Super::BeginPlay();
	MoveToCurrentPatrolPoint();
}

void AEnemyAIController::MoveToCurrentPatrolPoint()
{
	AEnemy* EnemyCharcter = Cast<AEnemy>(GetPawn());
	if (!EnemyCharcter)
	{
		UE_LOG(LogTemp, Warning, TEXT("Controller cast failed."));
		return;
	}
	if (EnemyCharcter->PatrolPoints.Num() == 0)
	{
		UE_LOG(LogTemp, Warning, TEXT("There is no Patrol Points."));
		return;
	}
	MoveToActor(
		EnemyCharcter->PatrolPoints[CurrentPatrolPointIndex],
		5.0f,
		true,
		true,
		false,
		nullptr,
		true
	);
	
	CurrentPatrolPointIndex = (CurrentPatrolPointIndex + 1) % EnemyCharcter->PatrolPoints.Num();
}
//EnemyAIController.cpp
  • OnPossess - 컨트롤러가 캐릭터에 빙의하면 호출되는 함수. BeginPlay보다 먼저 호출된다.
  • BeginPlay - 이건 알지? 게임 시작시 순찰을 시작하는 MoveToCurrentPatrolPoint 함수를 호출함.

  • MoveToActor - 부모 클래스인 AIController의 메소드.
    캐릭터를 다른 액터의 위치로 이동시킨다. 지정 벡터로 이동하는 MoveToLocation이나 둘을 합친 MoveTo도 있음.
AAIController::MoveToActor(
	AActor* Goal,			// 목표로 할 액터
	float AcceptanceRadius,	// 이동 완료로 간주할 거리. 작을수록 목표 액터에 가까이 붙음
    bool bStopOnOverlap,	// 목표 액터와 충돌하면 멈추는지 여부, false로 하면 정확한 포인트에 도달하려고 함
    bool bUsePathfinding,	// 길찾기 알고리즘 사용 여부. 반드시 true
    bool bCanStrafe,		// false로 꺼줘야 함. 
    TSubclassOf<UNavigationQueryFilter> FilterClass, // 이동 경로에 필터를 적용하는 파라미터
    bool bAllowPartialPaths	// 전체 경로를 찾지 못했을 때, 유효한 부분 경로라도 이동할지 여부.
    )

//EnemyAIController.cpp
void AEnemyAIController::OnMoveCompleted(
	FAIRequestID RequestID,
    const FPathFollowingResult& Result)
{
	Super::OnMoveCompleted(RequestID, Result);
	
	if (Result.Code == EPathFollowingResult::Success)
	{
		UE_LOG(LogTemp, 
        Warning, 
        TEXT("Move is over. Going to next Point."));
		MoveToCurrentPatrolPoint();
	}
}
//EnemyAIController.cpp
//AIController.cpp
void AAIController::OnMoveCompleted(
	FAIRequestID RequestID,
    const FPathFollowingResult& Result)
{
	ReceiveMoveCompleted.Broadcast(RequestID, Result.Code);
	OnMoveCompleted(RequestID, Result.Code);
}
//AIController.cpp
  • OnMoveCompleted - 이동 완료시 콜백되는 함수. EPathFollowingResult 값은 다음과 같다.
namespace EPathFollowingResult
{
	enum Type : int
	{
		/** Reached destination */
		Success,

		/** Movement was blocked */
		Blocked,

		/** Agent is not on path */
		OffPath,

		/** Aborted and stopped (failure) */
		Aborted,

		/** DEPRECATED, use Aborted result instead */
		Skipped_DEPRECATED UMETA(Hidden),

		/** Request was invalid */
		Invalid,
	};
}

3.AI 캐릭터의 애니메이션 설정

  • 위처럼 적이 이동은 하는데, 이동 애니메이션이 나오지 않는다.
    원인은 애니메이션 블루프린트에 있다.

  • 애니메이션 블루프린트의 애니메이션 재생조건이 위 bShouldMove값에 따라 결정되는데 이미지를 보면 알 수 있듯 Get Current Acceleration은 플레이어의 입력이 있어야만 값이 존재하기 때문.
  • 상황에 따라, 폰의 owner를 체크해서 bShouldMove의 값이 달라지도록 하면 된다.
  • Try Get Pawn OwnerIs Player Controlled 노드로 해당 폰의 소유권이 플레이어 컨트롤러인지 확인한다.
    -true : 기존 bShouldMove로 연결될 값 그대로 연결
    -false : Ground Speed의 값만 bShouldMove로 전달되도록 설정


Behavior Tree와 Blackboard

PatrolPath로 순찰 로직 캡슐화

  • 앞에서 작업한 대로라면, 작동이 문제는 없으나 적 캐릭터가 바뀌거나, 미리 배치하지 않고 스폰되도록 하는 방식에선 비효율적
  • 사진과 같이 순찰 포인트를 캐릭터에 하나하나 지정해주는 것이 아니라, 이들을 관리하는 PatrolPath 클래스를 만들어보자.
//PatrolPath.h
#pragma once

#include "CoreMinimal.h"
#include "GameFramework/Actor.h"
#include "PatrolPath.generated.h"

UCLASS()
class TEMP_THIRD_API APatrolPath : public AActor
{
	GENERATED_BODY()
	
public:	
	APatrolPath();

	UPROPERTY(EditInstanceOnly, BlueprintReadWrite, Category = "Patrol")
	TArray<AActor*> Waypoints;

	AActor* GetWaypoint(int32 index) const;
	int32 Num() const;
};
//PatrolPath.h
  • 컨트롤러가 가지고 있던 순찰 포인트를 저장할 배열 Waypoints
  • 해당 배열에 접근해 순찰 포인트의 인덱스를 얻는 GetWaypoint()
  • 배열의 크기를 반환할 함수
//PatrolPath.cpp
#include "PatrolPath.h"

APatrolPath::APatrolPath()
{
	PrimaryActorTick.bCanEverTick = false;
}
AActor* APatrolPath::GetWaypoint(int32 index) const
{
	if (Waypoints.IsValidIndex(index))
	{
		return Waypoints[index];
	}
	return nullptr;
}
int32 APatrolPath::Num() const
{
	return Waypoints.Num();
}
//PatrolPath.cpp

  • 생성된 클래스에 경로 순서에 맞게 Target Point 들을 배정해준다.

Behavior Tree 를 활용한 AI 기초

Behavior Tree

  • AI의 의사결정을 트리 형태로 구성하여 단계적으로 처리하는 구조
  • 노드들의 조건과 태스크를 순서대로 평가, 행동 결정
  • 이하 BT

Blackboard

  • Behavior Tree가 필요로 하는 데이터를 저장, 공유하는 공간
  • 이하 BB

AI Perception

  • AI가 주변 환경을 인지하는 시스템
  • 시야, 청각 등 다양한 센서를 통해 목표나 이벤트를 감지
  • AI Perception 컴포넌트와 AI Stimuli Source 컴포넌트 필요함

Behavior Tree와 Blackboard 생성

  • 컨텐츠 브라우저에서 BTBB를 생성한다

  • BB에서 BT에 쓰일 (변수라고 생각하면 편함)를 등록해준다.

  • 이 BT가 사용할 BB 에셋을 선택

BT의 노드

  • 시퀀스 - 입력된 행동들을 순서대로, 좌측부터 실행
  • 셀렉터 - if문=branch와 같다다. 셀렉터 아래의 노드 중 하나가 정해진 조건을 만족하면 실행
  • 심플 패러렐 - 병렬 실행, AI가 이동하는 동안 감시하는 등 2가지 일을 같이 하도록 하고 싶을 때.

Decorator

  • Decorator - 노드의 보조 실행 조건

  • 블랙보드를 조건으로 사용할 것이며, 앞에서 설정한 블랙보드의 PlayerDetectedIs Set일 때 이 시퀀스가 실행된다. (예시 이미지. 실제 실행은 플레이어 감지되지 않아야 순찰을 돌기 때문에 Is Not Set이어야 함..)

Task 생성

  • 간단하게 말해서 함수라고 이해하면 된다
  • 위에서 순찰/추격 상황을 분류했으니, 해당 상황에 실행될 명령어 모음을 만들어야 하는데, 이것이 Task이다.

  • 상황에 대응되는 BTTask_PatrolBTTask_Chase를 만들어준다.
  • 각각의 시퀀스에서 화살표를 끌어 태스크를 배치.
  • 이제 남은 것은 각각 Task의 내부에 이벤트를 구성해주는 것이다.

Task 내부 구현

Task의 이벤트


  • Execute, ExecuteAI
    해당 Task가 활성화 될 때 한 번 호출되는 이벤트(BeginPlay처럼). AI가 붙으면 AI컨트롤러도 파라미터로 전달받음.
  • Tick,TickAI
    동명의 이벤트와 동일하다. 해당 Task가 실행되는 동안 매 프레임 호출되는 이벤트.
  • Abort, AbortAI
    해당 Task가 실행 종료될 때 한 번 호출되는 이벤트.
    (스카이림같은 게임에서 경비병이 무언가를 감지했지만 원인(플레이어)을 발견하지 못할 때마다 "It must have been the wind".. 라는 대사를 남기고 제자리로 돌아가는데, 수색 Task가 종료되면 AbortAI 이벤트에 등록된 내용이 실행되는 것이라 할 수 있겠지?)

블랙보드의 변수(키)를 Task Eventgraph에서 사용하는 법

  1. Get Blackboard Value as <TYPE> 노드를 생성한다. 이때 <TYPE>엔 블랙보드에서 생성한 키와 같은 타입이 들어가야 한다.
  2. 노드의 Key 소켓을 우클릭해 변수로 승격시킨후 이름을 정해준다.
  3. Instance Editable 체크박스에 체크해주면 왼쪽 변수 목록의 눈모양 아이콘이 변하게 된다.
  4. BT로 돌아가 키에 PatrolPath를 할당해준다.

BTTask_Patrol 의 내부 구현

  • 앞에서 C++로 구현할 때 AIController 클래스 안에 구현했던 캐릭터의 이동을 담당하는 함수와, 이동 완료 후 호출되는 함수가 Task 안에 들어가있다.

이동 구현 부분

  • AIController 클래스의 MoveTo에 해당하는 것이 AI MoveTo 노드이다. 이 노드에 필요한 파라미터는 움직일 액터목표 대상 혹은 좌표. 여기선 Target Point로 이동할 것이기에 타겟 액터에 연결된 모습.
  • 움직일 액터는 Controlled Pawn에서 바로 가져오면 되지만 목표 대상Waypoints를 가져오기 위해 캐스트가 필요.
  • PatrolPathKey 변수를 만들고 그 변수를 BP_PatrolPath에 캐스트->BP_PatrolPath의 멤버인 TArray<AActor*> Waypoints에 접근한다.
  • CurrentWaypointIndexKey 변수를 생성하고 블랙보드 변수에 연결
  • Waypoints 소켓에서 배열에 접근하는 get (a copy) 노드를 생성하여 Target Actor 소켓에 연결.

이동 완료 후 구현 부분

  • AI MoveToSucces 소켓에서 연결되는 부분이 이동 완료 후 호출되는 부분이다.
  • 원하는 것은 CurrentWaypointIndexKey가 1 증가하는 것.
  • Bind Event to MoveCompleted 노드는 AIController가 이미 가지고 있는 이동 완료 후 호출되는 이벤트 MoveCompleted를 오버라이드하는 노드이다.
  • 바인드 할 커스텀 이벤트를 생성하고 이름을 적당히 변경.
  • 커스텀 이벤트는 블랙보드의 키를 설정한 값으로 저장한다.
    - Key : CurrentWaypointIndex
    - Value : CurrentWaypointIndex = (CurrentWaypointIndex +1) % Waypoints.length()
  • 마지막에 태스크 종료를 의미하는 Finish Execute 연결해주면 끝.

컨트롤러 클래스와 연결

  • 직접적인 이동이 컨트롤러가 아니라 BT에서 이루어지기 때문에 빙의시 BT를 실행하도록 해야한다.
  • 추가로 블랙보드의 PatrolPath와 레벨에 배치된 PatrolPath가 동일하다는 것을 엔진에 알려준다. 이때 Key Name은 반드시 블랙보드의 키와 동일해야 한다!

BTTask_Chase 내부 구현

  • 단순하게 Task가 실행되면 플레이어 캐릭터에게 이동하는 것이기 때문에 순찰 태스크와는 다르게 비교적 간단하다.

Perception

  • 자극과 수용을 위한 컴포넌트를 플레이어 캐릭터와 적 캐릭터에 부착해줘야 한다.

AIPerception Component

  • 인지(수용)를 위한 컴포넌트
  • Senses Config에서 원하는 감각 추가 가능
  • Sense 토글 버튼을 누르면 자세한 설정 가능

AIPerceptionStimuliSourceComponent

  • 자극 발생 컴포넌트
  • 해당 컴포넌트를 가진 액터만 인지될 수 있음.
  • 어떤 종류의 감각을 자극할지 선택 가능

자극에 반응하도록 설정하기

  • 앞에서 간단한 자극-수용을 설정했으나 그 후 어떤 일이 일어날지를 설정하지 않았다.
  • BT에서 순찰/추적의 상황이 PlayerDetected 변수값에 따라 정해지는데, 이 값을 자극-수용의 결과로 결정할 수 있도록 해야한다.
  • AIPerception 컴포넌트의 디테일 패널에서 밑으로 내려보면 다양한 상황의 이벤트가 있다.
  • 이중 On Target Perception Updated 이벤트를 이용해 타겟을 포착했을 때 쫓아오는 행동을 설정하게 된다.
  • 구조는 간단하다.
    1. AISense_Sight에 포착된 액터가 플레이어라면
    2. 블랙보드의 PlayerDetected 키값을 true로 설정
    3. 블랙보드의 TargetActor 키값을 플레이어 캐릭터로 설정.

2025.4.22(화) 추가.

모든 레벨에서 내브메시 연산을 수행하게 되면 비효율적이고 리소스를 많이 차지한다. 상용 게임에서 그래픽 최적화를 위해 플레이어 시선이 닿는 부분만 렌더랑하듯, 내브메시 연산도 플레이어 일정 반경에서만 실행되도록 할 수 있다.

관련 문서 : 언리얼 엔진에서 내비게이션 인보커 사용하기

Build.cs 파일에 내비게이션 시스템 추가하기

  • NavigationInvokerComponent 사용을 위해 가장 먼저 해야할 것은 빌드 파일 수정.
  • 빌드 파일을 열어서 모듈 리스트에 NavigationSystem을 추가해준다.

캐릭터에 NavigationInvoker 컴포넌트 추가하기

  • 캐릭터 반경으로 내브메시의 활성화/비활성화 여부를 가릴 것이기 때문에, 캐릭터에게 컴포넌트를 추가해줘야 한다.
  • 헤더와 소스파일에 필요한 작업을 해주자.

캐릭터 헤더 파일

...
// 인보커를 달기 위해 필요한 헤더
#include "NavigationInvokerComponent.h"
...

class ANBC_CH5Character : public ACharacter
{
	GENERATED_BODY()
...

	// NavigationInvoke 세팅
	UPROPERTY(BlueprintReadWrite, Category = "Navigation", meta = (AllowPrivateAccess = "true"))
	UNavigationInvokerComponent* NavInvoker;

public:
	...
    // 내브메시 생성 반경과 제거 반경
	float NavGenerattionRadius;
	float NavRemovalRadius;

	/** Returns NavInvoker subobject**/
	FORCEINLINE class UNavigationInvokerComponent* GetNavInvoker() const { return NavInvoker; }

}

캐릭터 소스 파일

...
#include "NavigationInvokerComponent.h"
...
ANBC_CH5Character::ANBC_CH5Character()
{
...
	// 생성자에서 초기 설정
	NavGenerattionRadius = 10.0f;
	NavRemovalRadius = 15.0f;
    
	NavInvoker = CreateDefaultSubobject<UNavigationInvokerComponent>(TEXT("NavInvoker"));
	NavInvoker->SetGenerationRadii(NavGenerattionRadius, NavRemovalRadius);
}


빌드 후 에디터에서 설정 변경

프로젝트 설정 변경 2가지

  • 내브메시의 동적 변경을 위해 Runtime GenerationDynamic으로 변경

  • Generate Navigation Only Around Navigation Invokers 활성화


  • 이제 캐릭터를 열어보면 컴포넌트가 생겨난 것을 확인할 수 있다.

결과 확인

  • 캐릭터의 이동에 따라 NavMesh가 활성화되고 있다.
profile
재석로그

0개의 댓글