무한성이라고 불리던 스테이지를 일찍 탈출한 6명이서 사이드 프로젝트를 진행하기로 했다.
순전히 기능 공부를 위함이고 출시계획같은건 당연하게도 없다.
주제는 3D 뱀파이어 서바이벌이다.
개발 진행상황은 레포지토리 참조
MVP 스팩을 크게 6가지로 나눴다.
스킬 / 캐릭터 / 적 / 시스템(웨이브, 스포너) / 아이템 / UI
나는 그 중 시스템(웨이브, 스포너)를 담당하게 됐다.
이번 작업에서는 기존 Enemy 스폰 방식을 SpawnActor / Destroy 중심 구조에서 Object Pooling 기반 구조로 전환했다.
Vampire Survivors류 게임은 짧은 시간 안에 많은 적이 생성되고 사라진다. 이때 매번 Actor를 생성하고 파괴하면 성능 비용이 커질 수 있다.
그래서 Enemy가 죽었을 때 바로 Destroy()하지 않고, 비활성화한 뒤 풀에 반환하고, 다음 스폰 때 다시 꺼내 쓰는 구조를 추가했다.
기존 Enemy 흐름은 단순했다.
SpawnedEnemiesList에 등록Destroy() 호출CleanupInvalidEnemies()에서 !IsValid(Enemy) 기준으로 리스트 정리기존에는 Actor가 죽으면 Destroy되었기 때문에 IsValid()만으로도 리스트 정리가 가능했다.
하지만 풀링 구조에서는 Enemy가 죽어도 Actor가 파괴되지 않는다.
대신 다음과 같은 상태가 된다.
IsValid(Enemy) == true즉 기존처럼 !IsValid(Enemy)만 검사하면, 이미 죽어서 풀에 들어간 Enemy가 SpawnedEnemiesList에 계속 남는다.
나중에 리스트 기반으로 살아있는 적 수를 계산하거나 Wave 진행 조건을 판단하면, 죽은 Enemy도 살아있는 Enemy처럼 카운트될 수 있다.
이번 변경의 핵심은 이 문제를 해결하는 것이었다.
먼저 Actor Pool을 관리할 UPoolSubsystem을 추가했다.
PoolSubsystem은 UWorldSubsystem으로 구현했다. 그래서 GameMode나 Spawner에 컴포넌트처럼 붙이지 않는다.
필요한 곳에서 다음처럼 가져와 사용한다.
UPoolSubsystem* PoolSubsystem = GetWorld()->GetSubsystem<UPoolSubsystem>();
풀은 ActorClass 기준으로 관리한다.
USTRUCT()
struct FActorPool
{
GENERATED_BODY()
UPROPERTY()
TArray<TObjectPtr<AActor>> IdlePool;
};
UPROPERTY()
TMap<TSubclassOf<AActor>, FActorPool> IdlePools;
즉 BP_EnemyBase_C 풀, 다른 Enemy Blueprint 풀을 각각 따로 관리할 수 있다.
PrewarmPool()은 게임 시작 시 특정 ActorClass를 미리 생성해 IdlePool에 넣는 함수다.
void UPoolSubsystem::PrewarmPool(TSubclassOf<AActor> ActorClass, int32 Count)
{
if (!ActorClass || Count <= 0 || !GetWorld())
{
return;
}
FActorPool& Pool = IdlePools.FindOrAdd(ActorClass);
for (int32 i = 0; i < Count; ++i)
{
FActorSpawnParameters SpawnParams;
SpawnParams.SpawnCollisionHandlingOverride =
ESpawnActorCollisionHandlingMethod::AlwaysSpawn;
AActor* Actor = GetWorld()->SpawnActor<AActor>(
ActorClass,
FVector::ZeroVector,
FRotator::ZeroRotator,
SpawnParams
);
if (!Actor)
{
UE_LOG(LogTemp, Warning, TEXT("[Pool] Prewarm spawn failed: %s"), *ActorClass->GetName());
continue;
}
PrepareActorForPool(Actor);
Pool.IdlePool.Add(Actor);
}
UE_LOG(LogTemp, Warning, TEXT("[Pool] Prewarm %s / Available: %d"),
*ActorClass->GetName(),
Pool.IdlePool.Num());
}
여기서 중요한 부분은 AlwaysSpawn이다.
Prewarm 시점에는 Actor를 생성한 뒤 바로 숨기고 Collision을 끌 것이기 때문에, 스폰 위치 충돌 때문에 생성이 실패할 필요가 없다.
SpawnParams.SpawnCollisionHandlingOverride =
ESpawnActorCollisionHandlingMethod::AlwaysSpawn;
Enemy를 스폰할 때는 직접 SpawnActor하지 않고 PoolSubsystem에서 Actor를 꺼낸다.
AActor* UPoolSubsystem::GetActorFromPool(TSubclassOf<AActor> ActorClass, const FTransform& SpawnTransform)
{
if (!ActorClass || !GetWorld())
{
return nullptr;
}
FActorPool& Pool = IdlePools.FindOrAdd(ActorClass);
AActor* Actor = nullptr;
while (Pool.IdlePool.Num() > 0)
{
Actor = Pool.IdlePool.Pop();
if (IsValid(Actor))
{
break;
}
Actor = nullptr;
}
if (!Actor)
{
Actor = GetWorld()->SpawnActor<AActor>(ActorClass, SpawnTransform);
}
if (!Actor)
{
return nullptr;
}
PrepareActorForUse(Actor, SpawnTransform);
UE_LOG(LogTemp, Warning, TEXT("[Pool] Get %s / Available: %d / Active: %d"),
*ActorClass->GetName(),
Pool.IdlePool.Num(),
IdlePools.Num());
return Actor;
}
흐름은 다음과 같다.
즉 PrewarmCount는 최대 제한이 아니라 “미리 만들어둘 개수”다.
예를 들어 PrewarmCount = 300인데 동시에 301번째 Enemy가 필요하면, 301번째는 그 순간 새로 Spawn된다. 이후 죽으면 다시 Pool에 들어가므로 Pool 크기는 301개까지 늘어날 수 있다.
Enemy가 죽으면 Destroy하지 않고 Pool로 반환한다.
void UPoolSubsystem::ReturnActorToPool(AActor* Actor)
{
if (!IsValid(Actor))
{
return;
}
PrepareActorForPool(Actor);
IdlePools.FindOrAdd(Actor->GetClass()).IdlePool.Add(Actor);
}
Actor를 Pool에 넣기 전에는 비활성 상태로 만든다.
void UPoolSubsystem::PrepareActorForPool(AActor* Actor)
{
if (!IsValid(Actor))
{
return;
}
if (IPoolable* Poolable = Cast<IPoolable>(Actor))
{
Poolable->ReturnToPool();
}
Actor->SetActorHiddenInGame(true);
Actor->SetActorEnableCollision(false);
Actor->SetActorTickEnabled(false);
}
반대로 Pool에서 꺼낼 때는 다시 활성화한다.
void UPoolSubsystem::PrepareActorForUse(AActor* Actor, const FTransform& SpawnTransform)
{
if (!IsValid(Actor))
{
return;
}
Actor->SetActorTransform(SpawnTransform);
Actor->SetActorHiddenInGame(false);
Actor->SetActorEnableCollision(true);
Actor->SetActorTickEnabled(true);
if (IPoolable* Poolable = Cast<IPoolable>(Actor))
{
Poolable->GetFromPool();
}
}
PoolSubsystem은 모든 Actor의 내부 상태를 알 수 없다.
Enemy는 풀에서 나올 때 체력을 초기화해야 하고, 접촉 데미지 대상 목록도 비워야 한다. Projectile은 Projectile 나름의 초기화가 필요할 수 있다.
그래서 풀링 대상 Actor가 직접 초기화 로직을 구현할 수 있도록 IPoolable 인터페이스를 추가했다.
UINTERFACE(MinimalAPI)
class UPoolable : public UInterface
{
GENERATED_BODY()
};
class VAMPIRESURVIVAL_API IPoolable
{
GENERATED_BODY()
public:
virtual void GetFromPool()
PURE_VIRTUAL(IPoolable::GetFromPool,);
virtual void ReturnToPool()
PURE_VIRTUAL(IPoolable::ReturnToPool,);
};
이제 PoolSubsystem은 Actor가 IPoolable이면 다음 두 타이밍에 알려준다.
GetFromPool()ReturnToPool()풀링 구조에서 중요한 문제는 “Actor가 유효한가?”와 “게임상 살아있는가?”가 다르다는 점이다.
풀에 들어간 Enemy는 여전히 유효하다.
IsValid(Enemy) == true
하지만 게임상으로는 죽은 Enemy다.
그래서 별도의 상태값이 필요했다. 이를 위해 PoolableComponent를 추가했다.
UCLASS(ClassGroup=(Custom), meta=(BlueprintSpawnableComponent))
class VAMPIRESURVIVAL_API UPoolableComponent : public UActorComponent
{
GENERATED_BODY()
public:
UPoolableComponent();
void SetPoolStatus(bool Status);
bool GetPoolStatus() const;
protected:
bool isIdle = true;
};
bool UPoolableComponent::GetPoolStatus() const
{
return isIdle;
}
void UPoolableComponent::SetPoolStatus(bool Status)
{
isIdle = Status;
}
현재는 bool로 idle 상태만 관리한다.
true: Pool 안에 있음false: 현재 사용 중나중에는 이름을 IsIdle() / SetIdle()처럼 바꾸면 더 읽기 좋아질 수 있다.
EnemyBase는 이제 IPoolable을 구현한다.
class VAMPIRESURVIVAL_API AEnemyBase : public ACharacter, public IHitable, public IPoolable
그리고 PoolableComponent를 가진다.
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Enemy|Component")
TObjectPtr<UPoolableComponent> PoolableComponent;
생성자에서는 컴포넌트를 생성한다.
PoolableComponent = CreateDefaultSubobject<UPoolableComponent>(TEXT("PoolableComponent"));
기존에는 Enemy가 죽으면 바로 Destroy()했다.
풀링 구조에서는 먼저 PoolSubsystem에 반환을 시도한다.
void AEnemyBase::Death()
{
if (UWorld* World = GetWorld())
{
if (UPoolSubsystem* PoolSubsystem = World->GetSubsystem<UPoolSubsystem>())
{
PoolSubsystem->ReturnActorToPool(this);
return;
}
}
Destroy();
}
PoolSubsystem을 가져오지 못한 경우에는 fallback으로 Destroy()를 호출한다.
Enemy가 Pool에서 다시 나올 때는 전투 상태를 초기화해야 한다.
void AEnemyBase::GetFromPool()
{
ContactDamageTargets.Empty();
if (PoolableComponent)
{
PoolableComponent->SetPoolStatus(false);
}
if (HitableComponent)
{
HitableComponent->Initialize(MaxHP);
}
TargetActor = UGameplayStatics::GetPlayerCharacter(GetWorld(), 0);
}
풀에서 나올 때 하는 일은 다음과 같다.
반대로 Pool로 돌아갈 때는 임시 상태를 비운다.
void AEnemyBase::ReturnToPool()
{
ContactDamageTargets.Empty();
TargetActor = nullptr;
if (PoolableComponent)
{
PoolableComponent->SetPoolStatus(true);
UE_LOG(LogTemp, Warning, TEXT("Enemy Returned To Pool: %s"), *GetName());
}
}
여기서 PoolableComponent의 상태를 idle로 바꾸는 것이 중요하다.
이 값은 나중에 SpawnedEnemiesList 정리에 사용된다.
기존 EnemySpawner는 Enemy를 직접 Spawn했다.
AEnemyBase* SpawnedEnemy = GetWorld()->SpawnActor<AEnemyBase>(...);
이제는 PoolSubsystem에서 꺼내온다.
UPoolSubsystem* PoolSubsystem = GetWorld()->GetSubsystem<UPoolSubsystem>();
if (!PoolSubsystem)
{
return;
}
AActor* PooledActor = PoolSubsystem->GetActorFromPool(
EnemyData->EnemyClass,
FTransform(FRotator::ZeroRotator, SpawnLocation)
);
AEnemyBase* SpawnedEnemy = Cast<AEnemyBase>(PooledActor);
여기서 중요한 점은 EnemyData->EnemyClass를 기준으로 Pool에서 꺼낸다는 것이다.
현재 프로젝트는 Bat, Slime 같은 Enemy 차이를 별도 ActorClass로 나누기보다 EnemyDataAsset으로 주입하는 구조다. 따라서 Pool에서 꺼낸 뒤에는 현재 EnemyData를 다시 적용한다.
if (SpawnedEnemy)
{
SpawnedEnemy->InitializeFromData(EnemyData);
SpawnedEnemiesList.AddUnique(SpawnedEnemy);
UE_LOG(LogTemp, Warning, TEXT("SpawnedEnemiesList Count After Spawn: %d"), SpawnedEnemiesList.Num());
}
Add() 대신 AddUnique()를 사용한 이유는 풀링 구조에서는 같은 Actor가 여러 번 재사용되기 때문이다.
만약 정리 타이밍이 어긋나면 같은 Enemy Actor가 리스트에 중복 등록될 수 있다. AddUnique()는 이 문제를 막는 안전장치다.
기존 정리 방식은 단순했다.
SpawnedEnemiesList.RemoveAll([](const TObjectPtr<AActor>& Enemy)
{
return !IsValid(Enemy);
});
하지만 풀링 구조에서는 이 조건만으로 부족하다. 풀로 돌아간 Enemy는 여전히 유효하기 때문이다.
그래서 PoolableComponent의 idle 상태도 함께 확인한다.
void AEnemySpawner::CleanupInvalidEnemies()
{
const int32 BeforeCount = SpawnedEnemiesList.Num();
SpawnedEnemiesList.RemoveAll([](const TObjectPtr<AActor>& Enemy)
{
if (!IsValid(Enemy))
{
return true;
}
const UPoolableComponent* PoolableComponent = Enemy->FindComponentByClass<UPoolableComponent>();
if (PoolableComponent && PoolableComponent->GetPoolStatus())
{
return true;
}
return false;
});
const int32 AfterCount = SpawnedEnemiesList.Num();
UE_LOG(LogTemp, Warning, TEXT("Cleanup SpawnedEnemiesList: %d -> %d"), BeforeCount, AfterCount);
}
이제 제거 조건은 두 가지다.
이 변경으로 SpawnedEnemiesList는 “현재 활성화된 Enemy 목록”에 가까운 의미를 갖게 된다.
처음에는 Wave가 바뀔 때마다 WaveData를 읽어서 필요한 EnemyClass들을 Prewarm하는 방식도 고려했다.
하지만 현재 프로젝트 구조에서는 대부분의 Enemy가 같은 BP_EnemyBase_C를 사용한다.
즉 Bat, Slime, Skeleton이 서로 다른 ActorClass가 아니라 같은 EnemyBase Blueprint를 공유하고, 실제 차이는 EnemyDataAsset으로 적용된다.
그래서 Wave마다 Prewarm할 필요가 없다.
현재 방향은 EnemySpawner에 기본 Prewarm 설정을 두고, BeginPlay()에서 한 번만 Pool을 생성하는 것이다.
UPROPERTY(EditAnywhere, Category = "Spawner|Pool")
TSubclassOf<AEnemyBase> PrewarmEnemyClass;
UPROPERTY(EditAnywhere, Category = "Spawner|Pool")
int32 PrewarmCount = 300;
void AEnemySpawner::BeginPlay()
{
Super::BeginPlay();
CachedPlayerCharacter = Cast<APlayerCharacter>(UGameplayStatics::GetPlayerCharacter(this, 0));
if (!CachedPlayerCharacter)
{
UE_LOG(LogTemp, Error, TEXT("Player character not found"));
}
if (!PrewarmEnemyClass || PrewarmCount <= 0)
{
return;
}
UPoolSubsystem* PoolSubsystem = GetWorld()->GetSubsystem<UPoolSubsystem>();
if (!PoolSubsystem)
{
return;
}
PoolSubsystem->PrewarmPool(PrewarmEnemyClass, PrewarmCount);
}
주의할 점은 PrewarmEnemyClass와 EnemyData->EnemyClass가 반드시 같아야 한다는 것이다.
예를 들어 Prewarm은 C++ AEnemyBase로 하고, 실제 스폰은 BP_EnemyBase_C로 하면 서로 다른 풀로 취급된다.
따라서 에디터에서 EnemySpawner의 PrewarmEnemyClass에는 실제 EnemyDataAsset들이 사용하는 BP_EnemyBase_C를 넣어야 한다.
테스트 중에는 다음 흐름을 로그로 확인했다.
[Pool] Prewarm BP_EnemyBase_C / Available: 300
[Pool] Get BP_EnemyBase_C / Available: 299
SpawnedEnemiesList Count After Spawn: 1
[Pool] Get BP_EnemyBase_C / Available: 298
SpawnedEnemiesList Count After Spawn: 2
Available이 줄어든다는 것은 새로 Spawn하는 것이 아니라, 미리 생성해둔 Pool에서 꺼내고 있다는 뜻이다.
Enemy가 죽으면 다음 흐름을 기대한다.
Enemy Returned To Pool: BP_EnemyBase_C_12
Cleanup SpawnedEnemiesList: 10 -> 9
이렇게 나오면 Enemy가 Destroy되지 않고 Pool로 돌아가며, SpawnedEnemiesList에서도 제거되고 있다는 뜻이다.
현재 GetActorFromPool() 로그의 Active 값은 실제 Active Enemy 수가 아니다.
IdlePools.Num()
은 활성 Actor 수가 아니라 Pool에 등록된 ActorClass 종류 개수에 가깝다.
따라서 정확한 사용량을 보려면 나중에 다음과 같은 값이 필요하다.
TMap<TSubclassOf<AActor>, int32> TotalCreatedCounts;
그 후 다음처럼 계산할 수 있다.
Active = TotalCreated - Available;
또한 현재 PrewarmCount는 최대 제한이 아니다.
PrewarmCount = 300이어도 301번째 Enemy가 필요하면 새로 Spawn된다. 그리고 그 Enemy가 죽으면 Pool에 들어가므로 Pool 크기는 301까지 늘어날 수 있다.
즉 현재 구조는 고정 크기 풀이 아니라 동적 확장 풀이다.
이번 변경으로 Enemy 스폰 구조는 단순 생성/파괴 방식에서 재사용 가능한 Pooling 구조로 바뀌었다.
핵심은 단순히 Destroy()를 없애는 것이 아니었다.
풀링 구조에서는 Actor가 살아 있어도 게임상으로는 죽은 Enemy일 수 있다. 따라서 게임 로직이 이 상태를 이해할 수 있어야 한다.
이를 위해 다음 요소를 추가했다.
UPoolSubsystemIPoolableUPoolableComponentEnemyBase의 Pool 반환 처리EnemySpawner의 Pool 기반 스폰 처리SpawnedEnemiesList의 idle Enemy 정리결과적으로 Enemy는 죽어도 Actor가 파괴되지 않고 Pool로 돌아가며, 다음 스폰 때 재사용된다.
또한 PoolableComponent의 idle 상태를 기준으로 SpawnedEnemiesList를 정리해, 죽은 Enemy가 살아있는 Enemy처럼 카운트되는 문제도 막을 수 있게 됐다.
아직 개선할 부분도 남아 있다.
Active 수 계산 개선PrewarmPool()을 부족분만 생성하는 방식으로 변경SpawnedEnemiesList 타입을 AActor에서 AEnemyBase 중심으로 변경ReturnToPool 시 Spawner에 이벤트로 알리는 방식 검토현재 단계에서는 Enemy Pooling의 핵심 흐름이 잡혔다.
다음 단계에서는 디버그용 코드를 정리하고, Pool 사용량을 더 정확하게 추적하는 방향으로 개선하면 좋을 것 같다.