Unity - 8. 오브젝트 풀(Object pool)

땡구의 개발일지·2025년 4월 16일

Unity마스터

목록 보기
6/78
post-thumbnail

게임의 최적화와 관련된 디자인 패턴. 게임에서는 생성과 파괴에서 왜 파괴를 조심해야 되는지, 그리고 해결 방법이 무엇인지 알아보자
유니티 내장 오브젝트 풀

가비지 컬렉터

  • 오브젝트 풀 패턴을 쓰는 이유는 가비지 컬렉터와 밀접한 연관이 있다
  • 가비지컬렉터의 자세한 내용은 해당 포스트참조

메모리 할당

  • 게임 오브젝트 생성 시 인스턴스가 생성. 메모리 주소 앞쪽부터 할당 가능한 공간에 할당한다
  • C#의 가비지 컬렉터는 순환 메모리 참조 문제가 있다
  • 메모리 단편화가 일어날 수 있다
  • 메모리가 꽉 찬 상태에서 강제로 새로 할당 하려고 하면, 컴퓨터가 꺼질 수도 있다

메모리 해제

  • 게임 오브젝트 인스턴스를 삭제 시 메모리 해제가 일어난다
  • 가비지 컬렉터는 자동으로 미사용 중인 메모리를 할당 해제한다
  • 순환 참조가 되는 메모리의 경우, 할당 해제가 되지 않아서 메모리 누수가 일어난다

메모리 할당과 해제의 자동화

  • C#에서 메모리 할당과 해제를 프로그래머가 직접 주소를 지정하는 것이 아닌, 자동으로 지정되어 할당되고 가비지 컬렉터에 의해서 해제된다

메모리 누수

  • 할당된 메모리가 사용중이지 않음에도, 해제되지 않는 문제를 메모리 누수(Memory Leak) 이라고 한다

메모리 단편화(파편화)

  • 메모리의 앞부분 주소부터 할당 하다가, 중간중간 내용들만 삭제하는 것을 반복해보자(Destroy). 하다보면, 나중에 메모리를 새로 할당할 공간을 찾지 못하게 된다. 이를 메모리 단편화 라고 한다 그렇게 되면 게임이 꺼질 수도 있다. 심한 경우 컴퓨터가 멈출 수도 있다!
  • 이러한 이유 때문에 생성과 삭제를 자주하면 안된다. 문제를 해결하기 위해 오브젝트 풀 패턴을 사용한다

처음 저장할 때

중간 중간 삭제

새로운 메모리 저장 시도

  • 메모리를 할당할 공간을 찾지 못해 프로그램이 꺼지게 된다

GC 스파이크

  • GC 스파이크가 일어났을 때, 프리징이 걸린다
  • 앞서 보듯이 메모리 파편화가 일어난 빈 메모리 공간들을 활용하기 위해, 다시 메모리들을 앞으로 땡겨온다. 이때는 게임이 동작을 멈출 수밖에 없다. 즉, 프리징 현상이 일어난다. 이를 GC 스파이크라고 한다
  • 메모리 누수로 컴퓨터가 꺼지든, GC 스파이크로 게임이 프리징이 걸리든 둘 다 문제다. 이러한 문제를 해결하기 위해 오브젝트 풀 패턴을 사용한다

오브젝트 풀

  • 디자인 패턴 중 하나이다. 이름처럼 오브젝트가 모여있는 수영장. 즉 넓게 용량을 쓴다는 의미다
  • 사용할 게임 오브젝트를 미리 많~이 만들어 두고, 사용할 때는 그 오브젝트를 빌려서 사용하는 것이다. 이후 삭제 대신, 다시 오브젝트 풀로 반납한다
  • 오브젝트 풀의 개념으로 중요한건 빌리는 기능, 반납하는 기능

오브젝트 풀 예시

  • 크게 오브젝트 풀로 쓰일 게임 오브젝트와 풀에서 꺼내 쓸 게임 오브젝트 프리팹을 준비한다
  • 기능들을 구현할 스크립트 3개를 작성한다

ObjectPool

  • MonoBehaviour를 상속받지 않기 때문에, 게임 오브젝트에 직접적으로 상속하지 못한다. 하지만 ObjectSpawner스크립트에서 인스턴스로 사용하기 때문에 상관 없다
public class ObjectPool
{
    // 생성된 게임오브젝트들을 담을 오브젝트 풀
    private GameObject[] poolObject;

    // 생성자로 오브젝트 풀 생성 및 초기화
    public ObjectPool(int size, GameObject target, GameObject parent)
    {
        CreatePool(size, target, parent);
    }

