Optimiza la memoria en Unity

Uno de los problemas a los que te enfrentas cuando trabajas en plataformas de generaciones pasadas es su limitación en términos de memoria. Si has desarrollado algún juego para moviles con un par de años a sus espaldas, conocerás este problema. De hecho, dependiendo del juego, es realmente complicado seguir dando soporte a determinados modelos, especialmente aquellos con menos de 4 GB de RAM. Las consolas actuales, excepto Nintendo Switch, reducen este problema incorporando 8GB de base, pero es posible que sigas teniendo problemas si no tienes en cuenta algunos factores importantes.
Esta guía con tips rápidos os ayudará a reducir la memoria en tiempo de ejecución, evitando posibles crasheos o problemas a la hora de desarrollar para Android o de portar tu juego a Nintendo Switch.

Texturas

El arte en un videojuego suele ser uno de sus principales atractivos. A medida que el proyecto avanza lo habitual es que se llene de texturas que, si no controlamos desde inicio, pueden llegar a ser un auténtico drama una vez se acerque la fecha de lanzamiento. Voy a dividir esta sección en optimizaciones generales y optimizaciones para sprites que, aunqueno dejan de ser texturas, requieren de un trato especial como veremos.

  • Optimizaciones generales
    • Deshabilita la opción Generate Mip Maps cuando no sea necesaria. Esta optión genera versiones de la textura más pequeñas que sustituyen a la textura principal cuando esta se encuentra lejos de la cámara. Esto genera un 33% más de memoria pero en determinados casos es necesario su uso para prevenir otros problemas de rendimiento (3D).
    • Deshabilita la opción Read/Write Enabled cuando no sea necesaria. Esta opción habilita algunas funciones internas para modificar la textura en tiempo de ejecución pero, eso sí, a costa de duplicar su tamaño en memoria.
    • Activa siempre que sea posible la compresión de la textura (Compression). Yo recomiendo usar siempre High Quality si quieres mantener la máxima fidelidad posible, aunque cualquiera de las opciones es válida. El problema es que para poder activar la compresión la textura debe ser multiplo de 4. Lo que hace el algoritmo es reducir el tamaño de la textura x4 con un coste prácticamente inexistente a la hora de descomprimirla en tiempo de ejecución. Además, la textura se mantendrá comprimida en memoria durante todo el tiempo que se use, asique es importante trabajar con tamaños de textura que cumplan este requisito.
    • Selecciona el Max Size acorde con la plataforma objetivo. No tiene sentido usar texturas 8K si estás trabajando en una versión móvil. Unity te permite tener valores diferentes dependiendo de la plataforma.
    • Activa la compresión Crunch cuando lo necesites. Está compresión es compatible con la anterior y solamente afecta al tamaño en disco, no a la memoria en tiempo de ejecución. Es especialmente indicada cuando necesitas reducir el peso de una build, ya que reduce mucho el peso de las texturas, aunque en algunos casos puede provocar artefactos o errores.
  • Optimizaciones sprites (Sprite Atlas)
    Para organizar y optimizar las texturas es habitual empaquetarlas juntas en un mismo archivo. Aunque puede resultar trivial, es importante conocer muy bien el uso de todo aquello que estás uniendo ya que en memoria no podrás diferenciar. Toda la textura al completo será cargada. Teniendo esto claro, al igual que existen herramientas externas como Texture Packer, Unity te permite dentro del Engine crear tus propios empaquetados de texturas (atlas), siempre y cuando sean de tipo Sprite. Esto se vuelve muy necesario a medida que añades más y más texturas en tu juego, especialmente si tu juego tiene gran cantidad de GUI o es 2D. Es posible que quieras crear los atlas fuera de Unity para tener el control absoluto, pero si te decantas finalmente por usar el sistema interno, debes tener en cuenta algunos puntos:
    • Debes decidir muy bien que sprites colocas juntos. Tener gran cantidad de atlas te permitirá tener el control de qué y cuando se carga en memoria, a costa de un mayor peso en memoria. Un buen equilibrio tiene atlas con el tamaño máximo posible (esto puede variar dependiendo de la plataforma), pero con todo lo incluido relacionado entre si. Por ejemplo, todos los sprites del menu principal podrían estar empaquetados dentro de un atlas. Sin embargo si empaquetas toda la GUI en un mismo atlas, incluyendo el HUD, tendrás cargado en memoria durante todo el juego el paquete, tanto en el menu como en la escena de juego.
    • Ten en cuenta la paginación. Si todos los sprites que quieres añadir a un atlas no entran en una sola textura, Unity creará tantas texturas como sean necesarias para incluirlos. Debes medir bien si alguna página del atlas está bien optimizada, o vale más la pena reducir el tamaño para evitar huecos vacíos. Ten en cuenta que Unity controla la páginación en memoria, es decir, si no es necesaria una de las páginas, no la tendrá cargada. Opciones como el Padding y el Allow Rotation te permiten ajustar mejor los sprites dentro del atlas y te resuelven algunos problemas de tilling, especialmente con sistemas de partículas.
    • Ten en cuenta que internamente un Sprite Atlas es usado como una textura, por lo que es importante que marques la Compression, como comentamos en el apartado anterior.
    • Si estás usando Addressables recuerda crear solo grupos con los Sprite Atlas, los sprites contenidos dentro NO deben ser añadidos, ya que provocaría duplicados en disco y posiblemente en memoria (ya que lo detectará como una dependencia de los grupos con los atlas).

