Optimización en Unity

En este artículo vamos a hablar sobre algunas buenas prácticas para conseguir que nuestros juegos hechos en Unity no usen recursos innecesarios y sean más óptimos. Crear juegos con un buen rendimiento es tan importante como cualquier otro aspecto del desarrollo ya que, en definitiva, influirá directamente en cómo el jugador final percibirá nuestro juego (estabilidad y fluidez), pudiendo ser ésta una de las causas del éxito o fracaso de nuestro producto.

Físicas

Layers & Collision Matrix

Cualquier objeto creado en Unity, si no lo configuramos, tendrá asignado el Layer por defecto (Default). Esto quiere decir que, en caso de asociarle un collider al objeto, éste colisionará con todo el entorno ya que, por defecto, este Layer está configurado así. Sin embargo, en muchos casos, no necesitaremos que nuestros objetos colisionen con todos los demás objetos de nuestra escena. Para ello Unity nos ofrece el “Layer Collision Matrix“, que no es más que una matriz de layers donde configuraremos con qué puede y con qué no puede colisionar cada uno de los layers.

Edit > Project Settings > Physics -> Layer Collision Matrix

Dejar todos nuestros GameObjects con el layer por defecto, a no ser que así lo requiera nuestro juego, no es muy eficiente ya que, para cada objeto, Unity tiene que calcular sus colisiones con el resto de los existentes en la escena. Por tanto es nuestra responsabilidad crear nuestros propios layers, asignarlos a nuestros objetos y realizar una buena configuración del “Layer Collision Matrix” para conseguir así una mejor optimización.

Echa un vistazo a la documentación oficial sobre Layers y Collision Matrix aquí.

Raycasts

El uso de raycasts es una técnica muy útil que nos permite lanzar un rayo con una determinada longitud y una determinada dirección y saber si dicho rayo ha colisionado con algo de nuestra escena. Sin embargo hemos de saber que, a pesar de su utilidad, esta operación es costosa para el motor de físicas de Unity y que su rendimiento va a depender en gran parte de la longitud de los propios raycasts y del tipo de colliders que tengamos asignados a los objetos de nuestra escena.

He aquí algunos consejos para hacer un buen uso de los raycasts:

  • Esto es obvio pero… usa el menor número de raycasts que necesites para la tarea que estés desarrollando.
  • Cuida la longitud del raycast y no uses más de la que necesites ya que cuanto más longitud, más objetos de la escena serán evaluados por el raycast.
  • No uses raycasts dentro del evento FixedUpdate(), incluso en eventos como Update() y de manera no controlada puede ser excesivo. Lánzalos, en la medida de lo posible, en momentos controlados.
  • Cuidado con el tipo de colliders que asignas a los objetos de tu escena. No es lo mismo lanzar un raycast sobre un collider primitivo como puede ser un BoxCollider (menos costoso) que sobre uno complejo como es un MeshCollider (muy costoso).
  • Haz uso del parámetro “layerMask” al usar las funciones de tipo Raycast…(), ya que así podremos limitar de manera considerable los conjuntos de objetos sobre los que queremos comprobar las colisiones de los raycasts.

Físicas 2D y 3D

Este punto es simple: elige el tipo de físicas que más se adecuen a tu proyecto. Por ejemplo, usar físicas 3D para un juego 2D o 2.5D puede ser excesivo. Tener en cuenta esa tercera dimensión extra, para el motor repercute en un procesamiento extra de CPU de manera innecesaria.

Rigidbody

Los colliders asignados a un GameObject que no tiene asignado a su vez un componente Rigidbody son considerados como colliders estáticos. Bien, pues algo a tener muy en cuenta durante nuestro desarrollo es que resulta extremadamente costoso mover este tipo de colliders estáticos ya que todos sus cambios de posición obligan al motor de físicas a recalcular las físicas de toda la escena. Por tanto, como norma, a todo objeto que vayamos a mover en nuestro juego deberemos añadirle el componente Rigidbody (en modo kinemático o no).

Una forma fácil de saber si en algún momento estamos moviendo algún collider estático en nuestra escena es echar un vistazo a la ventana de Profiler, que explicaremos más adelante.

Fixed Timestep

Cambiar el valor del parámetro “Fixed Timestep” en las opciones del Time Manager de Unity (Edit > Project Settings > Time) afectará directamente a la tasa de actualización de físicas del evento FixedUpdate(). Es responsabilidad del desarrollador ajustar bien este parámetro para conseguir un buen balance entre precisión de cálculo de físicas y el coste de CPU necesario para dichos cálculos.

Ventana “Time Manager” en Unity

En definitiva, un valor bajo en el “Fixed Timestep” supondrá más precisión en las físicas de tu juego a costa de un menor rendimiento de CPU, y un valor alto supondrá una menor precisión de físicas pero ganando un mayor rendimiento en CPU.

