Optimización del Rendimiento de un Videojuego: Object Pool Pattern

Object Pooling
Object Pooling

Código Fuente

A mediados de la semana, ya trabajando en el prototipo del próximo videojuego que sacaré al mercado, quise implementar object pooling para mejorar el rendimiento del juego. Anteriormente ya en otros juegos había implementado object pooling pero de una manera muy instintiva, tan solo siguiendo lo que mi experiencia programando me decía (que por lo demás no son más que 5 años) y no buscando información relacionada en internet, pero por alguna razón para esta ocasión quise buscar información para determinar que tan cerca estaba mi solución intuitiva de la solución óptima del problema, y la verdad es que para mi satisfacción, la solución era completamente certera.

Es por esto que esta vez escribo este post con el fin que luego de que ustedes lo hayan leído, puedan comprender qué es el object pooling, entiendan para qué sirve, cómo y cuándo se utiliza y visualicen su implementación en el motor gráfico Unity. Sin más preámbulos, los dejo con su definición.


¿Qué es el Object Pooling?

El object pooling es un patrón de diseño de software  mediante el cual se pretende reducir el consumo de memoria RAM y a su vez mejorar el uso de la CPU. Esto a través de la re utilización de objetos instanciados, los que en vez de ser destruidos, son simplemente almacenados temporalmente para poder luego volver a utilizarse. En otras palabras existe un número máximo de objetos que pueden ser instanciados y a su vez utilizados. Es por esto que el Object Pool Pattern es principalmente utilizado para mejorar notablemente el rendimiento de una aplicación.


¿Cuándo utilizarlo?

Existe una razón fundamental detrás del uso de este patrón de diseño, y es que por ejemplo si queremos trabajar con una gran cantidad de objetos que son muy costosos (en tiempo de ejecución) de instanciar y cada objeto será necesitado solo durante un corto periodo de tiempo, entonces el rendimiento de la aplicación disminuirá debido a la cantidad de instancias y destrucciones que se realicen sobre estos objetos. Como ejemplo sobre un videojuego, podemos nombrar cuando se requiere de muchas partículas, o muchos enemigos, o balas de una nave, entre otras cosas.


¿Cómo se utiliza?

Debemos tener en cuenta que para la implementación de este patrón se deben tener las siguientes condiciones:

  • Cliente (Requiere de objetos)
  • Object Pool (“Piscina de objetos”, encargada de mantener los objetos instanciados y liberarlos o ingresarlos en caso de recibir un requerimiento)
  • Objetos (Que son las instancias en la “piscina”)

Entonces el flujo de trabajo sería, inicialmente nuestro Object Pool instanciará una cantidad de objetos N (esta puede ser definida), luego un cliente necesitará utilizar un nuevo objeto, por lo que se lo pedirá al Object Pool en vez de instanciar uno nuevo, el Object Pool, le retornará al cliente, el primer objeto listo para ser utilizado, luego el cliente lo utilizará y llegará un momento en que no lo necesite más y en vez de destruirlo, lo enviará al Object Pool, el Object Pool lo recibirá y el objeto quedará disponible para volver a ser utilizado en algún momento.


Consideraciones relevantes

  • Cuando el Object Pool se queda sin objetos para retornar, entonces normalmente se instancia uno nuevo, de lo contrario se puede enviar un mensaje de error.
  • Como los objetos son reutilizados muchas veces, es normal que estos cambien su estado (por ejemplo, un enemigo recién instanciado puede tener su vida al 100%, pero cuando vuelva a la Object Pool, probablemente venga con su vida al 0%), por lo que es importante que los objetos sean nuevamente colocados en su estado inicial antes de ser ingresados al Object Pool.
  • El tamaño asignado al Object Pool para almacenar N objetos, debe ser configurado y ajustado específicamente para las necesidades del videojuego. De otra manera, probablemente existan objetos que no se utilicen nunca (en el caso que el valor sea muy grande), o probablemente se instancien muchos objetos a medida que la aplicación se ejecuta (en el caso que el valor sea muy pequeño).
  • Debido a que un objeto que este siendo utilizado, no puede ser utilizado nuevamente, entonces se utiliza un contenedor donde se almacenan los objetos disponibles, por lo que aquellos que no están en este contenedor obviamente se encuentran siendo utilizados.

Implementación

