Buscar en Unity

Las mejores prácticas de Unity en constante evolución

Última actualización: diciembre de 2018

Lo que obtendrás de esta conferencia: consejos actualizados sobre el rendimiento y la optimización del scripting con Ian Dundore, los cuales reflejan la evolución de la arquitectura de Unity para adaptarse al diseño orientado a los datos.

Unity está evolucionando, y es posible que los trucos antiguos ya no sean los más indicados para obtener el mejor rendimiento del motor. En este artículo, verás un resumen sobre algunos cambios en Unity (desde Unity 5.x hasta 2018.x) y aprenderás cómo aprovecharlos. Todas estas prácticas provienen de la conferencia de Ian Dundore, por lo que, si lo prefieres, puedes ir directo al origen.

Rendimiento del scripting

Una de las tareas más difíciles durante la optimización es elegir cómo optimizar el código después de descubrir un punto sensible. Hay muchos factores diferentes involucrados: detalles de sistema operativo, CPU y GPU específicos para la plataforma; modelo de subprocesos (threading); acceso a la memoria, etc. Es difícil saber de antemano cuál es la optimización que producirá el mayor beneficio en el mundo real.

En general, la mejor opción es crear prototipos de las optimizaciones en proyectos pequeños de prueba, ya que esto te permite iterar de forma mucho más rápida. Sin embargo, también es complicado aislar el código en un proyecto de prueba: al aislar un fragmento de código, se modifica el entorno en el que se ejecuta. Los tiempos de los subprocesos pueden ser diferentes, y el conjunto administrado puede ser menor o estar menos fragmentado. Por lo tanto, es importante tener cuidado al diseñar tus pruebas.

Para empezar, piensa en las entradas de tu código y la manera en que el código reacciona cuando cambias esas entradas.

  • ¿Cómo reacciona ante datos altamente coherentes que se ubican en serie en la memoria?
  • ¿Cómo maneja la incoherencia de la memoria caché?
  • ¿Cuánto código has eliminado del bucle en el que se ejecuta tu código? ¿Has modificado el uso de la memoria caché de instrucciones del procesador?
  • ¿En qué hardware se está ejecutando? ¿Qué tan bien dicho hardware implementa la predicción de ramas? ¿Qué tan bien ejecuta microoperaciones fuera de servicio? ¿Cuenta con soporte SIMD?
  • Si tienes un sistema multihilos y estás ejecutando en un sistema con más núcleos frente a uno con menos núcleos, ¿cómo reacciona el sistema?
  • ¿Cuáles son los parámetros de modificación de escala de tu código? ¿La escala se modifica linealmente a medida que aumenta su conjunto de entradas, o más que linealmente?

Efectivamente, debes considerar exactamente lo que estás midiendo con tu test harness (arnés de prueba).

A modo de ejemplo, considera la siguiente prueba de una operación sencilla: la comparación de dos cadenas.

Cuando las API en C# comparan dos cadenas, realizan conversiones de lugares específicos para garantizar que diferentes personajes coincidan con diferentes personajes cuando procedan de culturas distintas, y te darás cuenta de que es bastante lento.

Aunque la mayoría de las API que comparan cadenas en C# distinguen las diferencias culturales, una de ellas no lo hace: String.Equals.

Si abrieras String.CS desde esa GitHub y observaras String.Equals, esto es lo que verías: una función muy sencilla que hace un par de verificaciones antes de pasar el control a una función denominada EqualsHelper, una función privada que no puedes llamar directamente sin reflexión.

lp MonoString CS1

EqualsHelper es un método sencillo que recorre las cadenas 4 bytes a la vez y compara los bytes sin procesar de las cadenas de entrada. Si encuentra una desigualdad, se detiene y devuelve "False".

Sin embargo, también existen otras opciones para revisar la igualdad de las cadenas. La opción que parece más inofensiva es hacer una sobrecarga de String.Equals, que acepta dos parámetros: la cadena que quieres comparar y un enum llamado StringComparison.

Ya hemos determinado que la sobrecarga de un solo parámetro de String.Equals solo realiza un pequeño trabajo antes de pasar el control a EqualsHelper. ¿Qué hace la sobrecarga de dos parámetros?

