오브젝트 풀 구현, SpawnActor와 Destroy는 비싸니까.

김지윤·2024년 11월 22일
0

UE5

목록 보기
4/16

게임 개발을 하다 보면 같은 객체 여러 개가 필요한 경우가 있다. 그 예시로 배틀 그라운드 같은 슈팅게임을 든다면, 분당 600발(게임에선 느린 편이다.) 사격 가능한 총을 잠깐 쏴도 정말 많은 총알이 필요할 것이다. 이와 같은 경우, 총알 인스턴스가 필요할 때마다 스폰시키고 다른 객체에 부딪혔을 때 폐기한다면 최적화 관점에서 그리 좋지 않다. 이유가 뭘까?

  • SpawnActor와 Destroy의 무게

    흔히들 SpawnActor와 Destroy가 비싼 함수라고들 얘기한다. 오브젝트 풀에 대해 얘기하기 전에 왜 비싸다는 건지 한 번 알아보자.
  1. SpawnActor의 작업 내용
  • 객체에 필요한 애셋(메시, 머티리얼 등)을 메모리로 불러온다.
  • 생성된 Actor의 초기화를 수행
  • Actor를 현재 월드에 등록하여 Tick, Physics, Collision 등과 연동
  • Actor의 생성자와 BeginPlay 등 초기화 이벤트가 호출
  1. Destroy의 작업 내용
  • 월드에서 객체를 제거하고 모든 레퍼런스를 무효화
  • Actor가 가지고 있는 모든 컴포넌트를 정리하고 메모리에서 해제
  • Collision, Physics, Rendering 등의 시스템에서 객체를 제거
  • 가비지 컬렉션 대기

우리가 호출하는 함수는 고작 1줄이지만, 언리얼상에선 많은 일이 일어나기에 비싼 함수라고들 얘기하는 거다. 이 문제를 해결하는 방법이 있는데, 그 중 대표적인 예시가 오늘 작성 중인 오브젝트 풀이다. 오브젝트 풀의 작동을 단순히 요약하면 '미리 스폰시켜놓기'다.

  • 오브젝트 풀 구현

    내가 구현해봤던 ItemEffect를 예시로 들어보자면, 이 로직은 아이템 사용 시마다 ItemEffect를 상속받는 클래스의 객체를 스폰시키고 할 일이 끝나면 Destroy되는 로직이다. 확장엔 용이하나 최적화 관점에서 그리 좋은 상태는 아니다. 그렇다면 여기에 오브젝트 풀을 도입하면 어떨까?
// ObjectPool.h
TMap<UClass*, TQueue<TSoftObjectPtr<AItemEffectBase>>> ItemEffectPool;

ObjectPool의 헤더파일에 'Class가 키, TQueue이 값'인 TMap(Pool)을 하나 선언했다. 미리 ItemEffect들을 스폰시켜두고 필요할 때마다 여기서 꺼내 쓰면 된다.

// ObjectPool.cpp
// 요청된 클래스와 일치하는 객체를 Pool에서 가져옴
AItemEffectBase* UObjectPool::GetPooledItemEffect(UClass* DesiredClass)
{
  	// 클래스(키)에 맞는 값(Queue)가 없는 경우 초기화해준다
    if (!ItemEffectPool.Contains(DesiredClass))
    {
        TQueue<TSoftObjectPtr<AItemEffectBase>> ItemEffectQueueToAdd;
        ItemEffectPool.Add(DesiredClass, ItemEffectQueueToAdd);
    }
  
  	// 클래스(키)를 통해 원하는 객체가 들어있을 Queue를 가져온다.
    TQueue<TSoftObjectPtr<AItemEffectBase>>* FoundItemEffectQueue = ItemEffectPool.Find(DesiredClass);

    // 풀에서 요청된 클래스와 일치하는 객체가 없는 경우 nullptr 반환
    if (FoundItemEffectQueue->IsEmpty())
        return nullptr;

    TSoftObjectPtr<AItemEffectBase> PooledItemEffect;

    AItemEffectBase* LoadedItemEffect;

    // 풀에서 요청된 클래스와 일치하는 객체를 제거 후 해당 객체를 반환
    if (FoundItemEffectQueue->Dequeue(PooledItemEffect))
    {
        if (PooledItemEffect.IsValid())
            LoadedItemEffect = PooledItemEffect.Get();
        else
            LoadedItemEffect = PooledItemEffect.LoadSynchronous();
    }

    return LoadedItemEffect;
}

Pool에 원하는 ItemEffect 클래스의 객체가 있는 경우 AItemEffectBase* 를 반환하고, 없는 경우 nullptr을 반환하는 함수다. 우린 이 함수를 통해 Pool에 원하는 ItemEffect가 있는지 확인할 수 있고, 있다면 그 인스턴스를 받아 활용할 수 있다.

