paint-brush
ObjectPool 패턴을 사용하여 게임 프로젝트를 향상하는 방법(Unity 가이드)~에 의해@jokeresdeu
900 판독값
900 판독값

ObjectPool 패턴을 사용하여 게임 프로젝트를 향상하는 방법(Unity 가이드)

~에 의해 Les Dibrivniy16m2023/05/10
Read on Terminal Reader
Read this story w/o Javascript

너무 오래; 읽다

개체 풀은 게임 프로젝트 최적화의 99%의 기초입니다. 이는 작업을 완료한 후 개체가 삭제되지 않고 별도의 환경으로 이동되어 검색 및 재사용이 가능하다는 기본 원칙을 기반으로 합니다. Les Dibrivniy는 이 패턴이 필수적인 이유를 설명합니다.
featured image - ObjectPool 패턴을 사용하여 게임 프로젝트를 향상하는 방법(Unity 가이드)
Les Dibrivniy HackerNoon profile picture
0-item
1-item

안녕하세요. 제 이름은 Oles Dibrivniy이고 Keiki 의 Unity 개발자입니다. 우리는 EdTech와 GameDev의 교차점에서 어린이 발달을 위한 앱과 웹 제품을 만듭니다. 이제 우리 제품의 사용자는 400만 명이 넘습니다.

취업 면접 중에 누군가 게임 개발에 사용하는 프로그래밍 패턴에 대해 묻는다면 가장 먼저 언급해야 할 것 중 하나가 ObjectPool입니다. 이는 작업을 완료한 후 개체가 삭제되지 않고 별도의 환경으로 이동되어 검색 및 재사용이 가능하다는 기본 원칙을 기반으로 합니다.

이 패턴은 앱에 대한 사용자의 인식에 직접적인 영향을 미치므로 이것이 중요합니다. 이는 게임 프로젝트 최적화의 99% 기초가 되어야 합니다.

이 기사는 프로그래밍 전문가에게는 적합하지 않을 수 있지만 그 반대의 경우도 마찬가지입니다. 여기서는 예시를 사용하여 이 패턴이 필수적인 이유를 설명하겠습니다.


ObjectPool 없이는 어떻게 진행되나요?

먼저 ObjectPool이 없는 프로젝트 사례를 설정부터 분석해보겠습니다. 주인공과 두 명의 적들이 불덩어리를 던지는 비교적 간단한 장면이 있습니다.

불 덩어리 자체가 어떻게 생겼는지 생각해 봅시다. 구역

 Rigidbody2D
그리고
 _speed
불덩어리의 움직임을 담당하게 됩니다. 그만큼
 OnTriggerEnter2D
메서드는 충돌 후에 작동합니다. FireBall 구성 요소가 있는 객체는 파괴됩니다.

 public class FireBall : MonoBehaviour 2 { 3 [SerializeField] private Rigidbody2D _rigidbody; 4 [SerializeField] private float _speed; 5   
6 public void FlyInDirection(Vector2 flyDirection) 7 { 8 _rigidbody.velocity = flyDirection * _speed; 9 } 10   
11 private void OnTriggerEnter2D(Collider2D other) 12 { 13 Destroy(gameObject); 14 } 15 }

적 역시 매우 단순해 보입니다.

 public class Enemy : MonoBehaviour 2 { 3 [SerializeField] private FireBall _fireBall; 4 [SerializeField] private Transform _castPoint; 5   
6 public void Cast() 7 { 8 FireBall fireBall = Instantiate(_fireBall, _castPoint.position, Quaternion.identity); 9 fireBall.FlyInDirection(transform.right); 10 } 11 }

전화를 걸 수 있습니다.

 Cast
프로젝트 또는 애니메이션 이벤트의 어느 시점에서든 방법을 사용할 수 있습니다. 다음과 같은 흐름이 있습니다. 적은 불덩어리를 생성하여 자신이 바라보는 방향으로 발사했습니다. 불덩어리는 도중에 첫 번째 장애물에 도달하면 파괴됩니다.

이 접근 방식은 최적인 것처럼 보이지만 최악의 옵션 중 하나입니다. 모든 것을 하나씩 분석해 보겠습니다.

1. 기기의 CPU와 메모리를 비효율적으로 사용합니다.

 Instantiate
그리고
 Destroy
