Replicated

[FPSScoring] ** Object Pooling in Unreal Engine ** 본문

언리얼 엔진/Mini Projects [Unreal]

[FPSScoring] ** Object Pooling in Unreal Engine **

라구넹 2025. 2. 27. 13:14

언리얼 엔진 하기 전에는 언리얼 엔진은 C++이니까 오브젝트 풀링같은 거 안해도 되겠지? 했다

그런데 그래봐야 Spawn할 때 힙에서 동적할당 받아오고 하는 등의 과정에서 오버헤드가 크고,

언리얼 C++은 가비지 컬렉터가 작동하기 때문에 오브젝트 풀링 해줘야 한다

 

일단 만드는 게임이 스폰되는 양이 너무 많아서 해줘야 한다

적이 스폰될 때마다 너무 끊긴다

 

일단 싱글턴으로 만들어야 하니 GameInstanceSubsystem 기반으로 만들어줬고,

TMap 써서 Initialize만 해주면 어떤 액터 파생 오브젝트이든 풀링되도록 해뒀다.

* TMap 안에 TArray 안들어가서 UStruct 써서 래퍼 구조체 만들고 넣었다

 

1. 오브젝트 풀링 구현

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

#pragma once

#include "CoreMinimal.h"
#include "Subsystems/GameInstanceSubsystem.h"
#include "FSObjectPoolSubsystem.generated.h"

USTRUCT()
struct FActorArrayWrapper
{
	GENERATED_BODY()

    UPROPERTY()
    TArray< TObjectPtr<AActor> > ActorArray;
};

/**
 * 
 */
UCLASS()
class FPSSCORING_API UFSObjectPoolSubsystem : public UGameInstanceSubsystem
{
	GENERATED_BODY()
	
public:
	UFSObjectPoolSubsystem();

	virtual void Initialize(FSubsystemCollectionBase& Collection) override;
	virtual void Deinitialize() override;

    UFUNCTION(BlueprintCallable, Category = "Object Pool")
    void InitializePool(TSubclassOf<AActor> ActorClass, int32 PoolSize);

    UFUNCTION(BlueprintCallable, Category = "Object Pool")
    AActor* GetPooledObject(TSubclassOf<AActor> ActorClass, const FVector& SpawnLocation, const FRotator& SpawnRotation);

    UFUNCTION(BlueprintCallable, Category = "Object Pool")
    void ReturnPooledObject(AActor* Actor);

protected:
    void ActiveAIBehaviorTree(AActor* Actor, bool IsActive);

    UPROPERTY()
    TMap<TSubclassOf<AActor>, FActorArrayWrapper > PooledActorsMap;
};

헤더 파일이다

InitializePool -> 풀 초기화, 디폴트 개수 지원

GetPooledObject -> 풀에서 오브젝트를 꺼내오는데 다 썼으면 새로 스폰해줌, 위치랑 회전 지정 가능

ReturnPooledObject -> 풀에 오브젝트 집어넣음

 

ActiveAIBehaviorTree -> 

언리얼 엔진은 기본적으로 오브젝트 자체를 UnActive하는게 불가능함

그래서 보통 틱 끄고 콜리전 끄고 렌더링 안되게 해서 UnActive 유사 효과를 구현함

근데 문제는 Behavior Tree를 쓰는 AI의 경우, 트리를 꺼주는 작업이 추가적으로 필요함

안 그러면 풀링 용으로 만들어놓은 AI들이 다 돌아가서 성능 저하가 큼

 

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


#include "Subsystem/FSObjectPoolSubsystem.h"
#include "Player/FSAIController.h"

UFSObjectPoolSubsystem::UFSObjectPoolSubsystem() : Super()
{
}

void UFSObjectPoolSubsystem::Initialize(FSubsystemCollectionBase& Collection)
{
    Super::Initialize(Collection);
}

void UFSObjectPoolSubsystem::Deinitialize()
{
    Super::Deinitialize();
}

void UFSObjectPoolSubsystem::InitializePool(TSubclassOf<AActor> ActorClass, int32 PoolSize)
{
    if (!ActorClass)
    {
        UE_LOG(LogTemp, Warning, TEXT("Invalid ActorClass"));
        return;
    }

    if (PooledActorsMap.Contains(ActorClass))
    {
        UE_LOG(LogTemp, Warning, TEXT("ActorClass is already Initalized"));
        return;
    }

    UWorld* World = GetWorld();
    if (!World)
    {
        UE_LOG(LogTemp, Warning, TEXT("Invalid World"));
        return;
    }

    TArray<AActor*> ActorPool;

    for (int32 i = 0; i < PoolSize; i++)
    {
        AActor* NewActor = World->SpawnActor<AActor>(ActorClass);
        if (NewActor)
        {
            NewActor->SetActorHiddenInGame(true);
            NewActor->SetActorEnableCollision(false);
            NewActor->SetActorTickEnabled(false);

            // AI
            ActiveAIBehaviorTree(NewActor, false);

            ActorPool.Emplace(NewActor);
        }
    }

    FActorArrayWrapper Wrapper;
    Wrapper.ActorArray = ActorPool;

    PooledActorsMap.Emplace(ActorClass, Wrapper);
}

