Chapter 10. Pool Manager

Date:     Updated:

카테고리:

태그:

인프런에 있는 Rookiss님의 [C#과 유니티로 만드는 MMORPG 게임 개발 시리즈] Part3: 유니티 엔진 강의를 듣고 정리한 필기입니다. 😀
🌜 강의 들으러 가기 Click

Chapter 10. Pool Manager

🚀 Object Pool

  • 오브젝트 풀을 사용하는 이유
    • 리소스 폴더에 있는 것을 Instantiate 하는 일련의 과정은 어마어마하게 느리다.
      • SSD와 CPU는 여전히 물리적으로 거리가 떨어져 있기 때문이다.
    • 👉 게임 시작 전에 미리 가져와서 로드해놓고 그를 재활용. 미리 로드해놓은 것을 켜주고 꺼주고 하는 식으로 관리하는 것.

런타임 도중의 생성이 빈번하게 일어날 오브젝트에 대해서만 풀링을 진행하며 된다.

풀링을 할 오브젝트, 풀링을 안 할 오브젝트를 구분해야 한다.

👉 이 구분을 풀링을 할 오브젝트들에만 📜Poolable 컴포넌트(스크립트)를 붙여 구분한다.

  • 📜PoolManager
    • 오브젝트 풀링 관리
    • 📜Manager로 부터 사용
    • 📜ResourceManager 를 보조 하는 역할.
  • 📜Poolable
    • 풀링 할 오브젝트들에 이 스크립트를 붙여 구분
      • 즉 풀링할 프리팹에 붙여주면 된다.

프리팹(원본)을 통해 그때 그때 Instantiate 해 클론 오브젝트를 생성하기보단 한번 Instantiate 한 것을 계속 재활용.

풀은 미리 만들어두고 사용되기를 기다리는 오브젝트들의 대기 모임

  • 풀에 미리 Instantiate 한 오브젝트들을 모아놓고 평소엔 비활성화 한다.
  • 그리고 사용할 때만 활성화하여 풀에서 빼내온다. 👉 Pop
  • 이제 사용하지 않을 때만 마치 파괴되는 것처럼 비활성화만 하고 다시 풀에 넣는다. 👉 Push

📜 Poolable

풀링 할 프리팹에 이 스크립트를 붙여 구분

  • UnityChan 프리팹에 붙어 있는 상태
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class Poolable : MonoBehaviour
{
	public bool IsUsing;
}

별 내용 없다! 그냥 이 스크립트가 붙어있으면 풀링 대상이라고 보기 위해 구분해주기 위해서 만든 스크립트.


📜 Manager

    PoolManager _pool = new PoolManager();

    public static PoolManager Pool { get { return Instance._pool; } }

    static void Init()
    {
        // ...
            s_instance._pool.Init();
        	
	}

    public static void Clear()
    {
        // ...
        Pool.Clear();
    }


📜 Pool 클래스

PoolManager 는 여러개의 Pool 들을 가지고 있다.

하나의 풀 Pool

- @Pool_Root            👉 전체의 풀들을 한데 모음
  - UnityChan_Root      👉 UnityChan_Root 원본 프리팹을 통해 만든 대기중인 UnityChan_Root 오브젝트들 모아둔 부모오브젝트, 풀
  - Bird_Root 👉 Bird_Root 원본 프리팹을 통해 만든 대기중인 Bird_Root 들 모아둔 부모오브젝트, 풀

UnityChan_Root, Bird_Root 같은게 각각 하나의 Pool 객체가 된다.

image

예시. 현재 UnityChan_Root 2개가 풀링 中. 즉 비활성화 상태로 대기하며, 재활용되어 사용되기를 기다리고 있다. 이렇게 풀링 중인 상태일 때는 @Pool_Root - UnityChan_Root 빈 오브젝트 산하에서 비활성화 상태로 있는다. (스택에 Push가 되어 있는 상태)

image

예시. 마치 Instantiate 된 것 같은 효과로, 풀링 되어 있던 UnityChan_Root 오브젝트 2개를 활성화하고 @Pool_Root - UnityChan_Root로부터 벗어나, 원래 Hierarchy상에서의 부모 산하로 옮겨준다. 이젠 풀링 대기 상태가 아닌 사용 중인 상태. (스택에서 Pop가 되어 있는 상태)

	class Pool
    {
        public GameObject Original { get; private set; } 
        public Transform Root { get; set; }

        Stack<Poolable> _poolStack = new Stack<Poolable>();
  • Original 원본 프리팹
  • Root 풀 이름 ex. UnityChan_Root, Bird_Root
  • _poolStack 풀에 모여 있는 오브젝트(📜Poolable 붙어있는 상태)들 스택으로 관리
        public void Init(GameObject original, int count = 5)
        {
            Original = original;
            // UnityChan_Root 빈 오브젝트 생성. 
            Root = new GameObject().transform;
            Root.name = $"{original.name}_Root";

            // count 개수의 오브젝트들을 UnityChan_Root의 자식으로. 이 5 개를 재활용할 것 👉 오브젝트 풀링 
            for (int i = 0; i < count; i++)
                Push(Create());
        }
  • 하나의 풀 초기화 (원본 프리팹 original, 풀링할 오브젝트 개수 count)
    • Original 원본프리팹
    • 풀링에 사용할 오브젝트들을 Root (ex. UnityChan_Root) 오브젝트 산하에 둘 것
    • count개수의 오브젝트를 생성하고 풀링하기 위해 스택에 넣어주기. 밑에 Push 참고
        Poolable Create()
        {
            GameObject go = Object.Instantiate<GameObject>(Original);
            go.name = Original.name; // 뒤에 붙는 (Clone) 없앰. 원본 프리팹과 이름 같게.
            return go.GetOrAddComponent<Poolable>();
        }
  • 원본 프리팹으로부터 풀링에 사용할 오브젝트를 생성한다. 그리고 이 오브젝트를 📜Poolable로서 리턴.
  • 이름은 원본 프리팹과 이름 같게.
        public void Push(Poolable poolable) // 풀에 넣어주기 (오브젝트 비활성화)
        {
            if (poolable == null)
                return;

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

            _poolStack.Push(poolable);
        }
  • 풀에 넣어준다는 것은 곧 오브젝트를 비활성화 해놓고 사용될 때까지 대기한다는 것이다. (마치 Destroy 하는 효과)
    • 풀에서 대기중인 오브젝트는 Root의 자식이어야 함
      • 풀에서 대기중일땐 UnityChan_Root 의 자식이다가 진짜 활성화되어 사용될 떈 풀에서 빠져나와 게임 중에서의 원래 부모의 자식으로 부모 바꿔 설정할 것
  • 부모는 Root로, 비활성화, 스택에 넣어 대기시키기
        public Poolable Pop(Transform parent) // 풀로부터 꺼내오기 (오브젝트 활성화)
        {
            Poolable poolable;

            if (_poolStack.Count > 0) // 스택(대기상태)이 빈 크기 X 즉 하나라도 재활용 할 수 있는 애가 있다면 
                poolable = _poolStack.Pop();
            else // 스택(대기상태)이 지금 비었다면 재활용 할 수 있는 애가 없으므로 새로 만들어야
                poolable = Create();

            poolable.gameObject.SetActive(true);  // 활성화 (poolable.gameObject로 접근해서 활성화)

            // DontDestroyOnLoad 해제 용도
            if (parent == null)
                poolable.transform.parent = Managers.Scene.CurrentScene.transform;

            // poolable 👉 풀에서 꺼낸 오브젝트의 Poolable
            poolable.transform.parent = parent; // 파라미터로 받은 parent 를 부모로 설정
            poolable.IsUsing = true;

            return poolable;
        }
    }
  • parent 👉 대기 상태가 아닌 활성화 상태로 풀 밖에서 게임 안에서 사용될 때의 부모. 원래 부모.
  • 풀에 빼낸다는 것은 곧 오브젝트를 활성화 해서 사용하는 것이다. 생성되는 것 같은효과.
    • poolable 에다가 오브젝트 받고 리턴
      • 스택이 비어있지 않다면 재활용할 수 있는 대기 상태인 오브젝트가 있다는 것이니 그것을 사용하도록 한다. 스택에서 빼서 사용
      • 스택이 비어있다면 새로 만들어야한다. Instantiate 필요. Create 호출.
  • 활성화 (poolable.gameObject로 접근해서 활성화)
  • 풀에서 대기 중일때의 부모로부터 원래 게임에서의 부모로 설정.
  • 아래 부분에 대한 설명은 맨 밑에 DontDestroyOnLoad 의 특성 참고
      // DontDestroyOnLoad 해제 용도
      if (parent == null)
          poolable.transform.parent = Managers.Scene.CurrentScene.transform;
    


📜 PoolManager

📜ResourceManager 를 보조 하는 역할.

여러개의 📜Pool 객체들을 관리. 즉 여러개의 풀 관리.

  • @Pool_Root 👉 전체 풀 관리
    • 여러개의 풀
      • 각각의 풀에 속한 오브젝트들(재활용 대상)

멤버

	Dictionary<string, Pool> _pool = new Dictionary<string, Pool>();
    Transform _root;
  • _pool 풀들을 미리 로드해와 모아둘 그 ‘Pool’
    • 관련있는 오브젝트들을 모으는 것도 하나의 Pool 이다. (위의 Pool 클래스)
    • 풀도 여러개일 수 있다.
      • ex)
        • 무기 프리팹으로 생성되어 재활용할 무기 오브젝트들 모여있는 풀
        • 플레이어 프리팹으로 생성되어 재활용할 플레이어 오브젝트들 모여있는 풀
        • 나무 프리팹으로 생성되어 재활용할 나무 오브젝트들 모여있는 풀
    • 이들을 모아둔 Dictionary이므로 즉, 게임 내의 모든 전체 풀들을 _pool에서 관리.
    • Key는 원본 프리팹의 이름으로 쓸 것!
  • 풀들을 _root(@Pool_Root)의 자식으로 묶어 정리할 것이다.