비용이 많이 드는 작업입니다. 몇 초마다 호출하면 지연이 발생합니다. 이는 메모리의 모든 바이트를 저장해야 하는 스마트폰과 같은 장치로 작업할 때 특히 두드러집니다. Unity 프로파일러를 열고 Hierarchy 창의 검색 필드에 Instantiate를 입력하면 이러한 작업이 게임에 얼마나 "고통스러운지" 확인할 수 있습니다. 다음이 있을 것입니다:

물론 드라마틱한 효과를 극대화하기 위해 적들이 한 번에 생성하는 파이어볼의 수를 500개로 늘렸습니다. 그러나 특히 UI 요소나 파티클을 다룰 때 더욱 지속적으로 생성되고 삭제되는 개체가 있는 프로젝트를 상상하기 쉽습니다.

이러한 프로세스는 런타임 중에 지속적으로 발생합니다. 스테이지에 수백 개의 개체를 생성한 후 지속적인 메모리 할당으로 인해 플레이어의 프레임이 눈에 띄게 저하될 수 있습니다.

2. 당신이 만든 모든 것은 파기하고 청소해야 합니다. 그만큼

 Destroy
메서드는 장면에서 게임 개체를 제거하여 가비지 수집기로 보냅니다. 수집기가 이를 처리하는 시기와 방법을 제어할 수 없습니다. 그런데,
 Destroy
매우 교활합니다. 게임 개체 자체는 삭제되지만 해당 구성 요소는 계속해서 별도로 존재할 수 있습니다. 다른 개체가 이 구성 요소에 연결된 경우 이런 일이 발생할 수 있습니다. 예를 들어 특정 이벤트에 대한 구독일 수 있습니다.

3. 코드 제어. 다양한 클래스가 객체 생성 및 삭제를 담당하며 프로젝트에는 수십 개가 포함될 수 있습니다. 생성되거나 삭제된 항목과 위치를 찾는 것은 때때로 간단한 작업이 아니며 지금은 계층 구조의 개체를 제어하는 것에 대해 침묵하고 있습니다.

ObjectPool을 프로젝트에 통합해 보겠습니다!

문제를 정의한 후 해당 솔루션으로 넘어 갑시다. 앞서 언급했듯이 ObjectPool 패턴 작업의 원리는 간단합니다. 객체 작업을 마친 후에는 삭제되지 않고 "풀"에 숨겨집니다. 객체를 검색하고 재사용할 수 있습니다.

하나의 엔터티는 객체의 생성, 재사용 및 파괴를 담당합니다. 이를 호출하겠습니다.

 ObjectPool
. 불덩어리를 사용하여 작업하려면 다음과 같이 만들 수 있습니다.

 public class ObjectPool : MonoBehaviour 2 { 3 [SerializeField] private FireBall _fireBallPrefab; 4   
5 private readonly List <FireBall> _freeFireBalls = new List <FireBall>(); 6   
7 public FireBall GetFireBall() 8 { 9 FireBall fireBall; 10       if (_freeFireBalls.Count > 0 ) 11 { 12 fireBall = _freeFireBalls[ 0 ]; 13 _freeFireBalls. Remove(fireBall);
14 } 15       else
16 { 17 fireBall = Instantiate(_fireBallPrefab, transform); 18 } 19       return fireBall; 20 } 21   
22 private void ReturnFireBall(FireBall fireBall) 23 { 24 _freeFireBalls.Add(fireBall); 25 } 26 }

그만큼

 _freeFireBalls
목록이 이 코드에 나타납니다. 우리는 거기에 작업을 수행한 생성된 불덩어리를 저장할 것입니다. 이제 적의 모습은 다음과 같습니다.

 public class Enemy : MonoBehaviour 2 { 3 [SerializeField] private ObjectPool _objectPool; 4 [SerializeField] private Transform _castPoint; 5   
6 public void Cast() 7 { 8 FireBall fireBall = _objectPool.GetFireBall(); 9 fireBall.transform.position = _castPoint.position; 10 fireBall.FlyInDirection(transform.right); 11 } 12 }

문제의 근본 원인은 어떻게 불덩어리를 수영장에 되돌릴 수 있을까요? 우리는 적에게 의지할 수 없습니다. 그는 불 덩어리가 언제 파괴될지 모릅니다. 우리는 또한 다음에 대한 불덩어리 지식을 제공하고 싶지 않습니다.

 ObjectPool
, 불필요한 연결이 생성되기 때문입니다.

