게임 개발 로그

유니티로 배우는 게임 디자인 패턴: ObjectPool 패턴 (Generic 사용하기) 본문

게임 디자인 패턴

유니티로 배우는 게임 디자인 패턴: ObjectPool 패턴 (Generic 사용하기)

03:00am 2024. 11. 30. 22:29
'유니티로 배우는 게임 디자인 패턴' 책을 베이스로 하여 코드 구조를 바꿔 봤습니다.

 

 

 

오브젝트 풀이란?

  오브젝트 풀 패턴은 자주 사용하는 오브젝트를 위해 일부 메모리를 '예약'하는 방법을 말한다. 오브젝트의 생성/삭제를 반복하지 않고, 풀에서 꺼냈다가 다시 집어넣는 형식이다. 이것은 새로운 인스턴스를 생성하는 초기화 비용을 아끼고, 가비지 컬렉터의 부하를 줄여 렉을 줄이는 것에 도움이 된다. 

  클라이언트가 특정 오브젝트의 인스턴스를 오브젝트 풀에 요청하면, 풀에서 해당 인스턴스를 "제거"하고 클라이언트에게 넘겨준다. 만약 풀이 없다면 새로운 오브젝트를 생성해서 넘겨준다. 클라이언트에서 해당 인스턴스가 필요없어지면 오브젝트는 풀로 돌아가는데, 풀에 공간이 없다면 해당 오브젝트 인스턴스는 파괴된다.

 

 

 

오브젝트 풀의 장점

  • 예측할 수 있는 메모리 사용: 특정한 종류의 인스턴스를 어느정도 유지하도록 메모리 일부를 미리 할당한다.
  • 성능 향상: 새로운 객체를 초기화하는 비용이 필요없다.

 

 

 

오브젝트 풀의 단점

  • 이미 관리되는 메모리에 대한 레이어링이 필요하다.
  • 예측 불가능한 객체 상태: 객체가 초기화되지 않은 상태로 반환된다면 다시 오브젝트 풀에 요청했을 때 문제가 생길 수 있다. 

 

책의 구현

아래 더보기 글은 '유니티로 배우는 게임 디자인 패턴' 책에 나오는 예제 코드이다. (해당 책의 깃허브에서도 볼 수 있다.)

https://github.com/PacktPublishing/Game-Development-Patterns-with-Unity-2021-Second-Edition/tree/main/Assets/Chapters/Chapter08

더보기

'유니티로 배우는 게임 디자인 패턴'에 나오는 코드 

1. 풀링 적용할 오브젝트인 Drone

public class Drone : MonoBehaviour
{
    public IObjectPool<Drone> Pool { get; set; }
    public float _currentHealth;

    [SerializeField]
    private float maxHealth = 100.0f;
    [SerializeField]
    private float timeToSelfDestruct = 3.0f;

    void Start()
    {
        _currentHealth = maxHealth;
    }

    void OnEnable()
    {
        AttackPlay();
        StartCoroutine(SelfDestruct());
    }

    private void OnDisable
    {
        ResetDrone();
    }

    IEnumerator SelfDestruct()
    {
        yield return new WaitForSeconds(timeToSelfDestruct);
        TakeDamage(maxHealth);
    }

    private void ReturnToPool()
    {
        Pool.Release(this);
    }

    private void ResetDrone()
    {
        _currentHealth = maxHealth;
    }

    public void AttackPlayer()
    {
        Debug.Log("Attack Player!");
    }

    public void TakeDamage(float amount)
    {
        _currentHealth -= amount;
        if (_currentHealth <= 0.0f)
            ReturnToPool();
    }
}

 

2. 오브젝트를 관리하는 클래스 DroneObjectPool

public class DroneObjectPool : MonoBehaviour
{
    public int maxPoolSize = 10;
    public int stackDefaultCapacity = 10;

    public IObjectPool<Drone> Pool
    {
        get {
            if (_pool == null)
                _pool = new ObjectPool<Drone>(
                    CreatedPooledItem,
                    OnTakeFromPool,
                    OnReturnedToPool,
                    OnDestroyPoolObject,
                    true,
                    stackDefaultCapacity,
                    maxPoolSize);
            return _pool;
        }
    }

    private IObjectPool<Drone> _pool;
    
    private Drone CreatedPooledItem()
    {
        var go = GameObject.CreatedPooledItem(PrimitiveType.Cube);
        Drone drone = go.AddComponent<Drone>();
        go.name = "Drone";
        drone.Pool = Pool;
        return drone;
    }

    private void OnReturnedToPool(Drone drone)
    {
        drone.gameObject.SetActive(false);
    }

    private void OnTakeFromPool(Drone drone)
    {
        drone.gameObject.SetActive(true);
    }

    private void OnDestroyPoolObject(Drone drone)
    {
        Destroy(drone.gameObject);
    }

    public void Spawn()
    {
        var amount = Random.Range(1, 10);

        for (int i = 0; i < amount; ++i)
        {
            var drone = Pool.Get();
            drone.transform.position = Random.insideUnitSphere * 10;
        }
    }
}

 

3. 실제 클라이언트에서 사용하는 코드인 ClientObjectPool