Init 초기화

    public void Init()
    {
        if (_root == null)
        {
            _root = new GameObject { name = "@Pool_Root" }.transform;
            Object.DontDestroyOnLoad(_root);
        }
    }
  • 풀링 할 오브젝트들을 모아서 그룹화해 정리할 @Pool_Root 오브젝트를 만든다.
    • 풀링 오브젝트들은 이 오브젝트의 자식으로 묶일 것이며
    • 게임 내내 유지되도록 @Pool_Root 오브젝트를 DontDestroyOnLoad 처리 한다.


Push 다 사용한 오브젝트 풀에 다시 넣어 대기 상태로 만들기

    public void Push(Poolable poolable)
    {
        string name = poolable.gameObject.name;
        if (_pool.ContainsKey(name) == false)
        {
            GameObject.Destroy(poolable.gameObject);
            return;
        }

        _pool[name].Push(poolable);
    }
  • 그냥 _pool[name].Push(poolable)을 해주면 땡
    • 이름(Key)과 일치하는 해당 풀에 해당 오브젝트 poolablePush 함수 호출해 넣어줌
  • 풀링하지 않는 오브젝트는 파괴


CreatePool 풀 만들기

    public void CreatePool(GameObject original, int count = 5)
    {
        Pool pool = new Pool();
        pool.Init(original, count); // Init 을 통해 해당 Pool은 DontDestroyOnLoad가 된다.
        pool.Root.parent = _root;

        _pool.Add(original.name, pool);
    }
  • 풀을 생성하고 풀의 Init 함수 호출
  • 풀들은 @Pool_Root(_root)의 자식이어야 한다.
  • _pool Dictionary에 추가해준다.
    • Key는 프리팹 이름인 original.name으로 풀을 추가해준다.


