게임에서 액터를 동적으로 생성하고 파괴하는 작업은 생각보다 비용이 크다.
새 오브젝트를 만들 때는 메모리를 할당하고 초기화하는 과정을 거치고, 파괴할 때는 시스템이 사용하던 메모리 공간을 정리해야 한다.
총알처럼 동일한 형태의 오브젝트가 짧은 시간에 대량으로 생성·파괴되는 상황이라면 이 과정이 계속 반복되면서 CPU에 부하가 쌓이고, 순간적인 프레임 드랍으로 이어질 수 있다.
오브젝트 풀링(Object Pooling) 은 이런 비용을 줄이기 위한 패턴이다.
즉, Spawn/Destroy를 반복하는 대신 만들어 둔 오브젝트를 계속 재사용하는 방식이다.
풀을 관리하는 주체는 여러 형태로 구현할 수 있다.
GetWorldSubsystem으로 접근 가능이번에는 World Subsystem 방식으로 구현했다.
매니저 액터를 레벨마다 배치할 필요가 없고, 엔진이 월드 생성 시 자동으로 인스턴스를 만들어 주기 때문에 어떤 블루프린트에서든 Get World Subsystem 노드 하나로 풀에 접근할 수 있다.
세 개의 클래스가 역할을 나눠서 협력한다.
// PooledObjectData.h
USTRUCT(BlueprintType)
struct FPooledObjectData
{
GENERATED_BODY()
FPooledObjectData()
{
ActorTemplate = nullptr;
PoolSize = 1;
bCanGrow = false;
ActorName = "default";
}
//어떤 액터 스폰?
UPROPERTY(EditAnywhere)
TSubclassOf<AActor> ActorTemplate;
//몇 개 스폰?
UPROPERTY(EditAnywhere)
int PoolSize;
//없으면 추가로 생성할 예정인지?
UPROPERTY(EditAnywhere)
bool bCanGrow;
//액터 이름
UPROPERTY(EditAnywhere)
FString ActorName;
};
풀링되는 액터에 붙는 컴포넌트다.
이 컴포넌트가 실제로 사용 중인지 체크해 주고, 이 컴포넌트를 통해서 반납 요청이 가능하다.
// PooledObject.h
UCLASS( ClassGroup=(Custom), meta=(BlueprintSpawnableComponent) )
class CONTAINERTEST_API UPooledObject : public UActorComponent
{
GENERATED_BODY()
public:
void Init(class UObjectPoolSubsystem* Owner);
// 다시 풀로 넣어서 재활용
UFUNCTION(BlueprintCallable)
void RecycleSelf();
// 풀이 사용 중인가?
UPROPERTY(VisibleInstanceOnly, BlueprintReadOnly)
bool bIsPoolActive;
private:
TObjectPtr<class UObjectPoolSubsystem> ObjectPool;
virtual void OnComponentDestroyed(bool bDestroyingHierarchy) override;
};
// PooledObject.cpp
void UPooledObject::Init(UObjectPoolSubsystem* Owner)
{
bIsPoolActive = false;
ObjectPool = Owner;
}
void UPooledObject::RecycleSelf()
{
ObjectPool->RecyclePooledObject(this);
}
void UPooledObject::OnComponentDestroyed(bool bDestroyingHierarchy)
{
ObjectPool->OnPoolerCleanup.RemoveDynamic(this, &UPooledObject::RecycleSelf);
Super::OnComponentDestroyed(bDestroyingHierarchy);
}
UWorldSubsystem을 상속받기 때문에 레벨에 배치할 필요가 없다.
대신 액터 버전과 달리 에디터 디테일 패널이 없으므로, 풀 구성은 InitializePools()를 통해 외부에서 전달받는다.
// ObjectPoolSubsystem.h
DECLARE_DYNAMIC_MULTICAST_DELEGATE(FPoolerCleanupSignature);
USTRUCT(BlueprintType)
struct FSingleObjectPool
{
GENERATED_BODY()
UPROPERTY(VisibleInstanceOnly, BlueprintReadOnly)
TArray<TObjectPtr<class UPooledObject>> PooledObjects;
};
UCLASS()
class CONTAINERTEST_API UObjectPoolSubsystem : public UWorldSubsystem
{
GENERATED_BODY()
public:
UPROPERTY()
FPoolerCleanupSignature OnPoolerCleanup;
// 레벨 블루프린트 등 외부에서 풀 레시피를 넘겨 초기화
UFUNCTION(BlueprintCallable)
void InitializePools(const TArray<FPooledObjectData>& InPoolData);
UFUNCTION(BlueprintCallable)
void Broadcast_PoolerCleanup();
UFUNCTION(BlueprintCallable)
AActor* GetPooledActor(FString Name);
UFUNCTION(BlueprintCallable)
void RecyclePooledObject(class UPooledObject* PoolCompRef);
UFUNCTION(BlueprintCallable)
void RecycleActor(AActor* PooledActor);
virtual void Deinitialize() override;
protected:
UPROPERTY(VisibleAnywhere, BlueprintReadOnly)
TArray<FPooledObjectData> PooledObjectData;
UPROPERTY(VisibleAnywhere, BlueprintReadOnly)
TArray<FSingleObjectPool> Pools;
private:
void SpawnSingleActor(int32 PoolIndex, FSingleObjectPool& PoolEntry);
void RegenItem(int32 PoolIndex, int32 PositionIndex);
};
// ObjectPoolSubsystem.cpp
void UObjectPoolSubsystem::InitializePools(const TArray<FPooledObjectData>& InPoolData)
{
PooledObjectData = InPoolData;
Pools.Reset();
for (int32 PoolIndex = 0; PoolIndex < PooledObjectData.Num(); PoolIndex++)
{
FSingleObjectPool NewPool;
for (int32 i = 0; i < PooledObjectData[PoolIndex].PoolSize; i++)
{
SpawnSingleActor(PoolIndex, NewPool);
}
Pools.Add(NewPool);
}
}
void UObjectPoolSubsystem::SpawnSingleActor(int32 PoolIndex, FSingleObjectPool& PoolEntry)
{
FActorSpawnParameters SpawnParams;
SpawnParams.SpawnCollisionHandlingOverride = ESpawnActorCollisionHandlingMethod::AlwaysSpawn;
AActor* SpawnedActor = GetWorld()->SpawnActor(
PooledObjectData[PoolIndex].ActorTemplate,
&FVector::ZeroVector, &FRotator::ZeroRotator,
SpawnParams
);
if (!SpawnedActor) { return; }
UPooledObject* PoolComp = NewObject<UPooledObject>(SpawnedActor);
PoolComp->RegisterComponent();
SpawnedActor->AddInstanceComponent(PoolComp);
PoolComp->Init(this);
PoolEntry.PooledObjects.Add(PoolComp);
//보관 상태로 전환 : 숨기고, 충돌 끄고, 틱 끄고, 물리 끄기
SpawnedActor->SetActorHiddenInGame(true);
SpawnedActor->SetActorEnableCollision(false);
SpawnedActor->SetActorTickEnabled(false);
if (UPrimitiveComponent* PrimComp = Cast<UPrimitiveComponent>(SpawnedActor->GetRootComponent()))
{
PrimComp->SetSimulatePhysics(false);
}
}
void UObjectPoolSubsystem::Broadcast_PoolerCleanup()
{
OnPoolerCleanup.Broadcast();
}
AActor* UObjectPoolSubsystem::GetPooledActor(FString Name)
{
int32 CurrentPoolIndex = -1;
//요청한 이름이 존재하는지 확인
for (int32 i = 0; i < Pools.Num(); i++)
{
if (PooledObjectData[i].ActorName == Name)
{
CurrentPoolIndex = i;
break;
}
}
//존재하지않다면 나가기
if (CurrentPoolIndex == -1) { return nullptr; }
int32 FirstAvailable = -1;
int32 PooledObjectCount = Pools[CurrentPoolIndex].PooledObjects.Num();
//Active중이지 않은 첫번째 애를 찾기
for (int32 i = 0; i < PooledObjectCount; i++)
{
if (Pools[CurrentPoolIndex].PooledObjects[i] != nullptr)
{
if (!Pools[CurrentPoolIndex].PooledObjects[i]->bIsPoolActive)
{
FirstAvailable = i;
break;
}
}
else //비어있다면 황급히 그 인덱스에 만들기
{
RegenItem(CurrentPoolIndex, i);
FirstAvailable = i;
break;
}
}
if (FirstAvailable >= 0)
{
UPooledObject* ToReturn = Pools[CurrentPoolIndex].PooledObjects[FirstAvailable];
ToReturn->bIsPoolActive = true;
//회수 예약
OnPoolerCleanup.AddUniqueDynamic(ToReturn, &UPooledObject::RecycleSelf);
//보관하며 꺼두었던 상태를 전부 복원
AActor* ToReturnActor = ToReturn->GetOwner();
ToReturnActor->SetActorHiddenInGame(false);
ToReturnActor->SetActorEnableCollision(true);
ToReturnActor->SetActorTickEnabled(true);
if (UPrimitiveComponent* PrimComp = Cast<UPrimitiveComponent>(ToReturnActor->GetRootComponent()))
{
PrimComp->SetSimulatePhysics(true);
}
return ToReturnActor;
}
//CanGrow가 켜져있다면 새로 만들어서 주는 로직 수행
if (!PooledObjectData[CurrentPoolIndex].bCanGrow) { return nullptr; }
SpawnSingleActor(CurrentPoolIndex, Pools[CurrentPoolIndex]);
UPooledObject* NewComp = Pools[CurrentPoolIndex].PooledObjects.Last();
if (!NewComp) { return nullptr; }
NewComp->bIsPoolActive = true;
OnPoolerCleanup.AddUniqueDynamic(NewComp, &UPooledObject::RecycleSelf);
AActor* NewActor = NewComp->GetOwner();
NewActor->SetActorHiddenInGame(false);
NewActor->SetActorEnableCollision(true);
NewActor->SetActorTickEnabled(true);
if (UPrimitiveComponent* PrimComp = Cast<UPrimitiveComponent>(NewActor->GetRootComponent()))
{
PrimComp->SetSimulatePhysics(true);
}
return NewActor;
}
//최종적으로 정리해주는 로직
void UObjectPoolSubsystem::RecyclePooledObject(UPooledObject* PoolCompRef)
{
OnPoolerCleanup.RemoveDynamic(PoolCompRef, &UPooledObject::RecycleSelf);
PoolCompRef->bIsPoolActive = false;
AActor* ReturningActor = PoolCompRef->GetOwner();
ReturningActor->SetActorHiddenInGame(true);
ReturningActor->SetActorEnableCollision(false);
ReturningActor->SetActorTickEnabled(false);
if (UPrimitiveComponent* PrimComp = Cast<UPrimitiveComponent>(ReturningActor->GetRootComponent()))
{
PrimComp->SetSimulatePhysics(false);
}
}
//블루프린트나 외부에서 호출해주면 반납해줌
void UObjectPoolSubsystem::RecycleActor(AActor* PooledActor)
{
if (UPooledObject* PoolCompRef = Cast<UPooledObject>(PooledActor->GetComponentByClass(UPooledObject::StaticClass())))
{
RecyclePooledObject(PoolCompRef);
}
}
//아이템 만들어주기
void UObjectPoolSubsystem::RegenItem(int32 PoolIndex, int32 PositionIndex)
{
FSingleObjectPool TempPool;
SpawnSingleActor(PoolIndex, TempPool);
if (TempPool.PooledObjects.Num() > 0)
{
Pools[PoolIndex].PooledObjects.Insert(TempPool.PooledObjects[0], PositionIndex);
}
}
void UObjectPoolSubsystem::Deinitialize()
{
OnPoolerCleanup.Clear();
Pools.Reset();
PooledObjectData.Reset();
Super::Deinitialize();
}
매니저 액터 방식에서는 보관할 때 풀 액터에 AttachToActor로 매달아 아웃라이너에서 한눈에 볼 수 있었지만, Subsystem은 액터가 아니므로 Attach 없이 Hidden + Collision/Tick/Physics Off 상태로 월드에 그대로 보관한다.
테스트를 위해 물리 시뮬레이션이 켜진 BP_Actor를 하나 만들고, 레벨 블루프린트에서 다음과 같이 구성했다.

Get World Subsystem(ObjectPoolSubsystem) → Initialize Pools에 레시피 배열을 전달해 한 번만 초기화Get Pooled Actor("default") → Return Value를 Set Actor Location의 Target에 연결해 TargetPoint 위치로 이동실행해 보면 풀에서 꺼내진 액터가 1초마다 TargetPoint 위치에 나타나는 것을 확인할 수 있다.
