유니티

오브젝트 풀링의 사용

미역제자 2024. 9. 22. 14:21

서바이벌 생존류 게임을 만들어보면서 자원 스폰을 담당하게 되었다.

자원이 필요할 때 마다 무턱대고 생성하는 것은 게임의 성능을 낮추고 최적화에 문제를 일으킬 수 있기 때문에, 오브젝트 풀링을 사용해 보고자 했다.


오브젝트 풀링이란?

사용할 오브젝트들을 웅덩이 Pool에 한번에 생성 한 뒤, 그 Pool에서 필요할 때 오브젝트를 꺼내와 사용합니다. 사용이 끝난 오브젝트는 다시 Pool에 반환함으로써 게임에서 오브젝트의 생성, 파괴를 최소화하는 방식입니다.

한두개의 오브젝트가 아닌, 수천개의 오브젝트들을 생성하고 파괴하는 일을 반복하다보면 CPU에 큰 부담이 되기 때문에 오브젝트의 생성과 파괴를 최소화 시켜주는 오브젝트 풀링을 사용하는 것입니다.

 


오브젝트 풀링의 사용 유무에 따른 성능 차이 비교는 추후에 하도록 하고, 이번엔 프로젝트에서 사용한 오브젝트 풀링 방법에 대해서 적어보고자 한다.

 

코드의 흐름은 다음과 같다.

 

Object Pooling을 담당해줄 PoolManager

Pooling할 오브젝트의 정보를 담고 있는 Poolable

오브젝트의 생성을 담당할 MaterialSpawn

 

Poolable

  • 프리팹 오브젝트
  • 최소 유지 개수
  • 한번에 소환하는 개수
  • 소환 주기
  • 좌표 간격 등

 

MaterialSpawn

  • MaterialSpawn에 소환할 오브젝트들의 데이터를 담고있는 Poolable을 List로 저장.
  • MaterialSpawn이 실행되면 PoolManager에서 오브젝트들을 Pool에 미리 소환.(CreatePool)
  • Update에서 일정 주기마다 자원을 스폰하는 Coroutine실행.
    • Coroutine을 사용하는 이유: 자원마다 소환을 동시에 하기 위해서. Coroutine으로 스폰하지 않으면 리스트에 저장한 순서에 맞춰서 소환되기 때문에 자원이 골고루 스폰되지 않는다.
  • 자원을 스폰하는 Coroutine : RespawnMaterial(i)
    • 오브젝트를 소환할 때, 오브젝트가 최대 소환개수를 넘어갔는지 판단 하고, 소환 개수를 안넘겼으면 소환한다. (CheckSpawnEachMaterials)
    • 소환 코루틴이 Update문에서 실행되고 있기 때문에 소환이 진행되고 있을때, 코루틴이 중복으로 발생하는 것을 막기 위해 코루틴 진행여부 플래그를 설정해준다. 이 플래그가 false(소환중이 아님)일 때 소환한다.
    • 오브젝트를 Pool에서 소환할 때 PoolManager에서 Pop해줌
    • 자원이 소환될 때 중복된 위치에서 소환되지 않도록 랜덤한 포지션에 소환해줌. 소환 위치는 특정 범위 안에서 랜덤.(SetRandomPosition)
    • 소환된 개수 증가.

사용한 코드 목록

더보기

Poolable

public class Poolable : MonoBehaviour
{
    // 오브젝트 풀링 대상에 붙이기.

    #region Field

    public bool IsUsing;

    public GameObject Prefab;

    // 현재 스폰되어 있는 자원 수.
    public int _CountNow = 0;

    // 최소 유지 자원 수.
    public int _KeepCount = 0;

    // 소환 한번에 생성되는 자원 수.
    public int _spawnAtOneTimeCount = 1;

    // 좌표
    public Vector3 _spawnPos;

    public float _spawnRadius = 15.0f; // 소환 좌표 간격.
    public float _spawnTime = 5.0f; // 소환 주기.

    #endregion
}

MaterialSpawn 

using System.Collections;
using System.Collections.Generic;
using Unity.VisualScripting;
using UnityEngine;

public class MaterialSpawn : MonoBehaviour
{
    #region Fields

    // 스폰할 오브젝트들 list로 받아오기.
    public List<Poolable> SpawnMaterials = new List<Poolable>();

    // 스폰 위치 부모 transform.
    private List<Transform> _parents = new List<Transform>();

    #endregion


    // 코루틴 중복 실행 방지용 플래그
    private bool _isSpawning;

    private void Awake()
    {
        //// TODO: ResourceManager에서 소환으로 변경.
        //Main.Resource.Instantiate("Tree");
        //Main.Resource.Instantiate("Stone");

        // 오브젝트 Pool에 생성.
        Main.Pool.Init();

        // Create
        for (int i = 0; i < SpawnMaterials.Count; i++)
        {
            Main.Pool.CreatePool(SpawnMaterials[i].Prefab, SpawnMaterials[i]._KeepCount);
            SpawnMaterials[i]._CountNow = 0;
            //프리팹 별로 부모 설정.
            _parents.Add(new GameObject { name = SpawnMaterials[i].Prefab.name + "_Root" }.transform);
            _parents[i].parent = transform;
        }
    }