Si observas el código de la sobrecarga de dos parámetros, realiza unas cuantas verificaciones adicionales antes de ingresar una declaración de cambio grande. Esta declaración prueba el valor de la enumeración StringComparison. Ya que lo que buscamos es paridad con la sobrecarga de un solo parámetro, queremos una comparación ordinal –una comparación byte por byte. En este caso, el control pasará por cuatro verificaciones antes de llegar al caso StringComparison.Ordinal, en el que el código es muy parecido a la sobrecarga String.Equals de un solo parámetro. Esto significa que, si usaras la sobrecarga de dos parámetros de String.Equals en lugar de la sobrecarga de un solo parámetro, el procesador realizaría unas cuantas operaciones más de comparación. Lo previsible sería que fuese más lento, pero vale la pena probarlo.

Tu intención no es limitarte únicamente a la prueba de String.Equals cuando estás interesado en todas las formas de comparar cadenas para verificar su igualdad. Existe una sobrecarga de String.Compare, que puede realizar comparaciones ordinales, además de un método denominado String.CompareOrdinal, que tiene dos sobrecargas distintas.

Como implementación de referencia, lo que sigue es un ejemplo sencillo de codificación manual. Es simplemente una pequeña función con un control de longitud que itera cada personaje en las dos cadenas de entrada y las verifica.

Después de examinar el código de todas ellas, existen cuatro casos de prueba distintos que parecen útiles de forma inmediata:

  • Dos cadenas idénticas, para probar el rendimiento en el peor escenario.
  • Dos cadenas de personajes aleatorios pero de idéntica longitud, para omitir los controles de longitud.
  • Dos cadenas de personajes aleatorios e idéntica longitud con un primer personaje idéntico, para omitir una interesante optimización hallada únicamente en String.CompareOrdinal.
  • Dos cadenas de personajes aleatorios y diferentes longitudes, para probar el rendimiento en el mejor escenario.

Después de realizar algunas pruebas, String.Equals es el claro vencedor. Esta afirmación se mantiene vigente independientemente de la plataforma, de la versión del runtime del scripting o de si estamos utilizando Mono o IL2CPP.

Vale la pena observar que String.Equals es el método utilizado por el operador de igualdad de cadenas, ==, ¡así que no te apresures y cambies a == b a a.Equals(b) en todo tu código!

En realidad, examinando los resultados, es extraño cuánto peor es la implementación de la referencia codificada manualmente. Al examinar el código IL2CPP, podemos ver que Unity introduce un montón de verificaciones de los límites del array y verificaciones nulas cuando nuestro código es generado por compilación cruzada.

Estas pueden deshabilitarse. En tu carpeta de instalación de Unity, encuentra la subcarpeta IL2CPP. Dentro de esa subcarpeta IL2CPP, encontrarás IL2CPPSetOptionAttributes.cs. Arrástrala a tu proyecto y tendrás acceso a Il2CppSetOptionAttribute.

Puedes decorar tipos y métodos con este atributo. Puedes configurarlo para deshabilitar las verificaciones nulas automáticas, las verificaciones automática de los límites del array, o ambas. Ello puede acelerar la ejecución del código –a veces en una medida importante. En este caso de prueba particular, ¡acelera el método de comparación de cadenas codificado manualmente en un 20 %!

lp: consejo para revisión de valores null (null check)

Transforms

No lo sabrías con la mera observación de la Jerarquía en el Editor de Unity, pero el componente Transform ha cambiado mucho entre Unity 5 y Unity 2018. Estos cambios también presentan algunas posibilidades nuevas e interesantes para la mejora del rendimiento.

Volviendo a Unity 4 y Unity 5.0, un objeto se asignaba en algún lugar de la pila de la memoria nativa de Unity cada vez que creabas un Transform. Ese objeto podía estar en cualquier lugar de la pila de la memoria nativa –no había ninguna garantía de que dos Transforms asignados de forma secuencial se asignaran uno cerca del otro, y tampoco había garantía de que un Transform hijo se asignara cerca de su padre.

Esto significaba que, al iterar por una Jerarquía de Transform linealmente, no estábamos iterando linealmente por una región contigua de memoria. Esto hacía que el procesador se detuviera repetidas veces mientras esperaba que se buscaran los datos del Transform desde la caché L2 o desde la memoria principal.