Sonido

El audio de un juego es tan importante como el apartado visual. Por ello algunos juegos acaban teniendo una lista de clips enorme que pueden llegar a ser un problema si no sabemos lo que está pasando con ellos. Cada vez más estudios acaban usando un middleware como FMOD o WISE que gestiona todo de una forma diferente, evitando estos problemas. Si no es tu caso estos son algúnos consejos de como deberías configurar los clips en tu juego:

  • Elige bien los Audio Import Settings:
    • Decompress on Load: Esta opción descomprime el audio en el momento que pasa a la memoria para ser usado. Esta opción es la menos recomendable ya que tendrás el audio sin descomprimir ocupando el máximo espacio en memoria posible. Su ventaja es que no requiere de uso de CPU para descomprimirlo mientras lo está reproduciendo, aunque este gasto en mínimo. Solo usar si estás MUY limitado por la CPU y tienes memoria de sobra para usar.
    • Compressed in Memory: Esta opción mantendrá el audio comprimido en memoria por lo que su peso será menor. Su desventaja es que para reproducirse va a necesitar una descomprensión (y decoding) en tiempo de ejecución lo que supone un coste de CPU.
    • Streaming: Esta opción va un paso más allá y no necesita tener el audio en memoria para reproducirlo. ¿Cómo lo hace? Pues en el mismo proceso lee de disco, descomprime, decodifica y reproduce sin necesidad de tenerlo guardado en memoria. Como supondrás, hacer esto supone un coste aun mayor de CPU, siendo este aún más relevante a mayor cantidad de audios en streaming sonando en pararelo. Además, no es cierto que no use nada de memoria, puesto que requiere almacenar 200 KB por cada audio que este en streaming.

      ¿Cuando es mejor usar Compressed in Memory y cuando Streaming?
      En mi opinión y experiencia, usa Streaming en todos los audios que pesen más de 5-10 MB. Serán por norma general los temas principales, o sonidos con una duración suficiente como para que valga la pena reproducirlos por streaming. A su vez, todos aquellos que pesen por debajo de 1 MB les marcaria el Compressed in Memory. Tened en cuenta que el Streaming genera una sobrecarga de 200 KB por lo que lo más probable es que no valga la pena su uso. Además asi evitas sobrecargar la CPU con multitud de audios en Streaming paralelamente.
      Para los demás audios, tendrás que sacar la balanza entre peso en memoria y consumo de CPU. Si no tienes problemas de CPU o tienes problemas de memoria, tendrás que aumentar la cantidad de audios en Streaming. Si por el contrario, tienes memoria de sobra pero tienes problemas de CPU, tendrás que aumentar la cantidad de audios comprimidos, o incluso usar Decompress on Load.
  • Activa la opción Force to mono si no requieres de sonido estéreo.
  • Usa Vorbis en Android (o MP3/Vorbis en IOS) ya que así evitas sobrecostes de Unity al decodificar el audio.