내가 만든 것을 당신이 알아차렸기를 바랍니다.

 ReturnFireBall
메소드 비공개. 따라서 기본 C# 패턴 중 하나인 관찰자 및 구현인 이벤트를 사용합니다. 이제 불덩이는 다음과 같이 보일 것입니다:

 public class FireBall : MonoBehaviour 2 { 3 [SerializeField] private Rigidbody2D _rigidbody; 4 [SerializeField] private float _speed; 5   
6 public event Action<FireBall> Destroyed; 7   
8 public void FlyInDirection(Vector2 flyDirection) 9 { 10 _rigidbody.velocity = flyDirection * _speed; 11 } 12   
13 private void OnTriggerEnter2D(Collider2D other) 14 { 15 Destroyed?.Invoke(this); 16 } 17 }

 ObjectPool
을 구독할 것이다
 Destroyed
객체를 세계에 전달한 후의 이벤트:

 public class ObjectPool : MonoBehaviour 2 { 3 [SerializeField] private FireBall _fireBallPrefab; 4 private readonly List <FireBall> _freeFireBalls = new List <FireBall>(); 5   
6 public FireBall GetFireBall() 7 { 8 FireBall fireBall; 9       if (_freeFireBalls.Count > 0 ) 10 { 11 fireBall = _freeFireBalls[ 0 ]; 12 _freeFireBalls. Remove(fireBall);
13 } 14       else
15 { 16 fireBall = Instantiate(_fireBallPrefab, transform); 17 } 18 fireBall.Destroyed += ReturnFireBall; 19       return fireBall; 20 } 21   
22 private void ReturnFireBall(FireBall fireBall) 23 { 24 fireBall.Destroyed -= ReturnFireBall; 25 _freeFireBalls.Add(fireBall); 26 } 27 }

이제 개체 풀을 개체에 걸어 두는 것이 남아 있습니다.

 Enemy
구성 요소 — 축하합니다! 가비지 수집기는 다른 장면으로 이동하거나 게임을 닫을 때까지 정지됩니다. 기본 구현에는 ObjectPool이 있지만 개선할 수 있습니다.

인터페이스 및 제네릭: ObjectPool과 완벽하게 일치

그만큼

 FireBall
개체 풀을 통해 실행할 유일한 개체 유형은 아닙니다. 별도로 작성하셔야 합니다
 ObjectPool
각각이 다른 유형과 함께 작동하도록 합니다. 이렇게 하면 코드 기반이 확장되고 코드 읽기가 어려워집니다. 따라서 모방품과 제네릭품을 사용해 봅시다.

우리가 실행하는 각 객체는

 ObjectPool
특정 유형에 바인딩되어야 합니다. 이는 특정 구현과 독립적으로 처리되어야 합니다. 풀이 처리할 개체의 기본 상속 계층 구조를 유지하는 것이 필수적입니다. 그들은 다음으로부터 상속받을 것이다
 MonoBehaviour
, 적어도. IPoolable 인터페이스를 사용해 보겠습니다.

 public interface IPoolable 2 { 3 GameObject GameObject { get ; } 4 event Action<IPoolable> Destroyed; 5 void Reset (); 6 }

우리는 상속

 FireBall
그것에서:

 public class FireBall : MonoBehaviour, IPoolable 2 { 3 [SerializeField] private Rigidbody2D _rigidbody; 4 [SerializeField] private float _speed; 5 public GameObject GameObject => gameObject; 6   
7 public event Action<IPoolable> Destroyed; 8   
9 private void OnTriggerEnter2D(Collider2D other) 10 { 11       Reset (); 12 } 13 public void Reset () 14 { 15 Destroyed?.Invoke(this); 16 } 17   
18 public void FlyInDirection(Vector2 flyDirection) 19 { 20 _rigidbody.velocity = flyDirection * _speed; 21 } 22 }

마지막 임무는 가르치는 것이다.

 ObjectPool
어떤 개체로든 작업할 수 있습니다. IPoolable로는 충분하지 않습니다. 왜냐하면 Instantiate는 객체의 복사본만 생성할 수 있기 때문입니다.
 gameObject
재산. 우리는 사용할 것이다
 Component
; 그러한 각 객체는 이 클래스에서 상속됩니다.
 MonoBehaviour
또한 그것으로부터 상속됩니다. 결과적으로 우리는 다음을 얻게 될 것이다.
 ObjectPool