En el backend de Unity, cada vez que una posición, rotación o escala de Transform cambiaba, ese Transform enviaba un mensaje OnTransformChanged. Este mensaje debía ser recibido por todos los Transforms hijos, lo que les permitía actualizar sus propios datos y dar aviso a cualquier otro Componente interesado en los cambios del Transform. Por ejemplo, un Transform hijo con un Collider añadido debe actualizar el sistema de Física en el momento en que cambie el Transform hijo o el Transform padre.

Este mensaje inevitable causaba muchos problemas de rendimiento, especialmente porque no había ninguna manera incorporada de evitar los mensajes falsos. Si te disponías a cambiar un Transform, y sabías que también ibas a cambiar a sus hijos, no había modo de impedir que Unity enviara un mensaje OnTransformChanged después de cada cambio. Esto desperdiciaba mucho tiempo del CPU.

Debido a este detalle, uno de los consejos más comunes en las versiones más antiguas de Unity es manejar por lotes los cambios en Transforms. Es decir, capturar una posición y rotación de Transform una vez, al inicio de un frame, y utilizar y actualizar los valores almacenados en la memoria caché durante el desarrollo del frame. Solo aplicar los cambios a la posición y rotación una vez, al final del frame. Es un buen consejo, en todas las versiones que hay hasta llegar a Unity 2017.2.

Por suerte, a partir de Unity 2017.4 y 2018.1, OnTransformChanged pertenece al pasado. Un sistema nuevo, TransformChangeDispatch, lo ha reemplazado.

TransformChangeDispatch se incluyó por primera vez en Unity 5.4. En esta versión, los Transforms dejaron de ser objetos aislados que podían localizarse en cualquier lugar de la pila de la memoria nativa de Unity. En lugar de ello, cada Transform raíz en una escena estaría representado por un búfer de datos contiguos. Este búfer, denominado estructura TransformHierarchy, contiene todos los datos para todos los Transforms por debajo del Transform raíz.

Además, una estructura TransformHierarchy también almacena metadatos acerca de cada Transform contenido en ella. Estos metadatos incluyen una máscara de bits que indica si un Transform determinado está «sucio» –si su posición, rotación o escala ha cambiado desde la última vez que el Transform fue marcado como «limpio». También incluye una máscara de bits para rastrear qué otros sistemas de Unity están interesados en los cambios realizados a un Transform específico.

Con estos datos, Unity puede ahora crear una lista de Transforms sucios para cada uno de sus otros sistemas internos. Por ejemplo, el sistema de Física puede consultar el TransformChangeDispatch para buscar una lista de Transforms cuyos datos hayan cambiado desde la última vez que el sistema de Física ejecutó un FixedUpdate.

Sin embargo, para recopilar esta lista de Transforms cambiados, el sistema TransformChangeDispatch no debe iterar todos los Transforms en una escena. Ello lo volvería muy lento si una escena incluyera muchos Transforms –especialmente porque, en la mayoría de los casos, muy pocos Transforms habrían cambiado.

Para arreglar esto, el TransformChangeDispatch rastrea una lista de estructuras TransformHierarchy sucias. Cuando un Transform cambia, se marca como sucio, marca a sus hijos como sucios y luego registra la TransformHierarchy en la que se encuentra almacenado con el sistema TransformChangeDispatch. Cuando otro sistema dentro de Unity solicita una lista de Transforms cambiados, el TransformChangeDispatch itera cada Transform almacenado dentro de cada una de las estructuras TransformHierarchy sucias. Los Transforms con el conjunto adecuado de bits sucios y que han sido de interés de otros sistemas se agregan a una lista y esta lista se devuelve al sistema que realiza la solicitud.

Debido a esta arquitectura, cuanto más dividas tu jerarquía, mayor será la capacidad de Unity para rastrear cambios a nivel granular. Cuantos más Transforms existan en la raíz de una escena, menor será la cantidad de transforms que tendremos que examinar cuando busquemos cambios.

Sin embargo, hay otra consecuencia. TransformChangeDispatch utiliza el sistema multihilos interno de Unity para dividir el trabajo que necesita realizar al examinar las estructuras TransformHierarchy. Esta división, y la fusión de los resultados, añade un poco de sobrecarga cada vez que un sistema necesita solicitar una lista de cambios de TransformChangeDispatch.