Para propósitos de una mejor compresión, voy a utilizar un ejemplo sencillo: creación de enemigos. En este contexto, nuestro cliente tendría que ser un EnemySpawn (Instanciador de enemigos), que requiriese instanciar muchos enemigos constantemente, luego nuestro objeto a instanciar sería un enemigo, y finalmente tendríamos un “Enemy Pool” (piscina de enemigos), pero para fines genéricos, crearemos un Object Pool (piscina de objetos) que instanciará simplemente prefabs (objetos prefabricados) de enemigos.

A continuación el código para el Object Pool.

using UnityEngine;
using System.Collections;
using System.Collections.Generic;

public class ObjectPool : MonoBehaviour {
public GameObject prefab;
public int amount;
private List<GameObject> _available = new List<GameObject>();

void Awake(){
InstantiateGameObjects();
}

private void InstantiateGameObjects(){
for(int i = 0; i < amount; i++){ GameObject go = Instantiate(prefab, transform.position, Quaternion.identity) as GameObject; go.SendMessage("SetInitialState", SendMessageOptions.RequireReceiver); _available.Add(go); } } public GameObject GetGameObject(){ if(_available.Count > 0){
GameObject go = _available[0];
go.SendMessage("SetAwakeState", SendMessageOptions.RequireReceiver);
_available.RemoveAt(0);
return go;
}else{
GameObject go = Instantiate(prefab, transform.position, Quaternion.identity) as GameObject;
go.SendMessage("SetAwakeState", SendMessageOptions.RequireReceiver);
return go;
}
}

public void ReleaseGameObject(GameObject go){
go.SendMessage("SetInitialState", SendMessageOptions.RequireReceiver);
_available.Add(go);
}
} 

En este código se tienen tres variables de la clase en total. Dos de ellas son públicas (evidentemente para ser modificadas directamente desde el inspector de Unity), la primera es el prefab, que no es más que el objeto prefabricado que deseamos instanciar, y en segundo lugar se encuentra la variable amount, que representa la cantidad de objetos que deseamos instanciar. Por último se tiene la lista de los objetos que están disponibles para utilizar (_available). Con respecto a las funciones y procedimientos, inicialmente lo primero que se realiza en el método Awake() es instanciar todos los objetos necesarios. Por lo que cada uno de estos objetos es instanciado, se llama a su estado inicial y se agrega a la lista de objetos disponibles. Luego hay una función llamada GetGameObject() que retorna precisamente un GameObject, esta función se utiliza para pedirle al Object Pool algún objeto si es que lo hay, es decir, si hay al menos uno disponible, de lo contrario, se instancia uno nuevo. Cabe destacar que el objeto se obtiene de la primera posición de la lista de disponibles, luego se le indica que debe configurar su estado de “despertar” y por último se agrega a la lista de objetos en uso. Finalmente se tiene el método ReleaseGameObject() que recibe por parámetro un GameObject el cual es configurado en su estado inicial, agregado a la lista de objetos disponibles y retirado de la lista de objetos en uso. Con esto es suficiente para que la clase haga todo lo necesario para simular una “piscina de objetos”.

Otra clase necesaria para resolver el ejemplo, es la clase de “Spawneo” de enemigos.

using UnityEngine;
using System.Collections;

public class EnemySpawn : MonoBehaviour {
public ObjectPool enemyPool;
[Range(1f, 5f)]
public float coolDownTime;

void Start(){
InvokeRepeating("SpawnEnemy", 1f, coolDownTime);
}
public void SpawnEnemy(){
if(enemyPool != null){
GameObject enemy = enemyPool.GetGameObject();
enemy.transform.position = this.transform.position;
enemy.GetComponent<Enemy>().Spawn = this;
}
}

public void RemoveEnemy(GameObject enemy){
if(enemyPool != null){
enemy.transform.position = enemyPool.transform.position;
enemyPool.ReleaseGameObject(enemy);
}
}
} 

