Cómo depurar LMKs

Resolver los LMK en tu juego de Unity es un proceso sistemático:

Figura 1. Pasos para resolver los Low Memory Kills (LMK) en juegos de Unity

Obtén una instantánea de memoria

Usa el Generador de perfiles de Unity para obtener una instantánea de memoria administrada por Unity. En la figura 2, se muestran las capas de administración de memoria que usa Unity para controlar la memoria en tu juego.

Figura 2. Descripción general de la administración de memoria de Unity

Memoria administrada

La administración de memoria de Unity implementa una capa de memoria controlada que usa un montón administrado y un recolector de elementos no utilizados para asignar memoria automáticamente. El sistema de memoria administrada es un entorno de secuencias de comandos de C# basado en Mono o IL2CPP. El beneficio del sistema de memoria administrada es que utiliza un recolector de elementos no utilizados para liberar automáticamente las asignaciones de memoria.

Memoria no administrada de C#

La capa de memoria no administrada de C# proporciona acceso a la capa de memoria nativa, lo que permite un control preciso sobre las asignaciones de memoria mientras se usa código C#. Se puede acceder a esta capa de administración de memoria a través del espacio de nombres Unity.Collections y mediante funciones como UnsafeUtility.Malloc y UnsafeUtility.Free.

Memoria nativa

El núcleo interno de C/C++ de Unity usa un sistema de memoria nativa para administrar escenas, recursos, APIs de gráficos, controladores, subsistemas y búferes de complementos. Si bien el acceso directo está restringido, puedes manipular datos de forma segura con la API de C# de Unity y beneficiarte del código nativo eficiente. La memoria nativa rara vez requiere interacción directa, pero puedes supervisar el impacto de la memoria nativa en el rendimiento con el Generador de perfiles y ajustar la configuración para optimizar el rendimiento.

La memoria no se comparte entre C# y el código nativo, como se muestra en la figura 3. Los datos que requiere C# se asignan en el espacio de memoria administrada cada vez que se necesitan.