:

 public class ObjectPool<T> where T : Component, IPoolable 2 { 3 private readonly List <IPoolable> _freeObjects; 4 private readonly Transform _container; 5 private readonly T _prefab; 6   
7 public ObjectPool(T prefab) 8 { 9 _freeObjects = new List <IPoolable>(); 10 _container = new GameObject().transform; 11 _container. name = prefab.GameObject. name ; 12 _prefab = prefab; 13 } 14   
15 public IPoolable GetFreeObject() 16 { 17 IPoolable poolable; 18       if (_freeObjects.Count > 0 ) 19 { 20 poolable = _freeObjects[ 0 ] as T; 21 _freeObjects. RemoveAt(0);
22 } 23       else
24 { 25 poolable = Object.Instantiate(_prefab, _container); 26 } 27 poolable.GameObject.SetActive(true); 28 poolable.Destroyed += ReturnToPool; 29       return poolable; 30 } 31   
32 private void ReturnToPool(IPoolable poolable) 33 { 34 _freeObjects.Add(poolable); 35 poolable.Destroyed -= ReturnToPool; 36 poolable.GameObject.SetActive(false); 37 poolable.GameObject.transform.SetParent(_container); 38 } 39 }

이제 우리는

 ObjectPool
상속 조건을 충족하는 모든 객체에 대해
 Component
및 IPool 가능. 나는
 ObjectPool
상속
 MonoBehaviour
, 따라서 객체에 매달릴 구성 요소의 수를 줄입니다.

해결해야 할 문제가 있습니다. 여러 명의 적들이 동시에 무대에 올 수 있으며, 각 적들은 동일한 불덩어리를 생성합니다. 모두가 같은 일을 겪었다면 참 좋았을 텐데

 ObjectPool
— 자원을 너무 많이 절약할 수는 없습니다! 하나의 클래스가 특정 객체 유형을 요청하도록 하는 것도 매우 편리합니다. 이러한 클래스는 객체의 생성, 제어 및 최종 폐기를 담당합니다. 이 옵션은 모든 관리를 위한 최선의 선택입니다.
 ObjectPool
프로젝트에서 개인적으로 어려운 일입니다.

기본 요구 사항을 충족하려면

 ObjectPool
, 장면이 로드될 때 설정된 수의 객체를 생성해야 합니다. 리소스 소비 문제를 방지하기 위해 로딩 창을 포함할 예정입니다. 이제 이를 구현해 보겠습니다.

추가 엔터티를 생성하겠습니다.

 PoolTask
최종 구현에서
 ObjectPool
. 이 클래스는 하나의 프리팹에서 생성된 객체에 대한 작업을 제어합니다.

 public class PoolTask 2 { 3 private readonly List <IPoolable> _freeObjects; 4 private readonly List <IPoolable> _objectsInUse; 5 private readonly Transform _container; 6   
7 public PoolTask(Transform container) 8 { 9 _container = container; 10 _objectsInUse = new List <IPoolable>(); 11 _freeObjects = new List <IPoolable>(); 12 } 13   
14 public void CreateFreeObjects<T>(T prefab, int count) where T : Component, IPoolable 15 { 16       for (var i = 0 ; i < count; i++) 17 { 18 var poolable = Object.Instantiate(prefab, _container); 19 _freeObjects.Add(poolable); 20 } 21 } 22   
23 public T GetFreeObject<T>(T prefab) where T : Component, IPoolable 24 { 25 T poolable; 26       if (_freeObjects.Count > 0 ) 27 { 28 poolable = _freeObjects[ 0 ] as T; 29 _freeObjects. RemoveAt(0);
30 } 31       else
32 { 33 poolable = Object.Instantiate(prefab, _container); 34 } 35 poolable.Destroyed += ReturnToPool; 36 poolable.GameObject.SetActive(true); 37 _objectsInUse.Add(poolable); 38       return poolable; 39 } 40   
41 public void ReturnAllObjectsToPool() 42 { 43 foreach (var poolable in _objectsInUse) 44 poolable. Reset (); 45 } 46   
47 public void Dispose() 48 { 49 foreach (var poolable in _objectsInUse) 50 Object.Destroy(poolable.GameObject); 51       
52 foreach (var poolable in _freeObjects) 53 Object.Destroy(poolable.GameObject); 54 } 55   
56 private void ReturnToPool(IPoolable poolable) 57 { 58 _objectsInUse. Remove(poolable);
59 _freeObjects.Add(poolable); 60 poolable.Destroyed -= ReturnToPool; 61 poolable.GameObject.SetActive(false); 62 poolable.GameObject.transform.SetParent(_container); 63 } 64 }

