언리얼엔진5/각종 지식

[UE5 C++] EQS Query Generator 커스터마이징

Rocketbabydolls 2025. 7. 11. 13:51

발단

 

팀 프로젝트 중, AI 스폰에 관련해서 세부 요구사항이 생겼다.

 

요구사항은 아래와 같았다.

 

AI 소환 요구사항

  • AI 는 랜덤한 NavMesh 위에 스폰되어야 한다.
  • 도넛 모양으로 플레이어의 일정 범위 내에는 AI 가 스폰되면 안 된다.

처음에는 맵의 여러 군데에 스포너를 배치해 가까운 스포너에서는 몬스터를 소환 못 하게 하는 방법을 사용하려고 했다. 혹은 다른 방법을 모색하려 했었다. 하지만 아래 이유로 내 방식대로 커스텀 하기로 했다.

 

  1. 플레이어 주변에만 몬스터가 소환되지 않으면 된다.
  2. 스포너를 사용할 경우 맵에 액터를 많이 배치해야 할 수 있다.

 

분석

 

언리얼 엔진 EQS Query 에는 이미 Generator 중에 Donut 이라는 것이 존재한다.

아래와 같이 사용할 수 있다.

블루프린트

 

사용 예시

 

 

그러나 팀 프로젝트에 있어서 이 도넛 모양의 소환은 어울리지 않았다. Spiral Pattern 을 사용 체크 한다면 조금 더 단조롭지 않게 만들 수 있지만, 원하는 것은 도넛 범위 내의 랜덤 스폰이었다.

 

따라서 커스터마이징 한 Generator 를 하나 만들어 볼까? 라는 생각이 들었다.

 

 

블루프린트로 만든 Query는 C++ 에서 레퍼런스를 받아와서 사용 해 봤었는데 커스터마이징 하는 것은 처음이었다.

무작정 시작해 보았다.

 

5.4.4 버젼 기준 UEnvQueryGenerator_Donut 의 코드는 아래와 같다.

 

엔진 소스 코드 (더보기 클릭)

더보기
// Copyright Epic Games, Inc. All Rights Reserved.

#pragma once

#include "CoreMinimal.h"
#include "UObject/ObjectMacros.h"
#include "Templates/SubclassOf.h"
#include "EnvironmentQuery/EnvQueryTypes.h"
#include "DataProviders/AIDataProvider.h"
#include "EnvironmentQuery/Generators/EnvQueryGenerator_ProjectedPoints.h"
#include "EnvQueryGenerator_Donut.generated.h"

UCLASS(meta = (DisplayName = "Points: Donut"), MinimalAPI)
class UEnvQueryGenerator_Donut : public UEnvQueryGenerator_ProjectedPoints
{
	GENERATED_UCLASS_BODY()

	/** min distance between point and context */
	UPROPERTY(EditDefaultsOnly, Category = Generator)
	FAIDataProviderFloatValue InnerRadius;

	/** max distance between point and context */
	UPROPERTY(EditDefaultsOnly, Category = Generator)
	FAIDataProviderFloatValue OuterRadius;

	/** number of rings to generate */
	UPROPERTY(EditDefaultsOnly, Category = Generator)
	FAIDataProviderIntValue NumberOfRings;

	/** number of items to generate for each ring */
	UPROPERTY(EditDefaultsOnly, Category = Generator)
	FAIDataProviderIntValue PointsPerRing;

	/** If you generate items on a piece of circle you define direction of Arc cut here */
	UPROPERTY(EditDefaultsOnly, Category = Generator, meta = (EditCondition = "bDefineArc"))
	FEnvDirection ArcDirection;

	/** If you generate items on a piece of circle you define angle of Arc cut here */
	UPROPERTY(EditDefaultsOnly, Category = Generator)
	FAIDataProviderFloatValue ArcAngle;

	/** If true, the rings of the wheel will be rotated in a spiral pattern.  If false, they will all be at a zero
	  * rotation, looking more like the spokes on a wheel.  */
	UPROPERTY(EditDefaultsOnly, Category = Generator)
	bool bUseSpiralPattern;

