내일배움캠프 5주차 2일차 TIL - 오브젝트 풀링

백흰범·2024년 5월 14일
0
post-thumbnail

오늘 한 일

  • 챌린지반 과제 진행
  • 개인 프로젝트 진행 후 제출
  • 오브젝트 풀링 파헤치기

오늘은 게임 개발에 널리 사용되는 테크닉 중 하나인 오브젝트 풀링에 대해서 알아보자


오브젝트 풀링

개념

필요한 객체를 미리 생성해두고 사용이 필요할 때 pool에서 꺼내고, 사용이 끝나면 다시 pool에 반납하는 방식의 테크닉이다.

방법

  1. 객체를 미리 생성해두는 방식
  2. 미리 안 만들어놓고 필요한 상황이 왔을 때 만드는 방식
  • 둘다 혼합해서 사용하는 경우도 있으니 요구 사항에 맞게 만들면 된다.


오브젝트 풀링을 사용하는 이유

1. 메모리 할당과 가비지 컬렉팅으로 인한 프레임 드랍 최소화

  • 프로그래밍에서 오브젝트를 생성하거나 파괴하는 작업은 꽤나 무거운 작업이다.
  • 오브젝트 생성은 메모리를 새로 할당하고 리소스를 로드하는등의 초기화 과정이 필요하다
  • 오브젝트 파괴는 파괴 이후에 발생하는 가비지 컬렉팅으로 인한 프레임 드랍이 발생할 수 있다.
    -> Destroy 함수로 게임 오브젝트를 파괴한다고 선언하면 곧바로 메모리에서 사라지는 것이아니라 가비지 콜렉터가 수거해서 파괴하기 전까지는 메모리에 남아있다. (쌓여있는 가비지가 많으면 많을수록 수거 파괴에 더 많은 시간이 걸린다.)

2. 메모리 단편화

  • 메모리를 제거하는 과정에서 빈 공간이 생기는데, 만약에 새로 할당될 메모리 크기가 빈 메모리 공간보다 크다면 해당 메모리 공간을 사용하지 못하고, 들어갈 수 있는 메모리 공간이 있는지 다시 탐색한다. 설령 흩어져있는 빈 메모리 공간들의 크기가 해당 메모리보다 크더라도 각자의 빈 메모리 공간이 부족하다면 말이다. 이것을 외부 메모리 단편화라고 부른다.
  • 외부 메모리 단편화의 문제점은 앞서 말했듯이 메모리 공간이 충분함에도 불구하고 메모리를 할당하지 못하는 현상이다. 유니티의 가비지 컬렉터는 이러한 메모리 외부 단편화를 해결해주지 못한다. (*Compaction 기능을 지원하지 않는다!)
*해당 문서 내용 중에서 -> 'The garbage collector is also non-compacting, which means that Unity doesn’t redistribute any objects in memory to close the gaps between objects'
-> "유니티의 가비지 컬렉터는 비압축 방식으로 메모리 상의 객체들 사이의 공간을 메우기 위해 객체들을 재배치하지 않습니다."

외부 메모리 단편화 예시

[1] 메모리2를 가비지 콜렉터가 수거해 파괴했다.


[2] 메모리4를 할당하려하지만 해당 빈 메모리 공간의 크기가 부족해 할당을 할 수가 없다.


[3] 메모리4는 자기가 들어갈 수 있는 공간을 찾아서 할당한다.


[4] 이 과정이 지속되면 메모리 공간이 충분함에도 불구하고 할당하지 못하는 상황이 발생한다.


그래서 위와 같은 이유로 인해 오브젝트 풀링을 사용해야하는 이유이다.

Object Pool의 2가지 구현 방법

클래스로 생성하기

UML 간략화

  • 해당 스크립트는 스파르타 코딩클럽에서 사용한 오브젝트 풀링 스크립트를 필수적인 부분만 남겨서 만든 스크립트입니다.


1. 오브젝트 생성 및 보관