Para que el código del juego administrado (C#) acceda a los datos de memoria nativa del motor, por ejemplo, una llamada a GameObject.transform realiza una llamada nativa para acceder a los datos de memoria en el área nativa y, luego, devuelve valores a C# con Bindings. Los enlaces garantizan las convenciones de llamada adecuadas para cada plataforma y controlan la serialización automática de tipos administrados en sus equivalentes nativos.

Esto sucede solo la primera vez, ya que el shell administrado para acceder a la propiedad transform se conserva en el código nativo. El almacenamiento en caché de la propiedad de transformación puede reducir la cantidad de llamadas de ida y vuelta entre el código administrado y el código nativo, pero la utilidad del almacenamiento en caché depende de la frecuencia con la que se usa la propiedad. Además, ten en cuenta que Unity no copia partes de la memoria nativa en la memoria administrada cuando accedes a estas APIs.

Figura 3. Acceso a la memoria nativa desde el código administrado de C#

Para obtener más información, consulta Introducción a la memoria en Unity.

Además, establecer un presupuesto de memoria es fundamental para que tu juego funcione sin problemas, y la implementación de un sistema de informes o análisis de consumo de memoria garantiza que cada versión nueva no exceda el presupuesto de memoria. Integrar pruebas de Play Mode con tu integración continua (CI) para verificar el consumo de memoria en áreas específicas del juego es otra estrategia para obtener una mejor información.

Administrar activos

Esta es la parte más impactante y práctica del consumo de memoria. Genera perfiles lo antes posible.

El uso de memoria en los juegos de Android puede variar significativamente según el tipo de juego, la cantidad y los tipos de recursos, y las estrategias de optimización de memoria. Sin embargo, los colaboradores comunes para el uso de memoria suelen incluir texturas, mallas, archivos de audio, sombreadores, animaciones y secuencias de comandos.

Detecta recursos duplicados

El primer paso es detectar recursos mal configurados y duplicados con el generador de perfiles de memoria, una herramienta de informes de compilación o el Auditor de proyectos.

Texturas

Analiza la compatibilidad de dispositivos de tu juego y decide el formato de textura correcto. Puedes dividir los paquetes de texturas para dispositivos de alta y baja gama con Play Asset Delivery, Addressable, o un proceso más manual con un AssetBundle.

Sigue las recomendaciones más conocidas disponibles en el Optimize Your Mobile Game Performance y en la publicación de debate Optimising Unity Texture Import Settings. Luego, prueba estas soluciones:

  • Comprime texturas con formatos ASTC para reducir la huella de memoria y experimenta con una tasa de bloques más alta, como 8x8.

    Si es necesario usar ETC2, empaqueta las texturas en Atlas. Colocar varias texturas en una sola garantiza su potencia de dos (POT), puede reducir las llamadas de dibujo y acelerar la renderización.

  • Optimiza el formato y el tamaño de la textura RenderTarget. Evita las texturas de resolución innecesariamente alta. El uso de texturas más pequeñas en dispositivos móviles ahorra memoria.

  • Usa el empaquetado de canales de textura para guardar la memoria de textura.

Mallas y modelos

Comienza por verificar la configuración fundamental (página 27) y verifica estos parámetros de configuración de importación de mallas:

  • Combina mallas redundantes y más pequeñas.
  • Reduce el recuento de vértices para objetos en escenas (por ejemplo, objetos estáticos o distantes).
  • Genera grupos de nivel de detalle (LOD) para recursos de alta geometría.

Materiales y sombreadores

  • Quita las variantes de sombreador no utilizadas de forma programática durante el proceso de compilación.
  • Consolida las variantes de sombreador que se usan con frecuencia en sombreadores uber para evitar la duplicación de sombreadores.
  • Habilita la carga dinámica de sombreadores para abordar la gran huella de memoria de los sombreadores precargados en VRAM/RAM. Sin embargo, presta atención si la compilación de sombreadores causa problemas de fotogramas.
  • Usa la carga dinámica de sombreadores para evitar que se carguen todas las variantes. Para obtener más información, consulta la entrada de blog Improvements to shader build times and memory usage.
  • Usa la creación de instancias de material correctamente aprovechando MaterialPropertyBlocks.

Audio

Comienza por verificar la configuración fundamental (página 41) y verifica estos parámetros de configuración de importación de mallas:

  • Quita las referencias AudioClip no utilizadas o redundantes cuando emplees motores de audio de terceros, como FMOD o Wwise.
  • Precarga datos de audio. Inhabilita la precarga para los clips que no se requieren de inmediato durante el tiempo de ejecución o el inicio de la escena. Esto ayuda a reducir la sobrecarga de memoria durante la inicialización de la escena.

Animaciones

  • Ajusta la configuración de compresión de animación de Unity para minimizar la cantidad de fotogramas clave y eliminar datos redundantes.
    • Reducción de fotogramas clave: Quita automáticamente los fotogramas clave innecesarios.
    • Compresión de cuaterniones: Comprime los datos de rotación para reducir el uso de memoria.

Puedes ajustar la configuración de compresión en Animation Import Settings en la pestaña Rig o Animation.

  • Reutiliza los clips de animación en lugar de duplicarlos para diferentes objetos.

    Usa controladores de anulación de Animator para reutilizar un controlador de Animator y reemplazar clips específicos para diferentes personajes.

  • Crea animaciones basadas en la física: Si tus animaciones son basadas en la física o de procedimiento, incorpóralas en clips de animación para evitar cálculos de tiempo de ejecución.

  • Optimiza el esqueleto: Usa menos huesos en tu esqueleto para reducir la complejidad y el consumo de memoria.

    • Evita el exceso de huesos para objetos pequeños o estáticos.
    • Si no se animan o no se necesitan ciertos huesos, quítalos del esqueleto.
  • Reduce la longitud del clip de animación.

    • Recorta los clips de animación para incluir solo los fotogramas necesarios. Evita almacenar animaciones no utilizadas o excesivamente largas.
    • Usa animaciones en bucle en lugar de crear clips largos para movimientos repetidos.
  • Asegúrate de que solo se adjunte o active un componente de animación. Por ejemplo, inhabilita o quita los componentes de animación heredados si usas Animator.

  • Evita usar el Animator si no es necesario. Para VFX simples, usa bibliotecas de interpolación o implementa el efecto visual en una secuencia de comandos. El sistema de Animator puede consumir muchos recursos, en especial en dispositivos móviles de baja gama.

  • Usa el sistema de trabajos para animaciones cuando manejes una gran cantidad de animaciones, ya que ese sistema se rediseñó por completo para ser más eficiente en cuanto a la memoria.

Escenas

Cuando se cargan escenas nuevas, incorporan recursos como dependencias. Sin embargo, sin una administración adecuada del ciclo de vida de los recursos, los contadores de referencia no supervisan estas dependencias. Como resultado, los recursos pueden permanecer en la memoria incluso después de que se hayan descargado las escenas no utilizadas, lo que causa la fragmentación de la memoria.

  • Usa el agrupamiento de objetos de Unity para reutilizar instancias de GameObject para elementos de juego recurrentes, ya que el agrupamiento de objetos usa una pila para contener una colección de instancias de objetos para su reutilización y no es seguro para subprocesos. Minimizar Instantiate y Destroy mejora el rendimiento de la CPU y la estabilidad de la memoria.
  • Descarga recursos:
    • Descarga recursos estratégicamente durante momentos menos críticos, como pantallas de presentación o pantallas de carga.
    • El uso frecuente de Resources.UnloadUnusedAssets causa picos en el procesamiento de la CPU debido a las grandes operaciones internas de supervisión de dependencias.
    • Verifica si hay picos grandes de CPU en el GC.MarkDependencies marcador de perfil. Quita o reduce su frecuencia de ejecución y descarga manualmente recursos específicos en su lugar con Resources.UnloadAsset en lugar de depender de la función integral Resources.UnloadUnusedAssets().
  • Reestructura las escenas en lugar de usar Resources.UnloadUnusedAssets constantemente.
  • Llamar a Resources.UnloadUnusedAssets() para Addressables puede descargar de forma involuntaria paquetes cargados de forma dinámica. Administra con cuidado el ciclo de vida de los recursos cargados de forma dinámica.

Varios

  • Fragmentación causada por transiciones de escena: Cuando se llama al método Resources.UnloadUnusedAssets(), Unity hace lo siguiente:

    • Libera memoria para los recursos que ya no están en uso.
    • Ejecuta una operación similar a un recolector de elementos no utilizados para verificar el montón de objetos administrados y nativos en busca de recursos no utilizados y los descarga.
    • Limpia la memoria de texturas, mallas y recursos, siempre que no exista una referencia activa.
  • AssetBundle o Addressable: Realizar cambios en esta área es complejo y exige un esfuerzo colectivo del equipo para implementar las estrategias. Sin embargo, una vez que se dominan estas estrategias, mejoran significativamente el uso de la memoria, reducen el tamaño de descarga y disminuyen los costos de la nube. Para obtener más información sobre la administración de recursos en Unity con, consulta Addressables.

  • Dependencias compartidas centralizadas: Agrupa las dependencias compartidas, como sombreadores, texturas y fuentes, de forma sistemática en paquetes dedicados o grupos Addressable. Esto reduce la duplicación y garantiza que los recursos innecesarios se descarguen de manera eficiente.

  • Usa Addressables para el seguimiento de dependencias: Addressables simplifican la carga y descarga, y pueden descargar automáticamente las dependencias a las que ya no se hace referencia. La transición a Addressables para la administración de contenido y la resolución de dependencias puede ser una solución viable, según el caso específico del juego. Analiza las cadenas de dependencias con la herramienta Analyze tool para identificar duplicados o dependencias innecesarias. Como alternativa, consulta las herramientas de datos de Unity si usas AssetBundles.

  • TypeTrees - si los Addressables y AssetBundles de tu juego se compilan y se implementan con la misma versión de Unity que el reproductor y no requieren retrocompatibilidad con otras compilaciones de reproductor, considera inhabilitar la escritura de TypeTree, lo que debería reducir el tamaño del paquete y el espacio en memoria del objeto de archivo serializado. Modifica el proceso de compilación en la configuración del paquete local Addressables local ContentBuildFlags a to DisableWriteTypeTree.

Escribe código compatible con el recolector de elementos no utilizados

Unity utiliza la recolección de elementos no utilizados (GC) para administrar la memoria mediante la identificación y la liberación automáticas de la memoria no utilizada. Si bien la GC es esencial, puede causar problemas de rendimiento (por ejemplo, picos de velocidad de fotogramas) si no se controla correctamente, ya que este proceso puede pausar el juego momentáneamente, lo que genera problemas de rendimiento y una experiencia del usuario no óptima.

Consulta el manual de Unity para obtener técnicas útiles para reducir la frecuencia de las asignaciones de montón administradas y el UnityPerformanceTuningBible, página 271, para obtener ejemplos.

  • Reduce las asignaciones del recolector de elementos no utilizados:

    • Evita LINQ, lambdas y cierres, que asignan memoria de montón.
    • Usa StringBuilder para cadenas mutables en lugar de concatenación de cadenas.
    • Reutiliza las colecciones llamando a COLLECTIONS.Clear() en lugar de volver a crear instancias de ellas.

    Hay más información disponible en el libro electrónico Ultimate Guide to Profiling Unity games.

  • Administra las actualizaciones del lienzo de la IU:

    • Cambios dinámicos en los elementos de la IU: Cuando se actualizan los elementos de la IU, como las propiedades de texto, imagen o RectTransform (por ejemplo, cambiar el contenido del texto, cambiar el tamaño de los elementos o animar posiciones), el motor puede asignar memoria para objetos temporales.
    • Asignaciones de cadenas: Los elementos de la IU, como el texto, suelen requerir actualizaciones de cadenas, ya que las cadenas son inmutables en la mayoría de los lenguajes de programación.
    • Lienzo sucio: Cuando cambia algo en un lienzo (por ejemplo, cambiar el tamaño, habilitar y deshabilitar elementos o modificar las propiedades de diseño), es posible que todo el lienzo o una parte de él se marquen como sucios y se vuelvan a compilar. Esto puede activar la creación de estructuras de datos temporales (por ejemplo, datos de malla, búferes de vértices o cálculos de diseño), lo que se suma a la generación de elementos no utilizados.
    • Actualizaciones complejas o frecuentes: Si el lienzo tiene una gran cantidad de elementos o se actualiza con frecuencia (por ejemplo, cada fotograma), estas recompilaciones pueden generar una saturación de la memoria significativa.
  • Habilita la GC incremental para reducir los picos grandes de recolección propagando las limpiezas de asignación en varios fotogramas. Genera perfiles para verificar si esta opción mejora el rendimiento y el espacio en memoria de tu juego.

  • Si tu juego requiere un enfoque controlado, configura el modo de recolección de elementos no utilizados en manual. Luego, en un cambio de nivel o en otro momento sin juego activo, llama a la recolección de elementos no utilizados.

  • Invoca llamadas manuales de recolección de elementos no utilizados GC.Collect() para transiciones de estado del juego (por ejemplo, cambio de nivel).

  • Optimiza los arrays a partir de prácticas de código simples y, si es necesario, usa arrays nativos o contenedores nativos para arrays grandes.

  • Supervisa los objetos administrados con herramientas como el Generador de perfiles de memoria de Unity para hacer un seguimiento de las referencias de objetos no administrados que persisten después de la destrucción.

    Usa un marcador de generador de perfiles para enviar a la herramienta de informes de rendimiento para un enfoque automatizado.

Evita las fugas de memoria y la fragmentación

Fugas de memoria

En el código C#, cuando existe una referencia a un objeto de Unity después de que se destruyó el objeto, el objeto contenedor administrado, conocido como shell administrado, permanece en la memoria. La memoria nativa asociada con la referencia se libera cuando se descarga la escena o cuando se destruye el GameObject al que está conectada la memoria, o cualquiera de sus objetos superiores, a través del método Destroy(). Sin embargo, si no se borraron otras referencias a la escena o al GameObject, la memoria administrada puede persistir como un objeto de shell filtrado. Para obtener más detalles sobre los objetos de shell administrados, consulta el manual de objetos de shell administrados.

Además, las fugas de memoria pueden deberse a suscripciones a eventos, lambdas y cierres, concatenaciones de cadenas y administración inadecuada de objetos agrupados:

  • Para comenzar, consulta Cómo encontrar fugas de memoria para comparar correctamente las instantáneas de memoria de Unity.
  • Verifica si hay suscripciones a eventos y fugas de memoria. Si los objetos se suscriben a eventos (por ejemplo, por delegados o UnityEvents), pero no se dan de baja correctamente antes de destruirse, el administrador o publicador de eventos puede conservar referencias a esos objetos. Esto evita que se recolecten elementos no utilizados de esos objetos, lo que genera fugas de memoria.
  • Supervisa los eventos de clase global o singleton que no se anulan en la destrucción de objetos. Por ejemplo, anula la suscripción o desconecta delegados en destructores de objetos.
  • Asegúrate de que la destrucción de objetos agrupados anule por completo las referencias a componentes de malla de texto, texturas y GameObjects superiores.
  • Ten en cuenta que, cuando comparas instantáneas del Generador de perfiles de memoria de Unity y observas una diferencia en el consumo de memoria sin un motivo claro, la diferencia puede deberse al controlador de gráficos o al sistema operativo en sí.

Fragmentación de memoria

La fragmentación de memoria ocurre cuando se liberan muchas asignaciones pequeñas en un orden aleatorio. Las asignaciones de montón se realizan de forma secuencial, lo que significa que se crean nuevos fragmentos de memoria cuando el fragmento anterior se queda sin espacio. En consecuencia, los objetos nuevos no llenan las áreas vacías de los fragmentos antiguos, lo que genera fragmentación. Además, las asignaciones temporales grandes pueden causar fragmentación permanente durante la sesión de un juego.

Este problema es particularmente problemático cuando se realizan asignaciones grandes de corta duración cerca de las de larga duración.

Agrupa las asignaciones según su ciclo de vida. Lo ideal es que las asignaciones de larga duración se realicen juntas, al principio del ciclo de vida de la aplicación.

Observadores y administradores de eventos

  • Además del problema mencionado en la sección Fugas de memoria, con el tiempo, las fugas de memoria pueden contribuir a la fragmentación dejando memoria no utilizada asignada a objetos que ya no están en uso.
  • Asegúrate de que la destrucción de objetos agrupados anule por completo las referencias a componentes de malla de texto, texturas y GameObjects.
  • Los administradores de eventos suelen crear y almacenar listas o diccionarios para administrar las suscripciones a eventos. Si estos crecen y se reducen de forma dinámica durante el tiempo de ejecución, pueden contribuir a la fragmentación de la memoria debido a las asignaciones y desasignaciones frecuentes.

Código

  • Las corrutinas a veces asignan memoria, lo que se puede evitar fácilmente almacenando en caché la sentencia return del IEnumerator en lugar de declarar una nueva cada vez.
  • Supervisa continuamente los estados del ciclo de vida de los objetos agrupados para evitar mantener referencias fantasma de UnityEngine.Object.

Recursos

  • Usa sistemas de resguardo dinámicos para experiencias de juego basadas en texto para evitar la precarga de todas las fuentes para casos multilingües.
  • Organiza los recursos (por ejemplo, texturas y partículas) por tipo y ciclo de vida esperado.
  • Condensa los recursos con atributos de ciclo de vida inactivos, como imágenes de IU redundantes y mallas estáticas.

Asignaciones basadas en el tiempo de actividad

  • Asigna recursos de larga duración al comienzo del ciclo de vida de la aplicación para garantizar asignaciones compactas.
  • Usa NativeCollections o asignadores personalizados para estructuras de datos transitorias o que consumen mucha memoria (por ejemplo, clústeres de física).

El ejecutable y los complementos del juego también afectan el uso de memoria.

Metadatos de IL2CPP

IL2CPP genera metadatos para cada tipo (por ejemplo, clases, genéricos y delegados) en el momento de la compilación, que luego se usan en el tiempo de ejecución para la reflexión, la verificación de tipos y otras operaciones específicas del tiempo de ejecución. Estos metadatos se almacenan en la memoria y pueden contribuir significativamente al espacio en memoria total de la aplicación. La caché de metadatos de IL2CPP contribuye de manera significativa a los tiempos de inicialización y carga. Además, IL2CPP no anula la duplicación de ciertos elementos de metadatos (por ejemplo, tipos genéricos o información serializada), lo que puede generar un uso de memoria inflado. Esto se exacerba con el uso repetitivo o redundante de tipos en el proyecto.

Los metadatos de IL2CPP se pueden reducir de la siguiente manera:

  • Evitar el uso de APIs de reflexión, ya que pueden ser un contribuyente importante a las asignaciones de metadatos de IL2CPP
  • Inhabilitar paquetes integrados
  • Implementar el uso compartido genérico completo de Unity 2022 full generic sharing, lo que debería ayudar a reducir la sobrecarga causada por los genéricos. Sin embargo, para ayudar a reducir aún más las asignaciones, reduce el uso de genéricos.

Eliminación de código

Además de reducir el tamaño de la compilación, la eliminación de código también disminuye el uso de memoria. Cuando se compila en el backend de secuencias de comandos IL2CPP, la eliminación de código de bytes administrado (que se activa de forma predeterminada) quita el código no utilizado de los ensamblados administrados. El proceso funciona definiendo ensamblados raíz y, luego, usando el análisis de código estático para determinar qué otro código administrado usan esos ensamblados raíz. Se quita cualquier código al que no se pueda acceder. Para obtener más información sobre la eliminación de código administrado, consulta la entrada de blog TTales from the optimization trenches: Better managed code stripping with Unity 2020 LTS y la documentación sobre la eliminación de código administrado.

Asignadores nativos

Experimenta con asignadores de memoria nativa para ajustar los asignadores de memoria. Si el juego tiene poca memoria, usa bloques de memoria más pequeños, incluso si esto implica asignadores más lentos. Consulta el ejemplo de asignador de montón dinámico heap allocator example para obtener más información.

Administra complementos y SDKs nativos

  • Encuentra el complemento problemático: Quita cada complemento y compara las instantáneas de memoria del juego. Esto implica inhabilitar muchas funciones de código con Scripting Define Symbols y refactorizar clases altamente acopladas con interfaces. Consulta Level up your code with game programming patterns para facilitar el proceso de inhabilitación de dependencias externas sin que tu juego no se pueda jugar.

  • Comunícate con el autor del complemento o SDK: La mayoría de los complementos no son de código abierto.

  • Reproduce el uso de memoria del complemento: Puedes escribir un complemento simple (usa este complemento de Unity como referencia) que realice asignaciones de memoria. Inspecciona las instantáneas de memoria con Android Studio (ya que Unity no realiza un seguimiento de estas asignaciones) o llama a la MemoryInfo clase y al Runtime.totalMemory() método en el mismo proyecto.

Un complemento de Unity asigna memoria nativa y de Java. A continuación, te mostramos cómo hacerlo:

Java

byte[] largeObject = new byte[1024 * 1024 * megaBytes];
list.add(largeObject);

Nativa

char* buffer = new char[megabytes * 1024 * 1024];

// Random data to fill the buffer
for (int i = 1; i < megabytes * 1024 * 1024; ++i) {
   buffer[i] = 'A' + (i % 26); // Fill with letters A-Z
}