Pop 풀로부터 사용할 오브젝트 리턴

    public Poolable Pop(GameObject original, Transform parent = null)
    {
        if (_pool.ContainsKey(original.name) == false) // Key는 원본 프리팹 이름으로 저장되므로 해당 프리팹으로 만든 오브젝트풀이 있나 검색. 
            CreatePool(original); // 없다면 새로운 풀을 만든다. 

        return _pool[original.name].Pop(parent); // 풀이 없다면 여기서 런타임 에러 날 것이므로 위의 과정을 해주는 것. 아룸아 original.name인 풀이 아직 없다면 만들어주기.
    }

_pool Dictionary에서 보관 중인 original 프리팹 이름에 해당하는 Key의 Value인 풀을 리턴한다.

  • 리턴한 Pool에서 Pop 호출
    • 풀 Stack (풀 마다 본인의 오브젝트들 보관하는_poolStack)에서 가장 위에 있는 오브젝트를 pop하고(후입선출) 활성화하고 그 오브젝트의 부모를 parent로 한다.
  • CreatePool(original); 👉 디폴트로 5 개 생성


GetOriginal 프리팹 가져오기

    public GameObject GetOriginal(string name)
    {
        if (_pool.ContainsKey(name) == false)
            return null;
        return _pool[name].Original;
    }
  • 📜 ResourceManager 의 Load 함수에서 호출 시킬 것이다.
    • 그래서 public 이고
    • original.name을 사용하지 않고 그냥 name 매개 변수로 설정.
  • _pool Dictionary을 통해 Pool Value의 Original에 원본 프리팹 담고 있으니 이를 리턴해주면 된다.
    • Key가 없을 수도 있으니 위에 미리 체크. 없다면 null 리턴.