Escribir código eficiente

Mueve código fuera de los bucles cuando sea posible

Los bucles son los lugares más comunes donde solemos cometer errores de ineficiencia, sobre todo cuando son ejecutados frecuentemente y además se encuentran dentro de varios objetos en nuestra escena.

Por ejemplo en este código estamos iterando a lo largo de todo el bucle por cada frame de nuestro juego, sin importar si la condición se cumple o no:

void Update()
{
   for (int i = 0; i < enemyList.Length; i++)
   {
      if (enemyTurn)
      {
         MoveEnemy(enemyList[i]);
      }
   }
}

Una manera de hacer este código más eficiente sería simplemente sacando la condición fuera del bucle:

void Update()
{
   if (enemyTurn)
   {
      for (int i = 0; i < enemyList.Length; i++)
      {
         MoveEnemy(enemyList[i]);
      }
   }
}

Merece la pena revisar nuestro código e identificar casos como este en el que podamos mejorar el uso de bucles.

Analiza si tu código debe ser ejecutado en cada frame

Como ya sabemos, el evento Update() se ejecuta una vez por cada frame en pantalla. Dicho evento es un buen lugar para meter código que necesitemos ejecutar muy frecuentemente durante la ejecución de nuestro juego, sin embargo puede haber código que no necesite ejecutarse tan a menudo. Mover este código fuera del evento Update() puede ser una buena forma de mejorar el rendimiento de nuestro proyecto.

Pongamos el siguiente ejemplo en el que tenemos la función GetCoin() encargada de añadir una moneda a la bolsa de monedas de nuestro jugador y que se ejecuta de manera aislada cada vez que el jugador coge una moneda. Por otro lado tenemos la función DisplayCoins() encargada de actualizar el valor del número de monedas de nuestro jugador en pantalla y que se ejecuta dentro del evento Update(), es decir una vez por cada frame:

public void GetCoin()
{
   coins++;
}

void Update()
{
   DisplayCoins();
}

La pregunta es: ¿es necesario estar actualizando el valor de las monedas en pantalla continuamente? La respuesta es no ya que tan solo es necesario realizar dicha actualización cuando el jugador consigue una moneda, del siguiente modo:

public void GetCoin()
{
   coins++;
   DisplayCoins();
}

Por tanto, con una simple modificación estaremos mejorando mucho el performance de nuestro código, ahorrando carga innecesaria a la CPU.

Incluso si necesitamos que nuestro código se ejecute de manera muy frecuente, no significa que dicho código tenga que ir directamente dentro del evento Update(). En estos casos podemos evaluar la opción de ejecutar ese código cada X frames. Podemos hacer algo así:

void Update()
{
   private int numFrames = 5;

   if (Time.frameCount % numFrames == 0)
   {
      EjemploDeFuncionCostosa();
   }
}

En este ejemplo estamos ejecutando la función solamente cada 5 frames y no frame a frame, lo cual se traduce en un menor procesamiento por segundo. Por ello es aconsejable analizar bien los requerimientos de nuestras funciones y, en la medida de lo posible, ejecutar su código solo lo necesario.

Cachea el acceso a componentes

Una de las operaciones que solemos hacer cuando desarrollamos con Unity es acceder a un componente mediante la instrucción GetComponent(). Un error muy común suele ser el llamar a dicha instrucción dentro del evento Update() cada vez que necesitamos acceder al componente. Por ejemplo:

void Update()
{
   if (someCondition)
   {
      Animator playerAnimator = GetComponent<Animator>();
      playerAnimator.SetBool("Attack", true);
   }
}

Esto es muy ineficiente ya que, cada vez que necesitamos usar el componente, estamos realizando su búsqueda para acceder a él. Para evitar esto, lo correcto es buscar ese componente tan solo una vez al inicio, normalmente dentro del evento Awake() o Start(), y almacenar su referencia en una variable de modo que, cada vez que necesitemos acceder a él, usemos esa variable que ya apunta al componente. Esta técnica es conocida como cacheo (caching) de componentes. Siguiendo nuestro ejemplo, lo haríamos así:

private Animator _playerAnimator;

void Start()
{
   _playerAnimator = GetComponent<Animator>();
}

void Update()
{
   if (someCondition)
   {
      _playerAnimator.SetBool("Attack", true);
   }
}

Object Pooling

Un aspecto muy a tener en cuenta en nuestros proyectos es ser conscientes de que instanciar y destruir objetos en tiempo de ejecución suelen ser operaciones bastante costosas y, por tanto, debemos evitarlas en la medida de lo posible. Cuando necesitamos crear y destruir muchas copias del mismo objeto (véase un GameObject “bala”), es muy útil recurrir al pooling de objetos (object pooling).

