Otimização da memória de jogos do Unity no Android

Embora a capacidade física de RAM dos dispositivos móveis modernos continue crescendo, o consumo de memória de jogos está superando esse crescimento devido a recursos de alta resolução e pipelines de renderização complexos.

Principais problemas causados pelo uso da memória excessivo

  • Encerramentos do low memory killer (LMK): o SO fecha à força apps em segundo plano ou até mesmo em primeiro plano para resolver a escassez de memória em todo o sistema.
  • Quedas de frame e travamentos (jank): picos frequentes de coleta de lixo (GC) no heap gerenciado ou na troca de memória no nível do SO criam gargalos de processamento.
  • Limitação térmica e consumo de bateria: a alocação contínua de memória, a desalocação e a confirmação de páginas de memória incorrem em uma sobrecarga significativa da CPU, levando ao aumento do calor e ao esgotamento mais rápido da bateria.

Atualizações do gerenciamento de memória do Android

  • Android 17: introdução do MemoryLimiter: o Android 17 apresenta MemoryLimiter que monitora ativamente o consumo de memória do aplicativo em relação a limites específicos do dispositivo. Os apps que excedem o limite de memória são encerrados imediatamente no nível do sistema. Como esse mecanismo é mais rigoroso do que o LMK tradicional, o gerenciamento do uso máximo de memória agora é mais importante do que nunca.

Reduzir o uso da memória no Unity

No Unity, quando o mecanismo expande os pools de memória internos (alocadores de blocos nativos e o heap gerenciado) para acomodar uma carga de pico alta, ele retém essas páginas de memória em vez de retorná-las imediatamente ao SO. Consequentemente, mesmo depois que recursos pesados são descarregados, a memória residente de referência permanece inflacionada, tornando o aplicativo altamente vulnerável a encerramentos de processos do SO Android (como LMK ou MemoryLimiter).

Para evitar a aplicação no nível do SO, a otimização da memória precisa ser abordada por três pilares principais:

  • Categoria A: reduzir o uso da memória de pico
  • Categoria B: eliminar pegadas desnecessárias de recursos e sistemas
  • Categoria C: eliminar alocações de GC desnecessárias

Categoria A: reduzir o uso da memória de pico

Os alocadores do Unity retêm páginas de memória liberadas para reutilização em vez de retorná-las ao SO imediatamente. Assim, a memória de referência tende a refletir o pico mais alto alcançado em vez do uso atual. Portanto, evitar picos em primeiro lugar é mais eficaz do que confiar na limpeza depois do fato.

1. Evite AssetBundles muito grandes

Devido ao mecanismo de carregamento de pacotes do Unity, os AssetBundles gigantes levam a um inchaço grave da memória e a falhas de OOM. Mantenha os pacotes modulares para garantir o gerenciamento eficiente de recursos.

Principais problemas

  • Alta sobrecarga de memória: solicitar um único recurso pequeno força o Unity a carregar todo o arquivo de pacote (incluindo cabeçalhos, metadados e buffers de streaming) na RAM.
  • A armadilha de descarregamento: se qualquer recurso em um pacote estiver em uso ativo, o pacote inteiro não poderá ser descarregado, prendendo dados não utilizados na RAM.

Práticas recomendadas

  • Mantenha os pacotes modulares: agrupe os recursos logicamente por cena ou ciclo de vida.
  • Dica do Unity 6.6 e versões mais recentes: use diretórios de conteúdo para evitar dependências não intencionais entre pacotes.

2. Otimize as referências de recursos em ScriptableObjects

Campos serializados UnityEngine.Object diretos em um ScriptableObject criam referências fixas diretas, forçando todos os recursos referenciados a serem carregados na RAM assim que o ScriptableObject for carregado ou instanciado.

// 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. Configure clipes e canais de áudio

Carregar todos os clipes de áudio descompactados diretamente na memória cria picos de memória grandes e permanentes. As configurações de carregamento de áudio precisam ser configuradas com base no perfil de uso:

Categoria de áudio Tipo de carga Motivo
BGM (música de fundo) Streaming Transmite áudio do disco em buffers pequenos para eliminar picos de memória.
SFX longo Compactado na memória Mantém a sobrecarga de RAM baixa e descompacta o áudio em tempo real durante a reprodução.
SFX curto e frequente Descompactar ao carregar Descompacta o áudio na RAM ao carregar para evitar a sobrecarga da CPU em tempo de execução durante a reprodução.

Além dos tipos de carga, aplique as seguintes configurações de áudio para reduzir ainda mais a sobrecarga de memória:

  • Ativar a opção "Forçar para mono" para áudio não direcional: muitos recursos de áudio, como efeitos sonoros da interface, dublagens e efeitos sonoros espacializados em 3D, não exigem separação de canais estéreo. Ative a opção Forçar para mono nas configurações de importação AudioClip para esses recursos para reduzir o consumo de memória e o tamanho do buffer pela metade (redução de 50%).
  • Limitar canais de áudio ativos por nível de dispositivo: cada voz de áudio ativa aloca o mixer DSP nativo e buffers de descompressão. Em dispositivos com pouca memória, reduza o número máximo de canais ativos simultâneos, por exemplo, diminuindo Max Real Voices (como de 32 para 16) usando AudioSettings.Reset no Unity ou restringindo canais de reprodução de software em mecanismos de áudio de terceiros.