    // 오브젝트 풀의 생성 및 초기화 함수
    private void CreatePool(int size,GameObject target, GameObject parent)
    {
        // 입력받은 사이즈로 배열을 동적 할당
        poolObject = new GameObject[size];
        // 배열 순회
        for(int i = 0; i<size; i++)
        {
            // 오브젝트를 생성하고 부모 객체의 자식 객체로 상속시킨다
            // Instantiate는 Object 클래스의 함수
            // MonoBehaviour <- Behaviour <- Component <- Object 순으로 상속
            // 결과적으로 MonoBehaviour를 쓰면 함수를 쓸 수 있음
            GameObject obj = MonoBehaviour.Instantiate(target,parent.transform);
            // 비활성화
            obj.SetActive(false);
            // 오브젝트 풀에 추가
            poolObject[i] = obj;

        }
    }
    // 활성화 및 비활성화
    public void Activate(bool select)
    {
        // 오브젝트 풀을 앞에서 부터 순회하며 순서대로 활성화 및 비활성화 가능
        foreach(var element in poolObject)
        {
            if (element.activeSelf != select)
            {
                element.SetActive(select);
                return;
            }
        }
        // 1. 활성화
        //  - 만약 1,2,3 번까지 사용중인 상황이면 4번이 활성화
        // 2. 비활성화
        //  - 만약 2,3,4,5가 활성화 상태이면 2번이 비활성화
    }
    // 비활성화 처리
    // 객체 전부 파괴
    public void DestroyAll()
    {
        // 오브젝트 풀 모든 요소 파괴
        foreach(var element in poolObject)
        {
            MonoBehaviour.Destroy(element);
        }
        poolObject = null;
    }
}

ObjectSpawner

  • 이 스크립트는 씬에서 프리팹들을 생성할 위치의 게임 오브젝트에 컴포넌트로 추가한다. 탱크로 치면 muzzlePoint
public class ObjectSpawner : MonoBehaviour
{
    // 오브젝트 풀을 생성하기 위한 필드
    [SerializeField] private GameObject prefab;
    [SerializeField] private int poolSize;
    private ObjectPool objectPool;

    // 오브젝트 풀을 생성자로 통해 생성
    private void Awake()
    {
        objectPool = new ObjectPool(poolSize, prefab, gameObject);
    }
    private void Update()
    {
        // 오브젝트 생성 : 빌리기
        if(Input.GetKeyDown(KeyCode.A))
        {
            objectPool.Activate(true);
        }

        // 오브젝트 삭제 : 반납하기
        if(Input.GetKeyDown(KeyCode.S))
        {
            objectPool.Activate(false);
        }
    }

    // 오브젝트 풀 삭제하기
    private void OnDestroy()
    {
        objectPool.DestroyAll();
    }
}

MyObjectPool

  • 이 스크립트는 생성할 프리팹에 컴포넌트로 추가한다
  • 자동으로 일정 시간이 지나면 사라지게 구현
  • 완성된 프리팹은 스포너의 prefab드래그&드롭으로 추가하면 된다
public class MyObjectController : MonoBehaviour
{
    // 어트리뷰트로 반납할 시간을 설정. 시간을 슬라이드로 정할 수 있다
    [SerializeField][Range(0, 10)] private float returnTime;
    private float timer;

    private void OnEnable()
    {
        timer = returnTime;
    }

    private void Update()
    {
        // 시간이 실제 시간에 맞게 줄어들도록 한다
        timer -= Time.deltaTime;
        if(timer <=0)
        {
            gameObject.SetActive(false);
        }
    }
}

TIP

  • 탄피는 오브젝트 풀, 발사음 같은 경우에는 사운드 매니저로 중첩해서 쓸 수 잇는 방법이 있다. 발사 효과 이펙트는 파티클 시스템을 이용하면 된다

실습