Clear 풀 날리기

    public void Clear()
    {
        foreach (Transform child in _root)
            GameObject.Destroy(child.gameObject);

        _pool.Clear();
    }

여러 가지의 Pool을 전부 날리자. Dictionary도 비우기. 풀에 비활성화 상태로 대기 중인 오브젝트들은 _root(@Pool_Root) child(UnityChan_Root)의 자식들로 있는 상태일테니 이것들도 다 날라갈 것.. 다른 씬에서는 해당 풀에 있는 오브젝트들을 다신 안 쓰는 경우가 생기면 이렇게 풀을 다 날려 버리는 기능이 필요할 것이다.


📜 ResourceManager

풀링이 필요한 오브젝트인지 아닌지를 구분해서 로드 및 생성 및 파괴할 필요가 있음.

  • Init
    1. 원본(프리팹)을 이미 들고 있다면 바로 사용하기
    2. 매번 Instantiate으로 사본 생성하는 것이 아니라 혹시 풀링된 애가 있으면 그것을 재활용하기
  • Destroy
    • 만약 풀링이 필요한 애라면 파괴하는게 아니라 풀링 매니저에게 위탁해서 단순 비활성화시키기


Load

    public T Load<T>(string path) where T : Object
    {
        if (typeof(T) == typeof(GameObject))
        {
            string name = path;
            int index = name.LastIndexOf('/'); // '/' 뒤의 이름 추출. 
            if (index >= 0)
                name = name.Substring(index + 1); // 이게 바로 프리팹의 이름.

            GameObject go = Managers.Pool.GetOriginal(name);
            if (go != null)
                return go as T;
        }

        // 풀에서 못 찾았다면 힘들게 로딩
        return Resources.Load<T>(path); // UnityEngine의 Resource.
    }

프리팹을 로드하는 것 또한 풀에 있으면 풀에서 가져온다. Instantiate을 줄이려고 하듯, 로드 또한 최대한 줄이기 위해!

  • 프리팹을 로드할 때 프리팹 또한 Pool에 있으면 로드하지 않고 거기서 가져 온다.
    • 이미 Pool 에 프리팹으로 생성한 오브젝트가 있다면 PoolOriginal에 저장되어 있을 것이기 때문에 GetOriginal 함수를 통해 가져올 수 있다.
    • 풀에 없는 프리팹이라면 힘겹게 로컬 폴더로부터 Resources.Load<T>(path)을 호출해 로딩.


Instantiate

    public GameObject Instantiate(string path, Transform parent = null)
    {
        GameObject original = Load<GameObject>($"Prefabs/{path}");
        if (original == null)
        {
            Debug.Log($"Failed to load prefab : {path}");
            return null;
        }

        if (original.GetComponent<Poolable>() != null)
            return Managers.Pool.Pop(original, parent).gameObject;

        GameObject go = Object.Instantiate(original, parent);
        go.name = original.name;
        return go;
    }