4. Implementar estratégias de pool de objetos e lançamento

Instâncias não liberadas que permanecem em pools de objetos em transições de cena retêm a memória reservada indefinidamente, mantendo o consumo de memória de referência desnecessariamente inflacionado.

  • Ação: limpe ou corte periodicamente objetos agrupados não utilizados durante transições de cena ou períodos de baixa atividade, permitindo que o Unity retorne ou reutilize esse espaço reservado para outras alocações.

Categoria B: eliminar o uso da memória desnecessário

A eliminação de recursos gráficos redundantes e a renderização de buffers de destino reduzem diretamente a pegada de memória de referência.

1. Otimizar texturas de renderização e profundidade da câmera

  • Remover buffers de profundidade/stencil: defina o formato de stencil de profundidade como Nenhum para texturas de renderização que exigem apenas dados de cor.
  • Desativar a textura de profundidade da câmera da interface: para câmeras da interface em que os dados de profundidade são desnecessários, desative a geração de textura de profundidade nas configurações da câmera URP para eliminar a passagem CopyDepth e a memória de textura da GPU associada.

2. Otimizar texturas e malhas

Categoria Diretriz de otimização
Compactação de textura Sempre aplique formatos de compactação da plataforma de destino (por exemplo, ASTC para Android).
Leitura/gravação ativada Mantenha desativado, a menos que seja necessário. A ativação duplica a memória de textura na RAM da CPU e da GPU.
Mipmaps Desative os mipmaps para texturas ou objetos da interface fixados a uma distância constante da câmera, economizando cerca de 33% da memória de textura.
Complexidade da malha Reduza as contagens de polígonos e os fluxos de vértices desnecessários para diminuir o consumo de memória nativa e da GPU.

3. Remover variantes de sombreador e otimizar a memória

Os Uber-shaders (por exemplo, o URP Lit Shader) encapsulam vários recursos usando as palavras-chave #multi_compile e shader_feature. Sem otimização, a explosão combinatória cria dezenas de milhares de variantes de sombreador exclusivas, levando a tamanhos de build inflacionados, consumo de memória nativa enorme e problemas de compilação de driver de GPU durante o jogo.

A. Mecânica da sobrecarga de memória da variante de sombreador

  • Explosão combinatória: o total de variantes possíveis cresce exponencialmente com cada grupo de palavras-chave adicionado.
  • Arquitetura de alocação de blocos: o Unity agrupa variantes binárias compiladas em blocos de memória compactados chamados blocos (padrão: 4 MB).
  • Inchaço de memória nativa: quando o código de tempo de execução solicita até mesmo uma única variante dentro de um bloco, todo o bloco de 4 MB é descompactado na RAM. Se não forem otimizadas, milhares de variantes não utilizadas empacotadas nesses blocos ocuparão permanentemente a memória nativa.

B. Pipeline de remoção de várias fases integrado ao Unity: o Unity remove automaticamente variantes desnecessárias no momento da criação com base nas configurações gráficas da plataforma de destino e nos recursos do mecanismo não utilizados (por exemplo, configurações de névoa, mapas de luz e XR configurações). Além disso, as variantes shader_feature são filtradas automaticamente se as palavras-chave não forem usadas ativamente por nenhum material no projeto, enquanto as variantes #multi_compile são incluídas à força, independente do uso. No entanto, se os recursos de renderização ou pós-processamento (como Luzes adicionais, sombras em tempo real ou efeitos UberPost não utilizados) permanecerem ativados nas configurações do URP ou do pipeline, o Unity não poderá determinar se o conteúdo realmente os usa e gera todas as variantes #multi_compile correspondentes. Audite e desative as configurações de pipeline não utilizadas para que elas correspondam aos seus requisitos de renderização reais e evitem a geração de variantes desnecessárias.

C. Arquitetura de remoção automatizada personalizada (IPreprocessShaders) Como a análise estática não consegue detectar palavras-chave modificadas dinamicamente usando scripts C# de tempo de execução (Material.EnableKeyword), a remoção padrão geralmente é insuficiente. Para garantir que apenas as variantes realmente usadas sejam incluídas, você pode coletar variantes durante os conjuntos de testes de QA usando Player.log (ativando Log Shader Compilation nas configurações do editor) ou rastros do Profiler (Shader.CreateGPUProgram marcadores). Em seguida, implemente IPreprocessShaders.OnProcessShader em um script do editor para filtrar variantes que nunca foram executadas durante o tempo de execução, mantendo apenas as necessárias no build.