AActor* UFSObjectPoolSubsystem::GetPooledObject(TSubclassOf<AActor> ActorClass, const FVector& SpawnLocation, const FRotator& SpawnRotation)
{
    if (!PooledActorsMap.Contains(ActorClass))
    {
        UE_LOG(LogTemp, Warning, TEXT("No Actor Pool : %s"), *ActorClass->GetName());
        return nullptr;
    }

    FActorArrayWrapper& ActorPool = PooledActorsMap[ActorClass];

    for (AActor* Actor : ActorPool.ActorArray)
    {
        if (Actor && Actor->IsHidden())
        {
            Actor->SetActorHiddenInGame(false);
            Actor->SetActorEnableCollision(true);
            Actor->SetActorTickEnabled(true);
            ActiveAIBehaviorTree(Actor, true);
            Actor->SetActorLocationAndRotation(SpawnLocation, SpawnRotation);


            //UE_LOG(LogTemp, Log, TEXT("Get Actor : %s"), *ActorClass->GetName());

            return Actor;
        }
    }

    // No Actor in Pool -> Spawn
    UWorld* World = GetWorld();
    if (World)
    {
        FActorSpawnParameters SpawnParams;
        SpawnParams.SpawnCollisionHandlingOverride = ESpawnActorCollisionHandlingMethod::AlwaysSpawn;
        //UE_LOG(LogTemp, Log, TEXT("Spawn Actor : %s"), *ActorClass->GetName());
        return World->SpawnActor<AActor>(ActorClass, SpawnLocation, SpawnRotation, SpawnParams);
    }

	return nullptr;
}

void UFSObjectPoolSubsystem::ReturnPooledObject(AActor* Actor)
{
    if (!Actor)
    {
        UE_LOG(LogTemp, Warning, TEXT("No Actor"));
        return;
    }

    TSubclassOf<AActor> ActorClass = Actor->GetClass();

    if (!PooledActorsMap.Contains(ActorClass))
    {
        UE_LOG(LogTemp, Warning, TEXT("No Pool: %s"), *ActorClass->GetName());
        return;
    }

    Actor->SetActorHiddenInGame(true);
    Actor->SetActorEnableCollision(false);
    Actor->SetActorTickEnabled(false);

    // AI
    ActiveAIBehaviorTree(Actor, false);
    
    FActorArrayWrapper& ActorPool = PooledActorsMap[ActorClass];

    if (!ActorPool.ActorArray.Contains(Actor))
    {
        //UE_LOG(LogTemp, Log, TEXT("Pool Actor : %s"), *ActorClass->GetName());
        ActorPool.ActorArray.Emplace(Actor);
    }
}

void UFSObjectPoolSubsystem::ActiveAIBehaviorTree(AActor* Actor, bool IsActive)
{
    if (APawn* Pawn = Cast<APawn>(Actor))
    {
        if (Pawn->GetController() == nullptr) return;

        if (AFSAIController* AIController = Cast<AFSAIController>(Pawn->GetController()))
        {
            if ( IsActive )
            {
                AIController->OnBehaviorTree();
            }
            else
            {
                AIController->OffBehaviorTree();
            }
        }
    }
}

구현 파일

Active랑 AI 처리 말고는 유니티랑 별반 차이는 없음

 

void AFSAIController::OnBehaviorTree()
{
	RunBehaviorTree(BTAsset);
}

void AFSAIController::OffBehaviorTree()
{
	BrainComponent->StopLogic(TEXT("Off"));
}

AI 내부 코드

트리 작동은 RunBehaviorTree, 중지는 BrainComponent에서 StopLogic 필요 (안에 TEXT는 그냥 아무거나)

 

2. 초기화

void AFSGameMode::BeginPlay()
{
    Super::BeginPlay();

    /*
    -- Skip --
    */

    UFSObjectPoolSubsystem* ObjectPool = GetGameInstance()->GetSubsystem<UFSObjectPoolSubsystem>();
    if (ObjectPool)
    {
        ObjectPool->InitializePool(AFSBullet::StaticClass(), 20);
        ObjectPool->InitializePool(AFSCharacterPatrol::StaticClass(), 60);
    }

}

GameMode의 BeginPlay에서 오브젝트 풀을 초기화해줘야 함

GameInstance의 Init 시점에는 월드가 없을 수도 있어서 안됨

DECLARE_DYNAMIC_MULTICAST_DELEGATE(FOnPoolInitialized);

UCLASS()
class FPSSCORING_API AFSGameMode : public AGameMode
{
	GENERATED_BODY()
	
public:
	AFSGameMode();