public class ClientObjectPool : MonoBehaviour
{
    private DroneObjectPool _pool;
    
    void Start()
    {
        _pool = gameObject.AddComponent<DroneObjectPool>();
    }

    void OnGUI()
    {
        if (GUILayout.Button("Spawn Drones"))
            _pool.Spawn();
    }
}

 

 

활용

위의 기본적인 코드를 바탕으로 코드를 재구성하여 재사용성을 높여보았다. 

 

1. ObjPool class

  책의 DroneObjectPool 클래스의 내용과 아주 비슷하지만 제너릭(Generic) 형식을 이용하여 재사용성을 높였다. 제너릭을 사용하면 재사용성 뿐만 아니라 컴파일 단계에서 타입이 결정되기 때문에 박싱/언박싱에 비해 안정성과 성능이 좋다. 

public class ObjPool<T> where T : Obj<T>
{
	public int maxPoolSize = 10;
	public int stackDefaultCapacity = 10;
	public GameObject par = null;

	public IObjectPool<T> Pool
	{
		get
		{
			if (_pool == null)
				_pool = new ObjectPool<T>(
					CreatedPooledItem,
					OnTakeFromPool, 
					OnReturnedToPool,
					OnDestroyPoolObject,
					true,
					stackDefaultCapacity,
					maxPoolSize);
			return _pool;
		}
	}

	private IObjectPool<T> _pool;

	private T CreatedPooledItem()
	{
		var go = GameObject.CreatePrimitive(PrimitiveType.Cube);
		T obj = go.AddComponent<T>();
		go.name = obj.GetType().ToString();
		if (par == null)
			par = new GameObject("@" + obj.GetType().ToString());
		go.transform.parent = par.transform;
		obj.Pool = Pool;
		return obj;
	}

	private void OnReturnedToPool(T obj)
	{
		obj.gameObject.SetActive(false);
	}

	private void OnTakeFromPool(T obj)
	{
		obj.gameObject.SetActive(true);
	}

	private void OnDestroyPoolObject(T obj)
	{
		Object.Destroy(obj.gameObject);
	}

	public void Spawn()
	{
		var amount = Random.Range(1, 10);	
		for ( int i =0; i < amount; ++i)
		{
			var obj = Pool.Get();
			obj.transform.position = Random.insideUnitSphere * 10;
		}
	}
}

 

 

2. Obj Class

  ObjPool에 담을 수 있는 것은 Obj 클래스로 한정했다. 실제 게임 환경에서 오브젝트 풀링이 일어나는 것은 Object 뿐일 것이라고 예상(설계)했다. 따라서 ObjPool에는 Obj 타입만 들어갈 수 있고, 그 Obj 타입은 Pool을 가지고 있도록 만들었다. 

public class Obj<T> : MonoBehaviour where T : Obj<T>
{
	public IObjectPool<T> Pool { get; set; }
}

 

 

3. Drone Class

  실제 Obj로서 구현된 객체다. 이 Drone들은 모두 오브젝트 풀로 관리될 것이다. 책의 코드와 다른 점은 Obj<Drone>을 상속받고 있다는 점뿐이다. 

public class Drone : Obj<Drone>
{
    public float _currentHealth;
    [SerializeField] 
    private float maxHealth = 100.0f;
    [SerializeField]
    private float timeToSelfDestruct = 3.0f;

    private void Start()
    {
        _currentHealth = maxHealth;
    }

    private void OnEnable()
    {
        AttackPlayer();
        StartCoroutine(SelfDestruct());
    }
    private void OnDisable()
    {
        ResetDrone();
    }

    IEnumerator SelfDestruct()
    {
        yield return new WaitForSeconds(timeToSelfDestruct);
        TakeDamage(maxHealth);
    }

    private void ReturnToPool()
    {
        Pool.Release(this);
    }

    private void ResetDrone()
    {
        _currentHealth = maxHealth;
    }

    public void AttackPlayer()
    {
        Debug.Log("Attack Player!");
    }


    public void TakeDamage(float damage)
    {
        _currentHealth -= damage;

        if (_currentHealth <= 0.0f)
            ReturnToPool();
    }
}

 

 

4. ClientObjectPool

  실제로 ObjectPool을 사용해 보는 코드이다. 책의 내용과 달라진 점은 DroneObjectPool을 사용하던 것을 ObjPool<Drone>로 바꿨다는 점이다. 이것을 바꾸게 되면서 기존에 gameObject.AddComponent<DroneObjectPool>() 로 추가하던 부분을 private ObjPool<Drone> _pool 로 변경하게 되었다.

public class ClientObjectPool : MonoBehaviour
{
	private ObjPool<Drone> _pool;

	private void Start()
	{
		_pool = new ObjPool<Drone>();
	}

	private void OnGUI()
	{
		if (GUILayout.Button("Spawn Drones"))
			_pool.Spawn();
	}
}

 

 

 

 

제너릭 형식으로 바꾸는 것이 생각보다 어려웠는데 결과적으로 잘 작동한다!

여전히 완벽하지는 않은 코드겠지만 조금씩 수정하면서 다듬는 과정이 즐거웠다.