탱크에서 총알이 나가게 구현하기

  • 생성 Instantiate

    public class Practice : MonoBehaviour
    {
        [SerializeField] public GameObject bulletPrefab;
        //[SerializeField] GameObject muzzlePoint;
        [SerializeField] public Transform muzzlePoint; // Transform이 GameObject를 상속받는다
        private GameObject bulletOut;
    
        [Range(1, 50)]
        [SerializeField] public float speed;
    
        // Update is called once per frame
        void Update()
        {
            if(Input.GetKeyDown(KeyCode.Space))
            {
                //Instantiate(bulletPrefab, muzzlePoint.transform.position, muzzlePoint.transform.rotation);
                bulletOut = Instantiate(bulletPrefab, muzzlePoint.position, muzzlePoint.rotation);
                Rigidbody rig = bulletOut.GetComponent<Rigidbody>();
                rig.velocity = muzzlePoint.forward * speed;
            }
        }
    }

    • 탱크 모델링의 포탑 앞부분에 MuzzlePoint 오브젝트를 만든다. Transform의 정보만 가지고 있는 오브젝트다.
    • 이를 계층상 포탑 아래에 있게 넣어두면 탱크가 움직이든, 포탑을 회전시키든 같이 따라서 잘 움직인다
    • 작성한 컴포넌트는 탱크에 넣는다
    • Bullet PrefabShell을, Muzzle PointMuzzlePoint를 넣는다
  • 삭제

    • 기존 코드에 몇 줄 추가한다
    public float clearTime;
    private float timer;
    
    void Start()
    {
       timer = clearTime;
    }
    
    void Update()
    {
    	timer -= Time.deltaTime;
       if(timer<=0)
       {
       	Destroy(bulletOut);
       }
    }
  • 문제 발생

    • 한 발까지는 삭제가 되는데, 그 이후 발사한 것들은 삭제가 안된다...
    • 이는 bulletOut이 첫 한 발에만 적용되어서 생긴 문제다. 각각의 발사체에다가 적용시킬 필요가 있다.
  • 문제 해결

    private Queue<GameObject> que;
    private Queue<float> timeQue;
    public float clearTime;
    private float timer;
    
    void Awake()
    {
    	que = new Queue<GameObject>();
    timeQue = new Queue<float>();
    }
    
    void Update()
    {
    	if (que.Count > 0 && timeQue.Peek() + clearTime <= Time.time)
    	{			
    		Debug.Log("제거합니다");
    		Destroy(que.Dequeue());
    		timeQue.Dequeue();
        }
     }
    • Queue를 추가해 해결했다.
    • 총알이 발사 될 때마다 Queue에 해당 오브젝트와 시간을 추가해서, 업데이트마다 Peek()으로 타이머를 현재시간과 비교해, Peek()의 시간 + 클리어 시간이 같거나 작다면 오브젝트를 지운다. 그리고 오브젝트 큐와 시간 큐를 각각 Dequeue()한다
  • 단순화

    -큐, 타이머 다 필요없다. 삭제하고 싶은 시간만 정하면 된다

    Destroy(bulletOut,clearTime);
    • 애초에 Destroy() 오버로딩으로 기능이 구현되어 있었다...

오브젝트 풀 구현

  • 오브젝트 풀 클래스

using System.Collections.Generic;
using UnityEngine;

public class ObjectPool : MonoBehaviour
{
    [SerializeField] Stack<OutObject> bullets;
    [SerializeField] Stack<OutObject> bursts;
    [SerializeField] OutObject prefab;
    [SerializeField] GameObject prefab1;
    [SerializeField] float speed;
    public Transform muzzlePoint;
    public int magazineCapacity;
    void Awake()
    {
        bullets = new Stack<OutObject>();
        for (int i = 0; i < magazineCapacity; i++)
        {
            OutObject instance = Instantiate(prefab);
            instance.gameObject.SetActive(false);
            bullets.Push(instance);
        }
    }

    public OutObject GetObjectPool()
    {
        if (bullets.Count == 0)
        {
            OutObject bulletOut1 = Instantiate(prefab, muzzlePoint.position, muzzlePoint.rotation);
            Rigidbody rig1 = bulletOut1.GetComponent<Rigidbody>();
            rig1.velocity = muzzlePoint.forward * speed;
            return bulletOut1;
        }
        OutObject bulletOut = bullets.Pop();
        bulletOut.returnPool = this;
        bulletOut.transform.position = muzzlePoint.position;
        bulletOut.transform.rotation = muzzlePoint.rotation;
        bulletOut.gameObject.SetActive(true);
        Rigidbody rig = bulletOut.GetComponent<Rigidbody>();
        rig.velocity = muzzlePoint.forward * speed;

        return bulletOut;
    }
    public void ReturnObjectPool(OutObject instance)
    {
        instance.transform.rotation = Quaternion.identity;
        instance.gameObject.SetActive(false);
        bullets.Push(instance);
        Instantiate(prefab1,instance.transform.position,instance.transform.rotation);
    }
}
  • 오브젝트풀에서 빌려온 오브젝트 클래스

using UnityEngine;

public class OutObject : MonoBehaviour
{
    public ObjectPool returnPool;
    [SerializeField] float clearTime;
    private float timer;
    private void OnEnable()
    {
        timer = clearTime;
    }
    private void Update()
    {
        timer -= Time.deltaTime;
        if(timer<=0)
        {
            ReturnPool();
        }
    }
    public void ReturnPool()
    {
        if (returnPool == null)
        {
            Destroy(gameObject);
        }
        else
        {
            returnPool.ReturnObjectPool(this);
        }
    }
}

  • 핵심은 빌려온 것을 반납하는 거다. 구현 자체는 간단한 개념
  • Instantiate로 생성한 복제품을 stack으로 넣었다
  • 시간이 지나면 자동으로 반납하게 구현 되어 있다

참고

오브젝트 풀을 대여(리스트에서 삭제후 꺼내기 말고 리스트에서 직접사용하기)

  • bool isUsable 로 판단해서 대여
  • isUsable이 배열 순회해서 전부 false면 새로 만들어줌
  • 반납도 새로 만든 것이면 삭제, false인 애면 true로 바꿔주고 속성 리셋하면 끝

유니티 내장 오브젝트 풀 조사

  • 유니티가 자체적으로 제공하는 오브젝트 풀을 사용해보자. C#의 인터페이스를 어떻게 활용해 볼지 알아보자
profile
개발 박살내자

0개의 댓글