Modelos

Si tu juego es 3D (o usa modelos 3D) tienes que tener en cuenta que la configuración al importarlos es muy importante ya que el uso de memoria en runtime va a depender principalmente de ello. Unity internamente tiene dos metodos a la hora de optimizar los meshes, uno global y uno individual. Ambos pueden ser usados de forma conjunta en un projecto pero no pueden solaparse en un mismo mesh:

  • Vertex Compression: esta es la compresión global que, si está activa, afecta a todos los modelos del juego. Principalmente reduce la precisión de la información alojada en el mesh para, de estar manera, reducir el tamaño en memoria, el tamaño en disco y el uso de la GPU.
    Para usar esta compresión debes activarla en Project Settings/Player/Other Settings/Vertex Compression. Cada canal que añadas comprimirá su campo correspondiente de la información. Pero lamentablemente esta compresión tiene una serie de requisitos en la importación del mesh que deben cumplirse para hacer uso de ella:
    • El mesh en cuestión debe tener deshabilitada la opción Read/Write Enabled. Activar esta opción posibilita modificaciones de la malla en tiempo real, asique debes tenerlo en cuenta antes de quitarlo. Además, si decidimos dejarlo activo, el mesh duplicará su tamaño en memoria. Asique vale la pena revisar los mesh y desmarcar esto cuando no sea necesario su uso.
    • El mesh no debe ser un Skinned Mesh. En el caso que lo sea, el mesh será ignorado por esta compresión.
    • El mesh debe tener desmarcada la opción Mesh Compressión. En caso de que este activo, se priorizará dicha compresión en su lugar.
    • La plataforma objetivo debe soportar valores FP16.
  • Mesh Compression: esta compresión, a nivel de mesh como su nombre indica, es mucho más agresiva que la anterior ya que reemplaza cada valor por un rango entre un minimo y un maximo. Como vimos anteriormente, se marca en cada Mesh Import Settings y una vez activa, deshabilita la Vertex Compression (el mesh será ignorado en su compresión). Esta compresión no tiene requisitos pero unicamente afecta al tamaño del asset en disco. Una vez el mesh es descomprimido, su tamaño en memoria será el mismo. Además, este proceso de descompresión requiere de memoria temporal adicional y hace uso de la CPU, por lo que los tiempos de carga pueden aumentar significativamente. Por ello, debes valorar bien si esto merece la pena en tu proyecto ya que puede causar otros problemas. Puede usarse, por ejemplo, cuando necesitas que el peso de tu build se reduzca considerablemente (cosa habitual en dispositivos moviles).

    Como apunte final, ambas compresiones pueden provocar errores de precisión que provoquen artefactos en pantalla o problemas visuales derivados, por lo que si estás teniendo problemas de este tipo, revisa los ajustes o deshabilita la compresión.

Código