D. Consolidar sombreadores redundantes: Uber-shaders como Lit e Simple Lit contêm muitas #multi_compile diretivas e consomem memória substancial memória ao gerar várias variantes:

  • Perfil de uso simultâneo: crie um perfil de uso da memória para verificar se os dois shaders consomem memória significativa ao mesmo tempo e verifique se o projeto realmente precisa dos dois.
  • Padronizar em um único sombreador: quando a renderização baseada em física (PBR) não for necessária, configure seus recursos para eliminar o uso redundante do sombreador Lit e padronize o Simple Lit (por exemplo, verifique se os modelos FBX exportados têm materiais Lit padrão aplicados sem querer durante a importação).

Categoria C: eliminar alocações de GC desnecessárias

As alocações de coleta de lixo (GC) no heap gerenciado levam à fragmentação da memória, expansões de heap que nunca diminuem e quedas de frame graves durante as pausas de GC.

1. Evitar alocações de fechamento lambda

Quando uma expressão lambda captura variáveis locais externas, o C# gera uma classe de exibição implícita no heap. A execução dela dentro de Update aloca instâncias de fechamento a cada frame.

// 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. Usar stackalloc e Span

Evite alocações de heap para matrizes temporárias de curta duração usando a memória de pilha.

// 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. Otimizar a iteração da coleção

Evite alocações de heap de boxing e enumerador causadas por extensões LINQ ou acessadores 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. Regras adicionais de prevenção de alocação de GC

  • Evite usar Camera.allCameras, porque ele gera uma nova matriz Camera[] no heap em cada chamada. Em vez disso, armazene em cache uma matriz de câmera e transmita-a para Camera.GetAllCameras(_allCameras).
  • Evite foreach em IReadOnlyList<T>: a iteração em uma interface causa o boxing do enumerador de struct, gerando alocações de GC. Use um loop for padrão.
  • Objetos de corrotina de cache: armazene em cache instâncias WaitForSeconds em vez de instanciar yield return new WaitForSeconds(time); repetidamente.
  • Chaves de struct personalizadas em dicionários: o uso de structs personalizados como chaves de dicionário chama o Equals padrão, acionando o boxing de objetos. Implemente IEqualityComparer<T> e transmita-o ao construtor do dicionário.
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. Minimizar o uso genérico e de reflexão

Embora os métodos genéricos ofereçam excelente reutilização e capacidade de manutenção do código, o uso excessivo deles pode afetar negativamente seu projeto no contexto do back-end do Unity IL2CPP (Intermediate Language to C++).

  • Inchaço do código IL2CPP: para cada combinação de tipo genérico exclusiva, o IL2CPP gera uma versão especializada do código. O uso excessivo de genéricos complexos pode levar a uma "explosão combinatória" de código C++ gerado, aumentando significativamente o tamanho binário e a pegada de memória nativa do aplicativo.
  • Sobrecarga de reflexão: métodos que usam reflexão, como System.Reflection APIs, são inerentemente lentos e geralmente causam alocações de heap durante o tempo de execução.
  • Prática recomendada: use genéricos com moderação. Priorize-os para clareza arquitetônica em vez de aplicação ampla e indiscriminada. Quando o desempenho é fundamental, favoreça tipos concretos ou polimorfismo baseado em interface. Para reflexão, armazene em cache resultados como MethodInfo ou FieldInfo durante a inicialização em vez de consultá-los no loop de atualização.

6. Evitar shells gerenciados vazados

Cada UnityEngine.Object, como MonoBehaviour, Texture ou GameObject, tem um wrapper de "shell gerenciado" em C# que se comunica com o mecanismo nativo de C++.

  • O problema: se um shell gerenciado for mantido na memória por uma referência estática, uma assinatura de evento persistente ou um fechamento não limpo, o GC não poderá recuperar a memória. Mesmo que o objeto nativo seja destruído, o wrapper gerenciado persiste, levando a vazamentos de memória "fantasma" que incham o heap gerenciado.
  • Resolução: sempre implemente padrões de limpeza robustos. Ao destruir objetos ou fazer a transição de cenas, cancele explicitamente a inscrição de eventos usando o operador -= e anule as referências estáticas aos tipos UnityEngine.Object. Essa limpeza garante que o GC possa coletar o wrapper depois que o mecanismo nativo liberar o identificador.

7. Usar catálogos binários para Addressables

No Unity 2022 e versões anteriores, o Addressables usa catálogos JSON em vez de catálogos binários (que são o padrão no Unity 2023 e versões mais recentes). Se o projeto usa catálogos JSON, o carregamento de um catálogo grande na inicialização lê todo o arquivo em uma única string C# enorme e aloca milhares de strings gerenciadas e matrizes byte[] temporárias durante a análise JSON e a decodificação Base64. Isso causa um grande pico de alocação de GC que expande permanentemente o heap gerenciado. Portanto, mude para um catálogo binário para transmitir dados compactos diretamente.