총알을 맡으면서 고민이 됐던 것이 있습니다.
플레이어, 적들 모두 총알을 발사하는데 한 발만 발사하는 것이 아니라 공격 키를 누르던, 일정 시간이 흐르던 계속해서 총알이 발사되어야 합니다.
이 때, 계속해서 총알 객체를 생성, 소멸을 반복시키면 게임의 성능에 영향을 줄 수 있습니다.
어떻게 하면 이런 반복적인 생성, 소멸을 해결하면서 많은 양의 총알을 화면에 보여줄 수 있을까에 대한 고민을 한창 하고 있었습니다.
그러던 와중, 강의를 들으면서 이러한 제 고민을 해결해줄 수 있는 방법을 알게 됐습니다.
바로 많이 사용될 것 같은 오브젝트들을 미리 그 양만큼 만들어 두고 필요할 때마다 이 오브젝트들을 재사용하는 오브젝트 풀링 기법이었습니다.
가장 기본적인 최적화 방법으로 알고 있는 오브젝트 풀링에 대해 공부하고 어떻게 적용했는지에 대해 적어보겠습니다.
오브젝트 풀링 : 객체들을 미리 생성한 뒤, 필요할 때마다 미리 생성한 객체들을 사용하고 사용이 끝나면 다시 회수하는 방식
오브젝트 풀링은 객체의 생성과 소멸 과정을 최소화해서 성능을 향상시킬 수 있습니다.
게임이 실행 중일 때, 객체가 생성되고 소멸되면 그 비용이 굉장히 큽니다.
그래서 실행 중일 때 이러한 과정을 반복한다면 게임의 성능을 떨어뜨릴 수 있는데, 오브젝트 풀링은 이 과정을 수행하지 않게 하기 때문에 성능을 높일 수 있습니다.
게임에서 빈번하게 생성되고 파괴되는 객체들(총알, 파티클 등)에 오브젝트 풀링 기법을 적용한다면 메모리 할당과 가비지 컬렉션에 따른 성능 저하를 방지할 수 있습니다.
당연하지만, 오브젝트 풀링이 장점만 있는 것은 아닙니다. 게임에 사용될 객체들을 미리 많이 만들어두는 것이기 때문에 전체 진행에 사용되지 못한 객체들도 있을 수 있습니다. 따라서, 이러한 불필요한 메모리 사용을 증가시킬 수 있습니다.
오브젝트 풀의 크기를 적절히 조절해서 큰 성능 개선을 가져올 수 있게 신중하게 사용해야 합니다.
이제 이러한 오브젝트 풀링을 저희 프로젝트에 어떻게 적용했는지 적어보겠습니다.
먼저 오브젝트 풀을 만들어줘야 합니다.
// ObjectPool.cs
using System.Collections.Generic;
using UnityEngine;
public class ObjectPool : MonoBehaviour
{
// 에디터 상에서 설정할 수 있도록 System.Serializable 사용
[System.Serializable]
// 오브젝트 풀에 저장될 데이터 모음을 정의
public class Pool
{
public string tag; // 풀에 저장하고 꺼내올 때 사용될 이름
public GameObject prefab; // 풀에 저장할 게임 오브젝트 프리팹
public int size; // 풀의 크기
}
...
}
오브젝트 풀을 만들기 위해 스크립트를 만들었습니다.
먼저, 오브젝트 풀에 저장될 데이터 모음을 Pool이라는 클래스로 만들어 줬습니다. 여기에는 저장과 사용, 회수에 쓰일 tag와 저장될 게임 오브젝트 prefab, 풀의 크기인 size가 있습니다.
public class ObjectPool : MonoBehaviour
{
...
// 에디터 상에서 pool을 추가하기 위해 SerializeField 사용
[SerializeField] private List<Pool> pools = new List<Pool>();
// 실제 Object Pool들을 저장하는 Dictionary
private Dictionary<string, Queue<GameObject>> PoolDictionary;
// tag로 pools에 저장된 Pool 객체를 접근하기 위한 Dictionary
private Dictionary<string, int> PoolIndexDictionary;
// pool에서 꺼내거나 회수할 게임 오브젝트들을 임시 저장할 필드
private GameObject obj = null;
private void Awake()
{
// Object Pool에 사용될 Dictionary들 생성
PoolDictionary = new Dictionary<string, Queue<GameObject>>();
PoolIndexDictionary = new Dictionary<string, int>();
// Pool들의 총 개수인 pools의 크기만큼 반복
for (int i = 0; i < pools.Count; ++i)
{
// Object Pool에 저장할 Queue 생성
Queue<GameObject> queue = new Queue<GameObject>();
// pool에 저장될 객체들의 수만큼 반복
for (int j = 0; j < pools[i].size; ++j)
{
// pool에 저장할 게임 오브젝트 프리팹을 미리 생성
obj = Instantiate(pools[i].prefab, transform);
// 생성한 객체 비활성화
obj.SetActive(false);
// Queue에 생성한 obj를 저장
queue.Enqueue(obj);
}
// PoolDictionary에 tag와 함께 queue를 저장
PoolDictionary.Add(pools[i].tag, queue);
// PoolIndexDictionary에 tag와 함께 index를 저장
PoolIndexDictionary.Add(pools[i].tag, i);
}
}
...
}
Object Pool들을 한 곳에 모아 관리하는데, 원하는 Pool을 빠르게 찾아 꺼내올 수 있도록 Dictionary를 사용했습니다.
저장할 때, Pool 클래스의 tag를 Key로 해서 저장합니다.
가장 먼저 저장된 Game Object를 먼저 사용하기 위해 선입선출 구조의 Queue를 사용해서 Pool을 만들었습니다.
Queue에 Pool 클래스의 prefab을 size번 생성해 저장하고 tag와 Queue를 Key, Value로 해서 PoolDictionary에 저장합니다.
그 후, PoolIndexDictionary에 tag와 index를 Key, Value로 해서 저장했습니다.
public class ObjectPool : MonoBehaviour
{
...
// GameObject 사용을 위해 Pool에서 꺼내오는 메서드
public GameObject SpawnFromPool(string tag)
{
// 해당 tag가 PoolDictionary에 없을 경우, null 반환
if (!PoolDictionary.ContainsKey(tag))
return null;
// Pool에 남아있는 GameObject가 없을 경우
if (0 == PoolDictionary[tag].Count)
// tag에 따른 index로 Pool 클래스에 접근해 객체 생성
obj = Instantiate(pools[PoolIndexDictionary[tag]].prefab, transform);
// Pool에 남아있는 GameObject가 있을 경우
else
// Pool에서 가장 먼저 저장된 Object를 꺼냄
obj = PoolDictionary[tag].Dequeue();
// 꺼낸 Object를 활성화
obj.SetActive(true);
// Object 반환
return obj;
}
// 사용을 마친 GameObject를 Pool로 회수하는 메서드
public void RetrieveObject(string tag, GameObject obj)
{
// GameObject 비활성화
obj.SetActive(false);
// 해당 tag에 맞는 Pool에 다시 저장
PoolDictionary[tag].Enqueue(obj);
}
// Queue에서 삭제하자마자 바로 삽입하는 메서드
public GameObject LinkedSpawnFromPool(string tag)
{
// 해당 tag가 PoolDictionary에 없을 경우, null 반환
if (!PoolDictionary.ContainsKey(tag))
return null;
// Pool에서 가장 먼저 저장된 Object를 꺼냄
obj = PoolDictionary[tag].Dequeue();
// 꺼낸 Object를 활성화
obj.SetActive(true);
// 바로 다시 Pool에 삽입
PoolDictionary[tag].Enqueue(obj);
// Object 반환
return obj;
}
}
LinkedSpawnFromPool 메서드를 만든 이유는 화면 밖에서 플레이어를 향해 총알을 발사하는 적들 때문입니다.
이 적들은 그 수가 더 늘어나지 않고 계속해서 위치를 바꾸면서 플레이어에게 공격을 하게끔 만들기 위해서 바로바로 재활용을 할 수 있게 다른 함수를 만들어서 Pool을 사용했습니다.
// 공격 이벤트 발생 시, 호출되는 메서드 OnShoot
protected virtual void OnShoot(AttackSO attackSO)
{
// GameManager에 있는 ObjectPool에서 총알 tag에 맞는 총알을 꺼내 옴
GameObject obj = GameManager.Instance.CurrentObjectPool.SpawnFromPool(attackSO.bulletNameTag);
obj.transform.position = transform.position;
BulletController attackController = obj.GetComponent<BulletController>();
// 총알의 Controller 클래스의 초기화 함수를 호출해 초기화
attackController.InitailizeAttack(aimDirection, attackSO);
}
공격을 하는 Shoot 이벤트에 연결된 OnShoot 메서드입니다.
ObjectPool은 GameManager에 있기 때문에 GameManager에 접근해서 ObjectPool의 메서드로 객체들을 꺼내와서 사용합니다.
GameManager에 ObjectPool 스크립트를 추가해서 사용할 Pool들을 추가한 모습입니다.
플레이어, 적들의 총알이나 심지어 적들 까지도 Object Pool을 적용시켰고, 피타격 이펙트 또한 Object Pool로 관리했습니다.
Pool에서 꺼내와 활성화된 Object들은 위 사진처럼 Hierarchy 창에서 확인할 수 있습니다.
기본적인 최적화 방법이면서도 강력하다고 생각하는 Object Pool에 대한 내용을 적었습니다.
이 후, 다음 프로젝트들에도 Object Pool을 적용해서 게임의 안정성을 높일 수 있도록 할 것입니다.
더욱 안정적인 Object Pool 방법을 공부하게 된다면, 해당 내용에 대해서도 적어보겠습니다.