게임 개발을 하다 보면 같은 객체 여러 개가 필요한 경우가 있다. 그 예시로 배틀 그라운드 같은 슈팅게임을 든다면, 분당 600발(게임에선 느린 편이다.) 사격 가능한 총을 잠깐 쏴도 정말 많은 총알이 필요할 것이다. 이와 같은 경우, 총알 인스턴스가 필요할 때마다 스폰시키고 다른 객체에 부딪혔을 때 폐기한다면 최적화 관점에서 그리 좋지 않다. 이유가 뭘까?
우리가 호출하는 함수는 고작 1줄이지만, 언리얼상에선 많은 일이 일어나기에 비싼 함수라고들 얘기하는 거다. 이 문제를 해결하는 방법이 있는데, 그 중 대표적인 예시가 오늘 작성 중인 오브젝트 풀이다. 오브젝트 풀의 작동을 단순히 요약하면 '미리 스폰시켜놓기'다.
// 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);