Llegados a este punto, ya tendríamos los assets más importantes del juego optimizados. Genial pero… ¿Qué pasa con el código? En desarrollos pequeños no es algo a lo que se suela prestar especial atención, pero a medida que el proyecto y todos los sistemas derivados de este lo hacen, puede llegar a ser un dolor de cabeza para el programador. Además las soluciones no suelen ser tan triviales como con los assets anteriormente comentados, por lo que no llevar un buen mantenimiento desde el inicio puede provocar problemas irreversibles en el futuro. Aquí dejo una lista con los problemas más habituales que yo me he encontrado, especialmente cuando he tenido que portar el juego a plataformas como Nintendo Switch (esta tiene disponible aproximadamente 3 GB de RAM):

  • «El GC Alloc en el Profiler parece un monitor de constantes vitales»: Unity recomienda reducir a cero (o lo más cercana a cero posible) la memoria temporal. Esta memoria es la que se va necesitando internamente en cada función y que será limpiada una vez termine. En los siguientes puntos vemos algunas formas de reducir esta memoria para acercarnos al objetivo.
  • «Se nos ha ido la olla con los strings»: Es habitual tener miles (incluso millones) de strings repartidos por el juego. Los strings son inmutables, es decir, una vez creados no pueden ser modificados. Cuando lo haces, internamente crea un nuevo string y lo iguala al anterior. Podrás imaginar que pasa cuando encadenas más de un string con el «+» o con las funciones asociadas. Lo habitual cuando necesitas muchos cambios de un string es usar la clase String Builder como auxiliar. Pero esto solo afecta a la memoria temporal, por lo que una vez usada, se limpiará. El mayor problema es cuando tenemos strings repetidos como variables en memoria. En lugar de repetir strings por el código, es habitual tener una clase estática (al estilo string.Empty) con los strings necesarios almacenados en ella.
  • «Si lo necesito 30 veces, lo creo 30 veces» Es muy común ver una cantidad insana de variables temporales dentro de funciones continuas, como pueden ser los Updates o FixedUpdates. Esto obliga a crear en cada frame todas esas variables y a liberarlas al finalizar… una y otra vez, hasta el final de los tiempos (o hasta que cierres el juego). Guarda esas variables y reúsalas una y otra vez (cacheo), asi evitarás tener que crearlas y borrarlas cada vez, con la implicación de memoria temporal que eso conlleva. Además esto no solo ayuda en funciones continuas, también en aquellas que se llaman de forma periódica y regular, por el mismo motivo.
    También es habitual usar pools de objetos para evitar crear y eliminar objetos en tiempo de ejecución. Estableces una cantidad de objetos de un tipo concreto y los creas al empezar, configurando y limpiando cada uno de ellos para permitir su reutilización.
  • «Ya lo limpiará alguien» También es común, y aquí yo me incluyo, dejar al recolector de basura que trabaje por nosotros. Es gratis y funciona bastante bien, ¿por qué tengo yo que preocuparme? En la mayoría de ocasiones hará lo que tú quieres, pero en otras, lamentablemente por falta de información no. Por ejemplo, si tenemos variables que no van a ser usadas, pero que son referenciadas por otras clases, es posible que el recolector de basura las conserve por si acaso. En este caso tendremos memoria no recolectada pero que no vamos a necesitar durante un tiempo. Para solucionar este problema, lo ideal es vaciar aquellas variables que no necesitemos, es decir ponerla a null si estamos seguros de que no se usará. De esta forma, forzamos la limpieza nosotros y evitamos tener en memoria referencias no necesarias.
  • «Todo lo que me haga falta, lo engancho» Otra pecado que cometemos a menudo es referenciar absolutamente todo lo necesario a un objeto. Esto hace que cuando este objeto sea cargado, todas las dependencias asociadas vayan con él a la memoria. Además hasta que este objeto no se descargue, sus dependencias asociadas no lo harán tampoco. Una buena alternativa es añadir referencias indirectas, es decir, en lugar de referencias directamente al asset, referencies a un contenedor de assets que se encargue de cargar el asset cuando sea necesario. De esta forma cuando el asset no este, significa que el objeto instanciado no lo necesita. Una forma aún mejor sería usar Addressables, que esta creando principalmente con este principio en mente.
  • «El jefe final está en memoria desde que inicias el juego»: A raiz del anterior, en muchas ocasiones tenemos objetos estáticos o singletons que controlan multitud de sistemas dentro del juego. El problema es que todas las referencias que tengan estos objetos acompañaran al jugador durante toda su aventura. En más de una ocasión una referencia a un objeto dentro de un manager ha provocado que objetos que, hasta bien adentrado en el juego no deberían de estar, aparezcan misteriosamente en memoria. Por ello, reduce las dependencias directas especialmente en objetos estáticos o que estén siempre cargados en memoria.
  • «Las partículas son espéctacularmente costosas» A todos nos encantan las partículas. Incluso a veces un buen uso de ellas hace dar un salto sustancial de calidad al juego. Pero a veces esta mejora va asociada a un gasto en memoria demencial que acaba provocando problemas cuando vas avanzando en el juego. Cada partícula que tenemos instanciada tendrá como mínimo 3500 bytes de tamaño en memoria. Además hasta que no se destruye, no se libera, por lo que desactivarlas para una pool puede no ser una solución factible. Unity recomienda tener una pool de partículas no excesivamente grande, e ir reusándolos tú mismo, modificando sus variables en tiempo de ejecución. Es cierto que esto requiere de un sobrecoste en tiempo, pero en el caso de que tengas miles de partículas, es posible que no te quede más remedio.
  • «El juego crashea al guardar» El último consejo es el más duro de todos. No hay nada peor que recibir un mensaje de un jugador diciendo que el juego crashea al guardar o incluso, en el peor de los casos, que su partida se ha corrompido. Llegados a este punto, las soluciones no van a ser fáciles, ya que muy probablemente la solución va a requerir un puente entre el salvado antiguo y el nuevo, una vez arreglado. Por ello, una de las decisiones más importantes que se deben tomar al principio es qué hay serializar (y cuando hacerlo) a la hora de guardar la partida. Hay que tener en cuenta que serializar no es gratis. Un juego con mucha información que guardar y con muchos slots de guardado disponibles puede acabar provocando picos de memoria que desemboquen en tragedia si no se tiene un cuidado especial. Mantén lo más reducido posible tu archivo de serialización y presta especial atención al proceso de serialización en sí mismo, ya que algunas conversiones a JSON/Binario o algunas funciones con strings, al tratarse de datos muy pesados, pueden suponer un pico de memoria muy importante que acabe provocando el temido out of memory!.

