Mejorar el rendimiento en Unity

Mejorar el rendimiento en Unity

En este artículo vamos a aprender a como mejorar el rendimiento de nuestros proyectos en Unity. Cuando nosotros estamos diseñando un proyecto, una parte fundamental es que todo funcione correctamente y fluido, para así disfrutar de la experiencia completa de nuestro proyecto. Vamos a repasar algunos puntos que van a mejorar el rendimiento de tus proyectos.

Mantenlo simple

Pongo esto en primer lugar, ya que debería ser una regla general para cualquier juego que construyas. Siempre que diseñamos un juego, debemos determinar específicamente lo que necesita y, lo que es más importante, lo que no necesita incluir.

Al crear un proyecto, piensa teniendo en cuenta el rendimiento cuando lo diseñes por primera vez. No te limites demasiado, pero comprende que es más fácil crear un juego eficaz desde el principio que intentar reestructurar las cosas para que funcionen mejor más adelante.

Usa el profiler

Antes de comenzar a eliminar líneas de código, refinar las casas prefabricadas y tratar de que todo funcione, debes saber qué está causando realmente los problemas de rendimiento.

Si nuestro juego tiene problemas de rendimiento, lo primero que debemos hacer es usar la ventana Profiler de Unity para establecer si nuestros problemas de rendimiento se deben a que la CPU no puede completar sus tareas a tiempo. Window/Analysis/Profiler.

Deberás mantener la ventana visible mientras juegas. Mostrará categorías como uso de CPU, uso de GPU, renderizado, física, audio y más. A continuación, puede reducir aún más los detalles dentro de cada categoría.

Una vez establecido esto, debemos determinar si los scripts de usuario son la causa del problema, o si el problema es causado por alguna otra parte de nuestro juego: física compleja o animaciones, por ejemplo.

Tutorial de cómo usar el Profiler.

También podemos mirar en la pestaña Stats cualquier información extra de nuestra escena.

Agrupamiento de Draw Calls

Para dibujar un objeto en la pantalla, el motor tiene que emitir un draw call (llamado de dibujo) al API de gráficas (OpenGL o Direct3D). Los Draw Calls a menudo son costosos, con el API de gráficas haciendo un trabajo significante para cada draw call, causando una sobrecarga en el rendimiento por el lado del CPU. Esto en su mayoría es causado por los cambios de estado hechos entre los draw calls (cambiando a un material diferente), que causa pasos de validación costosos y de translación en el driver gráfico.

Unity utiliza varias técnicas para abordar esto:

  • Dynamic Batching: para una cantidad pequeña de meshes, transforma sus vertices en el CPU, agrupa aquellos bastantes similares, y los dibuja en una sola pasada.
  • Static Batching: combina objetos estáticos (i.e. sin movimiento) a meshes grandes, y los renderiza de una manera rápida.

El procesamiento por batching (lotes) integrado ofrece varios beneficios en comparación con la fusión manual de GameObjects; lo más notable, los GameObjects todavía se pueden seleccionar de forma individual. Sin embargo, también tiene algunas desventajas; el procesamiento por lotes estático genera una sobrecarga de memoria y almacenamiento, y el dynamic batching (procesamiento de lotes dinámicos) genera cierta sobrecarga de la CPU.

Puedes encender y apagar el Dynamic and Static batching en el Player Settings/Other Settings.

Reduce y reúsa texturas

Solamente los objetos compartiendo el mismo material pueden ser batched juntos. Por lo tanto, si queremos lograr un buen batching, necesitamos compartir el mismo material entre diferentes objetos lo máximo posible.