	/** context */
	UPROPERTY(EditAnywhere, Category = Generator)
	TSubclassOf<class UEnvQueryContext> Center;

	UPROPERTY(EditAnywhere, Category = Generator, meta=(InlineEditConditionToggle))
	uint32 bDefineArc : 1;

	AIMODULE_API virtual void GenerateItems(FEnvQueryInstance& QueryInstance) const override;

	AIMODULE_API virtual FText GetDescriptionTitle() const override;
	AIMODULE_API virtual FText GetDescriptionDetails() const override;

#if WITH_EDITOR
	AIMODULE_API virtual void PostEditChangeProperty(struct FPropertyChangedEvent& PropertyChangedEvent) override;
#endif // WITH_EDITOR

protected:
	AIMODULE_API FVector::FReal GetArcBisectorAngle(FEnvQueryInstance& QueryInstance) const;
	AIMODULE_API bool IsAngleAllowed(FVector::FReal TestAngleRad, FVector::FReal BisectAngleDeg, FVector::FReal AngleRangeDeg, bool bConstrainAngle) const;
};
// Copyright Epic Games, Inc. All Rights Reserved.

#include "EnvironmentQuery/Generators/EnvQueryGenerator_Donut.h"
#include "EnvironmentQuery/Contexts/EnvQueryContext_Querier.h"

#include UE_INLINE_GENERATED_CPP_BY_NAME(EnvQueryGenerator_Donut)

#define LOCTEXT_NAMESPACE "EnvQueryGenerator"

UEnvQueryGenerator_Donut::UEnvQueryGenerator_Donut(const FObjectInitializer& ObjectInitializer) : Super(ObjectInitializer)
{
	Center = UEnvQueryContext_Querier::StaticClass();
	InnerRadius.DefaultValue = 300.0f;
	OuterRadius.DefaultValue = 1000.0f;
	NumberOfRings.DefaultValue = 3;
	PointsPerRing.DefaultValue = 8;
	ArcDirection.DirMode = EEnvDirection::TwoPoints;
	ArcDirection.LineFrom = UEnvQueryContext_Querier::StaticClass();
	ArcDirection.Rotation = UEnvQueryContext_Querier::StaticClass();
	ArcAngle.DefaultValue = 360.f;
	bUseSpiralPattern = false;

	ProjectionData.TraceMode = EEnvQueryTrace::None;
}

FVector::FReal UEnvQueryGenerator_Donut::GetArcBisectorAngle(FEnvQueryInstance& QueryInstance) const
{
	FRotator::FReal BisectAngle = 0.;

	FVector Direction;
	if (bDefineArc)
	{
		if (ArcDirection.DirMode == EEnvDirection::TwoPoints)
		{
			TArray<FVector> Start;
			TArray<FVector> End;
			QueryInstance.PrepareContext(ArcDirection.LineFrom, Start);
			QueryInstance.PrepareContext(ArcDirection.LineTo, End);

			if (Start.Num() > 0 && End.Num() > 0)
			{
				const FVector LineDir = (End[0] - Start[0]).GetSafeNormal();
				const FRotator LineRot = LineDir.Rotation();
				BisectAngle = LineRot.Yaw;
			}
		}
		else
		{
			TArray<FRotator> Rot;
			QueryInstance.PrepareContext(ArcDirection.Rotation, Rot);

			if (Rot.Num() > 0)
			{
				BisectAngle = Rot[0].Yaw;
			}
		}
	}

	return BisectAngle;
}

bool UEnvQueryGenerator_Donut::IsAngleAllowed(FVector::FReal TestAngleRad, FVector::FReal BisectAngleDeg, FVector::FReal AngleRangeDeg, bool bConstrainAngle) const
{
	if (bConstrainAngle)
	{
		const FVector::FReal TestAngleDeg = FMath::RadiansToDegrees(TestAngleRad);
		const FVector::FReal AngleDelta = FRotator::NormalizeAxis(TestAngleDeg - BisectAngleDeg);
		return (FMath::Abs(AngleDelta) - 0.01) < AngleRangeDeg;
	}

	return true;	
}

