공부/인프런 - Rookiss

Part 3-10-1. Object Pooling : Pool Manager

셩잇님 2023. 8. 28. 23:39
반응형

 

 

 

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 객체가 된다.

 

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

 

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


 

Pool

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 붙어있는 상태)들 스택으로 관리

 

Init

        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를 참고한다.

 

Create

        Poolable Create()
        {
            GameObject go = Object.Instantiate<GameObject>(Original);
            go.name = Original.name; // 뒤에 붙는 (Clone) 없앰. 원본 프리팹과 이름 같게.
            return go.GetOrAddComponent<Poolable>();
        }

 

원본 프리팹으로부터 풀링에 사용할 오브젝트를 생성한다. 그리고 이 오브젝트를 📜Poolable로서 리턴한다. 이름은 원본 프리팹과 이름 같게 :)

 

Push

        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로, 비활성화시 스택에 넣어 대기시킨다.

 

Pop

        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 에다가 오브젝트 받고 리턴
  • 1. 스택이 비어있지 않다면 재활용할 수 있는 대기 상태인 오브젝트가 있다는 것이니 그것을 사용하도록 한다. 스택에서 빼서 사용
  • 2. 스택이 비어있다면 새로 만들어야한다. 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)
    1. 무기 프리팹으로 생성되어 재활용할 무기 오브젝트들 모여있는 풀
    2. 플레이어 프리팹으로 생성되어 재활용할 플레이어 오브젝트들 모여있는 풀
    3. 나무 프리팹으로 생성되어 재활용할 나무 오브젝트들 모여있는 풀
  • 이들을 모아둔 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)과 일치하는 해당 풀에 해당 오브젝트 poolable을 Push 함수 호출해 넣어줌

풀링하지 않는 오브젝트는 기존처럼 파괴한다.

 

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

원본(프리팹)을 이미 들고 있다면 바로 사용하기

매번 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 에 프리팹으로 생성한 오브젝트가 있다면 Pool의 Original에 저장되어 있을 것이기 때문에 GetOriginal 함수를 통해 가져올 수 있다.
  • 풀에 없는 프리팹이라면 힘겹게 로컬 폴더로부터 Resources.Load(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 한다.

 

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

2. 해당 프리팹에 📜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);
    }

 

1. 해당 프리팹에 📜Poolable 이 있다는 것은 풀링으로 관리된다는 오브젝트라는 것이다. 따라서 이 경우엔 Destroy 하지 않고 나중에 다시 재사용할 수 있도록 비활성화해서 풀의 스택에 다시 Push 해준다.

2. 해당 프리팹에 📜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개 만들어보자.

 

(i = 0 ~ i = 4) 5개 UnityChan 오브젝트

 

1. DontDestroyOnLoad인 @Pool_Root 생성 (이 자식들도 전부 DontDestroyOnLoad)

 

2. 첫번째 UnityChan 오브젝트 생성시

 

2 - 1.📜Resource의 Instantiate 부분 안에서 📜PoolManager의 Pop 함수 호출 👉 풀이 존재하지 않는 상태이므로 CreatePool 호출 👉 풀을 생성하고 Pool 클래스의 Init 호출 👉 count 의 디폴트 값은 5 이므로 기본적으로 5 개의 게임 오브젝트를 생성해서 스택에 넣어준다.

  • 스택에 넣어주는 Pool 클래스의 Push 함수 과정에서 이 오브젝트들의 부모는 @Pool_Root 산하가 된다.

 

2 - 2. 첫번째 오브젝트는 풀을 만들고 스택에 5 개 오브젝트를 생성해 넣어주는 위 과정을 끝내고 여기에서 바로 Pop 된다.

 

3. 두번째 ~ 다섯번재 오브젝트들은 위에서 만든 5 크기의 스택에서(현재 크기 4) 하나 하나 빼와서 재활용한다. 비활성화 된 상태에서 풀에 있던 오브젝트들을 뺴와 활성화함.

 

4. 이 과정에서 첫 번째 오브젝트 생성시 만들어둔 풀 스택에 비활성화 상태로 대기하고 있던 5 개의 오브젝트들을 Pop 하고 활성화되면서 더 이상 @Pool_Root 오브젝트의 자식들이 아니게 된다.

  • 그러나 Pop을 통해 이제 transform.parent = null 부모가 없는 상태가 되고 활성화되더라도 DontDestroyOnLoad 는 한번 이 DontDestroyOnLoad 범위 안에서 생성이 되었으면 DontDestroyOnLoad을 벗어나지 못하기 때문에 위 사진처럼 5개의 오브젝트는 '@Pool_Root' 부모로부터는 벗어났지만 여전히 DontDestroyOnLoad을 범위 안에 있는 것을 확인할 수 있다.

 

(i = 5 ~ i = 9) 5개 UnityChan 오브젝트

 

1. 풀은 존재하긴 하지만 스택에 아무것도 없기 때문에 (i = 0 ~ i = 4 에서 다 Pop 해가서 실제로 사용중이다.) 재사용을 할 수 없어 새로 만들어야 한다. Pool 클래스의 Pop에서 else에 걸려 Create() 된다.

  • 이 경우엔 CreatePool을 통한 Init 을 거치지 않아서 @Pool_Root 산하에 있지 않는다. 그냥 이건 풀에 넣어주기 위해 생성하는게 아니라 그냥 바로 실사용으로 쓰기 위해 Instantiate 하는 것이기 때문이다.

 

2. 따라서 이때 생성된 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 이 될 것이다. 😁

 

해결! 😎

 

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");
    }

 

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);
        }
    }


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


 

반응형