DEV Community

Vicente Contreras
Vicente Contreras

Posted on • Updated on

Patrón de diseño Object Pooling en Unity

¿Qué es el Object Pooling?

Imagina cualquier videojuego del genero shooter, ya sea en primera persona o tercera persona; hay un objeto que tienen en común tanto el jugador como los enemigos, que es su armamento, específicamente el uso de balas para las diferentes armas. Hay varias maneras de plantear el disparo en este genero, y en Unity se puede hacer con raycast o spawnear un objeto, a manera de prefab, y agregarle una fuerza y una dirección.

Ahora, si optamos por utilizar el crear objetos que tendrán el comportamiento de la bala, lo más normal es que cuando se presione el botón de disparo tengamos un método para crear una bala, esto a través del método Instantiate que nos provee unity, al cual deberemos asignar que objeto se quiere crear, asignar una posición y rotación.

public GameObject bulletPrefab; //El objeto bala que se va a crear

public Transform spawnPoint;    //El lugar donde saldrá la bala, cañón de arma

//Este método se manda llamar en cada frame
public void Update()
{
     if(Input.GetMouseButtonDown(0))
     {
        Instantiate(bulletPrefab, spawnPoint.position, spawnPoint.rotation);
     }
}

Hay que dejar en claro que este método Instantiate() nos va a estar creando un nuevo objeto cada vez que lo mandemos llamar, en este caso cuando el jugador presione el botón izquierdo del mouse; haciendo los ajustes necesarios, los enemigos, IA, tendrían algo similar para cuando ellos disparen al jugador, lo cual nos va a generar muchos objetos en escena, que vendrían a ser los objetos del tipo bala, que estarán consumiendo memoria y más cuando no determinemos en que momento esa bala no la necesitemos en tiempo de ejecución, esto puede ocurrir en dos escenarios, la bala impacta sobre un objeto que puede recibir daño, sea jugador, enemigos, barriles explosivos, etc., o impactar sobre una pared, un carro o alguna parte del decorado del nivel, y lo que se suele hacer es lo siguiente:

//script que se encuentra en el objeto bala

void OnCollisionEnter(Collision col)
{
    if(col.gameObject.CompareTag("Enemy"))
    {
       Destroy(gameObject, 0f); //Método que destruye a la bala
    } 
}

Como se muestra en el código de arriba, en cuanto la bala detecte una colisión y se cumpla la condición se destruirá, el método Destroy() quitará de memoria el objeto que reciba como parámetro, hacer esto un par de veces no afecta; en nuestro ejemplo del shooter digamos que nos encontráramos con 10 enemigos cada uno tendrá sus propias balas que pueden tener de referencia el mismo prefab del tipo bala, cuando cada enemigo dispare mandara llamar el método Instantiate() y a su vez el jugador también lo hará para responder al ataque, entonces estamos hablando de que 11 objetos (10 enemigos y 1 jugador) estarán creando nuevos objetos del tipo bala cada X segundos dependiendo de la velocidad de disparo de cada quien, haciendo crecer la cantidad de objetos que se muestran en escena y cada uno tendrá su propio comportamiento y su propia detección de colisión para destruirse cuando llegue el momento. Ya podemos empezar a imaginar la enorme cantidad de procesos que se estarán llevando, ya que el Instantiate() y el Destroy tiene un costo relativamente elevado y al hacerlo X cantidad de veces de de forma rápida puede causar estragos.

Es aquí donde el object pool puede ayudarnos a mejorar el rendimiento de nuestro juego, la idea es tener una "alberca" de un determinado tipo de objetos con una cantidad finita de cuantos existirán desde que inicia el juego, se basa que en vez de destruir y crear objetos, los vamos a estar reutilizando y cuando no los necesitemos los mandaremos a una alberca.

Un patrón de diseño muy fácil de implementar

Vamos a empezar por definir una clase que estará controlando la alberca de objetos:

public class PoolManagerObjects : MonoBehaviour
{
   public GameObject objPrefab;  //El objeto que vamos a estar reutilizando

   public int poolSize;          //Cuantos objetos se necesitaran

   private Queue<GameObject> objPool; //La "alberca" donde estarán los objetos

}

Utilizamos una cola, Queue, ya que su funcionamiento de primero en entrar, primero en salir viene perfecto para este patrón de diseño, aquí va estar la referencia a los objetos que podremos estar reutilizando en cuanto lo necesitemos.

Inicializamos la alberca:

public class PoolManagerObjects : MonoBehaviour
{
   public GameObject objPrefab;  //El objeto que vamos a estar reutilizando

   public int poolSize;          //Cuantos objetos se necesitaran

   private Queue<GameObject> objPool; //La "alberca" donde estarán los objetos