void UEnvQueryGenerator_Donut::GenerateItems(FEnvQueryInstance& QueryInstance) const
{
	TArray<FVector> CenterPoints;
	QueryInstance.PrepareContext(Center, CenterPoints);

	if (CenterPoints.Num() <= 0)
	{
		return;
	}

	UObject* BindOwner = QueryInstance.Owner.Get();
	InnerRadius.BindData(BindOwner, QueryInstance.QueryID);
	OuterRadius.BindData(BindOwner, QueryInstance.QueryID);
	NumberOfRings.BindData(BindOwner, QueryInstance.QueryID);
	PointsPerRing.BindData(BindOwner, QueryInstance.QueryID);
	ArcAngle.BindData(BindOwner, QueryInstance.QueryID);

	FVector::FReal ArcAngleValue = ArcAngle.GetValue();
	FVector::FReal InnerRadiusValue = InnerRadius.GetValue();
	FVector::FReal OuterRadiusValue = OuterRadius.GetValue();
	int32 NumRings = NumberOfRings.GetValue();
	int32 NumPoints = PointsPerRing.GetValue();

	if ((InnerRadiusValue < 0.) || (OuterRadiusValue <= 0.) ||
		(InnerRadiusValue > OuterRadiusValue) ||
		(NumRings < 1) || (NumPoints < 1))
	{
		return;
	}

	const FVector::FReal ArcBisectDeg = GetArcBisectorAngle(QueryInstance);
	const FVector::FReal ArcAngleDeg = FMath::Clamp(ArcAngleValue, 0., 360.);

	const FVector::FReal RadiusDelta = (OuterRadiusValue - InnerRadiusValue) / (NumRings - 1);
	const FVector::FReal AngleDelta = 2. * UE_DOUBLE_PI / NumPoints;
	FVector::FReal SectionAngle = FMath::DegreesToRadians(ArcBisectDeg);

	TArray<FNavLocation> Points;
	Points.Reserve(NumPoints * NumRings);

	if (!bUseSpiralPattern)
	{
		for (int32 SectionIdx = 0; SectionIdx < NumPoints; SectionIdx++, SectionAngle += AngleDelta)
		{
			if (IsAngleAllowed(SectionAngle, ArcBisectDeg, ArcAngleDeg, bDefineArc))
			{
				const FVector::FReal SinValue = FMath::Sin(SectionAngle);
				const FVector::FReal CosValue = FMath::Cos(SectionAngle);

				FVector::FReal RingRadius = InnerRadiusValue;
				for (int32 RingIdx = 0; RingIdx < NumRings; RingIdx++, RingRadius += RadiusDelta)
				{
					const FVector RingPos(RingRadius * CosValue, RingRadius * SinValue, 0.);
					for (int32 ContextIdx = 0; ContextIdx < CenterPoints.Num(); ContextIdx++)
					{
						const FNavLocation PointPos = FNavLocation(CenterPoints[ContextIdx] + RingPos);
						Points.Add(PointPos);
					}
				}
			}
		}
	}
	else
	{	// In order to spiral, we need to change the angle for each ring as well as each spoke.  By changing it as a
		// fraction of the SectionAngle, we guarantee that the offset won't cross to the starting point of the next
		// spoke.
		const FVector::FReal RingAngleDelta = AngleDelta / NumRings;
		FVector::FReal RingRadius = InnerRadiusValue;
		FVector::FReal CurrentRingAngleDelta = 0.f;

		// So, start with ring angle and then add section angle.
		for (int32 RingIdx = 0; RingIdx < NumRings; RingIdx++, RingRadius += RadiusDelta, CurrentRingAngleDelta += RingAngleDelta)
		{
			for (int32 SectionIdx = 0; SectionIdx < NumPoints; SectionIdx++, SectionAngle += AngleDelta)
			{
				FVector::FReal RingSectionAngle = CurrentRingAngleDelta + SectionAngle;
				if (IsAngleAllowed(RingSectionAngle, ArcBisectDeg, ArcAngleDeg, bDefineArc))
				{
					const FVector::FReal SinValue = FMath::Sin(RingSectionAngle);
					const FVector::FReal CosValue = FMath::Cos(RingSectionAngle);

					const FVector RingPos(RingRadius * CosValue, RingRadius * SinValue, 0.);
					for (int32 ContextIdx = 0; ContextIdx < CenterPoints.Num(); ContextIdx++)
					{
						const FNavLocation PointPos = FNavLocation(CenterPoints[ContextIdx] + RingPos);
						Points.Add(PointPos);
					}
				}
			}
		}
	}

	ProjectAndFilterNavPoints(Points, QueryInstance);
	StoreNavPoints(Points, QueryInstance);
}

