paint-brush
¿Cómo mejorar su proyecto de juego utilizando el patrón ObjectPool? (Una guía de unidad)por@jokeresdeu
906 lecturas
906 lecturas

¿Cómo mejorar su proyecto de juego utilizando el patrón ObjectPool? (Una guía de unidad)

por Les Dibrivniy16m2023/05/10
Read on Terminal Reader

Demasiado Largo; Para Leer

Object Pool es la base del 99% de la optimización de los proyectos de juegos. Se basa en un principio elemental: después de completar su tarea, el objeto no se elimina sino que se mueve a un entorno separado y se puede recuperar y reutilizar. Les Dibrivniy explica por qué este patrón es esencial.
featured image - ¿Cómo mejorar su proyecto de juego utilizando el patrón ObjectPool? (Una guía de unidad)
Les Dibrivniy HackerNoon profile picture
0-item
1-item

Hola, mi nombre es Oles Dibrivniy y soy un desarrollador de Unity en Keiki . Creamos productos en la intersección de EdTech y GameDev: aplicaciones y productos web para el desarrollo de los niños. Ahora nuestros productos tienen más de cuatro millones de usuarios.

Si alguien te pregunta durante una entrevista de trabajo sobre los patrones de programación que usas en el desarrollo de juegos, una de las primeras cosas que debes mencionar es ObjectPool. Se basa en un principio elemental: después de completar su tarea, el objeto no se elimina sino que se mueve a un entorno separado y se puede recuperar y reutilizar.

Este patrón afecta directamente la percepción que tiene el usuario de la aplicación, por eso es crucial. Debería ser la base del 99% de la optimización de los proyectos de juegos.

Este artículo puede no ser relevante para los gurús de la programación, pero para los principiantes, viceversa. Aquí usaré ejemplos para explicar por qué este patrón es esencial.


¿Cómo van las cosas sin ObjectPool?

Primero, analizaremos el caso del proyecto sin ObjectPool, comenzando con la configuración. Tenemos una escena relativamente sencilla con el protagonista y dos enemigos lanzando bolas de fuego:

Consideremos cómo se verá la bola de fuego. Los campos

 Rigidbody2D
y
 _speed
será responsable del movimiento de las bolas de fuego; el
 OnTriggerEnter2D
El método funcionará después de una colisión: el objeto con el componente FireBall se destruirá:

 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 }

El enemigo también se verá bastante simple:

 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 }

Puedes llamar al

 Cast
método desde cualquier punto de su proyecto o eventos de animación. Tenemos el siguiente flujo: el enemigo creó una bola de fuego y la lanzó en la dirección de su mirada. La bola de fuego se destruye al alcanzar el primer obstáculo en el camino.

Este enfoque parece óptimo, pero es una de las peores opciones. Analicemos todo punto por punto:

1. Uso ineficiente de la CPU y la memoria del dispositivo.

 Instantiate
y
 Destroy
son operaciones costosas: llamarlas cada pocos segundos provoca demoras y retrasos. Esto es especialmente notable cuando se trabaja con dispositivos como teléfonos inteligentes, donde es necesario guardar cada byte de memoria. Puede abrir Unity Profiler y escribir Instanciar en el campo de búsqueda de la ventana Jerarquía para ver cuán "dolorosas" son estas operaciones para el juego. Habrá lo siguiente:

Claro, aumenté la cantidad de bolas de fuego creadas por cada enemigo a la vez a 500 para maximizar el efecto dramático. Sin embargo, es fácil imaginar proyectos con objetos creados y eliminados aún más constantemente, especialmente cuando se trata de elementos o partículas de la interfaz de usuario.

Estos procesos ocurren constantemente en tiempo de ejecución. El jugador puede experimentar una caída notable en los cuadros debido a la asignación de memoria constante después de generar cientos de objetos en el escenario.

2. Todo lo que has creado debe ser destruido y limpiado. El

 Destroy
El método elimina el objeto del juego de la escena y lo envía al recolector de basura. No tiene control sobre cuándo o cómo lo procesa el recopilador. Por cierto,
 Destroy
es muy astuto. Elimina el objeto del juego en sí, pero sus componentes pueden continuar viviendo por separado. Esto puede suceder si otro objeto está vinculado a este componente. Por ejemplo, puede ser una suscripción a un determinado evento.

3. Control de código. Diferentes clases son responsables de crear y destruir un objeto, y docenas de ellas pueden estar en el proyecto. Encontrar qué y dónde se crea o elimina no es una tarea trivial a veces, y ahora no digo nada sobre el control de objetos en la jerarquía.

¡Integremos ObjectPool en el proyecto!

Después de definir el problema, pasemos a su solución. Como mencioné anteriormente, el principio de la operación del patrón ObjectPool es simple: después de terminar de trabajar con el objeto, no se elimina sino que se oculta en el "grupo". El objeto se puede recuperar y reutilizar de él:

Una entidad será responsable de crear, reutilizar y destruir un objeto; lo llamaremos

 ObjectPool
. Podemos hacer que se vea así para trabajar con una bola de fuego:

 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 }