ObjectPool 클래스는 오브젝트를 초기 생성 후 타 클래스에게 오브젝트를 넘겨주는 역할을 해준다.

코드 예시)

public class ObjectPool : MonoBehaviour
{
    public GameObject prefab; 	// 투사체 정보 보관 
    public int size; 			// 초기 생성 갯수

    public Queue<GameObject> objectPool;	// 오브젝트를 보관할 큐

    void Awake()
    {
        objectPool = new Queue<GameObject>();		// 큐 초기화
        for (int i = 0; i < size; i++)	// 정해진 갯수만큼 반복
        {
            GameObject obj = Instantiate(prefab);	// 오브젝트 생성
            obj.SetActive(false);					// 비활성화
            objectPool.Enqueue(obj);				// 큐에 할당
        } // 해당 과정이 오브젝트를 생성하고 풀에 보관하는 과정이다.
    }

런타임

  • 초기 갯수를 20으로 정해놓으면 프로젝트 실행 시 이와 같이 Prefab을 인스턴스화 해준다.


2. 오브젝트 전달

오브젝트가 필요하게 되면 ObjectPool이 TopDownShooting에게 오브젝트를 전달해주는 과정이다.

코드 예시)

// ObjectPool.cs
   public GameObject SpawnFromPool()
   {
       GameObject obj = objectPool.Dequeue();	// 큐에서 나온 걸 obj에 할당
       objectPool.Enqueue(obj);					// obj를 다시 큐에 할당
       obj.SetActive(true);		// obj 활성화
       return obj;				// obj를 반환
   }
  
// TopDownShooting.cs
    private TopDownController controller;	// 옵서버 관리 클래스
    private ObjectPool objectPool;			// 오브젝트 풀
    [SerializeField] private Transform projectileSpawnPosition; // 투사체 생성 위치
    private Vector2 aimDirection = Vector2.right;	// 마우스 위치 

	void Awake()
    {
        controller = GetComponent<TopDownController>();
        objectPool = GetComponent<ObjectPool>();
    }

  void Start()
  {
      controller.OnAttackEvent += OnShoot;	// 클릭할 시 호출되는 이벤트에 할당
      controller.OnLookEvent += OnAim;		// 마우스 위치를 전달해주는 이벤트에 할당
  }
  private void OnAim(Vector2 newAimDirection)
  { 
      aimDirection = newAimDirection.normalized;	// 받은 마우스 방향을 보관
  }

  private void OnShoot()
  { 
      CreateProjectile();	// 투사체 생성
  }

  private void CreateProjectile()	// 투사체를 가져오고 필요한 정보를 전달
  {
      GameObject obj = objectPool.SpawnFromPool();	// Pool로부터 obj 반환 받기
      ...
  }

런타임

  • 마우스 클릭 이벤트로 인해 맨 위에 있는 오브젝트들부터 활성화되는 모습이다.


3. 오브젝트 사용 및 정보 전달

전달받은 오브젝트에 필요한 정보를 전해준다.

  • TopDownShooting.cs

  • ProjectileController.cs

코드 예시)

 // TopDownShooting.cs 
 	private void CreateProjectile()
    {
        ...
        obj.transform.position = projectileSpawnPosition.position;	// 생성 위치 전달
        ProjectileController attackController = obj.GetComponent<ProjectileController>();	// obj로부터 스크립트 가져오기
        attackController.InitializeAttack(aimDirection); // 가져온 스크립트의 메서드를 실행
    }
   
// ProjectileController.cs
    private Vector2 direction;	// 날라갈 방향
    private bool isReady;		// 생성 시간 동안 준비되지 않은 상태에서 움직이는 거 막는 용도

    private Rigidbody2D rigidbody;	// 위치 이동에 필요한 컴포넌트

    private void Start()
    {
        rigidbody = GetComponent<Rigidbody2D>();
    }

    private void Update()
    {
    	if (!isReady) { return; }
        ...
        rigidbody.velocity = direction * 5;	// 해당 방향으로 이동
    }

    public void InitializeAttack(Vector2 direction)	// 호출된다면 초기 세팅 메서드
    {
        this.direction = direction;			// 전달받은 방향 할당
        transform.right = this.direction;	// 스프라이트의 렌더러 방향 정하는 용도
        ...
        isReady = true;	// 사용 준비 완료
    }