FText UEnvQueryGenerator_Donut::GetDescriptionTitle() const
{
	FFormatNamedArguments Args;
	Args.Add(TEXT("DescriptionTitle"), Super::GetDescriptionTitle());
	Args.Add(TEXT("DescribeContext"), UEnvQueryTypes::DescribeContext(Center));

	return FText::Format(LOCTEXT("DescriptionGenerateDonutAroundContext", "{DescriptionTitle}: generate items around {DescribeContext}"), Args);
}

FText UEnvQueryGenerator_Donut::GetDescriptionDetails() const
{
	FFormatNamedArguments Args;
	Args.Add(TEXT("InnerRadius"), FText::FromString(InnerRadius.ToString()));
	Args.Add(TEXT("OuterRadius"), FText::FromString(OuterRadius.ToString()));
	Args.Add(TEXT("NumRings"), FText::FromString(NumberOfRings.ToString()));
	Args.Add(TEXT("NumPerRing"), FText::FromString(PointsPerRing.ToString()));

	FText Desc = FText::Format(LOCTEXT("DonutDescription", "radius: {InnerRadius} to {OuterRadius}\nrings: {NumRings}, points per ring: {NumPerRing}"), Args);

	if (bDefineArc)
	{
		FFormatNamedArguments ArcArgs;
		ArcArgs.Add(TEXT("Description"), Desc);
		ArcArgs.Add(TEXT("AngleValue"), ArcAngle.DefaultValue);
		ArcArgs.Add(TEXT("ArcDirection"), ArcDirection.ToText());
		Desc = FText::Format(LOCTEXT("DescriptionWithArc", "{Description}\nLimit to {AngleValue} angle both sides on {ArcDirection}"), ArcArgs);
	}

	FText ProjDesc = ProjectionData.ToText(FEnvTraceData::Brief);
	if (!ProjDesc.IsEmpty())
	{
		FFormatNamedArguments ProjArgs;
		ProjArgs.Add(TEXT("Description"), Desc);
		ProjArgs.Add(TEXT("ProjectionDescription"), ProjDesc);
		Desc = FText::Format(LOCTEXT("OnCircle_DescriptionWithProjection", "{Description}, {ProjectionDescription}"), ProjArgs);
	}

	return Desc;
}

#if WITH_EDITOR

void UEnvQueryGenerator_Donut::PostEditChangeProperty(FPropertyChangedEvent& PropertyChangedEvent)
{
	if (PropertyChangedEvent.Property != NULL)
	{
		const FName PropName = PropertyChangedEvent.MemberProperty->GetFName();
		if (PropName == GET_MEMBER_NAME_CHECKED(UEnvQueryGenerator_Donut, ArcAngle))
		{
			ArcAngle.DefaultValue = FMath::Clamp(ArcAngle.DefaultValue, 0.0f, 360.f);
			bDefineArc = (ArcAngle.DefaultValue < 360.f) && (ArcAngle.DefaultValue > 0.f);
		}
		else if (PropName == GET_MEMBER_NAME_CHECKED(UEnvQueryGenerator_Donut, NumberOfRings))
		{
			NumberOfRings.DefaultValue = FMath::Max(1, NumberOfRings.DefaultValue);
		}
		else if (PropName == GET_MEMBER_NAME_CHECKED(UEnvQueryGenerator_Donut, PointsPerRing))
		{
			PointsPerRing.DefaultValue = FMath::Max(1, PointsPerRing.DefaultValue);
		}
	}

	Super::PostEditChangeProperty(PropertyChangedEvent);
}