    private void Update()
    {
        for (int i = 0; i < SpawnMaterials.Count; i++)
        {
            if (CheckSpawnEachMaterials(SpawnMaterials[i]) && !_isSpawning)
            {
                StartCoroutine(RespawnMaterial(i));
            }
        }
    }

    private bool CheckSpawnEachMaterials(Poolable _pool)
    {
        return _pool._CountNow < _pool._KeepCount;
    }

    IEnumerator RespawnMaterial(int i)
    {
        _isSpawning = true; // 코루틴 시작 시 플래그 설정

        yield return new WaitForSeconds(SpawnMaterials[i]._spawnTime);

        Poolable spawnedObject = Main.Pool.Pop(SpawnMaterials[i].Prefab, _parents[i]);

        SetRandomPosition(spawnedObject.transform, i);

        SpawnMaterials[i]._CountNow++;

        _isSpawning = false; // 코루틴 종료 시 플래그 해제
    }

    private void SetRandomPosition(Transform objTransform, int index)
    {
        Vector3 randDir = Random.insideUnitSphere * Random.Range(1, SpawnMaterials[index]._spawnRadius);
        randDir.y = 0; // 높이(Y)를 고정하여 평면 상에서 위치를 랜덤하게 설정
        Vector3 randPos = SpawnMaterials[index]._spawnPos + randDir;
        objTransform.position = randPos;
    }
}

 


PoolManager 

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using static UnityEngine.UI.Image;

public class PoolManager
{
    #region Pool
    private class Pool
    {
        public GameObject Original { get; private set; }
        public Transform Root { get; set; } // 풀링 할 오브젝트별 정리용 루트.

        private Stack<Poolable> _poolStack = new();

        public void Init(GameObject original, int count = 5)
        {
            Original = original;
            Root = new GameObject().transform;
            Root.name = $"{original.name}_Root";

            for (int i = 0; i < count; i++) Push(Create());
        }

        Poolable Create()
        {
            GameObject go = Object.Instantiate(Original); // 원본을 복사하여 루트 생성.
            go.name = Original.name;
            Poolable poolable = go.GetComponent<Poolable>();
            if (poolable == null)
                poolable = go.AddComponent<Poolable>();
            return poolable;
        }

        // 만들어진 오브젝트를 종류별로 넣어주기.
        public void Push(Poolable poolable)
        {
            if (poolable == null) return;

            poolable.transform.parent = Root;
            poolable.gameObject.SetActive(false);
            poolable.IsUsing = false;

            _poolStack.Push(poolable);
        }

        // 사용 할 오브젝트 내보내기.
        public Poolable Pop(Transform parent)
        {
            Poolable poolable;

            if (_poolStack.Count > 0) 
                poolable = _poolStack.Pop();
            else
                poolable = Create();

            poolable.gameObject.SetActive(true);

            // 씬이 이동되었을 때, DontDestroy 해제 용도.
            //if (parent == null)
            //    poolable.transform.parent = Main.Scene.현재씬.transform;

            poolable.transform.parent = parent;
            poolable.IsUsing = true;

            return poolable;
        }
    }
    #endregion

    private Dictionary<string, Pool> _pool = new();
    private Transform _root;

    public void Init()
    {
        if (_root == null)
        {
            _root = new GameObject { name = "@Pool_Root" }.transform; // 풀링 할 오브젝트들의 루트.
            Object.DontDestroyOnLoad(_root);
        }
    }

    public void CreatePool(GameObject original, int count = 5)
    {
        Pool pool = new();
        pool.Init(original, count);
        pool.Root.parent = _root; // 풀링 된 오브젝트들의 루트인 @Pool_Root에 연결.

        _pool.Add(original.name, pool);
    }

    // 사용하지 않는 오브젝트 반환.
    public void Push(Poolable poolable)
    {
        string name = poolable.gameObject.name;
        if (_pool.ContainsKey(name) == false)
        {
            GameObject.Destroy(poolable.gameObject);
            return;
        }

        _pool[name].Push(poolable);
    }

    public Poolable Pop(GameObject original, Transform parent = null)
    {
        if (_pool.ContainsKey(original.name) == false)
            CreatePool(original);

        return _pool[original.name].Pop(parent);
    }

    public GameObject GetOriginal(string name)
    {
        // 이미 만들어졌던 오브젝트인지 확인.
        if (_pool.ContainsKey(name) == false)
            return null;

        return _pool[name].Original;
    }

    // 오브젝트 삭제.
    // 씬과 씬에서 넘어가는데 다른 구역으로 넘어가서 오브젝트들 구성이 크게 다르다 등
    public void Clear()
    {
        foreach(Transform child in _root)
            GameObject.Destroy(child.gameObject);

        _pool.Clear();
    }
}