Si bien la capacidad física de RAM de los dispositivos móviles modernos sigue aumentando, el consumo de memoria de los juegos supera este crecimiento debido a los recursos de alta resolución y las complejas cadenas de renderización.
Problemas clave causados por el uso excesivo de memoria
- Finalizaciones del optimizador de poca memoria (LMK): El SO cierra de forma forzosa las apps en segundo plano o incluso apps en primer plano para resolver la escasez de memoria en todo el sistema.
- Caídas de fotogramas y tartamudeo (jank): Los picos frecuentes de recolección de elementos no utilizados (GC) en el montón administrado o el intercambio de memoria a nivel del SO crean cuellos de botella de procesamiento.
- Limitación térmica y agotamiento de la batería: La asignación de memoria, la desasignación y las confirmaciones de páginas de memoria continuas generan una sobrecarga significativa de la CPU, lo que provoca un aumento del calor y un agotamiento más rápido de la batería.
Actualizaciones de la administración de memoria de Android
- Android 17: Introducción de
MemoryLimiter: Android 17 presentaMemoryLimiter, que supervisa de forma activa el consumo de memoria de la aplicación en función de los umbrales específicos del dispositivo. Las apps que superan su límite de memoria se finalizan de inmediato a nivel del sistema. Debido a que este mecanismo es más estricto que el LMK tradicional, administrar el uso máximo de memoria es ahora más importante que nunca.
Reduce el uso de memoria en Unity
En Unity, una vez que el motor expande sus grupos de memoria internos (Native Block Allocators y el Managed Heap) para adaptarse a una carga máxima alta, conserva esas páginas de memoria en lugar de devolverlas de inmediato al SO.
En consecuencia, incluso después de que se descargan los recursos pesados, la memoria residente de referencia permanece inflada, lo que hace que la aplicación sea muy vulnerable a las finalizaciones de procesos del SO Android (como LMK o MemoryLimiter).
Para evitar la aplicación a nivel del SO, la optimización de la memoria debe abordarse a través de tres pilares clave:
- Categoría A: Reduce el uso máximo de memoria
- Categoría B: Elimina las huellas innecesarias de recursos y sistemas
- Categoría C: Elimina las asignaciones innecesarias de GC
Categoría A: Reduce el uso máximo de memoria
Los asignadores de Unity conservan las páginas de memoria liberadas para su reutilización en lugar de devolverlas al SO de inmediato, por lo que la memoria de referencia tiende a reflejar el pico más alto alcanzado en lugar del uso actual. Por lo tanto, evitar los picos en primer lugar es más eficaz que depender de la limpieza después del hecho.
1. Evita los AssetBundles demasiado grandes
Debido al mecanismo de carga de paquetes de Unity, los AssetBundles gigantes generan una gran cantidad de memoria y fallas de OOM. Mantén los paquetes modulares para garantizar una administración eficiente de los recursos.
Problemas clave
- Sobrecarga de memoria alta: Solicitar un solo recurso pequeño obliga a Unity a cargar todo el archivo del paquete (incluidos los encabezados, los metadatos y los búferes de transmisión) en la RAM.
- La trampa de descarga: Si algún recurso dentro de un paquete está en uso activo, no se puede descargar todo el paquete, lo que atrapa los datos sin usar en la RAM.
Prácticas recomendadas
- Mantén los paquetes modulares: Agrupa los recursos de forma lógica por escena o ciclo de vida.
- Sugerencia de Unity 6.6 y versiones posteriores: Usa Content Directories para evitar dependencias no deseadas entre paquetes.
2. Optimiza las referencias de recursos en ScriptableObjects
Los campos serializados UnityEngine.Object directos en un ScriptableObject crean referencias directas y forzosas, lo que obliga a que todos los recursos a los que se hace referencia se carguen en la RAM en cuanto se carga o instancia el ScriptableObject.
// BEFORE: Loading SceneRequiredAssets forces _worldAsset and _spawnSettings into RAM immediately
public class SceneRequiredAssets : ScriptableObject
{
public string sceneName;
public Object _worldAsset;
public Object _spawnSettings;
}
// AFTER: Use AssetReference to enable asynchronous, on-demand loading using Addressables
public class SceneRequiredAssets : ScriptableObject
{
public string sceneName;
public AssetReference _worldAsset;
public AssetReference _spawnSettings;
}
3. Configura los clips y canales de audio
Cargar todos los clips de audio sin comprimir directamente en la memoria crea picos de memoria grandes y permanentes. La configuración de carga de audio debe configurarse según su perfil de uso:
| Categoría de audio | Tipo de carga | Motivo |
|---|---|---|
| BGM (música de fondo) | Transmisión | Transmite audio desde el disco en búferes pequeños para eliminar los picos de memoria. |
| SFX largos | Comprimido en la memoria | Mantiene baja la sobrecarga de la RAM y descomprime el audio sobre la marcha durante la reproducción. |
| SFX cortos y frecuentes | Descomprimir cuando finalice la carga | Descomprime el audio en la RAM cuando se carga para evitar la sobrecarga de la CPU en el tiempo de ejecución durante la reproducción. |
Además de los tipos de carga, aplica las siguientes configuraciones de audio para reducir aún más la sobrecarga de memoria:
- Habilita Force To Mono para el audio no direccional: Muchos recursos de audio, como los efectos de sonido de la IU, las voces en off y los efectos de sonido espacializados en 3D, no requieren separación de canales estéreo. Habilita Force To Mono en la
AudioClipconfiguración de importación para que estos recursos reduzcan a la mitad su espacio en memoria y el tamaño del búfer (reducción del 50%). - Limita los canales de audio activos por nivel de dispositivo: Cada voz de audio activa
asigna el mezclador DSP nativo y los búferes de descompresión. En dispositivos con poca memoria,
reduce la cantidad máxima de canales activos simultáneos, por ejemplo, disminuyendo Max Real Voices (como de 32 a 16) con
AudioSettings.Reseten Unity o restringiendo los canales de reproducción de software en motores de audio de terceros.
4. Implementa estrategias de agrupación y liberación de objetos
Las instancias no liberadas que permanecen dentro de los grupos de objetos en las transiciones de escena conservan la memoria reservada de forma indefinida, lo que mantiene el espacio en memoria de referencia innecesariamente inflado.
- Acción: Borra o recorta periódicamente los objetos agrupados sin usar durante las transiciones de escena o los períodos de baja actividad, lo que permite que Unity devuelva o reutilice ese espacio reservado para otras asignaciones.
Categoría B: Elimina el uso innecesario de memoria
Eliminar los recursos gráficos redundantes y renderizar los búferes de destino reduce directamente el espacio en memoria de referencia.
1. Optimiza las texturas de renderización y la profundidad de la cámara
- Quita los búferes de profundidad/stencil: Establece el formato de profundidad de stencil en None para las texturas de renderización que solo requieren datos de color.
- Inhabilita la textura de profundidad de la cámara de la IU: Para las cámaras de la IU en las que los datos de profundidad no son
necesarios, inhabilita la generación de texturas de profundidad en la configuración de la cámara URP para
eliminar el paso
CopyDepthy la memoria de textura de la GPU asociada.
2. Optimiza las texturas y las mallas
| Categoría | Lineamiento de optimización |
|---|---|
| Compresión de texturas | Aplica siempre los formatos de compresión de la plataforma de destino (por ejemplo, ASTC para Android). |
| Lectura/escritura habilitada | Mantén la opción inhabilitada, a menos que sea necesario. Si habilitas esta opción, se duplica la memoria de textura en la RAM de la CPU y la GPU. |
| Mipmaps | Inhabilita los mipmaps para las texturas o los objetos de la IU que se fijan a una distancia constante de la cámara, lo que ahorra aproximadamente un 33% de la memoria de textura. |
| Complejidad de la malla | Reduce los recuentos de polígonos y los flujos de vértices innecesarios para disminuir el espacio en memoria de la GPU y la memoria nativa. |
3. Quita las variantes de sombreador y optimiza la memoria
Los uber-shaders (por ejemplo, URP Lit Shader) encapsulan numerosas funciones con las palabras clave #multi_compile y shader_feature. Sin optimización,
la explosión combinatoria crea decenas de miles de
variantes de sombreador únicas, lo que genera tamaños de compilación inflados, un consumo masivo de memoria nativa
y problemas de compilación del controlador de la GPU durante el juego.
A. Mecánica de la sobrecarga de memoria de la variante de sombreador
- Explosión combinatoria: Las variantes posibles totales crecen de forma exponencial con cada grupo de palabras clave agregado.
- Arquitectura de asignación de fragmentos: Unity agrupa las variantes binarias compiladas en bloques de memoria comprimidos llamados fragmentos (predeterminado: 4 MB).
- Inflación de memoria nativa: Cuando el código de tiempo de ejecución solicita incluso una sola variante dentro de un fragmento, se descomprime todo el fragmento de 4 MB en la RAM. Si no se optimizan, miles de variantes sin usar empaquetadas en esos fragmentos ocupan de forma permanente la memoria nativa.
B. Canalización de eliminación de varias etapas integrada en Unity: Unity quita automáticamente
variantes innecesarias en el momento de la compilación según la configuración de gráficos de la plataforma de destino
y las funciones del motor sin usar (por ejemplo, la configuración de niebla, mapas de luz y XR
). Además, las variantes shader_feature se filtran automáticamente si sus palabras clave no se usan de forma activa en ningún material del proyecto, mientras que las variantes #multi_compile se incluyen de forma forzosa, independientemente del uso. Sin embargo, si las funciones de renderización o posprocesamiento (como
luces adicionales, sombras en tiempo real o efectos UberPost sin usar) permanecen
habilitadas en la configuración de URP o de la canalización, Unity no puede determinar si tu
contenido realmente las usa y genera todas las #multi_compile
variantes correspondientes. Audita y deshabilita la configuración de canalización sin usar para que coincida con tus
requisitos de renderización reales y evita la generación innecesaria de variantes.
C. Arquitectura de eliminación automática personalizada
(IPreprocessShaders) Debido a que el análisis estático no puede
detectar palabras clave modificadas de forma dinámica con secuencias de comandos de C# en tiempo de ejecución
(Material.EnableKeyword), la eliminación estándar suele ser insuficiente. Para
asegurarte de que solo se incluyan las variantes que se usan realmente, puedes recopilar variantes
durante los conjuntos de pruebas de QA con Player.log (habilitando
Log Shader Compilation en la configuración del editor) o Profiler Traces
(Shader.CreateGPUProgram marcadores). Luego, implementa
IPreprocessShaders.OnProcessShader en una secuencia de comandos del editor para filtrar las
variantes que nunca se ejecutaron durante el tiempo de ejecución y conservar solo las necesarias
en la compilación.
D. Consolida los sombreadores redundantes: Los uber-shaders, como Lit y Simple
Lit, contienen muchas directivas #multi_compile y consumen una gran cantidad de
memoria cuando generan numerosas variantes:
- Crea un perfil del uso simultáneo: Crea un perfil de tu uso de memoria para verificar si ambos sombreadores consumen una cantidad significativa de memoria al mismo tiempo y verifica si tu proyecto realmente los necesita.
- Estandariza un solo sombreador: Cuando no se requiere la renderización basada en la física (PBR) configura tus recursos para eliminar el uso redundante del sombreador Lit y estandarizar en Simple Lit (por ejemplo, verifica si los modelos FBX exportados tienen materiales Lit predeterminados aplicados de forma involuntaria durante la importación).
Categoría C: Elimina las asignaciones innecesarias de GC
Las asignaciones de recolección de elementos no utilizados (GC) en el montón administrado generan fragmentación de la memoria, expansiones de montón que nunca se reducen y caídas de fotogramas graves durante las pausas de GC.
1. Evita las asignaciones de cierre de lambda
Cuando una expresión lambda captura variables locales externas, C# genera una clase de visualización implícita en el montón. La ejecución de esta dentro de Update asigna instancias de cierre cada fotograma.
// Bad: Capturing local variable 'targetId' allocates a new closure object on the Heap every frame
void Update()
{
int targetId = 100;
Monster target = monsterList.Find(m => m.Id == targetId);
}
// Good 1: Replace with a standard 'for' loop (Recommended: 0 B allocation)
void Update()
{
int targetId = 100;
Monster target = null;
for (int i = 0; i < monsterList.Count; i++)
{
if (monsterList[i].Id == targetId)
{
target = monsterList[i];
break;
}
}
}
// Good 2: Use a static lambda (C# 9.0+) if no outer variables are captured
Monster target = monsterList.Find(static m => m.Id == 100);
2. Usa stackalloc y Span
Evita las asignaciones de montón para arrays temporales de corta duración con la memoria de pila.
// Before: Allocates an array on the Heap every call (GC Target)
Vector2[] pos = new Vector2[4];
// After: Utilizes Stack memory using System.Span (0 B Heap Allocation)
System.Span<Vector2> pos = stackalloc Vector2[4];
3. Optimiza la iteración de la colección
Evita el boxing y las asignaciones de montón de Enumerator causadas por las extensiones de LINQ o los descriptores de acceso ReadOnlyCollection.
// Before: LINQ Count() causes internal GetEnumerator() heap allocations
bool hasData = component != null && component.parameters.Count(parameter => parameter.overrideState) > 0;
// After: Replaced with indexer and direct loop iteration
bool hasData = HasDataOptimized(component);
private bool HasDataOptimized(TestComponent component)
{
if (component == null) return false;
var count = component.parameters.Count;
for (var i = 0; i < count; ++i)
{
if (component.parameters[i].overrideState)
return true;
}
return false;
}
4. Reglas adicionales de prevención de asignación de GC
- Evita usar
Camera.allCameras, ya que genera un arrayCamera[]nuevo en el montón en cada llamada. En su lugar, almacena en caché un array de cámaras y pásalo aCamera.GetAllCameras(_allCameras). - Evita
foreachenIReadOnlyList<T>: La iteración en una interfaz causa el boxing del enumerador de struct, lo que genera asignaciones de GC. Usa un bucleforestándar en su lugar. - Almacena en caché los objetos de corrutina: Almacena en caché las instancias de
WaitForSecondsen lugar de crear instancias deyield return new WaitForSeconds(time);de forma repetida. - Claves de struct personalizadas en diccionarios: El uso de structs personalizados como claves de diccionario
llama a
Equalspredeterminado, lo que activa el boxing de objetos. ImplementaIEqualityComparer<T>y pásalo al constructor de Dictionary.
public struct TypeKey
{
public int v1;
public int v2;
public class TypeKeyComparer : IEqualityComparer<TypeKey>
{
public bool Equals(TypeKey x, TypeKey y) => x.v1 == y.v1 && x.v2 == y.v2;
public int GetHashCode(TypeKey obj) => obj.v1.GetHashCode() ^ obj.v2.GetHashCode();
}
}
// Pass custom comparer during Dictionary initialization to prevent boxing
public readonly Dictionary<TypeKey, int> _typeKeyDictionary = new(new TypeKey.TypeKeyComparer());
5. Minimiza el uso genérico y de reflexión
Si bien los métodos genéricos proporcionan una excelente reutilización y capacidad de mantenimiento del código, el uso excesivo de ellos puede afectar negativamente tu proyecto en el contexto del backend de Unity IL2CPP (lenguaje intermedio a C++).
- Inflación de código IL2CPP: Para cada combinación de tipo genérico único, IL2CPP genera una versión especializada del código. El uso excesivo de genéricos complejos puede generar una "explosión combinatoria" de código C++ generado, lo que aumenta significativamente el tamaño binario de la aplicación y la huella de memoria nativa.
- Sobrecarga de reflexión: Los métodos que usan reflexión, como las APIs de
System.Reflection, son inherentemente lentos y, a menudo, causan asignaciones de montón durante el tiempo de ejecución. - Práctica recomendada: Usa los genéricos con prudencia. Priorízalos para la
claridad arquitectónica en lugar de una aplicación amplia e indiscriminada. Cuando el rendimiento es fundamental, favorece los tipos concretos o el polimorfismo basado en interfaces. Para la reflexión, almacena en caché los resultados, como
MethodInfooFieldInfo, durante la inicialización en lugar de consultarlos en el bucle de actualización.
6. Evita los shells administrados con fugas
Cada UnityEngine.Object, como MonoBehaviour, Texture o GameObject, tiene un wrapper de "shell administrado" de C# que se comunica con el motor nativo de C++.
- El problema: Si una referencia estática, una suscripción de eventos persistente o un cierre no limpiado mantienen un shell administrado en la memoria, el GC no puede recuperar la memoria. Incluso si se destruye el objeto nativo, el wrapper administrado persiste, lo que genera fugas de memoria "fantasma" que inflan el montón administrado.
- Resolución: Implementa siempre patrones de limpieza sólidos. Cuando destruyas objetos o hagas la transición de escenas, anula la suscripción de forma explícita a los eventos con el operador
-=y anula las referencias estáticas a los tiposUnityEngine.Object. Esta limpieza garantiza que el GC pueda recopilar correctamente el wrapper una vez que el motor nativo haya liberado su controlador.
7. Usa catálogos binarios para Addressables
En Unity 2022 y versiones anteriores, Addressables usa catálogos JSON de forma predeterminada en lugar de catálogos binarios (que son los predeterminados en Unity 2023 y versiones posteriores). Si tu proyecto usa catálogos JSON, cargar un catálogo grande al inicio lee todo el archivo en una sola cadena masiva de C# y asigna miles de cadenas administradas y arrays byte[] temporales durante el análisis JSON y la decodificación Base64. Esto provoca un gran pico de asignación de GC que expande de forma permanente el montón administrado, por lo que debes cambiar a un catálogo binario para transmitir datos compactos directamente.