Addressables

Para finalizar, quería comentar un poco las ventajas y desventajas que tiene usar este sistema que por suerte o por desgracia me ha tocado usar en los últimos años. Si no lo conocéis, es un sistema de Unity (evolución de los Asset Bundles) que te permite gestionar las dependencias como paquetes externos. Esto abre la posibilidad a crear contenido ampliable o descargable de forma relativamente simple. Además para gestionar parches de juego con un peso decente (sin tener que volver a descargar completamente el juego) es indispensable y casi obligatorio para algunas plataformas como Nintendo Switch. Por lo que tener conocimiento del sistema y hacer uso de él puede ayudarte a aprender un poco más como Unity gestiona internamente las referencias e incluso a tener tu el control de que y cuando se carga en memoria.

  • Ventajas
    • Te permite tener el control absoluto de la memoria. Cargar y descargar lo que necesites, cuando lo necesites.
    • Fácil de usar aunque complicado de dominar. El sistema se encarga de cargar las referencias necesarias en las escenas, simplemente llamando a la función de cargado. Obviamente tendrás que tener todos los grupos bien estructurados para evitar cargar dependencias no necesarias.
    • Permite gestionar de forma adecuada (aunque no perfecta) todo el contenido post lanzamiento de un juego, así como los parches necesarios.
    • Permite separar los assets del juego del propio juego y mantener estos como dependencias externas. Esto permite dar soporte a mods o a descargas online de forma más simple, por ejemplo.
  • Desventajas
    • A poco que profundices en él te encuentras con cuellos de botella provocados por el Engine en sí mismo.
    • Si no está perfectamente organizado, puede provocar un problema mayor. Con solo un objeto que no se cargue donde no debe, puede invocar un árbol de dependencias imparable.
    • Limitaciones a nivel visual. Usar la ventana de Addressables es un completo desastre. No es nada intuitiva y a menudo intentas hacer algo que supones debería funcionar, pero no lo hace. Es necesario tener tu propio sistema por encima para acelerar todo el proceso de creación y organización de grupos.
    • Los tiempos de compilación son insufribles. Además necesario tirar una build de addressables para cada una de las plataformas objetivo, lo que dispara los tiempos. Prácticamente necesitas un PC para este proceso si no quieres acabar desesperado.

Espero que os haya sido útil, adjunto algunos recursos externos donde ampliar la información:
Unity best practices
Unite 2016

Hasta pronto!

Language »