	virtual void BeginPlay() override;
	virtual void StartPlay() override;
	virtual void Tick(float DeltaSeconds) override;

	void SetScoreText(FString InText);
	void SetTimeText(float InTime);
	void SetMaxScoreText(FString InText);
	void EndGame();

	FOnPoolInitialized OnPoolInitialized;
    ...

 

그리고 델리게이트를 하나 만들어주는데

 

void AFSGameMode::StartPlay()
{
    Super::StartPlay();
    OnPoolInitialized.Broadcast();
}

StartPlay에서 브로드캐스트

이게 필요한 이유 :

1. BeginPlay에서 오브젝트 풀을 이용하는게 불가능함

- 일단 BeginPlay 이전에는 월드가 없을 수도 있어서 BeginPlay에서 초기화 가능 (스폰 때문에 그럼)

- 그런데 Spawner가 BeginPlay에서 스폰을 하는 경우, BeginPlay 시점 관계 상 GameMode에서 Initialize 하기 전에 스폰할 수 있음 -> 스폰이 안됨. 풀이 초기화 안됐으니까

 

2. 그래서 특정 시점이 필요 -> 그게 StartPlay

- StartPlay는 모든 액터의 BeginPlay가 끝난 이후에 호출되는 GameMode만의 함수

- Spawner의 BeginPlay는 델리게이트를 구독하고 GameMode의 StartPlay에서 브로드캐스트하면 문제 해결

 

void AFSPatrolSpawner::BeginPlay()
{
	Super::BeginPlay();
	
    if ( AFSGameMode* GameMode = Cast<AFSGameMode>(GetWorld()->GetAuthGameMode()) )
    {
        GameMode->OnPoolInitialized.AddDynamic(this, &AFSPatrolSpawner::StartSpawn);
    }
}

void AFSPatrolSpawner::StartSpawn()
{
    SpawnPatrol();
    GetWorld()->GetTimerManager().SetTimer(SpawningHandle, this, &AFSPatrolSpawner::SpawnPatrol, 1.0f, true);
}

이런 식으로 사용 가능

 

3. Get from pool

UFSObjectPoolSubsystem* ObjectPool = GetGameInstance()->GetSubsystem<UFSObjectPoolSubsystem>();
if (ObjectPool)
{
    if (SpawnCount >= MaxSpawnCount) return;

    ++SpawnCount;
    ObjectPool->GetPooledObject(AFSCharacterPatrol::StaticClass(), SpawnLocation, SpawnRotation);
}

그냥 클래스 정보랑 위치, 회전 정보만 넘기면 바로 얻어낼 수 있음

 

void AFSBullet::Reset()
{
	if (Movement)
	{
		Movement->StopMovementImmediately();
		Movement->Velocity = GetActorForwardVector() * Movement->InitialSpeed;
	}

	GetWorld()->GetTimerManager().SetTimer(PoolingTimer, this, &AFSBullet::PoolBullet, 3.0f, false);
}

void AFSCharacterPlayer::Shoot()
{
	if (bIsShooted) return;
	bIsShooted = true;

	UFSObjectPoolSubsystem* ObjectPool = GetGameInstance()->GetSubsystem<UFSObjectPoolSubsystem>();
	if ( ObjectPool )
	{
		FVector SpawnLocation = GetActorLocation() + GetActorForwardVector() * 100.0f;
		FRotator SpawnRotation = GetControlRotation();

		AFSBullet* Bullet = Cast<AFSBullet>(ObjectPool->GetPooledObject(AFSBullet::StaticClass(), SpawnLocation, SpawnRotation));
		Bullet->Reset();
	}
}

단, 탄환같은 경우엔 UProjectileMovementComponent 때문에 풀에서 가져온 다음엔 정지시킨 다음 속도 재지정이 필요

4. Return to pool

void AFSBullet::PoolBullet()
{
	UFSObjectPoolSubsystem* ObjectPool = GetGameInstance()->GetSubsystem<UFSObjectPoolSubsystem>();
	if (ObjectPool)
	{
		ObjectPool->ReturnPooledObject(Cast<AActor>(this));
	}
}

풀링하는 건 그냥 그대로 넣으면 됨

 

사용 결과

일단 오브젝트 풀링 적용 이전엔 엄청 끊겨서 하기 힘든 수준이었는데 이제 그냥 안끊긴다

대신 미리 오브젝트를 만들어 놓는 거니까 로딩 시간이 좀 길어졌다

FPS 측정을 굳이 하진 않았는데, Spawn 시랑 가비지 컬렉터 작동 시 프레임 개선이 굉장히 크게 됐을 거다

 

단, 멀티플레이가 고려되진 않았다

서버에서 관리하고 Active 설정만 RPC로 멀티캐스트하면 쉽게 될 듯 함

ChronoSpace만들 때 쓰면 좋을 듯