La mayoría de los sistemas internos de Unity solicitan actualizaciones una vez por frame, inmediatamente antes de ejecutarse. Por ejemplo, el sistema de Animación solicita actualizaciones inmediatamente antes de evaluar todos los Animators activos de tu escena. De forma similar, el sistema de renderizado solicita actualizaciones de todos los Renderers activos de tu escena antes de empezar a seleccionar la lista de objetos visibles.

Un sistema es algo distinto: Física.

En Unity 2017.1 (y versiones anteriores), las actualizaciones de Physics se realizaban de manera simultánea. Si movías o rotabas un Transform con un Collider conectado a este, nosotros actualizábamos de inmediato la escena de Physics. Esto garantizaba que el cambio de posición o la rotación del Collider se viese reflejada en el mundo de Physics, de manera que los Raycasts y otras consultas de Physics fuesen exactas.

Cuando en Unity 2017.2, hicimos que Physics migre de manera que pueda usar TransformChangeDispatch, a pesar de ser un cambio necesario, también nos creó problemas. Cada vez que hacías un Raycast, tenías que consultar TransformChangeDispatch y solicitar una lista de Transforms modificados y aplicarlos al mundo de Physics. Eso podría ser costoso, dependiendo de qué tan grande fuesen sus Transform Hierarchies y cómo llamaba tu código a las APl de Physics.

Este comportamiento es regulado por un nuevo ajuste, Physics.autoSyncTransforms. Desde Unity 2017.2 hasta Unity 2018.2, este ajuste ha sido de manera predeterminada "true" (verdadero), y Unity sincronizará automáticamente el mundo de Physics de acuerdo con las actualizaciones de Transform cada vez que invoques una APl de consulta de Physics como Raycast o Spherecast.

Este ajuste puede cambiarse, ya sea en tus Physics Settings en el Editor de Unity o en el runtime al configurar la propiedad Physics.autoSyncTransforms. Si lo configuras como «false» (falso) y deshabilitas la sincronización automática de Physics, entonces el sistema Physics consultará al sistema TransformChangeDispatch en relación con cambios en un determinado momento: justo antes de ejecutar FixedUpdate.

Si observas problemas de performance al invocar APl de consulta de Physics, hay dos formas adicionales de resolverlos.

En primer lugar, puedes definir Physics.autoSyncTransforms como «false». Esto eliminará valores atípicos debido a actualizaciones de TransformChangeDispatch y de escenas de Physics como resultado de consultas de Physics.

Sin embargo, si haces esto, los cambios en los Colliders no se sincronizarán en la escena hasta la siguiente FixedUpdate. Esto significa que si deshabilitas autoSyncTransforms, mueves un Collider y después invocas Raycast con un Ray dirigido a la nueva posición del Collider, el Raycast podría no darle al collider, y esto se debe a que el Raycast está operando en la última versión actualizada de la escena de Physics, y la escena de Physics todavía no se ha actualizado con la nueva posición del Collider.

Esto puede dar lugar a errores raros, y debes probar con mucho cuidado tu juego para asegurarse de que la deshabilitación de la sincronización automática de Transforms no te cause problemas. Si necesitas forzar Physics para que actualice la escena de Physics con cambios de Transform, puedes invocar Physics.SyncTransforms. ¡Esta APl es lenta, por lo que no debes invocarla varias veces por frame!

Ten en cuenta que, desde 2018.3 en adelante, de manera predeterminada Physics.autoSyncTransforms será «false».

La segunda forma de optimizar más el tiempo que se dedica a consultar TransformChangeDispatch es cambiar el orden de tus consultas y actualizaciones de la escena de Physics de manera que sean más amigables con el nuevo sistema.

Recuerda: si configuras Physics.autoSyncTransforms como «true», toda consulta de Physics verificará si hay cambios en TransformChangeDispatch. Sin embargo, si TransformChangeDispatch no tiene estructuras complicadas de TransformHierarchy que modificar, y el sistema Physics no ha actualizado Transforms para aplicarlos a la escena de Physics, casi no se añadirá sobrecarga a la consulta de Physics.

Entonces, podrías ejecutar todas tus consultas de Physics en un lote, y después aplicar todos los cambios de Transform en un lote. En la práctica, no mezcles cambios de Transforms con invocaciones a APl de consulta de Physics.

Este ejemplo explica la diferencia:

lp: ejemplos de transformaciones