#endif // WITH_EDITOR

#undef LOCTEXT_NAMESPACE

 

언리얼엔진 개발자들이 열심히 작성해놓은 아까의 Donut 엔진 코드이다.

많은 메소드 중에서 GenerateItems를 내 입맛대로 편집해볼 예정이다.

 

해당 클래스를 상속받는 UMJEnvQueryGenerator_MJDonut 을 생성했다. (MJ 는 프로젝트의 접두)

 

 

커스텀한 클래스의 헤더 파일

// Fill out your copyright notice in the Description page of Project Settings.

#pragma once

#include "CoreMinimal.h"
#include "EnvironmentQuery/Generators/EnvQueryGenerator_Donut.h"
#include "MJEnvQueryGenerator_MJDonut.generated.h"

/**
 * Class Description: EQS Query which generate random points in donut range
 * Author: Cha Tae Gwan
 * Created Date: 2025-07-03
 * Last Modified By: Cha Tae Gwan
 * Last Modified Date: 2025-07-03
 */
UCLASS()
class PROJECTMJ_API UMJEnvQueryGenerator_MJDonut : public UEnvQueryGenerator_Donut
{
    GENERATED_BODY()

public:
    UMJEnvQueryGenerator_MJDonut(const FObjectInitializer& ObjectInitializer);
    
protected:

    UPROPERTY(EditDefaultsOnly, Category = Generator)
    FAIDataProviderIntValue NumberOfRandomPoints;

    UPROPERTY(EditDefaultsOnly, Category = Debug)
    bool ShowDebugDonut = true;

    UPROPERTY(EditDefaultsOnly, Category = Debug)
    float ShowDebugLifeTime = 3.0f;
    
    virtual void GenerateItems(FEnvQueryInstance& QueryInstance) const override;
    
};

 

 

요구사항에 맞춰서 찍을 랜덤 포인트 개수를 정할 수 있게 하고, 디버그 출력을 도와줄 변수를 선언하고, GenerateItems 함수만 오버라이드 하였다. (나머지 로직은 그대로 사용할 것이기 때문에)

 

커스텀한 클래스의 구현부

// Fill out your copyright notice in the Description page of Project Settings.


#include "MJEnvQueryGenerator_MJDonut.h"

#include "EnvironmentQuery/EQSTestingPawn.h"
#include "EnvironmentQuery/Contexts/EnvQueryContext_Querier.h"
#include "EnvironmentQuery/Generators/EnvQueryGenerator_Donut.h"
#include "Kismet/GameplayStatics.h"


UMJEnvQueryGenerator_MJDonut::UMJEnvQueryGenerator_MJDonut(const FObjectInitializer& ObjectInitializer) : Super(ObjectInitializer)
{
	Center = UEnvQueryContext_Querier::StaticClass();
	InnerRadius.DefaultValue = 700.0f;
	OuterRadius.DefaultValue = 1300.0f;
	NumberOfRandomPoints.DefaultValue = 10;
	NumberOfRings.DefaultValue = 3;
	PointsPerRing.DefaultValue = 8;
	ArcDirection.DirMode = EEnvDirection::TwoPoints;
	ArcDirection.LineFrom = UEnvQueryContext_Querier::StaticClass();
	ArcDirection.Rotation = UEnvQueryContext_Querier::StaticClass();
	ArcAngle.DefaultValue = 360.f;
	bUseSpiralPattern = false;

	ProjectionData.TraceMode = EEnvQueryTrace::Navigation;
}

