📌 NavMesh의 동적 변화
NavMesh는 런타임에 동적으로 생성은 불가능하다.
하지만 레벨이 런타임중에 변화되었을 때 동적으로 NavMesh의 계산을 동적으로 변경할 수 있다.
그 세팅은 위와같이 Project Settings에서 Engine-> Navigation Mesh 카테고리에서 Runtime 섹션을 살펴보면 Runtime Generation 항목이 있는데 그것을 기본 Static 값에서 Dynamic 값으로 변경해주면된다.
참고로 이 기능은 리소스를 많이 소모하게 된다는 점은 알고 있자.
🔹 좌(Dynamic 설정) / 우(Dynamic Modifiers Only 설정)
Dynamic Modifers 경우엔 Static과 Dynamic 옵션을 섞은 것과 동일하지만, NavMesh 영역에서 "빼기" 만 가능하다는 차이점이 핵심이다.
오른쪽 Gif를 보면 큐브 위쪽에 NavMesh 영역이 생성되지 않는 것을 확인할 수 있다.
또한 해당 액터에 Nav Modifier Component가 존재해야만 정상적으로 Dynamic Modifier 기능이 동작할 수 있다.
Dynamic Modifiers Only는 사실 딥한 영역이여서 사용할일이 아주 많지는 않지만 어떤 차이점이 있는지 알아두자.
📌 Navigation Invoker 기능을 활용한 최적화
이전에 Gunfire Paragon 팀프로젝트에서 AI를 담당해 구현하였었는데, 이때 AI가 가지 못하는 곳이 없도록 NavMesh를 모든 월드를 감싸도록 하였었다.
이때는 소규모 프로젝트라 월드 공간이 작아서 큰 문제는 없었지만, 만약 대규모 MMORPG를 제작한다고 했을 때, 그 큰 월드 전체에 NavMesh를 도포한 후 그대로 사용한다면 불필요한 연산 과정과 리소스가 낭비될 것이다.
따라서 언리얼에서는 이러한 문제점을 고려해 Navigation Invoker라는 기능을 제공한다.
이 Navigation Invoker라는 컴포넌트를 가진 액터의 주변 영역만 연산해서 리소스를 아끼고, 게임플레이 상에서 문제가 없도록 하는 기능이다.
✅ Character에 Navigation Invoker Component 추가
#include "NavigationInvokerComponent.h"
UCLASS(config=Game)
class AAI_TestCharacter : public ACharacter
{
GENERATED_BODY()
public:
AAI_TestCharacter();
/** Invoker Component */
UPROPERTY(BlueprintReadWrite, Category = Navigation, meta = (AllowPrivateAccess = "true"))
UNavigationInvokerComponent* NavInvoker;
/** 네비게이션 메시 생성 반경 */
float NavGenerationRadius;
/** 네비게이션 메시 제거 반경 */
float NaveRemovalRadius;
/** Returns NavInvoker subobject **/
FORCEINLINE class UNavigationInvokerComponent* GetNavInvoker() const { return NavInvoker; }
};
AAI_TestCharacter::AAI_TestCharacter()
{
NavGenerationRadius = 10.0f;
NaveRemovalRadius = 15.0f;
NavInvoker = CreateDefaultSubobject<UNavigationInvokerComponent>(TEXT("NavInvoker"));
NavInvoker->SetGenerationRadii(NavGenerationRadius, NaveRemovalRadius);
}
✅ Build.cs에 모듈 추가
using UnrealBuildTool;
public class AI_Test : ModuleRules
{
public AI_Test(ReadOnlyTargetRules Target) : base(Target)
{
PCHUsage = PCHUsageMode.UseExplicitOrSharedPCHs;
PublicDependencyModuleNames.AddRange(new string[] {
"Core",
"CoreUObject",
"Engine",
"InputCore",
"EnhancedInput",
"NavigationSystem"
});
}
}
"NavigationSystem" 모듈 추가
✅ Project Settings → Navigation System 설정
Project Settings → Navigation System → Generate Navigation Only Around Navigation Invokers → True
위와 같이 체크를 해준다.
또한 앞서 설명했던 Navigation Mesh의 Runtime Generation은 Dynamic으로 설정한다.
🔔 테스트 결과
AI 캐릭터가 이동할 수 있도록 전역에 NavMesh를 설정하였지만, AI 캐릭터 주변에만 NavMesh가 활성화 되는 모습을 확인할 수 있다.
📌 AI의 경로 탐색(Pathfinding) 원리
언리얼 엔진의 AI는 A* 혹은 다익스트라 알고리즘 등과 같이 특정 경로로 이동하기 위해 비용 최적화 경로 탐색 공식을 바탕으로 경로를 탐색한다.
다시 말해, 언리얼 엔진의 Pathfinding의 알고리즘은 가장 저렴한 비용으로 목표 지점까지 이동하도록 설계되어있다.
앞서 NavMesh에서 현재 사용되지 않는 부분까지 전부 계산해야되는 비효율을 해결하기 위해 Navigation Invoker를 언급했었는데, 이것을 사용하는 이유는 Runtime중에 NavMesh를 동적 생성이 불가능하다는 것이었다. 이는 엔진의 아키텍쳐 설계상의 이유로 Brush가 Runtime 중에 재생성 되지 않기 때문이다.
그럼 만약에 Runtime 중 발생한 이벤트로 인해 월드 내의 지형지물에 변화가 생겼다거나, 특정 시점에 플레이어를 추적을 포기하게 하는 등의 로직 구현이 어려워질 수 있다.
이러한 문제를 해결하기 위해 Nav Modifier를 활용하면 경로탐색 알고리즘의 매커니즘을 응용해서 실시간으로 AI의 경로를 수정하도록 유도할 수 있다.
Nav Modifier의 Area Class를 활용하면 특정 구역별 비용을 차등 설정할 수 있고, 이를 통해 AI를 원하는 경로로 이동하도록 설계할 수 있다.
✅ 레벨에 Nav Modifier Volume 배치
레벨에 Nav Modifier Volume을 적절히 배치하고, 배치된 Nav Modifier를 클릭한 뒤 Details 패널에서 Area Class를 Obstacle로 설정해준다.
- NavArea_Default:
- 기본 네비게이션 지역
- 특별한 제약이나 추가 비용 없이 AI가 자유롭게 지나갈 수 있는 구역
- NavArea_Obstacle:
- 장애물 지역
- AI가 해당 영역을 지나가지 않도록 완전히 막는 구역
- NavArea_LowHeight:
- 통과는 할 수 있지만, 이동 비용(Cost)가 기본 값보다 높게 설정
- AI는 가능한 우회하려고 시도
- 만약 다른 경로가 너무 멀거나 막혀있다면, 결국 LowHeight 경로 사용
- NavArea_Null:
- 비 네비게이션 지역으로 네비게이션 완전 제거
- AI가 완전히 무시하는 공간으로 처리
✅ 레벨에 Target Point 배치
Nav Modifier를 가운데로 두고 양쪽에 Target Point 배치
✅ Character 기능 추가
#pragma once
#include "CoreMinimal.h"
#include "GameFramework/Character.h"
#include "Logging/LogMacros.h"
#include "NavigationInvokerComponent.h"
#include "Navigation/PathFollowingComponent.h"
#include "AI_TestCharacter.generated.h"
UCLASS(config=Game)
class AAI_TestCharacter : public ACharacter
{
GENERATED_BODY()
public:
AAI_TestCharacter();
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "AI Movement")
bool bIsSucceeded;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "AI Movement")
AActor* Target;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "AI Movement")
AActor* Target2;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "AI Movement")
float AcceptanceRadius;
UFUNCTION(BlueprintCallable, Category = "AI Movement")
void MoveToTarget();
UFUNCTION()
void OnMoveCompleted(FAIRequestID RequestID, EPathFollowingResult::Type Result);
UFUNCTION(BlueprintCallable, Category = "AI Movement")
void StartMoving();
UFUNCTION(BlueprintCallable, Category = "AI Movement")
void FindTargetPoints();
protected:
virtual void BeginPlay() override;
virtual void NotifyControllerChanged() override;
private:
UPROPERTY()
class AAIController* AIController;
UPROPERTY()
bool bIsMoving;
}
#include "AIController.h"
#include "Kismet/GameplayStatics.h"
#include "NavigationSystem.h"
#include "Engine/TargetPoint.h"
DEFINE_LOG_CATEGORY(LogTemplateCharacter);
AAI_TestCharacter::AAI_TestCharacter()
{
// AI Modifier 테스트 관련 변수 초기화
bIsSucceeded = false;
bIsMoving = false;
AcceptanceRadius = 50.0f; // 블루프린트에서 5.0으로 설정된 것으로 보이지만, 언리얼 단위로 변환
}
void AAI_TestCharacter::NotifyControllerChanged()
{
Super::NotifyControllerChanged();
// Add Input Mapping Context
if (APlayerController* PlayerController = Cast<APlayerController>(Controller))
{
if (UEnhancedInputLocalPlayerSubsystem* Subsystem = ULocalPlayer::GetSubsystem<UEnhancedInputLocalPlayerSubsystem>(PlayerController->GetLocalPlayer()))
{
Subsystem->AddMappingContext(DefaultMappingContext, 0);
}
}
else // Input Mapping Context가 없는 경우(AI) 컨트롤러 추가
{
AIController = Cast<AAIController>(Controller);
if (AIController)
{
AIController->ReceiveMoveCompleted.AddDynamic(this, &AAI_TestCharacter::OnMoveCompleted);
}
}
}
void AAI_TestCharacter::BeginPlay()
{
Super::BeginPlay();
AIController = Cast<AAIController>(GetController());
if (AIController)
{
// 디버깅 에러 방지를 위해 언바인딩
AIController->ReceiveMoveCompleted.RemoveDynamic(this, &AAI_TestCharacter::OnMoveCompleted);
// 이동 완료 이벤트 델리게이트 바인딩
AIController->ReceiveMoveCompleted.AddDynamic(this, &AAI_TestCharacter::OnMoveCompleted);
// 타겟 포인트 찾기
FindTargetPoints();
StartMoving();
}
}
void AAI_TestCharacter::MoveToTarget()
{
if (!AIController) return;
if (bIsMoving) return;
AActor* SelectedTarget = bIsSucceeded ? Target : Target2;
if (SelectedTarget)
{
bIsMoving = true;
FVector TargetLocation = SelectedTarget->GetActorLocation();
EPathFollowingRequestResult::Type MoveResult = AIController->MoveToLocation(
TargetLocation,
AcceptanceRadius,
true, // 목적지에 오버랩 되면 도착으로 판정할지 여부
true, // 경로 찾기 사용
false, // 프로젝션 사용 안함
true // 네비게이션 데이터 사용
);
if (MoveResult == EPathFollowingRequestResult::Failed)
{
bIsMoving = false;
}
else
{
UE_LOG(LogTemplateCharacter, Display, TEXT("Moving to %s (IsSucceeded: %s)"),
*SelectedTarget->GetName(), bIsSucceeded ? TEXT("True") : TEXT("False"));
}
}
else
{
UE_LOG(LogTemplateCharacter, Error, TEXT("Selected target is not valid! Make sure Target and Target2 are set."));
}
}
void AAI_TestCharacter::OnMoveCompleted(FAIRequestID RequestID, EPathFollowingResult::Type Result)
{
bIsMoving = false;
// 이동 결과에 따라 IsSucceeded 값 토글
if (Result == EPathFollowingResult::Success)
{
// 성공적으로 이동 완료됨
bIsSucceeded = !bIsSucceeded; // 값 토글
UE_LOG(LogTemplateCharacter, Display, TEXT("Move completed successfully. IsSucceeded toggled to: %s"),
bIsSucceeded ? TEXT("True") : TEXT("False"));
// 지연 후 다음 이동 시작
FTimerHandle TimerHandle;
GetWorldTimerManager().SetTimer(TimerHandle, this, &AAI_TestCharacter::MoveToTarget, 0.5f, false);
}
else
{
// 이동 실패
UE_LOG(LogTemplateCharacter, Warning, TEXT("Move failed with result: %d"), static_cast<int32>(Result));
// 실패 시에도 다시 시도
FTimerHandle TimerHandle;
GetWorldTimerManager().SetTimer(TimerHandle, this, &AAI_TestCharacter::MoveToTarget, 1.0f, false);
}
}
void AAI_TestCharacter::StartMoving()
{
// 타겟 포인트를 찾고 이동 시작
FindTargetPoints();
MoveToTarget();
}
void AAI_TestCharacter::FindTargetPoints()
{
// 타겟 포인트가 설정되어 있지 않은 경우 자동으로 찾기
// "||"는 A OR B로 A 혹은 B가 True일 경우 True를 반환하는 논리연산자입니다.
// 여기서는 역논리 연산자가 bool 변수 앞에 붙어있으므로 bool변수가 하나라도 false 일 경우 True로 판정합니다.
if (!Target || !Target2)
{
TArray<AActor*> FoundTargets;
UGameplayStatics::GetAllActorsOfClass(GetWorld(), ATargetPoint::StaticClass(), FoundTargets);
if (FoundTargets.Num() >= 2)
{
Target = FoundTargets[0];
Target2 = FoundTargets[1];
UE_LOG(LogTemplateCharacter, Display, TEXT("Found TargetPoints: %s and %s"),
*Target->GetName(), *Target2->GetName());
}
else
{
UE_LOG(LogTemplateCharacter, Warning, TEXT("Not enough TargetPoints found in the level, need at least 2!"));
}
}
}
🔔 테스트 결과
AI가 Target Point 두 곳을 왕복 이동할 때 직선으로 가면 제일 빠르지만, 레벨에 배치되었던 Nav Modifier의 Area Class가 NavArea_Obstacle로 설정되어있어 해당 경로는 장애물에 막혀있다고 판단되어 좌측으로 돌아서 이동하는 것을 확인할 수 있었다.
🔎Nav Modifer 런타임 중 이동
레벨에 배치된 Nav Modifier를 다른 쪽으로 치워놓고, Level Blueprint에서 키보드 0번을 눌렀을 때 원래 있던 위치로 이동하게 만들었다.
이후 플레이헤서 테스트 해보니 직선으로 이동하다가 Nav Modifier가 제자리로 오면서 경로를 막자, 실시간으로 저비용 경로로 우회해서 이동하는 것을 확인할 수 있었다.
이 기능을 이용해서 AI를 레벨 디자인에 맞춰 특정 경로로 유도하는 등 유용하게 쓸 수 있는 기능인 것 같다.
📍 Nav Invoker + Nav Modifier 활용
Invoker 기능을 켜고 마지막으로 테스트해보았다.
월드 전역에 깔린 NavMesh에서 AI캐릭터 주변에만 활성화 되는 모습까지 확인할 수 있었고, 정상적으로 Nav Modifier를 우회해서 경로를 탐색해 Target Point를 왕복할 수 있었다.
'내배캠 > Unreal Engine' 카테고리의 다른 글
AI_Perception(플레이어 감지) (0) | 2025.04.23 |
---|---|
AI_RVO(회피 이동) (0) | 2025.04.23 |
미로 게임 구현(Dedicated Server) (0) | 2025.03.25 |
언리얼 엔진 패키징(데디케이티드 서버 빌드) (0) | 2025.03.21 |
채팅으로 하는 숫자 야구 게임 구현(Listen Server) (0) | 2025.03.19 |