La diferencia en la performance entre estos dos ejemplos es impresionante, y se hace todavía más notoria cuando una escena contiene solo jerarquías pequeñas de Transform.

lp: resultados de los ejemplos de transformaciones

El sistema de Audio

Internamente, Unity emplea un sistema llamado FMOD para reproducir AudioClips. FMOD ejecuta sus propios procesos, y estos se encargan de decodificar y mezclar el audio. Sin embargo, la reproducción de sonido no es totalmente gratis. Tiene que hacerse cierto trabajo en el proceso principal para cada Audio Source que esté activa en la escena. De igual manera, en plataformas con menos núcleos (como los teléfonos celulares más antiguos), es posible que los procesos de audio de FMOD compitan por núcleos del procesador con los procesos principal y de renderizado de Unity.

En cada frame, Unity realiza loops sobre todas las Audio Sources activas. Para cada Audio Source, Unity calcula la distancia entre la fuente de audio y el oyente activo del audio, y de algunos otros parámetros. Estos datos se utilizan para calcular la atenuación de volumen, desplazamiento Doppler y otros efectos que pueden afectar las Audio Sources individuales.

Un problema común ocurre con la casilla de verificación «Mute» (Silenciar) de una Audio Source. Podrías pensar que configurar Mute como «true» eliminaría todo cálculo relacionado con la Audo Source silenciada, ¡pero no es así!

lp: casilla de audio

En cambio, el ajuste «Mute» sencillamente restringe el parámetro Volume a cero, después de ejecutar todos los demás cálculos relacionados con el volumen, incluida la comprobación de distancia. Unity enviará también la Audio Source silenciada a FMOD, que entonces la ignorará. El cálculo de los parámetros de Audio Source y el envío de las Audio Sources a FMOD aparecerá como AudiosSystem.Update en el Unity Profiler.

Si notas que se asigna mucho tiempo al marcador Profiler, verifica si tienes muchas Audio Sources que estén silenciadas. Si este es el caso, evalúa deshabilitar los componentes de la Audio Source silenciada en lugar de silenciarlos, o deshabilitar su GameObject. También puedes invocar AudioSource.Stop, lo que detendrá la reproducción.

Otra cosa que puedes hacer es restringir el conteo de voz en la Audio Settings de Unity. Para hacerlo, puedes invocar AudioSettings.GetConfiguration, que devuelve una estructura que contiene dos valores de interés: el conteo de voz virtual, y el conteo de voz real.

Al reducir la cantidad de Virtual Voices (voces virtuales), se reducirá la cantidad de Audio Sources que FMOD analizará al determinar qué Audio Sources reproducirá realmente. Al reducir el conteo de voz real, se reducirá la cantidad de Audio Sources que FMOD mezcla realmente para producir el audio de tu juego.

Para cambiar la cantidad de voces Virtuales o Reales que FMOD utiliza, debes cambiar los valores adecuados en la estructura AudioConfiguration que es devuelta por AudioSettings.GetConfiguration, después restaurar el sistema de Audio con la configuración nueva transfiriendo la estructura AudioConfiguration como un parámetro a AudioSettings.Reset. Ten presente que esto interrumpe la reproducción de audio, por lo que te recomendamos que este proceso tenga lugar cuando los jugadores no vayan a notar el cambio, como durante una pantalla de carga o en el momento de iniciar el juego.

Animaciones

En Unity, las animaciones pueden reproducirse por medio de dos sistemas diferentes: el sistema Animator y el sistema Animation.

Al decir «sistema Animator» nos referimos al sistema que incluye al componente Animator, que está vinculado a los GameObjects para poder animarlos, y al asset AnimatorController, al que hace referencia uno o más Animators. Históricamente, este sistema se denominó Mecanim, y ofrece numerosas funciones.

En un Animator Controller, tú defines estados. Estos estados pueden ser un Animation Clip o un Blend Tree. Los estados se organizan en Layers o capas. Cada frame, el estado activo en que se evalúa cada Layer, y los resultados de cada Layer se mezclan y aplican al modelo animado. Al producirse la transición entre dos estados, se evalúan ambos estados.

El otro sistema se ha denominado «Animation system» (sistema de animación). Está representado por el componente Animation, y es muy sencillo. Cada frame, cada componente activo de la Alineación itera linealmente a través de todas las curvas en su Animation Clip o clip de animación vinculado, evalúa esas curvas y aplica los resultados.

