1. 이 기능이 필요한 이유
게임 속 NPC가 단순히 "A 지점에서 B 지점으로 이동한다"는 행위는 겉보기엔 단순해 보이지만, 실제 게임플레이에서는 훨씬 복잡한 요구사항이 따라옵니다.
적 AI는 불길이 타오르는 구역을 가급적 돌아가야 하고, 중장갑 유닛은 좁은 통로보다 넓은 대로를 선호해야 하며, 은밀 임무 중인 캐릭터는 조명이 밝은 구역의 비용을 높게 책정해 어두운 경로를 우선시해야 합니다. 이 모든 요구사항을 단순히 MoveToLocation 한 줄로 해결할 수는 없습니다.
언리얼 엔진 5는 이 문제를 두 축으로 해결합니다. AI가 이동을 어떻게 요청하는지(AIController와 MoveTo), 그리고 그 이동 경로의 비용과 규칙을 어떻게 조정하는지(UNavArea와 UNavigationQueryFilter)가 그것입니다. 이 두 가지를 이해하면 NPC가 단순히 최단 거리가 아닌, 게임 디자인 의도에 맞는 경로를 스스로 선택하도록 만들 수 있습니다.
2. 전체 구조 개요
이동 요청부터 경로 결정까지의 흐름은 다음과 같습니다.
AIController::MoveToActor / MoveToLocation
↓
FAIMoveRequest (목적지, 허용반경, FilterClass 등을 담은 구조체)
↓
NavigationSystem::FindPathSync
↓ ← UNavigationQueryFilter가 이 단계에서 비용을 조정함
Detour Pathfinding (NavMesh 폴리곤 비용 계산)
↓
UPathFollowingComponent (실제 이동 실행)
↓
AAIController::OnMoveCompleted (결과 콜백)
AAIController는 이동을 "지시"하는 역할을 담당하고, UNavigationQueryFilter는 그 이동 경로를 "평가"하는 기준을 제공합니다. 두 클래스는 서로 독립적으로 설계되어 있으므로, 동일한 AIController가 상황에 따라 서로 다른 Filter를 사용하는 것이 가능합니다.
3. AAIController와 MoveTo 기본 사용법
3-1. AAIController 서브클래스 생성
AAIController를 상속받아 커스텀 AI 컨트롤러를 만드는 것이 일반적인 시작점입니다. 빌드 파일에 AIModule 의존성을 반드시 추가해야 합니다.
MyProject.Build.cs에 아래와 같이 모듈을 추가합니다.
PublicDependencyModuleNames.AddRange(new string[]
{
"Core", "CoreUObject", "Engine",
"AIModule", // AIController, PathFollowing 등
"NavigationSystem" // UNavigationQueryFilter, NavArea 등
});
이제 헤더 파일을 작성합니다. OnPossess를 오버라이드하면 Pawn이 이 컨트롤러에 빙의되는 순간 초기화 코드를 실행할 수 있습니다.
// MyAIController.h
#pragma once
#include "CoreMinimal.h"
#include "AIController.h"
#include "MyAIController.generated.h"
UCLASS()
class MYPROJECT_API AMyAIController : public AAIController
{
GENERATED_BODY()
public:
// Pawn이 이 컨트롤러에 빙의될 때 호출됩니다.
virtual void OnPossess(APawn* InPawn) override;
// 이동 완료(성공/실패 포함) 시 호출되는 콜백입니다.
virtual void OnMoveCompleted(FAIRequestID RequestID,
const FPathFollowingResult& Result) override;
};
위 코드에서 OnMoveCompleted는 UPathFollowingComponent가 이동 결과를 통보할 때 호출되는 가상 함수입니다. 이동 성공, 실패, 중단 등 모든 종료 사유를 FPathFollowingResult로 판별할 수 있습니다.
3-2. MoveToLocation과 MoveToActor
AAIController는 이동 요청을 위한 두 가지 핵심 함수를 제공합니다.
// MyAIController.cpp
#include "MyAIController.h"
#include "Navigation/PathFollowingComponent.h"
void AMyAIController::OnPossess(APawn* InPawn)
{
Super::OnPossess(InPawn);
// 목적지 위치로 이동을 요청합니다.
// AcceptanceRadius: 목표 지점에서 이 반경 안에 들어오면 "도착"으로 간주합니다.
FVector TargetLocation = FVector(1000.f, 500.f, 0.f);
MoveToLocation(TargetLocation, /* AcceptanceRadius */ 50.f);
}
void AMyAIController::OnMoveCompleted(FAIRequestID RequestID,
const FPathFollowingResult& Result)
{
Super::OnMoveCompleted(RequestID, Result);
if (Result.IsSuccess())
{
UE_LOG(LogTemp, Log, TEXT("이동 완료: 목적지에 도착했습니다."));
}
else if (Result.HasFlag(FPathFollowingResultFlags::OwnerFinished))
{
UE_LOG(LogTemp, Warning, TEXT("이동 중단: 외부에서 이동이 취소되었습니다."));
}
else
{
UE_LOG(LogTemp, Warning, TEXT("이동 실패: 경로를 찾을 수 없거나 도달 불가능합니다."));
}
}
위 코드처럼 FPathFollowingResult의 플래그를 확인하면 이동이 성공인지, 목표에 도달했는지, 중단된 것인지를 정확히 구분할 수 있습니다.
MoveToActor는 목표가 움직이는 액터일 때 사용합니다. 내부적으로 목표 액터의 위치를 추적하며 경로를 동적으로 갱신합니다.
// 플레이어 캐릭터를 향해 이동합니다.
APlayerController* PC = GetWorld()->GetFirstPlayerController();
if (PC && PC->GetPawn())
{
// bStopOnOverlap: true이면 목표 Actor의 콜리전과 겹칠 때 도착으로 간주합니다.
MoveToActor(PC->GetPawn(), /* AcceptanceRadius */ -1.f, /* bStopOnOverlap */ true);
}
위 코드에서 AcceptanceRadius를 -1로 전달하면 UPathFollowingComponent의 기본값(기본적으로 5cm)이 사용됩니다.
3-3. EPathFollowingRequestResult로 요청 결과 즉시 확인하기
MoveToLocation과 MoveToActor는 반환값으로 EPathFollowingRequestResult::Type을 반환합니다. 이 값은 이동 완료 여부가 아닌, 이동 요청이 성공적으로 접수되었는지 여부를 나타냅니다.
EPathFollowingRequestResult::Type Result = MoveToLocation(TargetLocation, 50.f);
switch (Result)
{
case EPathFollowingRequestResult::AlreadyAtGoal:
// 이미 목적지에 있으므로 이동 없이 즉시 완료로 처리됩니다.
UE_LOG(LogTemp, Log, TEXT("이미 목적지에 있습니다."));
break;
case EPathFollowingRequestResult::RequestSuccessful:
// 경로 탐색이 시작되었으며, 결과는 OnMoveCompleted에서 통보됩니다.
UE_LOG(LogTemp, Log, TEXT("이동 요청 접수. 경로 탐색 중..."));
break;
case EPathFollowingRequestResult::Failed:
// NavMesh가 없거나 목적지가 NavMesh 밖에 있는 경우 즉시 실패합니다.
UE_LOG(LogTemp, Error, TEXT("이동 요청 실패. NavMesh를 확인하십시오."));
break;
}
위 코드처럼 요청 결과를 즉시 확인하면, NavMesh가 빠진 레벨이나 목적지가 NavMesh 범위 밖인 상황에서 발생하는 이동 불가 버그를 초기에 잡아낼 수 있습니다.
4. FAIMoveRequest로 세밀하게 이동 요청하기
MoveToLocation과 MoveToActor는 내부에서 FAIMoveRequest를 생성하여 MoveTo(const FAIMoveRequest&)를 호출하는 래퍼 함수입니다. 파라미터 수가 늘어날수록 이 구조체를 직접 구성하는 방식이 가독성과 유지보수 측면에서 유리합니다.
// FAIMoveRequest를 직접 구성하여 이동을 요청하는 예시입니다.
void AMyAIController::RequestMoveWithCustomSettings(AActor* TargetActor)
{
FAIMoveRequest MoveRequest;
MoveRequest.SetGoalActor(TargetActor); // 목표를 Actor로 설정합니다.
MoveRequest.SetAcceptanceRadius(100.f); // 도착 판정 반경을 100cm로 설정합니다.
MoveRequest.SetStopOnOverlap(true); // 목표 Actor와 겹치면 도착으로 처리합니다.
MoveRequest.SetAllowPartialPath(true); // 완전한 경로가 없어도 부분 경로를 허용합니다.
MoveRequest.SetUsePathfinding(true); // NavMesh 기반 경로 탐색을 사용합니다.
// 이 시점에서는 아직 NavigationQueryFilter를 적용하지 않았습니다.
// FilterClass를 설정하지 않으면 기본 필터가 사용됩니다.
MoveTo(MoveRequest);
}
위 코드처럼 FAIMoveRequest의 각 항목을 메서드 체인 형태로 명시적으로 설정하면, 파라미터 순서를 외울 필요 없이 의도가 코드에 그대로 드러납니다. bAllowPartialPath를 활성화하면 목적지에 완전히 도달하기 어려운 상황(장애물 등)에서도 가능한 한 근접한 지점까지 이동합니다.
5. NavArea로 이동 비용 정의하기
UNavArea는 NavMesh의 특정 폴리곤에 비용(Cost) 과 통과 가능 여부를 부여하는 클래스입니다. 경로 탐색 알고리즘은 누적 비용이 가장 낮은 경로를 최적 경로로 선택하므로, 비용이 높은 구역은 "우회 가능하다면 피해 가는" 구역이 됩니다.
5-1. 커스텀 NavArea 클래스 작성
// NavArea_Hazard.h
// 위험 구역을 나타내는 커스텀 NavArea입니다.
#pragma once
#include "NavAreas/NavArea.h"
#include "NavArea_Hazard.generated.h"
UCLASS()
class MYPROJECT_API UNavArea_Hazard : public UNavArea
{
GENERATED_BODY()
public:
UNavArea_Hazard()
{
// 기본 비용의 5배로 설정합니다. 경로 탐색 시 우회가 유리해집니다.
DefaultCost = 5.0f;
// 이 구역의 NavMesh 폴리곤 색상(에디터 표시용)을 주황색으로 설정합니다.
DrawColor = FColor(255, 128, 0);
}
};
위 코드에서 DefaultCost는 해당 구역의 폴리곤 하나를 통과할 때 추가되는 비용 배수입니다. 기본값은 1.0이며, 이 값을 높일수록 해당 구역을 통과하는 경로는 상대적으로 불리해집니다.
5-2. NavArea를 레벨에 적용하는 방법
레벨에서 특정 구역에 커스텀 NavArea를 적용하는 방법은 두 가지입니다.
방법 A — Nav Modifier Volume: 에디터에서 Nav Modifier Volume 액터를 배치하고 Area Class를 NavArea_Hazard로 설정합니다. 브러시(Box, Sphere 등)로 영역을 지정하면 해당 범위 내 NavMesh 폴리곤에 비용이 적용됩니다.
방법 B — NavModifierComponent: 특정 액터(예: BombTrap)가 스스로 주변 NavMesh를 오염시키도록 하고 싶을 때 사용합니다.
// 액터 생성자에서 NavModifierComponent를 추가하는 예시입니다.
ABombTrap::ABombTrap()
{
NavModifier = CreateDefaultSubobject<UNavModifierComponent>(TEXT("NavModifier"));
// 이 액터의 콜리전 범위 안의 NavMesh를 Hazard 구역으로 표시합니다.
NavModifier->AreaClass = UNavArea_Hazard::StaticClass();
}
위 코드처럼 UNavModifierComponent를 사용하면, 해당 액터가 레벨에 배치되거나 런타임에 스폰될 때 자동으로 NavMesh에 구역 정보가 반영됩니다. 이동하는 위험 구역(이동하는 용암 덩어리 등)에도 적용할 수 있습니다.
6. UNavigationQueryFilter로 비용 규칙 조합하기
UNavArea가 구역 하나의 비용을 정의한다면, UNavigationQueryFilter는 어떤 NavArea를 허용/차단할지, 각 구역의 비용을 얼마로 볼 것인지를 AI별로 다르게 설정할 수 있는 필터입니다.
같은 NavArea_Hazard 구역이라도, 일반 병사에게는 비용 5배를 부여하고 중장갑 유닛에게는 비용 2배(덜 회피)로, 불사 유닛에게는 완전히 무시(비용 1배)하도록 각자의 QueryFilter를 만들 수 있습니다.
6-1. 커스텀 UNavigationQueryFilter 작성
// NavigationQueryFilter_HeavyUnit.h
// 중장갑 유닛용 NavigationQueryFilter입니다.
// Hazard 구역을 일반 병사보다 덜 기피합니다.
#pragma once
#include "NavigationQueryFilter.h"
#include "NavigationQueryFilter_HeavyUnit.generated.h"
UCLASS()
class MYPROJECT_API UNavigationQueryFilter_HeavyUnit : public UNavigationQueryFilter
{
GENERATED_BODY()
public:
UNavigationQueryFilter_HeavyUnit()
{
// Hazard 구역의 비용을 기본값(5.0)의 절반인 2.0으로 줄입니다.
// 중장갑 유닛은 위험 구역을 어느 정도 감수하고 직진합니다.
AddTravelCostOverride(UNavArea_Hazard::StaticClass(), 2.0f);
// 특정 구역을 완전히 통행 불가로 막으려면 SetIsExcluded를 사용합니다.
// 예: 좁은 통로 구역을 중장갑이 통과할 수 없도록 설정합니다.
// SetExcludedArea(UNavArea_NarrowPass::StaticClass());
}
};
위 코드에서 AddTravelCostOverride는 특정 NavArea를 통과할 때 적용할 비용 배수를 재정의합니다. 이 값이 낮을수록 해당 구역을 경로 탐색 시 덜 기피하게 됩니다.
6-2. MoveToActor에 FilterClass 적용하기
작성한 QueryFilter를 실제 이동 요청에 연결하는 방법은 간단합니다. MoveToActor 또는 MoveToLocation의 FilterClass 파라미터에 클래스를 전달하면 됩니다.
// 중장갑 유닛 AIController에서 이동 요청 시 전용 필터를 사용합니다.
void AHeavyUnitAIController::MoveToTarget(AActor* Target)
{
MoveToActor(
Target,
/* AcceptanceRadius */ 100.f,
/* bStopOnOverlap */ true,
/* bUsePathfinding */ true,
/* bCanStrafe */ false,
/* FilterClass */ UNavigationQueryFilter_HeavyUnit::StaticClass(),
/* bAllowPartialPath */ true
);
}
또는 FAIMoveRequest를 통해 동일하게 적용할 수 있습니다.
void AHeavyUnitAIController::MoveToTargetWithRequest(AActor* Target)
{
FAIMoveRequest MoveRequest;
MoveRequest.SetGoalActor(Target);
MoveRequest.SetAcceptanceRadius(100.f);
// 전용 필터를 이동 요청에 연결합니다.
MoveRequest.SetNavigationFilter(UNavigationQueryFilter_HeavyUnit::StaticClass());
MoveTo(MoveRequest);
}
위 코드처럼 FAIMoveRequest::SetNavigationFilter를 사용하면 필터 설정이 MoveRequest 객체 안에 명시적으로 포함되므로, 이동 요청 로직 전체를 한 눈에 파악할 수 있습니다.
7. 런타임에 Filter를 동적으로 교체하기
상황에 따라 같은 AI가 서로 다른 필터를 사용해야 하는 경우가 있습니다. 예를 들어, 순찰 중에는 비용에 민감한 기본 필터를 사용하다가, 전투 상태에 돌입하면 위험 구역을 무시하는 공격적인 필터로 전환하는 패턴입니다.
// AIController 헤더에 상태별 필터를 선언합니다.
UPROPERTY(EditDefaultsOnly, Category = "AI|Navigation")
TSubclassOf<UNavigationQueryFilter> PatrolFilter;
UPROPERTY(EditDefaultsOnly, Category = "AI|Navigation")
TSubclassOf<UNavigationQueryFilter> CombatFilter;
// 현재 전투 상태에 따라 적절한 필터로 이동을 요청합니다.
void AMyAIController::ChaseTarget(AActor* Target, bool bIsInCombat)
{
TSubclassOf<UNavigationQueryFilter> ActiveFilter =
bIsInCombat ? CombatFilter : PatrolFilter;
FAIMoveRequest MoveRequest;
MoveRequest.SetGoalActor(Target);
MoveRequest.SetAcceptanceRadius(50.f);
MoveRequest.SetNavigationFilter(ActiveFilter); // 상황에 맞는 필터를 선택합니다.
MoveTo(MoveRequest);
}
위 코드처럼 필터 클래스를 UPROPERTY로 외부에 노출시키면, 블루프린트나 에디터 데이터 애셋에서 디자이너가 직접 필터를 교체할 수 있어 코드 수정 없이도 AI 행동 튜닝이 가능해집니다.
8. 일반 C++과의 비교
일반 C++ 코드에서 경로 탐색 로직은 개발자가 직접 그래프 탐색 알고리즘(Dijkstra, A* 등)을 구현하거나 서드파티 라이브러리를 통합해야 합니다. 비용 함수 역시 개발자가 직접 정의하고, 호출 시점과 연산 결과의 캐싱 전략도 수동으로 관리해야 합니다.
일반 C++로 A* 알고리즘을 구현하면 대략 다음과 같은 구조가 됩니다.
// 일반 C++에서의 A* 경로 탐색 (의사 코드 수준)
struct Node { FVector Position; float GCost; float HCost; Node* Parent; };
std::vector<Node*> FindPath(FVector Start, FVector End, CostFunction costFn)
{
// Open/Closed Set 관리, 이웃 노드 탐색, 비용 계산을 직접 구현해야 합니다.
// 장애물 처리, 동적 장애물 갱신, 이동 명령 전달도 모두 직접 처리해야 합니다.
...
}
언리얼 엔진 5의 네비게이션 시스템은 이 모든 과정을 Detour(NavMesh 기반 경로 탐색 라이브러리)와 UNavigationSystemV1으로 추상화합니다. 개발자는 비용 함수 로직이 담긴 UNavArea와 UNavigationQueryFilter 클래스만 작성하면 되고, 경로 탐색 요청과 실행은 AAIController가 위임받아 처리합니다. 동적 NavMesh 재빌드와 경로 재탐색도 시스템 레벨에서 자동으로 처리됩니다.
9. 유니티 엔진과의 비교
유니티의 네비게이션 시스템은 NavMesh.CalculatePath와 NavMeshAgent를 중심으로 구성됩니다. NavMeshAgent.SetDestination(target)이 언리얼의 MoveToActor에 해당하는 가장 직접적인 대응점입니다.
비용 조정 측면에서 유니티는 NavMesh.SetAreaCost(areaIndex, cost) 정적 메서드를 통해 전역적으로 NavMesh 구역 비용을 설정하며, 이 비용은 모든 NavMeshAgent에 동일하게 적용됩니다. 개별 에이전트마다 서로 다른 비용 기준을 가지려면 NavMeshAgent.areaMask로 통과 가능 구역을 비트 마스킹하는 방식을 사용해야 합니다.
언리얼 엔진은 이 부분에서 명확한 설계 강점을 가집니다. UNavigationQueryFilter는 에이전트 유형마다 독립적인 비용 테이블을 가지므로, 동일한 NavArea 구역이라도 AI 종류에 따라 전혀 다른 비용을 적용하는 것이 기본 설계 패턴입니다. 유니티처럼 비트 마스크를 직접 조작하지 않고도 클래스 단위로 의도를 명확히 표현할 수 있습니다.
또한 언리얼은 OnMoveCompleted 가상 함수 오버라이드와 FPathFollowingResult 플래그를 통해 이동 종료 이유를 세밀하게 구분할 수 있습니다. 유니티의 NavMeshAgent는 경로 완료 여부를 remainingDistance < stoppingDistance 조건으로 Tick마다 폴링해야 하는 방식에 가깝습니다.
10. 주의사항
10-1. NavArea 클래스 등록
NavigationQueryFilter를 사용하려면 해당 NavArea 클래스가 프로젝트의 Navigation 설정에 등록되어 있어야 합니다. 에디터에서 Project Settings → Navigation System → Supported Agents와 Supported Areas 항목을 확인하십시오. 커스텀 UNavArea가 이 목록에 없으면 폴리곤에 실제로 적용되지 않습니다.
10-2. Travel Cost 비용 0 설정 시 주의
AddTravelCostOverride에서 비용을 0으로 설정하는 것은 구역을 "무료"로 만드는 것이 아니라 해당 폴리곤을 사실상 무한히 선호하게 만드는 결과를 낳을 수 있습니다. 통행 불가를 의도한다면 SetExcludedArea를 사용해야 합니다.
10-3. MoveToActor 호출 시 성능 고려
MoveToActor는 내부적으로 매 틱마다 목표 액터의 위치를 추적합니다. 목표가 빠르게 움직이는 경우 경로 재탐색이 빈번해져 성능에 영향을 줄 수 있습니다. 이 경우 UPathFollowingComponent의 MinAgentRadiusPct나 MinLOSDistanceSq 설정을 조정하거나, 일정 주기로만 MoveToActor를 재호출하는 방식을 고려하십시오.
10-4. bAllowPartialPath true 설정 시 주의
bAllowPartialPath를 항상 true로 설정하면 목적지에 도달 불가능한 상황에서도 AI가 "부분 이동"을 완료로 보고하며 OnMoveCompleted의 IsSuccess()가 true를 반환할 수 있습니다. 실제 목적지 도달 여부를 반드시 확인해야 하는 로직에서는 이 점을 주의하셔야 합니다.
10-5. BehaviorTree 에서는 BTTask_MoveTo 호출
AAIController의 MoveToActor와 MoveToLocation은 기존 진행 중인 이동 요청을 자동으로 취소(Abort)하고 새 요청을 시작합니다. BehaviorTree의 BTTask_MoveTo와 함께 사용할 경우, AITask 계층에서 이동을 관리하므로 MoveToActor를 직접 호출하면 충돌이 발생할 수 있습니다. BehaviorTree 환경에서는 가급적 BTTask_MoveTo 노드를 통해 이동을 요청하는 것이 권장됩니다.
AAIController의 MoveToActor / MoveToLocation은 AI 이동의 진입점이고, FAIMoveRequest는 그 이동 요청을 정밀하게 구성하는 수단이며, UNavArea와 UNavigationQueryFilter는 이동 경로의 비용과 규칙을 AI 유형별로 독립적으로 정의하는 도구입니다. 세 가지를 함께 활용하면 각기 다른 전술적 특성을 가진 AI 유닛들이 동일한 레벨 위에서 각자의 판단 기준으로 경로를 선택하도록 만들 수 있으며, 이는 솔직하게 말해 대부분의 액션 게임 AI 이동 시스템의 핵심 뼈대가 됩니다.
'언리얼 엔진 5' 카테고리의 다른 글
| [UE5] UNavLocalGridManager - 로컬 내비게이션 그리드로 AI 경로 탐색 제어하기 (0) | 2026.03.28 |
|---|---|
| [UE5] NavLink - 단절된 NavMesh 구간을 연결하는 기술 (1) | 2026.03.23 |
| [UE5] NavMesh 런타임 생성, 업데이트 & 경로 탐색 알고리즘 (0) | 2026.03.21 |
| [UE5] 네비게이션 시스템 개요 및 NavMesh 기초 (0) | 2026.03.19 |
| [UE5] UPrimitiveComponent 의 바운딩 박스 (1) | 2026.03.18 |