void UMJEnvQueryGenerator_MJDonut::GenerateItems(FEnvQueryInstance& QueryInstance) const
{
	// Didn`t Call Super cuz it`s customization of Engine API.
	//Super::GenerateItems(QueryInstance);

	TArray<FVector> CenterPoints;
	QueryInstance.PrepareContext(Center, CenterPoints);

	if (CenterPoints.Num() <= 0)
	{
		return;
	}

	UObject* BindOwner = QueryInstance.Owner.Get();
	InnerRadius.BindData(BindOwner, QueryInstance.QueryID);
	OuterRadius.BindData(BindOwner, QueryInstance.QueryID);

	FVector::FReal InnerRadiusValue = InnerRadius.GetValue();
	FVector::FReal OuterRadiusValue = OuterRadius.GetValue();
	int32 NumPoints = NumberOfRandomPoints.GetValue();

	if ((InnerRadiusValue < 0.) || (OuterRadiusValue <= 0.) ||
		(InnerRadiusValue > OuterRadiusValue))
	{
		return;
	}

	TArray<FNavLocation> Points;
	Points.Reserve(NumPoints);
	
	for (int32 PointIdx = 0 ; PointIdx < NumPoints ; ++PointIdx)
	{
		FVector Dir = FMath::VRand();
		Dir.Z = 0.f;
		Dir.Normalize();
		
		float Dist = FMath::FRandRange(InnerRadiusValue,OuterRadiusValue);
		FVector Location = CenterPoints[0] + Dir * Dist;
		
		const FNavLocation PointPos = FNavLocation(Location);
		Points.Add(PointPos);
	}

#if WITH_EDITOR
	// Not properly working now
	if (ShowDebugDonut) 
	{
		AActor* TestingPawn = UGameplayStatics::GetActorOfClass(GetWorld(),AEQSTestingPawn::StaticClass());

		if (TestingPawn)
		{
			if (TestingPawn->IsSelected())
			{
				DrawDebugCircle(GetWorld(), CenterPoints[0], InnerRadiusValue, 20, FColor::Red, false,ShowDebugLifeTime,0,5.0f,FVector(1,0,0), FVector(0,1,0), false);
				DrawDebugCircle(GetWorld(), CenterPoints[0], OuterRadiusValue, 20, FColor::Red, false,ShowDebugLifeTime,0,5.0f,FVector(1,0,0), FVector(0,1,0), false);
			}
			else
			{
				FlushPersistentDebugLines(GetWorld());
			}
		}
	}
#endif

	ProjectAndFilterNavPoints(Points, QueryInstance);
	StoreNavPoints(Points, QueryInstance);
}

 

 

 

우선 Super로 상위 클래스의 로직을 실행시키지 않았다. 그 이유는 원래 있던 Donut 프레임워크를 사용할 것이 아니면서, Beginplay 같이 여러 번 상위 클래스로 올라가 호출 할 필요가 없고, 찾아본 결과 상위 클래스의 로직이 단 한 개 밖에 없었기 때문이다.

 

찾아본 결과 Super 를 호출하지 않아도 되겠다고 판단했다.

 

 

핵심 로직

InnerRadius, OuterRadius, RandomPoints 변수를 받아서 랜덤한 방향을 선택해 정규화 한다. 평면 위에 찍을 예정이기에 Z값은 0으로 만들어준다.

그 후 InnerRadius ~ OuterRadius  사이의 랜덤한 값을 받아와 방향벡터와 곱해준 후 중심점과 더해준다.

 

약간의 수학?

 

그런 다음 엔진 코드와 똑같이 아래 함수들을 호출해 주었다. 네비게이션 메쉬에 점을 투영해 저장하는 로직이 담겨 있는 함수들이다. 

 

함수 이름만으로 기능을 예측할 수 있다.

 

 

결과

EQS 블루프린트에서 MJDonut 을 사용할 수 있다.

 

 

 

간편하게 아까 선언해 놓았던 InnerRadius, OuterRadius, Number of Random Points 를 에디터에서 설정할 수 있다!

 

실행 화면

 

빨간색 라인은 DebugCircle 이다.

 

 

총평

팀 프로젝트의 요구사항에 맞춰 엔진 코드를 가져와 커스텀 해보는 시간을 가졌다. 직접 구현 능력도 올라가면서, 엔진 API 에 대한 이해도도 많이 올라갔다고 느꼈다. 이 로직들은 전반적 AI 소환에 사용될 예정이다.