La técnica de object pooling no es más que el hecho de desactivar temporalmente objetos para después volverlos a activar, en vez de destruirlos y después instanciarlos. Utilizando una expresión más cotidiana, es el hecho de reciclar objetos.

Si quieres saber más sobre esta técnica, echa un vistazo a este tutorial.

Evita llamadas a métodos Find…()

A pesar de que este tipo de métodos son muy potentes, son bastante costosos ya que obligan a Unity a recorrer todos los GameObjects en memoria hasta encontrar el objeto deseado. Esto es un poco menos preocupante en proyectos pequeños, donde hay un número muy pequeño de objetos, pero conforme la complejidad del proyecto aumenta, menos aconsejable se vuelven estas llamadas a dichas funciones.

Como recomendación, usa lo menos posible estos métodos e intenta acceder a los objetos que buscas de otros modos como pueden ser cacheando sus referencias en código, mediante una referencia en el inspector… entre otros.

Evita llamadas a Camera.main

Camera.main es una variable que nos ofrece Unity para acceder directamente a la referencia de la cámara principal (Main Camera) de nuestra escena. Sin embargo, aunque a priori podamos pensar que se trata de una referencia cacheada por el motor, no es así. Internamente su funcionamiento es similar al de los métodos Find…(), recorre todos los objetos en memoria hasta encontrar la cámara principal, lo cual es costoso. Para evitarlo, la recomendación es la misma que en el punto anterior.

No definas eventos sin código

Quizás podamos pensar que el hecho de que tengamos la función Update() sin ningún código dentro de ella no suponga ningún trabajo para el motor de Unity, pero la realidad es que sí que supone un trabajo. Esta función, al igual que el resto de eventos de Unity, tienen una pequeña carga de procesamiento detrás que se encarga de comunicar a dicho evento con el código del motor. Además, cada vez que se llama a uno de estos eventos, Unity realiza una serie de comprobaciones de seguridad. En conclusión, evita siempte tener eventos vacíos.

Optimización de la GPU

Occlussion Culling

Occlussion Culling es una técnica que nos ofrece el motor de Unity para optimizar el renderizado de nuestras escenas. Su funcionamiento se basa simplemente en desactivar el renderizado de todos aquellos objetos que no están siendo visualizados por ninguna cámara. Su uso es muy recomendable.

Aprende más sobre esta técnica y cómo configurarlo en la documentación oficial, aquí.

Level of Detail (LOD)

Level of Detail (más conocido como LOD) es otra técnica muy común de optimización de renderizado que se basa en lo siguiente: los objetos cercanos al jugador son renderizados con el máximo detalle (usando mayas y texturas detalladas), mientras que los objetos lejanos pasan a tener un renderizado mucho menos detallado.

Ejemplo de un modelo configurado para LOD

Para aprender más sobre LOD, puedes acceder a su documentación oficial aquí.

Uso del “Profiler”

El editor de Unity nos ofrece la herramienta “Profiler” que se encarga de darnos, en tiempo real, todo tipo de información sobre el rendimiento de nuestro juego. Por ejemplo, si tenemos problemas en cuanto al uso de memoria, a través de esta ventana podremos saber qué elemento (o elementos) de nuestro proyecto lo está provocando.

Window -> Profiler

Gracias a esta herramienta podremos echar un vistazo al performance de nuestro juego. Detalles como el uso de memoria que estamos utilizando, cuánto tiempo de CPU se está usando para determinada tarea, en qué momentos se está realizando cálculos físicos, etc… todo ello podremos analizarlo desde esta ventana.

No hace falta hablar sobre la importancia de hacer uso de esta útil herramienta durante todo el proceso de desarrollo de nuestro juego, pero… ¡úsala!

Si quieres aprender a interpretar bien todos los datos que nos da el Profiler, te recomiendo que eches un vistazo a este manual. Una vez que aprendas a interpretarlo y te acostumbres a usarlo, serás capaz de desarrollar juegos más eficientes anticipándote a los posibles problemas.

También recomiendo leer este artículo sobre optimización en dispositivos móviles donde se habla de esta herramienta.

Anuncios

Responder

Introduce tus datos o haz clic en un icono para iniciar sesión:

Logo de WordPress.com

Estás comentando usando tu cuenta de WordPress.com. Cerrar sesión / Cambiar )

Imagen de Twitter

Estás comentando usando tu cuenta de Twitter. Cerrar sesión / Cambiar )

Foto de Facebook

Estás comentando usando tu cuenta de Facebook. Cerrar sesión / Cambiar )

Google+ photo

Estás comentando usando tu cuenta de Google+. Cerrar sesión / Cambiar )

Conectando a %s