최적화의 기본, 오브젝트 풀링 [유니티 C#]

게임에 오브젝트가 1,000개 또는 10,000개 생성되면 어떻게 될까요?

오늘은 이런 상황에서 사용하실 수 있는 간단하지만 효과적인 최적화 방식인
‘오브젝트 풀링(Object pooling)’을 살펴보겠습니다.

1. 생성과 제거

오브젝트 풀링 기법 없이 일반적인 총알의 생성과 삭제를 구현해보겠습니다.

C#
using UnityEngine;

public class Bullet : MonoBehaviour
{
    private void OnTriggerEnter(Collider other)
    {
        // Wall에 닿을 시 삭제
        if (other.CompareTag("Wall"))
        {
            Destroy(gameObject);
        }
    }
}
C#
using UnityEngine;

public class BulletCreator : MonoBehaviour
{
    // 총알 프리팹
    [SerializeField] private GameObject _bulletPrefab;


    private void Update()
    {
        // 좌클릭 시 총알 생성
        if (Input.GetMouseButtonDown(0))
        {
            Instantiate(_bulletPrefab, transform.position, transform.rotation);
        }
    }
}

위 코드에서 총알의 생성은 Instantiate로, 삭제는 Destroy로 작동합니다.

하지만, 위 두 함수는 모두 실행 비용이 높은 함수에 속합니다.
그리고 총알을 1,000개, 10,000개 생성 할 경우 눈에 띄는 프레임 하락으로 이어집니다.

그렇기 때문에 필요 할 때마다 생성 및 삭제 하는 방식이 아닌, 미리 만들어둔 후 필요 시 활성화, 사용 완료 시 비활성화 하는 방식으로 구현하면 프로세스 입장에서 훨씬 빠르게 처리할 수 있습니다.

이 방법이 ‘오브젝트 풀링(Object pooling)’입니다.

2. 오브젝트 풀링 구현

앞서 살펴본 예제를 오브젝트 풀링을 적용한 코드로 바꿔보겠습니다.

C#
using System.Collections.Generic;
using UnityEngine;

public class BulletPool : MonoBehaviour
{
    // 프리팹
    [SerializeField] private GameObject _bulletPrefab;
    // 풀 크기 (최초 생성 개수)
    [SerializeField] private int _poolSize = 20;
    
    // 오브젝트 큐
    private Queue<GameObject> _bulletQueue = new Queue<GameObject>();

    // 싱글톤 인스턴스
    public static BulletPool instance;


    private void Awake()
    {
        if (instance == null) instance = this;
        else Destroy(gameObject);

        // 풀 초기화
        InitializePool();
    }


    // 풀 초기화
    private void InitializePool()
    {
        // 풀 크기만큼 오브젝트 생성
        for (int i = 0; i < _poolSize; i++)
        {
            CreateBullet();
        }
    }


    // 오브젝트 생성
    private void CreateBullet()
    {
        GameObject bullet = Instantiate(_bulletPrefab);
        bullet.SetActive(false);

        // 큐에 오브젝트 추가
        _bulletQueue.Enqueue(bullet);
    }


    // 풀 사용 요청
    public GameObject GetBullet()
    {
        // 풀이 비어있으면 새로 생성
        if (_bulletQueue.Count == 0)
        {
            CreateBullet();
        }

        // 큐에서 오브젝트 꺼내기
        GameObject bullet = _bulletQueue.Dequeue();

        // 오브젝트 활성화
        bullet.SetActive(true);

        // 사용할 오브젝트 반환
        return bullet;
    }


    // 풀 반납 요청
    public void ReturnBullet(GameObject bullet)
    {
        // 오브젝트 비활성화
        bullet.SetActive(false);

        // 큐에 오브젝트 다시 넣기
        _bulletQueue.Enqueue(bullet);
    }
}

Awake함수 호출 시점에 싱글톤 인스턴스를 생성하여 어디서든 접근할 수 있도록 만들고, 오브젝트를 최초 생성 수량만큼 생성합니다.

오브젝트를 사용할 클래스에서 BulletPool 스크립트의 GetBullet() 함수를 통해 사용 할 오브젝트를 꺼내오고, 오브젝트 사용 완료 시 ReturnBullet() 함수를 호출하여 다시 반납하는 형태입니다.

오브젝트의 데이터를 보관하는 타입을 선입선출(First-In-First-Out) 원칙을 따르는 컬렉션 클래스인 ‘Queue‘로 설정하여 꺼내오고, 반납하는 과정을 간단하게 처리할 수 있습니다.

생성된 풀로 인해 Hierarchy가 지저분해진다면, 오브젝트 생성 시 특정 부모 오브젝트의 자식 오브젝트로 지정해 주시면 Hierarchy를 깔끔하게 유지하실 수 있습니다!

C#
using UnityEngine;

public class Player : MonoBehaviour
{
    private void Update()
    {
        // 좌클릭시 총알 생성
        if (Input.GetMouseButtonDown(0))
        {
            // 풀에서 꺼내오며 위치 설정
            BulletPool.instance.GetBullet().transform.position = transform.position;
        }
    }
}
C#
using UnityEngine;

public class Bullet : MonoBehaviour
{
    private void OnTriggerEnter(Collider other)
    {
        // 충돌 시 풀에 반환
        BulletPool.instance.ReturnBullet(gameObject);
    }
}

위와 같이 풀을 이용할 수 있습니다.

3. 초기화 시점

이전 함수의 생명 주기에서 다루었듯이 오브젝트 풀링을 이용하는 프리팹 (위 예시에서는 Bullet 오브젝트)의 초기화 시점은 필요에 따라서 조절할 필요가 있습니다.

초기화가 필요한 경우 Start함수가 아닌 OnEnable함수에서 진행해야 오브젝트 풀링의 비활성화-활성화 구조 상 의도했던 결과가 나올 수 있습니다.

댓글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다