PoolTask에는 다음과 같은 추가 기능이 있습니다.

1. 필요한 경우 특정 순간에 해당 객체를 파기하거나 풀로 반환할 수 있도록 세상에 공개한 객체를 추적합니다.

2. 미리 결정된 수량의 무료 개체를 생성합니다.

마지막으로, 우리의 모든 요구 사항을 충족하고 개체의 제어 및 생성을 완전히 대신할 ObjectPool을 만들어 보겠습니다.

 public class ObjectPool 2 { 3 private static ObjectPool _instance; 4 public static ObjectPool Instance => _instance ??= new ObjectPool(); 5   
6 private readonly Dictionary<Component, PoolTask> _activePoolTasks; 7 private readonly Transform _container; 8  
9 private ObjectPool() 10 { 11 _activePoolTasks = new Dictionary<Component, PoolTask>(); 12 _container = new GameObject().transform; 13 _container. name = nameof(ObjectPool); 14 } 15   
16 public void CreateFreeObjects<T>(T prefab, int count) where T : Component, IPoolable 17 { 18       if (!_activePoolTasks.TryGetValue(prefab, out var poolTask)) 19 AddTaskToPool(prefab, out poolTask); 20       
21 poolTask.CreateFreeObjects(prefab, count); 22 } 23   
24 public T GetObject<T>(T prefab) where T : Component, IPoolable 25 { 26       if (!_activePoolTasks.TryGetValue(prefab, out var poolTask)) 27 AddTaskToPool(prefab, out poolTask); 28       return poolTask.GetFreeObject(prefab); 29 } 30   
31 public void Dispose() 32 { 33 foreach (var poolTask in _activePoolTasks.Values) 34 poolTask.Dispose(); 35 } 36   
37 private void AddTaskToPool<T>(T prefab, out PoolTask poolTask) where T : Component, IPoolable 38 { 39 var taskContainer = new GameObject 40 { 41           name = $ "{prefab.name}_pool" , 42 transform = 43 { 44 parent = _container 45 } 46 }; 47 poolTask = new PoolTask(taskContainer.transform); 48 _activePoolTasks.Add(prefab, poolTask); 49 } 50 }

싱글톤의 사용이 눈에 띌 수 있습니다. 이는 단지 예일 뿐입니다. 프로젝트의 사용법을 사용자 정의할 수 있습니다. 실행 중일 수도 있습니다.

 ObjectPool
생성자를 통해 또는 Zenject를 통한 주입을 통해.

우리는 최종 버전을 가지고 있습니다

 Cast
방법
 Enemy
:

 public class Enemy : MonoBehaviour 2 { 3 [SerializeField] private FireBall _prefab; 4 [SerializeField] private Transform _castPoint; 5   
6 public void Cast() 7 { 8 FireBall fireBall = ObjectPool.Instance.GetObject(_prefab); 9 fireBall.transform.position = _castPoint.position; 10 fireBall.FlyInDirection(transform.right); 11 } 12 }

generic을 사용하기 때문에 즉시 처리에 필요한 유형의 개체를 얻습니다.

 ObjectPool
프리팹에 따라 내부 작업을 그룹화합니다.
 FireBall
구성 요소를 사용하면 풀이 이를 올바르게 처리하고 올바른 것을 제공합니다. 이 접근 방식은 게임 장면에 대한 개체를 생성하는 데 도움이 됩니다.

그러나 UI 요소로 작업할 때는 주의하십시오. 다른 부모 변환 간에 개체를 이동할 때

 localScale
,
 localScale
개체 자체가 변경됩니다. 프로젝트에 적응형 UI가 있는 경우 캔버스 구성 요소를 사용한 변환은 UI를 변경합니다.
 localScale
확장자에 따라. 다음과 같은 간단한 작업을 수행하는 것이 좋습니다.

 poolable.GameObject.transform.localScale = Vector2. on e ;

다른 옵션에서는 3개의 스크립트를 사용할 수 있습니다.

 ObjectPool
,
 PoolTask
, 그리고
 IPoolable
. 따라서 프로젝트에 자유롭게 추가하고 개체 풀 패턴을 100% 사용하세요!