El

 _freeFireBalls
lista aparece en este código. Almacenaremos las bolas de fuego creadas que han hecho su trabajo allí. El enemigo ahora se verá así:

 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 }

La raíz de nuestro problema: ¿cómo podemos devolver la bola de fuego a la piscina? No podemos confiar en el enemigo; no sabe cuándo se destruirá la bola de fuego. Tampoco queremos dar conocimiento de bola de fuego sobre

 ObjectPool
, porque creará conexiones innecesarias.

Espero que hayas notado que hice el

 ReturnFireBall
método privado. Por lo tanto, usaremos uno de los patrones básicos de C#, el observador y su implementación, los eventos . La bola de fuego se verá así ahora:

 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
se suscribirá a la
 Destroyed
evento después de pasar el objeto al mundo:

 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 }

Queda por colgar el Object Pool en el objeto con el

 Enemy
componente — ¡felicidades! El recolector de basura descansará hasta que pases a otra escena o cierres el juego. Tenemos ObjectPool en su implementación básica, pero podemos mejorarlo.

Interfaces y genéricos: la combinación perfecta con ObjectPool

El

 FireBall
no es el único tipo de objeto que ejecutaremos a través del conjunto de objetos. Debes escribir por separado
 ObjectPool
para que cada uno funcione con otros tipos. Esto expandirá la base del código y hará que el código sea menos legible. Entonces, usemos imitaciones y genéricos.

Cada objeto que pasamos por el

 ObjectPool
debe estar vinculado a un tipo específico. Deben ser procesados independientemente de la implementación particular. Mantener la jerarquía de herencia básica de los objetos que procesará el grupo es esencial. heredarán de
 MonoBehaviour
, al menos. Usemos la interfaz IPoolable:

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

heredamos

 FireBall
de eso:

 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 }

La última tarea es enseñar.

 ObjectPool
para trabajar con cualquier objeto. IPoolable no será suficiente porque Instanciar solo puede crear una copia del objeto con el
 gameObject
propiedad. Usaremos
 Component
; cada uno de estos objetos hereda de esta clase.
 MonoBehaviour
también se hereda de él. Como resultado obtendremos lo siguiente
 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 }

Ahora podemos crear un

 ObjectPool
para cualquier objeto que cumpla las condiciones de herencia de
 Component
e IPonable. eliminé el
 ObjectPool
herencia de
 MonoBehaviour
, reduciendo así el número de componentes que colgaremos de los objetos.

Hay un problema que necesita ser resuelto. Varios enemigos pueden estar en el escenario al mismo tiempo, y cada uno de ellos genera las mismas bolas de fuego. seria genial que todos pasaran por lo mismo

 ObjectPool
— ¡nunca puede haber demasiados ahorros de recursos! Tener una clase que solicite un tipo de objeto específico también es bastante conveniente. Dicha clase se hará cargo de la generación, el control y la eventual eliminación del objeto. Esta opción es la mejor elección ya que gestionar cada
 ObjectPool
individualmente en el proyecto es un desafío.

Para completar los requisitos básicos de

 ObjectPool
, necesitamos generar un número determinado de objetos cuando se carga la escena. Para evitar cualquier problema de consumo de recursos, incluiremos una ventana de carga. Comencemos a implementar esto ahora.

Crearemos una entidad adicional

 PoolTask
en la ejecución final de
 ObjectPool
. Esta clase controlará el trabajo con objetos creados a partir de un prefabricado:

 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 tendrá características adicionales:

1. Rastrear los objetos que hemos lanzado al mundo para que, si es necesario, puedan ser destruidos o devueltos a la piscina en un momento específico;

2. Generar una cantidad predeterminada de objetos gratuitos.

Finalmente, creemos un ObjectPool que satisfaga todas nuestras necesidades y se haga cargo por completo del control y la generación de objetos:

 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 }

El uso de Singleton puede llamar su atención; es solo un ejemplo. Puede personalizar el uso en su proyecto; puede estar ejecutándose

 ObjectPool
a través de constructores o inyección a través de Zenject.

Tenemos la versión final de nuestro

 Cast
método en
 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 }

Obtenemos un objeto del tipo que necesitamos para procesar inmediatamente debido al uso de generic.

 ObjectPool
agrupará las tareas internas de acuerdo con el prefabricado, si hay varios con el
 FireBall
componente, el grupo los procesará correctamente y le dará el correcto. Este enfoque ayudará a generar cualquier objeto para la escena del juego.

Sin embargo, tenga cuidado al trabajar con elementos de la interfaz de usuario: al mover un objeto entre transformaciones principales con diferentes

 localScale
, el
 localScale
del objeto mismo cambiará. Si tiene una interfaz de usuario adaptable en su proyecto, las transformaciones con el componente de lienzo cambiarán su
 localScale
dependiendo de la extensión. Te aconsejo que hagas esta sencilla operación:

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

Puede usar 3 scripts en otras opciones:

 ObjectPool
,
 PoolTask
, y
 IPoolable
. ¡Así que siéntase libre de agregarlos a su proyecto y usar el patrón Object Pool al 100%!