발단
팀 프로젝트 중, AI 스폰에 관련해서 세부 요구사항이 생겼다.
요구사항은 아래와 같았다.
AI 소환 요구사항
- AI 는 랜덤한 NavMesh 위에 스폰되어야 한다.
- 도넛 모양으로 플레이어의 일정 범위 내에는 AI 가 스폰되면 안 된다.
처음에는 맵의 여러 군데에 스포너를 배치해 가까운 스포너에서는 몬스터를 소환 못 하게 하는 방법을 사용하려고 했다. 혹은 다른 방법을 모색하려 했었다. 하지만 아래 이유로 내 방식대로 커스텀 하기로 했다.
- 플레이어 주변에만 몬스터가 소환되지 않으면 된다.
- 스포너를 사용할 경우 맵에 액터를 많이 배치해야 할 수 있다.
분석
언리얼 엔진 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 같이 여러 번 상위 클래스로 올라가 호출 할 필요가 없고, 찾아본 결과 상위 클래스의 로직이 단 한 개 밖에 없었기 때문이다.
핵심 로직
InnerRadius, OuterRadius, RandomPoints 변수를 받아서 랜덤한 방향을 선택해 정규화 한다. 평면 위에 찍을 예정이기에 Z값은 0으로 만들어준다.
그 후 InnerRadius ~ OuterRadius 사이의 랜덤한 값을 받아와 방향벡터와 곱해준 후 중심점과 더해준다.
그런 다음 엔진 코드와 똑같이 아래 함수들을 호출해 주었다. 네비게이션 메쉬에 점을 투영해 저장하는 로직이 담겨 있는 함수들이다.
결과
간편하게 아까 선언해 놓았던 InnerRadius, OuterRadius, Number of Random Points 를 에디터에서 설정할 수 있다!
실행 화면
총평
팀 프로젝트의 요구사항에 맞춰 엔진 코드를 가져와 커스텀 해보는 시간을 가졌다. 직접 구현 능력도 올라가면서, 엔진 API 에 대한 이해도도 많이 올라갔다고 느꼈다. 이 로직들은 전반적 AI 소환에 사용될 예정이다.
'언리얼엔진5 > 각종 지식' 카테고리의 다른 글
[UE5] 옵저버 + 중재자 패턴을 이용한 느슨한 결합의 구현 (0) | 2025.06.30 |
---|---|
[UE5 C++] AIPerception을 활용하여 AI 가 캐릭터를 감지하게 만들기 (1) | 2025.05.23 |
[UE5 C++] 언리얼엔진 Timeline C++ 에서 구현하기 (0) | 2024.06.19 |
[UE5] ProjectileMovementComponent (0) | 2024.06.12 |
[UE5] Quat(쿼터니언) / Rotator (로테이터) (0) | 2024.06.12 |