런타임

  • 전달받은 정보대로 투사체가 잘 날라가는 모습이다.

4. 사용이 끝나면 비활성화

코드 예시)

public class MyProjectileController : MonoBehaviour
{
    private float currentDuration; // 생성 경과 시간
 	...
  
	private void Update()
	{
	    ...
    	currentDuration += Time.deltaTime;	// 생성 경과 시간 업데이트
    	if (currentDuration > 2) { DestroyProjectile(); }	// 생성 경과 시간이 일정 이상 넘으면 파괴 메서드 호출
    	...
	}
   
    public void InitializeAttack(Vector2 direction)
	{
	    currentDuration = 0;	// 생성 경과 시간 초기화
	}

    private void DestroyProjectile() // 사용이 끝날 때 호출되는 메서드
    { gameObject.SetActive(false); }

런타임

  • 생성 후 일정 이상의 시간이 넘어가면 사라지는 모습이다.


구현 후 프로젝트 실행의 모습




UnityEngine.Pool 사용하기

using UnityEngine.Pool을 활용해서 오브젝트 풀링을 구현하는 방법이다.

UML 간략화

  • 위 방식은 필요 시에 생성되고 재활용하는 방식을 채용했다.


nameSpace UnityEngine.Pool

  • 해당 네임스페이스를 디컴파일해보면 위와 같은 형태로 되어있다.
    메서드 내부는 생략했다
  • 각각의 필드를 해석해주자면
    m_List - 오브젝트 풀
    m_CreatFunc - 생성 델리게이트
    m_ActionOnGet - 사용 델리게이트
    m_ActionOnRelease - 해제 델리게이트
    m_ActionOnDestroy - 파괴 델리게이트
    m_MaxSize - 최대 보관 갯수
    m_CollectionCheck - 컬렉션 체크 활성 또는 비활성화 (리스트로 돌아올 때 겹치는 오브젝트가 있는지 확인하는 용도이다.)
    CountAll - 총 오브젝트 갯수
    CountActive - 활성화 중인 오브젝트 갯수
    CountInActive - 비활성화 중인 오브젝트 갯수
  • ++는 생성자에 해당하고 +는 메서드에 해당한다


오브젝트 풀 생성하기

  • 필수 요구 사항으로 생성 메서드만 줄 것 같아도 되지만, 그러면 말 그대로 생성만 되기 때문에 결국 원활한 사용을 위해서는 사용 메서드와 사용 해제 메서드까지는 있어야한다.

코드 예시)

public class Shooter : MonoBehaviour
{
    [SerializeField] private GameObject _BulletPrefab; // 투사체 Prefab

    private IObjectPool<Bullet> _Pool;	// 오브젝트 풀

    private void Awake()
    {
        _Pool = new ObjectPool<Bullet>(CreateBullet, OnGetBullet, OnReleaseBullet, OnDestroyBullet, maxSize:50); // 오브젝트 풀을 생성하게 담기
    }

    private Bullet CreateBullet()	// 투사체 생성
    {
        Bullet bullet = Instantiate(_BulletPrefab).GetComponent<Bullet>(); // 투사체 생성
        bullet.SetManagedPool(_Pool);	// 투사체에 해제 및 파괴 메서드 사용 때문에 필요하다. 
        return bullet;
    } // 미리 생성되는 건 아니고 필요할 시 생성하게 된다. (ObjectPool의 Get() 발동 시)

    private void OnGetBullet(Bullet bullet) // 투사체 사용 
    {
        bullet.gameObject.SetActive(true);
    }

