Priv's Blog
5. Object pool 본문
1. 오브젝트 풀
오브젝트 풀링(Object pooling)은 수많은 게임 오브젝트를 생성 및 파괴할 때 CPU 자원 소모를 최적화하기 위한 기술입니다.
오브젝트 풀 패턴은 비활성화된 “풀(pool)” 안에서 준비되어 대기 중인 초기화된 오브젝트들의 묶음을 사용하는 패턴입니다. 오브젝트가 필요할 때, 여러분의 애플리케이션은 해당 오브젝트를 인스턴스화하지 않습니다. 그 대신, 여러분은 풀 안에 있는 게임 오브젝트를 요청하고이를 활성화합니다.
오브젝트 사용이 끝나면, 여러분은 해당 오브젝트를 비활성화하고 파괴하는 것 대신 풀로 반환해야 합니다.
오브젝트 풀은 가비지 컬랙션 스파이크(Garbage collection spikes) 현상으로 인해 발생할 수 있는 버벅댐을 줄일 수 있습니다. GC 스파이크는 메모리의 허용치를 웃도는 방대한 양의 오브젝트들을 생성 또는 삭제할 때 자주 발생합니다. 로딩 스크린처럼 사용자들이 버벅댐이 발생하는 것을 알아차릴 수 없는 때에 맞춰서 오브젝트 풀을 미리 생성해 둘 수 있습니다.
2. 예제: 간단한 풀 시스템 실습
MonoBehaviour클래스를 상속하는 간단한 풀링 시스템은 아래 두 가지 요소를 고려해야 합니다:
- ObjectPool 클래스는 게임 내에 생성해야 하는 게임 오브젝트들의 집합을 저장하고 있습니다.
- PooledObject 컴포넌트는 프리팹에 추가되는 컴포넌트입니다. 이는 복사된 각 아이템이 오브젝트 풀에 대한 참조를 따르도록 해줍니다.
ObjectPool 클래스 안에는 풀의 크기를 직접 설정할 수 있는 필드, 풀에 저장하고 싶은 오브젝트를 나타내는 PooledObject 프리팹, 풀 그 자체를 구성하는 컬렉션(아래 코드에 존재하는 스택)이 포함되어 있습니다.
public class ObjectPool : MonoBehaviour
{
[SerializeField] private uint initPoolSize;
[SerializeField] private PooledObject objectToPool;
// store the pooled objects in a collection
private Stack<PooledObject> stack;
private void Start()
{
SetupPool();
}
// creates the pool (invoke when the lag is not noticeable)
private void SetupPool()
{
stack = new Stack<PooledObject>();
PooledObject instance = null;
for (int i = 0; i < initPoolSize; i++)
{
instance = Instantiate(objectToPool);
instance.Pool = this;
instance.gameObject.SetActive(false);
stack.Push(instance);
}
}
}
SetupPool 메서드는 오브젝트 풀을 구성하는 메서드입니다. PooledObject의 새로운 스택을 생성하고, initPoolSize의 크기만큼objectToPool 복사본들을 인스턴스화하여 스택을 채웁니다. Start 메서드에서 SetupPool 메서드를 깨워서(Invoke) 게임을 플레이하는 동안 해당 메서드가 한 번만 실행되는지 확인해 보세요.
또한 풀에서 꺼내진 아이템을 회수하는 메서드(GetPooledObject)와 아이템을 풀로 반환하는 메서드(ReturnToPool)도 필요합니다:
// returns the first active GameObject from the pool
public PooledObject GetPooledObject()
{
// if the pool is not large enough, instantiate a new PooledObjects
if (stack.Count == 0)
{
PooledObject newInstance = Instantiate(objectToPool);
newInstance.Pool = this;
return newInstance;
}
// otherwise, just grab the next one from the list
PooledObject nextInstance = stack.Pop();
nextInstance.gameObject.SetActive(true);
return nextInstance;
}
public void ReturnToPool(PooledObject pooledObject)
{
stack.Push(pooledObject);
pooledObject.gameObject.SetActive(false);
}
}
GetPooledObject 메서드는 풀이 비어있을 경우, 새로운 PooledObject 타입의 오브젝트를 하나 생성합니다. 반면에, 이 메서드는 간단하게 유효한 다음 요소를 반환합니다. 만약 풀의 크기가 충분하다면, 대부분의 시간을 기존에 있는 게임 오브젝트의 참조를 받아오는 데 사용할 것입니다.
클라이언트가 GetPooledObject 메서드를 호출하면 풀에서 꺼내진 오브젝트를 제자리로 이동 및 회전시킬 필요가 있습니다.
풀에서 꺼내진 각 요소는 간단한 PooledObject 컴포넌트를 가지고 있을 것입니다. 이는 ObjectPool을 참조합니다:
public class PooledObject : MonoBehaviour
{
private ObjectPool pool;
public ObjectPool Pool { get => pool; set => pool = value; }
public void Release()
{
pool.ReturnToPool(this);
}
}
Release 메서드를 호출하면 게임 오브젝트를 비활성화하고, 풀 큐(pool queue)로 이를 반환합니다.
함께 제공되는 프로젝트에서 기본적인 사용 예시를 확인할 수 있습니다. 여기, ExampleGun 스크립트가 게임 오브젝트에 부착되어 있습니다. 이는 오브젝트 풀에 레퍼런스를 저장합니다. 사용자가 총을 격발하면, 무기에 부착된 스크립트는 Object.Instantiate 대신 GetPooledObject 메서드를 깨웁니다(Invoke).
무기의 총알 그 자체는 ExampleProjectile 스크립트와 PooledObject 스크립트를 가지고 있습니다. ExampleProjectile 스크립트는 발사된 각각의 총알 게임 오브젝트를 발사된 지 몇 초 후에 비활성화하고, 활성화된 풀에 이를 반환하는 Deactivate 메서드를 가지고 있습니다.
이러한 방법은 스크린 위에 발사된 총알 수천 개가 나타나게 만들 수 있지만, 실제로는 총알 오브젝트를 단순하게 비활성화하는 방법으로 재활용하는 것입니다. 여러분이 사용하는 풀 사이즈가 현재 활성화된 오브젝트들을 모두 보여줄 수 있을 만큼 충분히 큰지 확인만 해주세요.
만약 풀 사이즈를 초과해야 할 경우, 풀은 추가로 오브젝트들을 인스턴스화할 수 있습니다. 그러나, 대부분의 경우에는 기존의 비활성화되어 있는 오브젝트들을 풀에서 꺼내와 사용할 것입니다.
만약 여러분이 Unity 엔진이 제공하는 파티클 시스템(ParticleSystem)을 사용할 경우, 이미 오브젝트 풀을 직접 경험해 보셨을 것입니다. ParticleSystem 컴포넌트는 파티클의 최대 개수를 지정하는 설정을 포함하고 있습니다. 이 설정은 간단하게 사용할 수 있는 파티클들을 재활용하여 이펙트의 개수가 설정된 최댓값을 넘지 않도록 막아주는 역할을 합니다. 오브젝트 풀 또한 이와 유사하게 동작합니다. 단지 파티클이 아니라 여러분이 선택한 게임 오브젝트를 다룬다는 것만 다를 뿐입니다.
3. 오브젝트 풀 개량하기
위의 예시는 단순한 예시입니다. 오브젝트 풀을 실제 프로젝트에 적용하여 개발할 때는 아래와 같은 개량 요소들을 고려해 보아야 합니다:
- static 키워드를 사용하거나 싱글턴 패턴 적용하기: 만약 여러분이 여러 소스 코드로부터 오브젝트 풀을 사용해 오브젝트들을 생성해야 한다면, 오브젝트 풀을 정적(static)으로 만드는 방법을 고려해 보세요. 이는 여러분의 애플리케이션 내 어디에서나 접근할 수 있게 만들어주지만 Inspector 창에서는 사용할 수 없습니다. 그 대안으로는, 오브젝트 풀 패턴을 싱글턴 패턴과 결합하여 더 간편하게 어디에서나 접근할 수 있도록 만드는 것입니다.
- 딕셔너리(Dictionary)를 사용하여 다수의 풀 관리하기: 만약 여러 개의 서로 다른 프리팹을 오브젝트 풀을 사용해 관리하고 싶으시다면, 분리된 여러 개의 오브젝트 풀에 이를 저장하고 키-값의 쌍으로 데이터를 관리하도록 하세요. 이렇게 하면 어떤 오브젝트 풀을 쿼리(Query)해야 하는지 알아낼 수 있답니다. (프리팹이 가지고 있는 인스턴스 ID는 고유한 키로 사용할 수 있습니다)
- 사용되지 않는 게임 오브젝트를 창의적으로 제거하기: 오브젝트 풀을 효과적으로 사용하는 한 가지 방법은 사용하지 않는 오브젝트들을 숨기고 오브젝트 풀로 반환하는 것입니다. 모든 기회를 사용해 풀링된 오브젝트를 비활성화하세요. (예: 화면 암전, 폭발하는 이펙트를 사용해 숨기기 등)
- 에러 체크하기: 이미 오브젝트 풀에 담겨 있는 오브젝트를 반환하지 않도록 주의하세요. 그렇지 않으면 런타임 오류가 발생할 수 있습니다.
- 오브젝트 풀의 최대 크기/용량 설정하기: 많은 양의 풀링된 오브젝트들은 그만큼 많은 양의 메모리를 소모합니다. 오브젝트 풀이 과하게 자원을 소모하지 않도록 한계치를 초과하는 오브젝트를 제거해야 할 수도 있습니다.
오브젝트 풀을 어떻게 사용하느냐는 애플리케이션에 따라 달라집니다. 이 패턴은 탄막 게임처럼 총 또는 무기가 수많은 투사체를 발사해야 하는 장르의 게임에서 흔히 볼 수 있습니다.
여러분이 방대한 양의 오브젝트들을 인스턴스화해야 하는 순간마다 GC 스파이크(Garbage-Collection Spike)로 인한 버벅댐이 발생할 위험이 뒤따릅니다. 오브젝트 풀은 이러한 문제를 완화하여 여러분의 게임이 더 원활하게 실행될 수 있도록 만들어줍니다.
만약 여러분이 Unity 2021 이후 버전을 사용하고 있으시다면, 오브젝트 풀링 시스템이 내장되어 있을 것입니다. 그러므로 앞에서 살펴보았던 PooledObject 또는 ObjectPool 클래스를 여러분이 직접 작성하실 필요가 없습니다.
4. UnityEngine.Pool
오브젝트 풀 패턴은 어디에서나 광범위하게 쓰이는 패턴으로, Unity 엔진 2021 버전부터는 UnityEngine.Pool API를 통해 공식적으로 지원하고 있습니다. 이는 오브젝트 풀 패턴을 사용해 여러분의 오브젝트들을 추적할 수 있는 스택 기반으로 동작하는 ObjectPool을 제공합니다. 필요에 따라서 CollectionPool (List, HashSet, Dictionary 등)을 사용할 수도 있습니다.
샘플 프로젝트 내에서는 이제 더 이상 여러분이 직접 오브젝트 풀 컴포넌트를 제작할 필요가 없습니다. 그 대신, gun 스크립트 파일을 열어서 코드 최상단에 using UnityEngine.Pool;을 추가해 주면 됩니다. 이는 발사체 풀을 Unity 엔진에 내장된 ObjectPool을 사용하여 제작할 수 있도록 만들어 줍니다.
using UnityEngine.Pool;
public class RevisedGun : MonoBehaviour
{
...
// stack-based ObjectPool available with Unity 2021 and above
private IObjectPool<RevisedProjectile> objectPool;
// throw an exception if we try to return an existing item, already in the pool
[SerializeField] private bool collectionCheck = true;
// extra options to control the pool capacity and maximum size
[SerializeField] private int defaultCapacity = 20;
[SerializeField] private int maxSize = 100;
private void Awake()
{
objectPool = new ObjectPool<RevisedProjectile>(CreateProjectile,
OnGetFromPool, OnReleaseToPool, OnDestroyPooledObject,
collectionCheck, defaultCapacity, maxSize);
}
// invoked when creating an item to populate the object pool
private RevisedProjectile CreateProjectile()
{
RevisedProjectile projectileInstance = Instantiate(projectilePrefab);
projectileInstance.ObjectPool = objectPool;
return projectileInstance;
}
// invoked when returning an item to the object pool
private void OnReleaseToPool(RevisedProjectile pooledObject)
{
pooledObject.gameObject.SetActive(false);
}
// invoked when retrieving the next item from the object pool
private void OnGetFromPool(RevisedProjectile pooledObject)
{
pooledObject.gameObject.SetActive(true);
}
// invoked when we exceed the maximum number of pooled items (i.e. destroy the pooled object)
private void OnDestroyPooledObject(RevisedProjectile pooledObject)
{
Destroy(pooledObject.gameObject);
}
private void FixedUpdate()
{
...
}
}
대부분의 스크립트 코드는 원본 ExampleGun 스크립트에서 잘 작동합니다. 그러나, 이제 ObjectPool의 생성자는 다음과 같은 경우에 일부 로직을 설정하는 데 도움이 되는 유용한 기능들을 포함하고 있습니다.
- 먼저 풀을 채우기 위해 풀링된 아이템을 생성합니다.
- 풀에서 아이템을 가져옵니다.
- 아이템을 풀로 반환합니다.
- 풀링된 오브젝트를 삭제합니다. (만약 최대한도에 도달했을 경우)
여러분은 그다음에, 생성자에 전달할 몇 가지 상용하는 메서드들을 정의해야 합니다.
Unity 엔진에 내장된 ObjectPool에는 기본 풀 사이즈와 풀의 최대 사이즈 옵션도 포함하고 있습니다. 최대 풀 사이즈를 초과하는 아이템들은 트리거를 작동시켜 자기 자신을 삭제하며, 지속해서 메모리 사용량을 검사합니다.
발사체 스크립트는ObjectPool을 계속 참조하도록 약간의 수정이 이루어졌습니다. 이렇게 하면 다시 풀로 오브젝트를 반환하는 것이 조금 더 편리해집니다.
수고하셨습니다!
'Unity Learn > Game Programming Patterns' 카테고리의 다른 글
7. Command pattern (0) | 2023.11.12 |
---|---|
6. Singleton pattern (0) | 2023.10.31 |
4. Factory pattern (0) | 2023.10.11 |
3. Design patterns for game development (0) | 2023.10.05 |
2. The SOLID principles (0) | 2023.10.01 |