La diferencia entre estos dos sistemas no solo son funciones, sino también los detalles de implementación detrás de estas.

El sistema Animator tiene muchos hilos o threads. Su performance cambia radicalmente de acuerdo con las distintas CPU y la cantidad de núcleos de estas. En general, escala menos que linealmente a medida que se incrementa la cantidad de curvas en sus Animation Clips. Por lo tanto, su performance es muy buena al evaluarse animaciones complejas con una gran cantidad de curvas. Sin embargo, tiene un costo general bastante alto.

El sistema Animation, al ser sencillo, casi no tiene costos generales. Su performance escala lineamente con la cantidad de curvas de los Animation Clips que se reproducen.

La diferencia es más notoria al comparar los dos sistemas durante la reproducción de Animation Clips idénticos.

lp: prueba de clips de animación

Al reproducir Animation Clips, intenta elegir el sistema que se adecue mejor a la complejidad de tu contenido y al hardware en el que se ejecutará tu juego.

Otro problema común es el uso excesivo de Layers en Animator Controllers. Si un Animator está ejecutándose, evaluará todas las Layers de su Animator Controller en cada frame. Esto incluye Layers con un Layer Weight configurado como cero, lo que significa que no hace contribuciones visibles a los resultados finales de la animación.

Cada Layer adicional añadirá un cálculo adicional a cada Animator Controller y a cada frame. Por lo tanto, en términos generales, intenta utilizar las Layers con moderación. Si tienes Layers de depuración, demo o cinemáticas en un Animator Controller, intenta volver a factorizarlas. Fusiónalas en las Layers existentes, o elimínalas antes de despachar tu juego.

Rig o plataforma humanoide versus genérica

De manera predeterminada, Unity importa modelos animados con la plataforma Generic o genérica, pero es común entre los desarrolladores pasar a la plataforma Humanoid cuando intentan animar un personaje. Sin embargo, esto no es gratis.

La plataforma Humanoid añade dos funciones adicionales al Animator System: cinemática inversa y reorientación (retargeting) de animaciones. La reorientación de animaciones es increíble, porque te permite volver a utilizar animaciones en diferentes avatares.

Sin embargo, inclusivo si no estás utilizando IK o Animation Retargeting, un Animator del personaje de la plataforma Humanoid calculará los datos de IK y Retargeting cada frame. Esto consume aproximadamente de 30 a 50 % más tiempo del CPU que la plataforma Genérica, que no hace estos cálculos.

Si no estás utilizando las funciones de la plataforma Humanoid, deberías utilizar la plataforma Generic.

Animator Pooling o Agrupación de animadores

Object Pooling es una estrategia clave para evitar picos de performance durante el juego. Sin embargo, los Animators históricamente han sido difíciles de usar con Object Pooling. Cada vez que se habilita un GameObject de un Animator, debe reconstruir búfers de datos intermedios para su uso al evaluar el Animator Controller del Animator. A esto se le denomina Animator Rebind, y se muestra en el Unity Profiler como Animator.Rebind.

Antes de Unity 2018, la única solución era deshabilitar el Animator Component, no el GameObject. Esto tenía efectos colaterales: si tenías MonoBehaviors, Mesh Colliders o Mesh Renderers en tu personajes, no ibas a querer deshabilitarlos también. De esa manera, podrías ahorrar todo el tiempo de CPU que era utilizado por tu personaje. Pero esto hace más complejo tu código y es fácil de romper.

Con Unity 2018.1, introdujimos el Animator.KeepControllerStateOnEnable API. Esta propiedad, de manera predeterminada es falsa, lo que significa que el Animator se comportará tal como siempre lo ha hecho: anulando la asignación de sus búferes de datos intermedios cuando está deshabilitado y reasignándolos cuando está habilitado.

Sin embargo, si defines esta propiedad como verdadera, los Animators retendrán sus búferes mientras estén deshabilitados. Esto significa que no habrá Animator.Rebind cuando se vuelva a habilitar el Animator. ¡Los Animators ya pueden agruparse!

Mas recursos

¡Debemos saberlo! ¿Te gustó este contenido?

Sí. Que sigan llegando Me da igual. Podría ser mejor