Si tenemos dos materiales idénticos que difieren solo en texturas, podemos combinar esas texturas a una sola gran textura, un proceso llamado texture atlasing. ( https://en.wikipedia.org/wiki/Texture_atlas). Una vez las texturas estén en el mismo atlas, podemos utilizar un solo material.

Si necesitas acceder las propiedades compartidas del material desde los scripts, entonces es importante tener en cuenta que modificar Renderer.material va a crear una copia del material. En vez, debes utilizar Renderer.sharedMaterial para mantener el material compartido.

Optimizar los cálculos físicos

  • Raycasts

Los Raycasts se utilizan a menudo para detectar otros objetos para diversas cosas, como comprobar la distancia, los impactos de armas, la dirección, el espacio libre, etc. Encuentra solo lo que necesitas cuando uses un Raycast. No use varios rayos si uno es suficiente y no los extienda más allá de la longitud que necesita para viajar.

Cuanto menos necesite detectar, mejor. Con algo como Physics.Raycast, tiene la opción de utilizar una LayerMask que le permite detectar solo objetos en una capa específica.

  • Colliders

Dentro de Unity, tenemos distintos tipos de colliders (colisiones), desde box, capsule, hasta de malla (se adapta al modelo). Para mejorar el rendimiento de nuestros proyectos es recomendable usar siempre colliders primitivos (Box, Sphere, Capsule). Los colliders de malla (Mesh Colliders) toman la forma de la malla que indiquemos.

Esto es increíblemente caro de usar y debe evitarse si es posible. Si es absolutamente necesario, podemos crear una versión con pocos polígonos para el collider.

  • RigidBody

Los Rigidbody, se utilizan normalmente para añadir peso a un objeto. Si un objeto tiene un RigidBody adjunto, puede verse afectado por la física, como la gravedad y otras fuerzas.

Es importante tener en cuenta que tener demasiados objetos Rigidbody dentro de su juego afectará negativamente al rendimiento. Reducirlos a la cantidad mínima que necesita es el primer paso. También puedes mejorar el rendimiento de los Rigidbodies que está utilizando determinando cuándo deben dormir o kinematic.

Optimizar el código

Cuando creamos nuestro juego, Unity empaqueta todo lo necesario para ejecutar nuestro juego en un programa que puede ser ejecutado por nuestro dispositivo objetivo. Las CPU solo pueden ejecutar código escrito en lenguajes muy simples conocidos como código máquina o código nativo; no pueden ejecutar código escrito en lenguajes más complejos como C #. Esto significa que Unity debe traducir nuestro código a otros idiomas. Este proceso de traducción se llama compilación.

El código que aún no se ha compilado se conoce como código fuente. El código fuente que escribimos determina la estructura y el contenido del código compilado.

En su mayor parte, el código fuente bien estructurado y eficiente dará como resultado un código compilado bien estructurado y eficiente. Sin embargo, es útil para nosotros saber un poco sobre el código nativo para que podamos entender mejor por qué algunos códigos fuente se compilan en un código nativo más eficiente.

En primer lugar, algunas instrucciones de la CPU tardan más en ejecutarse que otras. Un ejemplo de esto es calcular una raíz cuadrada. Este cálculo requiere más tiempo para que la CPU se ejecute que, por ejemplo, multiplicar dos números. La diferencia entre una sola instrucción de CPU rápida y una sola instrucción de CPU lenta es muy pequeña, pero es útil para nosotros entender que, fundamentalmente, algunas instrucciones son simplemente más rápidas que otras.

Saque el código de los bucles cuando sea posible

Los bucles son un lugar común para que se produzcan ineficiencias, especialmente cuando están anidados. Las ineficiencias pueden sumarse si están en un bucle que se ejecuta con mucha frecuencia, especialmente si este código se encuentra en muchos GameObjects de nuestro juego.

En el siguiente ejemplo simple, nuestro código itera a través del ciclo cada vez que se llama a Update() , independientemente de si se cumple la condición.

void Update()
{
    for (int i = 0; i < myArray.Length; i++)
    {
        if (ejemploBool)
        {
            EjemploFuncion(myArray[i]);
        }
    }
}

Podemos mejorar el código, itera a través del ciclo solo si se cumple la condición.

void Update()
{
    if (ejemploBool)
    {
        for (int i = 0; i < myArray.Length; i++)
        {
            EjemploFuncion(myArray[i]);
        }
    }
}

Este es un ejemplo simplificado, pero ilustra un ahorro real que podemos hacer. Deberíamos examinar nuestro código en busca de lugares en los que hemos estructurado mal nuestros bucles.

Considere si el código debe ejecutar cada fotograma

Update () es una función que Unity ejecuta una vez por fotograma. Update () es un lugar conveniente para colocar el código que debe llamarse con frecuencia o el código que debe responder a cambios frecuentes. Sin embargo, no es necesario que todo este código ejecute todos los fotogramas. Sacar el código de Update () para que se ejecute solo cuando sea necesario puede ser una buena forma de mejorar el rendimiento.

Ejecute código solo cuando las cosas cambien

Veamos un ejemplo muy simple de optimización de código para que solo se ejecute cuando las cosas cambien. En el siguiente código, EnseñarScore () se llama en Update () . Sin embargo, es posible que el valor de la puntuación no cambie con cada fotograma. Esto significa que estamos llamando innecesariamente a EnseñarScore () .

private int score;
public void SubirScore(int scoreExtra)
{
    score += scoreExtra;
}
void Update()
{
    EnseñarScore(score);
}

Con un simple cambio, ahora nos aseguramos de que EnseñarScore () se llame solo cuando el valor de la puntuación haya cambiado.

private int score;
public void SubirScore(int scoreExtra)
{
    score += scoreExtra;
    EnseñarScore(score);
}

Usar almacenamiento en caché

Si nuestro código llama repetidamente a funciones costosas que devuelven un resultado y luego descarta esos resultados, esta puede ser una oportunidad para la optimización. El almacenamiento y la reutilización de referencias a estos resultados puede resultar más eficaz. Esta técnica se conoce como almacenamiento en caché .

En Unity, es común llamar a GetComponent () para acceder a los componentes. En el siguiente ejemplo, llamamos a GetComponent () en Update () para acceder a un componente de Renderer antes de pasarlo a otra función. Este código funciona, pero es ineficaz debido a la llamada repetida a GetComponent () .

void Update()
{
    Renderer myRenderer = GetComponent<Renderer>();
    ExampleFunction(myRenderer);
}

El siguiente código llama a GetComponent () solo una vez, ya que el resultado de la función se almacena en caché. El resultado almacenado en caché se puede reutilizar en Update () sin más llamadas a GetComponent () .

private Renderer myRenderer;
void Start()
{
    myRenderer = GetComponent<Renderer>();
}
void Update()
{
    ExampleFunction(myRenderer);
}

Deberíamos examinar nuestro código en busca de casos en los que hacemos llamadas frecuentes a funciones que devuelven un resultado. Es posible que podamos reducir el costo de estas llamadas utilizando el almacenamiento en caché.

Usar Object Pooling

Por lo general, es más costoso crear una instancia y destruir un objeto que desactivarlo y reactivarlo. Esto es especialmente cierto si el objeto contiene código de inicio, como llamadas a GetComponent () en una función Awake () o Start (). Si necesitamos generar y deshacernos de muchas copias del mismo objeto, como balas en un juego de disparos, entonces podemos beneficiarnos de la Object Pooling .

El object Pooling es una técnica en la que, en lugar de crear y destruir instancias de un objeto, los objetos se desactivan temporalmente y luego se reciclan y reactivan según sea necesario. Aunque es una técnica muy conocida para administrar el uso de la memoria, object pooling también puede ser útil como técnica para reducir el uso excesivo de la CPU.

Evitar llamadas costosas:

  • SendMessage () y BroadcastMessage () son funciones muy flexibles que requieren poco conocimiento, estas funciones son muy útiles para la creación de prototipos o para la creación de scripts de nivel principiante. Sin embargo, su uso es extremadamente caro. Esto se debe a que estas funciones hacen uso de la reflexión. Reflexión es el término para cuando el código examina y toma decisiones sobre sí mismo en tiempo de ejecución en lugar de en tiempo de compilación. El código que usa la reflexión da como resultado mucho más trabajo para la CPU que el código que no usa la reflexión.

 

  • Find() y las funciones relacionadas son potentes pero caras. Estas funciones requieren que Unity itere sobre cada GameObject y Component en la memoria. Esto significa que no son particularmente exigentes en proyectos pequeños y simples, pero su uso se vuelve más costoso a medida que aumenta la complejidad de un proyecto. Es mejor usar Find () y funciones similares con poca frecuencia y almacenar en caché los resultados cuando sea posible. Algunas técnicas simples que pueden ayudarnos a reducir el uso de Find () en nuestro código incluyen establecer referencias a objetos usando el panel Inspector cuando sea posible, o crear scripts que administren referencias a cosas que se buscan comúnmente. Si necesita buscar un objeto de esta manera, podría ser mejor usar FindWithTag («MyTag») o FindObjectOfType<> .

 

  • Transform: Establecer la posición o rotación de una transformación hace que un evento interno OnTransformChanged se propague a todos los hijos de esa transformación. Esto significa que es relativamente caro establecer los valores de rotación y posición de una transformación, especialmente en transformaciones que tienen muchos hijos. Para limitar el número de estos eventos internos, debemos evitar establecer el valor de estas propiedades con más frecuencia de la necesaria. En este ejemplo, deberíamos considerar copiar la posición de la transformación en un Vector3, realizar los cálculos necesarios en ese Vector3 y luego establecer la posición de la transformación en el valor de ese Vector3. Esto daría como resultado un solo evento OnTransformChanged. position es un ejemplo de un descriptor de acceso que da como resultado un cálculo entre bastidores. Esto se puede contrastar con Transform.localPosition . El valor de localPosition se almacena en la transformación y la llamada a Transform.localPosition simplemente devuelve este valor. Sin embargo, la posición mundial de la transformación se calcula cada vez que llamamos a Transform.position. Si nuestro código hace un uso frecuente de Transform.position y podemos usar Transform.localPosition en su lugar, esto dará como resultado menos instrucciones de CPU y, en última instancia, puede beneficiar el rendimiento. Si hacemos un uso frecuente de Transform.position, deberíamos almacenarlo en caché siempre que sea posible.

 

  • LateUpdate () , Update () y otras funciones de eventos parecen funciones simples, pero tienen una sobrecarga oculta. Estas funciones requieren la comunicación entre el código del motor y el código administrado cada vez que se llaman. Además de esto, Unity lleva a cabo una serie de comprobaciones de seguridad antes de llamar a estas funciones. Las comprobaciones de seguridad garantizan que el GameObject esté en un estado válido, que no se haya destruido, etc. Esta sobrecarga no es particularmente grande para una sola llamada, pero puede sumarse en un juego que tiene miles de MonoBehaviours. Por esta razón, las llamadas vacías a Update () pueden ser un desperdicio particular.

 

Estos son algunos consejos que os mejoraran el rendimiento en Unity, para ver toda la información que da Unity pulse aquí

Escribe un comentario