   void Start()
   {
     objPool = new Queue<GameObject>();  //Inicializamos la cola

     for (int i = 0; i < poolSize; i++) //Vamos a llenar la alberca en base al tamaño
     {
         //Instanciamos el objeto y lo guardamos en una varible temporal    
         GameObject newObj = Instantiate(objPrefab);  
         objPool.Enqueue(newObj);   //Lo añadimos a la cola con Enqueue
         newObj .SetActive(false);    //Lo desactivamos ya que en ese momento no se requiere
     }
   }
}

Bien, haciendo uso del ciclo for, llenaremos la alberca con una determinada cantidad de objetos en base a la variable poolSize. Es verdad que estamos haciendo uso del Instantiate() pero solo se va a realizar una cantidad finita de veces y al inicio del juego, razón de que esto pase en el método Start().

Ahora, vamos a crear dos métodos que vendrán a sustituir el método Instantiate() y Destroy().

public class PoolManagerObjects : MonoBehaviour
{
   public GameObject objPrefab;  //El objeto que vamos a estar reutilizando

   public int poolSize;          //Cuantos objetos se necesitaran

   private Queue<GameObject> objPool; //La "alberca" donde estarán los objetos

   void Start()
   {
     objPool = new Queue<GameObject>();  //Inicializamos la cola

     for (int i = 0; i < poolSize; i++) //Vamos a llenar la alberca en base al tamaño
     {
         //Instanciamos el objeto y lo guardamos en una varible temporal    
         GameObject newObj = Instantiate(objPrefab);  
         objPool.Enqueue(newObj);   //Lo añadimos a la cola con Enqueue
         newObj .SetActive(false);    //Lo desactivamos ya que en ese momento no se requiere
     }
   }

   public GameObject GetObjFromPool(Vector3 newPosition, Quaternion newRotation)
   {
        //Se obtiene el 1er objeto disponible en la cola
        GameObject newObj = objPool.Dequeue();
        //Activamos el objeto, se activa su comportamiento
        newObj.SetActive(true);        
        //Le damos la posición y rotación, en donde se necesita que este
        newObj.transform.SetPositionAndRotation(newPosition, newRotation);    

        return newObj;
   }

   public void ReturnObjToPool(GameObject go)
   {
        go.SetActive(false);    //Lo desactivamos
        objPool.Enqueue(go); //Lo volvemos a añadir a la cola para reutilizarlo
   }

}

Para terminar, el método GetObjFromPool() sustituye al Instantiate(), pero en este caso solo nos interesa asignar la posición y rotación del lugar donde necesitemos el objeto de la alberca, eso si, tendremos que tener acceso a la clase PoolManagerObjects para hacer uso del mismo. Ojo aquí, que el método nos va a retornar el objeto.

Esto ocurre de igual forma con el método ReturnObjToPool() que sustituye al Destroy(), recibe de parámetro el objeto que queremos regresar a la alberca, se desactiva y se vuelve a encolar.

Volviendo al ejemplo cuando se presiona el botón del mouse, quedaría de la siguiente manera:

public PoolManagerObjects poolManager; //Hay que asignar desde el inspector el objeto que tenga el script PoolManagerObjects 

public Transform spawnPoint;    //El lugar donde saldrá la bala, cañón de arma

//Este método se manda llamar en cada frame
public void Update()
{
     if(Input.GetMouseButtonDown(0))
     {
        //Tenemos una referencia del objeto que tomamos de la alberca, 
       // esto puede ayudarnos para modificar valores de la bala, acceder a sus componentes, etc.
        GameObject theBullet = poolManager.GetObjFromPool(spawnPoint.position, spawnPoint.Rotation);
     }
}

Y para el caso cuando ya no necesitemos el objeto:

//script que se encuentra en el objeto bala

public PoolManagerObjects poolManager; //Hay que asignar desde el inspector el objeto que tenga el script PoolManagerObjects 

void OnCollisionEnter(Collision col)
{
    if(col.gameObject.CompareTag("Enemy"))
    {
       //En cuanto exista una colisión y se cumpla la condición del if
       // mandaremos llamar el método para regresar la bala a la alberca y la podamos reutilizar
       poolManager.ReturnObjToPool(this.gameObject);
    } 
}

Entonces, estamos mejorando bastante el rendimiento de nuestro juego, ya que pasamos de que 11 objetos (enemigos y jugador), estén creando y destruyendo X cantidad de balas, a que todos tengan acceso a una alberca con una cantidad finita de objetos y todos estén utilizando los mismos objetos en cuanto lo requieran.

Se puede mejorar bastante este patrón, añadiendo un par de elementos para que se vuelva más útil y permita utilizar distintas albercas con objetos diferentes cada una.

Espero quede entendible este patrón, cualquier comentario que ayude a mejorar es bienvenido.

Notas importante:
-A la variable poolSize, debemos darle un valor considerable para que no llegue a pasar el error de que se estén utilizando todos los objetos y la alberca este vacía, hay que poner un valor no muy alto pero tampoco tan bajo.

Oldest comments (0)