// ItemManager.cpp
void UItemManagerSubsystem::UseItem(FItemData ItemData)
{
    UClass* ItemEffectClass;
    if (ItemData->ItemEffect.IsValid())
        ItemEffectClass = ItemData->ItemEffect.Get();
    else
        ItemEffectClass = ItemData->ItemEffect.LoadSynchronous();

    if (!ItemEffectClass)
        return;

    AItemEffectBase* ItemEffect = ObjectPool->GetPooledItemEffect(ItemEffectClass);

	// Pool에 원하는 ItemEffect가 없는 경우 스폰
    if (!ItemEffect)
    {
        ItemEffect = GetWorld()->SpawnActor<AItemEffectBase>(ItemEffectClass);
    }

    // 객체 사용
    ItemEffect->ItemEffect(ItemReference);
}

먼저 ItemData(데이터 테이블에서 가져온 Row)에 있는 TSoftClassPtr를 통해 ItemEffect 클래스를 가져온다. 그 뒤 Pool에 해당 클래스의 객체가 있는지 확인 후, 없다면 직접 스폰시켜 사용하고 없는 경우엔 그대로 가져와 사용하면 된다.

    // 요청된 클래스와 일치하는 객체를 제거
  	FoundItemEffectQueue->Dequeue(PooledItemEffect)
    
    // 객체 사용
    ItemEffect->ItemEffect(ItemReference);

중요한 건 여기인데, 사용할 인스턴스를 Pool에서 제거하는 과정이다. 이 과정을 거치지 않으면 인스턴스 사용 시간이 길어졌을 때 같은 인스턴스를 참조하게 되어 예기치 못 한 충돌 오류를 겪을 수 있다. 글 처음에 든 예시의 배틀 그라운드 총알처럼 인스턴스 자체가 사용 시간이 긴 걸 전제로 만들어진 경우 말이다.(총알이 날아가는 도중에 다른 곳에서 참조해버리면 문제가 생김)

하지만 여기까지 구현하면 ItemEffect가 계속 스폰될 거다. 사용이 끝난 ItemEffect가 Pool로 반납되는 과정을 구현해보자. 언제 자신이 Pool로 반납돼야 하는지는 객체 자신이 가장 잘 알 것이다. 다시 총알로 예시를 들어보면 다른 객체에 부딪혔다는 걸 총알이 가장 먼저 알 수 있으니 말이다.

// ItemEffectBase.cpp
void AItemEffectBase::EndItemEffect()
{
	// ItemEffect가 끝나는 트리거 등을 통해 호출, 총알이라면 객체랑 부딪힌 순간 등
	// TODO: ItemEffect가 반환될 때 필요한 처리
    
    if (OnEndItemEffect.IsBound())
    	OnEndItemEffect.Execute(this);
}

// ItemManager.cpp
	...
    if (!ItemEffect)
    {
        ItemEffect = GetWorld()->SpawnActor<AItemEffectBase>(ItemEffectClass);
       	ItemEffect->OnEndItemEffect.BindUFunction(this, FName("ReturnObject"));
    }
    ...
    
void UItemManagerSubsystem::ReturnObject(AItemEffectBase* ItemEffect)
{
	ObjectPool->ReturnItemEffect(ItemEffect);
}

// ObjectPool.cpp
void UObjectPool::ReturnItemEffect(AItemEffectBase* ItemEffect)
{
    TQueue<TSoftObjectPtr<AItemEffectBase>>* QueueToReturn = ItemEffectPool.Find(ItemEffect->GetClass());
    QueueToReturn->Enqueue(ItemEffect);
}

ItemEffect가 ObjectPool을 참조하면 순환 참조가 발생할 여지가 있으니, 델리게이트를 통해 반환을 구성했다.
이처럼 ItemEffect 객체들을 필요할 때마다 스폰시키고 폐기하는 게 아닌, 필요할 때 스폰했다가 TMap으로 구현된 ObjectPool에 대기시켜 다시 꺼내 쓰는 방법으로 프로그램 최적화를 시도할 수 있다.

그런데 아뿔싸, TMap에 TQueue를 Add할 수 없다. 왜냐면 TQueue는 복사가 안 되기 때문이다.
그래서 찾아본 게 TUniquePtr이다. 한 객체를 참조하는 포인터가 단 하나일 수밖에 없는 포인터다. 만일 다른 포인터가 자신의 객체를 참조한다면 포인터를 해제해버린다. 즉, 복사가 아닌 이동만 가능하다는 것이다. TMap의 값으로 TQueue를 넣고 싶을 때 이 Ptr로 감싸서 관리한다면 좋을 듯 하다.

// 수정한 코드
  
// Pool 선언
    TMap<UClass*, TUniquePtr<TQueue<TSoftObjectPtr<AItemEffectBase>>>> ItemEffectPool;
  
// Pool에 Queue가 없을 때
    if (!ItemEffectPool.Contains(DesiredClass))
    {
        ItemEffectPool.Add(DesiredClass, MakeUnique<TQueue<TSoftObjectPtr<AItemEffectBase>>>());
    }
  
// Pool에 객체를 반납할 때
    TQueue<TSoftObjectPtr<AItemEffectBase>>* QueueToReturn = ItemEffectPool[ItemEffectClass].Get();
    QueueToReturn->Enqueue(ItemEffect);
profile
공부한 거 시간 날 때 작성하는 곳

0개의 댓글