Load 함수로부터 받은 프리팹을 통해 오브젝트를 Instantiate 한다.

  • 해당 프리팹에 📜Poolable 이 있다는 것은 풀링으로 관리된다는 오브젝트라는 것이다. 따라서 이 경우엔 Instantiate 하지 않고 풀에서 대기 중인 비활성화 오브젝트를 가져와 활성화하여 재사용한다.
    • 스택에서 pop (Pop 함수에서 해줄 것.)
  • 해당 프리팹에 📜Poolable 이 없다는 것은 풀링으로 관리되지 않는다는 것이니 Instantiate 해야 한다.


Destroy

    public void Destroy(GameObject go)
    {
        if (go == null)
            return;

        Poolable poolable = go.GetComponent<Poolable>();
        if (poolable != null)
        {
            Managers.Pool.Push(poolable);
            return;
        }

        Object.Destroy(go);
    }
  • 해당 프리팹에 📜Poolable 이 있다는 것은 풀링으로 관리된다는 오브젝트라는 것이다. 따라서 이 경우엔 Destroy 하지 않고 나중에 다시 재사용할 수 있도록 비활성화해서 풀의 스택에 다시 Push 해준다.
  • 해당 프리팹에 📜Poolable 이 없다는 것은 풀링으로 관리되지 않는다는 것이니 그냥 Destroy 한다.


📜테스트

public class LoginScene : BaseScene
{
    protected override void Init()
    {
        base.Init();

        SceneType = Define.Scene.Login;

        for (int i = 0; i < 10; i++)
            Managers.Resource.Instantiate("UnityChan");
    }

“LoginScene” 씬이 시작되면 UnityChan 오브젝트를 10 개 만들도록 한다.

image

  • (i = 0 ~ i = 4) 5UnityChan 오브젝트
    • DontDestroyOnLoad인 @Pool_Root 생성 (이 자식들도 전부 DontDestroyOnLoad)
    • 첫번째 UnityChan 오브젝트 생성시
      • 📜Resource의 Instantiate 부분 안에서 📜PoolManager의 Pop 함수 호출 👉 풀이 존재하지 않는 상태이므로 CreatePool 호출 👉 풀을 생성하고 Pool 클래스의 Init 호출 👉 count 의 디폴트 값은 5 이므로 기본적으로 5 개의 게임 오브젝트를 생성해서 스택에 넣어준다.
        • 스택에 넣어주는 Pool 클래스의 Push 함수 과정에서 이 오브젝트들의 부모는 @Pool_Root 산하가 된다.
      • 첫번째 오브젝트는 풀을 만들고 스택에 5 개 오브젝트를 생성해 넣어주는 위 과정을 끝내고 여기에서 바로 Pop 된다.
    • 두번째 ~ 다섯번재 오브젝트들은 위에서 만든 5 크기의 스택에서(현재 크기 4) 하나 하나 빼와서 재활용한다. 비활성화 된 상태에서 풀에 있던 오브젝트들을 뺴와 활성화함.
    • 이 과정에서 첫 번째 오브젝트 생성시 만들어둔 풀 스택에 비활성화 상태로 대기하고 있던 5 개의 오브젝트들을 Pop 하고 활성화되면서 더 이상 @Pool_Root 오브젝트의 자식들이 아니게 된다.
      • 그러나 Pop을 통해 이제 transform.parent = null 부모가 없는 상태가 되고 활성화되더라도 DontDestroyOnLoad 는 한번 이 DontDestroyOnLoad 범위 안에서 생성이 되었으면 DontDestroyOnLoad을 벗어나지 못하기 때문에 위 사진처럼 5개의 오브젝트는 `@Pool_Root` 부모로부터는 벗어났지만 여전히 DontDestroyOnLoad을 범위 안에 있는 것을 확인할 수 있다.
  • (i = 5 ~ i = 9) 5UnityChan 오브젝트
    • 풀은 존재하긴 하지만 스택에 아무것도 없기 때문에 (i = 0 ~ i = 4 에서 다 Pop 해가서 실사용中..) 재사용을 할 수 없어 새로 만들어야 한다. Pool 클래스의 Pop에서 else에 걸려 Create() 된다.
      • 이 경우엔 CreatePool을 통한 Init 을 거치지 않아서 @Pool_Root 산하에 있지 않는다. 그냥!!! 이건 풀에 넣어주기 위해 생성하는게 아니라 그냥 바로 실사용으로 쓰기 위해 Instantiate 하는 것이기 때문이다.
    • 따라서 이때 생성된 6 ~ 10번째 오브젝트들은 DontDestroyOnLoad 범위에 있지 않는다.


DontDestroyOnLoad 의 특성