    private void OnReleaseBullet(Bullet bullet) // 투사체 해제
    {
        bullet.gameObject.SetActive(false);
    }

    private void OnDestroyBullet(Bullet bullet) // 투사체 파괴 
    {
        Destroy(bullet.gameObject);
    }
}
  • Shooter 클래스가 기능이 겹친 것처럼 보이지만 사실상 네임스페이스에 있는 ObjectPool 클래스를 가져와서 사용하는 것이라 메서드만 들고 있지 기능은 분리되어있다고 봐도 무방하다.


오브젝트 사용 및 정보 전달

코드)

// Shooter.cs
    [SerializeField] private Transform _PlayerPosition;

    private TopDownController controller;
   
    void Start()
    {
        controller = GetComponentInParent<TopDownController>();
        controller.OnAttackEvent += ShootBullet; // 클릭 이벤트 할당
    }

    private void ShootBullet()
    {
        var direction = ((Vector2)transform.position - (Vector2)_PlayerPosition.position).normalized;
        var bullet = _Pool.Get(); // Pool에서 투사체를 가져온다. (없다면 생성해서 주고, 있다면 가진 오브젝트를 준다.)
        bullet.transform.position = transform.position; // 투사체 소환 지점 지정
        bullet.Shoot(direction); // 목표 방향을 전해준다.
    }

// Bullet.cs
    private Vector2 _Direction; // 방향

    [SerializeField]
    private float _Speed = 7f; // 속도

    void Update()
    {
        transform.Translate(_Direction * Time.deltaTime * _Speed); // 지정된 방향으로 이동
    }

    public void Shoot(Vector2 dir)
    {
        _Direction = dir; // 목표 방향을 할당 받는다.
 	    ...
    }


사용이 끝나면 비활성화

public class Bullet : MonoBehaviour
{
    private IObjectPool<Bullet> _ManagedPool;
 	...
  
    public void SetManagedPool(IObjectPool<Bullet> pool)
    {
        _ManagedPool = pool; // ObjectPool의 기능을 사용하기 위해 가져온다
    } 

    public void Shoot(Vector2 dir)
    {
    	...
        Invoke("DestroyBullet", 2f); // 2초 후에 총알 파괴메서드 호출
    }

    public void DestroyBullet()
    {
        _ManagedPool.Release(this);	// 현재 오브젝트에게 사용 해제 메서드를 호출한다.
    } // 지정한 Maxsize보다 많다면 해당 오브젝트는 파괴될 것이고, 적다면 리스트에 보관된다.
}


구현 후 프로젝트 실행의 모습

  • 필요한 만큼 생성하고, 사용되는 모습을 볼 수 있다.

  • maxSize보다 더 많은 오브젝트가 생성되었을 때 초과 생성된 오브젝트는 제거된다.

참고 자료

에셋

Object Pool

유니티 자료




그 외에 알게된 점

버그 리포트

  • 컴파일 오류가 생긴 상태에서 CreateAssetMenu를 사용하려 하면 적용이 안된다. (컴파일 오류를 해결하면 정상적으로 적용된다.)

투사체에게 의도되지 않은 물리적인 충격을 입고 싶지 않다면

  • 투사체의 Collider에서 Layer Overrides에서 Force Send Layers를 Nothing으로 바꿔주면 된다.



작성하면서 느낀점

코드를 작성하고 실행시키면서 알게된 사실은 코드를 읽는 능력이 매우 중요하다는 사실을 알게되었다. 특히 지금 단계와 같이 아직 모르는 코드들이 너무 많을 때에는 더 중요하게 작용하는 것 같다. 다양한 코드들을 해석해보는 시간과 모르는 코드들을 PsuedoCode화 시켜보는 시간을 많이 들여야될 것 같다.
그리고 TIL을 좀 더 효율적으로 썼으면 한다. 아직까지 TIL 작성에 시간적 효율이 좋지 않은 것 같다.

profile
게임 개발 꿈나무

0개의 댓글