Esta clase tiene dos variables públicas, la primera es una referencia al Object Pool (asignada obviamente a través del inspector) y la segunda un “cool down time”, que indica cada cuantos segundos se instancia un nuevo enemigo. Inicialmente se llama al método InvokeRepeating() que simplemente llamada repetidamente cada X segundos (cool down time) al método SpawnEnemy(), este último método en vez de INSTANCIAR UN NUEVO OBJETO (ENEMIGO), simplemente solicita un objeto al Object Pool, luego le indica al objeto su nueva posición, y por último le indica que su Spawn es el que realiza el llamado. Finalmente existe un método llamado RemoveEnemy() el cual recibe por parámetro al objeto enemigo, el cual es relocalizado y luego en ves de DESTRUIR AL OBJETO (ENEMIGO), se le entrega al Object Pool para que lo manipule.

La última clase necesaria para completar el ejemplo es el Enemigo.

using UnityEngine;
using System.Collections;

[RequireComponent(typeof (Rigidbody))]
public class Enemy : MonoBehaviour {
[Range(1, 100)]
public int maxHealth;
public int currentHealth;
[Range(1f, 50f)]
public float speed;
[Range(1f, 5f)]
public float coolDownDamageTime;
private EnemySpawn spawn;
private bool followPlayer;
private Transform player;

void Start(){
currentHealth = maxHealth;
followPlayer = false;
player = GameObject.FindWithTag("Player").transform;
InvokeRepeating("TakingDefaultDamage", 1f, coolDownDamageTime);
}

void FixedUpdate(){
if(followPlayer){
if(player != null){
Vector3 dir = (player.position - transform.position).normalized;
GetComponent<Rigidbody>().velocity =  dir * speed * Time.fixedDeltaTime;
}
}
}

public EnemySpawn Spawn(){
get{
return spawn;
}
set{
spawn = value;
}
}
public void TakingDefaultDamage(){
if(!followPlayer){
return;
}
if(currentHealth > 0){
currentHealth -= 1;
return;
}
if(spawn != null){
spawn.DeleteEnemy(gameObject);
}
}

public void SetInitialState(){
GetComponent<Rigidbody>().velocity = Vector3.zero;
currentHealth = maxHealth;
followPlayer = false;
}

public void SetAwakeState(){
followPlayer = true;
}
} 

La clase Enemy tiene cuatro variables públicas, que son, la máxima vida posible, la vida actual, la velocidad del enemigo y un “cool down damage time”, que representa cada cuantos segundos el enemigo pierde vida (todas estas variables deben ser asignadas a través del inspector). Por otra parte se tienen tres variables privadas, una variable Spawn, un booleano “follow player”, y un Transform “player”. La primera hacer referencia al Spawn al que pertenece este enemigo, la segunda indica si el enemigo debe o no seguir al jugador, y la tercera es simplemente una referencia a la posición, rotación y escala del jugador en el mundo (nivel o escenario). Lo primero que se hace es realizar asignación de variables e invocar repetidamente el método que le quita vida al enemigo TakingDefaultDamage(). Segundo hay un método FixedUpdate, en el que si el enemigo debe seguir al jugador, entonces realiza las acciones determinadas para encontrar la dirección en la que ir, y asignar la velocidad correspondiente para alcanzarlo. Además hay una propiedad (property) sobre la variable Spawn, con su respectivo get() y set(). Los últimos tres métodos simplemente indican cómo se pierde vida (nótese que si el enemigo no esta persiguiendo al jugador para el caso de este ejemplo, entonces no esta despierto, por lo que no se le quita vida), la configuración del estado inicial y la configuración del estado de “despertar”.

Estas tres clases en conjunto hacen que se pueda optimizar altamente la creación y destrucción de objetos, ya que solo en primera instancia se crean nuevos objetos, luego solo se reutilizan, una y otra vez, ahorrando grandes cantidades de procesamiento y memoria asignada.

Espero que la idea principal de este post haya quedado clara, de todos modos, pueden descargar el código fuente y proyecto completo desde este enlace.

Si es que algo no queda aún claro, te invito a preguntar abajo, no tengo problemas es conversar y discutir sobre la temática 🙂

Si te gustó este post y quieres seguir viendo más post como este, me ayudarías al compartirlo y darle me gusta. ¡Saludos a tod@s!

Advertisements

3 thoughts on “Optimización del Rendimiento de un Videojuego: Object Pool Pattern

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / Change )

Twitter picture

You are commenting using your Twitter account. Log Out / Change )

Facebook photo

You are commenting using your Facebook account. Log Out / Change )

Google+ photo

You are commenting using your Google+ account. Log Out / Change )

Connecting to %s