    // 📜PoolManager
    public void Init()
    {
        if (_root == null)
        {
            _root = new GameObject { name = "@Pool_Root" }.transform;
            Object.DontDestroyOnLoad(_root);
        }
    }

📜PoolManager 의 Init 에서 @Pool_Root라는 이름의 오브젝트를 생성하고 모든 풀들을 이 오브젝트의 자식으로 묶어서 관리를 할 것이다. 그리고 이 오브젝트는 DontDestroyOnLoad 되게끔 하여 풀들이 씬이 바뀌어도 삭제되지 않고 유지되도록 한다.

  • 풀에서 빠져나와 활성화되어 진짜 사용이 될 때는 부모가 @Pool_Root 산하로부터 바뀌어야 한다. 원래 게임 Hierarchy상에서의 부모의 자식으로!

그러나 문제는 DontDestroyOnLoad 가 되면 DontDestroyOnLoad 를 빠져나갈 수 없다. transform.parent = null이 되어도 DontDestroyOnLoad 안에서만 빠져나가게 된다.

  • 씬 이동을 할 때 5 개만 삭제되지 않고 나머지 5 개는 삭제되니 좀 일관적이지 못하다. 사실은 10개 다 실사용 하게 된 것이므로 10개 다 밖에 있어야 예쁜 모양인데 말이다. (정확히는 5개는 이미 생성된 풀에서 가져온 것, 5개는 Instantiate)
        public Poolable Pop(Transform parent) // 풀로부터 꺼내오기 (오브젝트 활성화)
        {
            //...

            // DontDestroyOnLoad 해제 용도
            if (parent == null)
                poolable.transform.parent = Managers.Scene.CurrentScene.transform; // @Scene 오브젝트
            
            poolable.transform.parent = parent;
            // ...
        }
    }

parent == null 상태라면 DontDestroyOnLoad 범위 안에서만 parent == null 상태가 될 것이므로 꼼수를 써서 Pop이 될 때는 DontDestroyOnLoad 밖에 있을 수 있도록 한번 @Scene오브젝트의 자식으로 설정해주어 DontDestroyOnLoad 를 빠져나가게 한 다음에 poolable.transform.parent = parent;에 의해 부모가 null 이 될 것이다.

image

해결된 것 확인!

public class LoginScene : BaseScene
{
    protected override void Init()
    {
        base.Init();

        SceneType = Define.Scene.Login;

        for (int i = 0; i < 2; i++)
            Managers.Resource.Instantiate("UnityChan");
    }

image

5개보다 작은 2개로 테스트 했을 땐 2개만 실사용되고 3개는 비활성화 상태로 풀에서 대기 중인 것을 확인할 수 있다.

public class LoginScene : BaseScene
{
    protected override void Init()
    {
        base.Init();

        SceneType = Define.Scene.Login;

        List<GameObject> list = new List<GameObject>();
        for (int i = 0; i < 5; i++)
            list.Add(Managers.Resource.Instantiate("UnityChan"));

        foreach (GameObject obj in list)
        {
            Managers.Resource.Destroy(obj);
        }
    }

image

전부 Destroy 해버리니까 진짜 파괴가 되버리는게 아니라 @Pool_Root 산하의 자식이 되어 비활성화 상태로 전환된 것을 확인할 수 있다.



🌜 개인 공부 기록용 블로그입니다. 오류나 틀린 부분이 있을 경우 
언제든지 댓글 혹은 메일로 지적해주시면 감사하겠습니다! 😄

맨 위로 이동하기

Unity Lesson 2 카테고리 내 다른 글 보러가기

댓글 남기기