Histórias das trincheiras de otimização: Salvando memória com Addressables

O fluxo eficiente de ativos que entram e saem da memória é um elemento essencial de qualquer jogo de qualidade. Como consultor da nossa equipe de serviços profissionais, tenho me esforçado para melhorar o desempenho de muitos projetos de clientes. É por isso que gostaria de compartilhar algumas dicas sobre como aproveitar o Unity Addressable Asset System para aprimorar sua estratégia de carregamento de conteúdo.
A memória é um recurso escasso que deve ser gerenciado com cuidado, especialmente ao transferir um projeto para uma nova plataforma. O uso de Addressables pode melhorar a memória de tempo de execução ao introduzir referências fracas para evitar que ativos desnecessários sejam carregados. Referências fracas significam que você tem controle sobre quando o ativo referenciado é carregado para dentro e para fora da memória; o Addressables System encontrará todas as dependências necessárias e as carregará também. Este blog abordará vários cenários e problemas com os quais você pode se deparar ao configurar seu projeto para usar o Unity Addressable Asset System - e explicará como reconhecê-los e corrigi-los prontamente.

Para esta série de recomendações, trabalharemos com um exemplo simples, configurado da seguinte forma:
- Temos um script InventoryManager na cena com referências aos nossos três ativos de inventário: Prefabs de espada, espada do chefe e escudo.
- Esses ativos não são necessários em todos os momentos do jogo.
Você pode fazer o download dos arquivos de projeto para este exemplo no meu GitHub. Estamos usando o pacote de visualização Memory Profiler para visualizar a memória em tempo de execução. No Unity 2020 LTS, você deve primeiro ativar os pacotes de visualização nas Configurações do projeto antes de instalar esse pacote do Package Manager.
Se estiver usando o Unity 2021.1, selecione a opção Adicionar pacote por nome no menu adicional (+) na janela Package Manager. Use o nome "com.unity.memoryprofiler".
Vamos começar com a implementação mais básica e, em seguida, trabalhar em direção à melhor abordagem para configurar nosso conteúdo Addressables. Simplesmente aplicaremos referências rígidas (atribuição direta no inspetor, rastreada pelo GUID) aos nossos prefabs em um MonoBehaviour que existe em nossa cena.

Quando a cena é carregada, todos os objetos da cena também são carregados na memória junto com suas dependências. Isso significa que cada prefab listado em nosso InventorySystem residirá na memória, juntamente com todas as dependências desses prefabs (texturas, malhas, áudio etc.)
Ao criarmos uma compilação e tirarmos um instantâneo com o Memory Profiler, podemos ver que as texturas de nossos ativos já estão armazenadas na memória, embora nenhuma delas esteja instanciada.

Problema: Há ativos na memória dos quais não precisamos no momento. Em um projeto com um grande número de itens de inventário, isso resultaria em uma pressão considerável na memória de tempo de execução.
Para evitar o carregamento de ativos indesejados, mudaremos nosso sistema de inventário para usar Addressables. O uso de Asset References em vez de referências diretas evita que esses objetos sejam carregados junto com a nossa cena. Vamos mover nossos prefabs de inventário para um Addressables Group e alterar o InventorySystem para instanciar e liberar objetos usando a API Addressables.

Crie o Player e tire uma foto instantânea. Observe que nenhum dos ativos está na memória ainda, o que é ótimo porque eles não foram instanciados.

Instancie todos os itens para vê-los aparecer corretamente com seus ativos na memória.

Problema: Se instanciarmos todos os nossos itens e removermos a espada do chefe, ainda veremos a textura "BossSword_E" da espada do chefe na memória, mesmo que ela não esteja em uso. O motivo disso é que, embora você possa carregar parcialmente os pacotes de ativos, é impossível descarregá-los parcialmente de forma automática. Esse comportamento pode se tornar particularmente problemático para pacotes com muitos ativos, como um único AssetBundle que inclui todos os prefabs do nosso inventário. Nenhum dos ativos do pacote será descarregado até que todo o AssetBundle não seja mais necessário ou até que chamemos a dispendiosa operação de CPU Resources.UnloadUnusedAssets().


Para corrigir esse problema, precisamos mudar a forma como organizamos nossos AssetBundles. Embora atualmente tenhamos um único Addressables Group que reúne todos os seus ativos em um AssetBundle, podemos criar um AssetBundle para cada prefab. Esses AssetBundles mais granulares aliviam o problema de grandes pacotes que retêm ativos na memória que não são mais necessários.
Fazer essa mudança é fácil. Selecione um grupo endereçável e, em seguida, Content Packaging & Loading > Advanced Options > Bundle Mode e vá até Inspector para alterar o Bundle Mode de Pack Together para Pack Separately.
Ao usar Pack Separately para criar esse Addressable Group, você pode criar um AssetBundle para cada ativo no Addressable Group.

Os ativos e os pacotes terão a seguinte aparência:

Agora, voltando ao nosso teste original: O surgimento de nossos três itens e o desaparecimento da espada do chefe não deixam mais ativos desnecessários na memória. As texturas da espada do chefe agora são descarregadas porque o pacote inteiro não é mais necessário.
Problema: Se gerarmos todos os nossos três itens e fizermos uma captura de memória, os ativos duplicados aparecerão na memória. Mais especificamente, isso resultará em várias cópias das texturas "Sword_N" e "Sword_D". Como isso pode acontecer se alterarmos apenas o número de pacotes?

Para responder a essa pergunta, vamos considerar tudo o que está incluído nos três pacotes que criamos. Embora tenhamos colocado apenas três ativos de pré-fabricados em pacotes, há outros ativos implicitamente puxados para esses pacotes como dependências dos pré-fabricados. Por exemplo, o ativo de pré-fabricação da espada também tem ativos de malha, material e textura que precisam ser incluídos. Se essas dependências não forem explicitamente incluídas em outro lugar no Addressables, elas serão automaticamente adicionadas a cada pacote que precisar delas.

Os Addressables incluem uma janela de análise para ajudar a diagnosticar o layout do pacote. Abra Window > Asset Management > Addressables > Analyze e execute a regra Bundle Layout Preview. Aqui, vemos que o pacote sword inclui explicitamente o sword.prefab, mas há muitas dependências implícitas que também são puxadas para esse pacote.

Na mesma janela, execute Check Duplicate Bundle Dependencies (Verificar dependências de pacotes duplicados). Essa regra destaca os ativos incluídos em vários pacotes de ativos com base em nosso layout atual de Addressables.

Podemos evitar a duplicação desses ativos de duas maneiras:
1. Coloque os prefabs Sword, BossSword e Shield no mesmo pacote para que eles compartilhem dependências, ou
2. Incluir explicitamente os ativos duplicados em algum lugar em Addressables
Queremos evitar colocar vários prefabs de inventário no mesmo pacote para impedir que ativos indesejados persistam na memória. Dessa forma, adicionaremos os ativos duplicados a seus próprios pacotes (Pacote 4 e Pacote 5).

Além de analisar nossos pacotes, as regras de análise podem corrigir automaticamente os ativos problemáticos por meio das regras de correção selecionadas. Pressione esse botão para criar um novo grupo Addressables chamado "Duplicate Asset Isolation", que contém os quatro ativos duplicados. Defina o Bundle Mode (Modo de pacote) desse grupo como Pack Separately (Empacotar separadamente ) para evitar que quaisquer outros ativos que não sejam mais necessários persistam na memória.

O uso dessa estratégia AssetBundle pode resultar em problemas em escala. Para cada AssetBundle carregado em um determinado momento, há uma sobrecarga de memória para os metadados do AssetBundle. É provável que esses metadados consumam uma quantidade inaceitável de memória se ampliarmos essa estratégia atual para centenas ou milhares de itens de inventário. Leia mais sobre os metadados do AssetBundle na documentação do Addressables.
Visualize o custo atual da memória de metadados do AssetBundle no Unity Profiler. Vá até o módulo de memória e tire um instantâneo da memória. Procure na categoria Outros > SerializedFile.

Há uma entrada SerializedFile na memória para cada AssetBundle carregado. Essa memória são os metadados do AssetBundle, e não os ativos reais nos pacotes. Esses metadados incluem:
- Dois buffers de leitura de arquivos
- Uma árvore de tipos listando cada tipo exclusivo incluído no pacote
- Um índice que aponta para os ativos
Desses três itens, os buffers de leitura de arquivos são os que ocupam mais espaço. Esses buffers são de 64 KB cada no PS4, Switch e Windows RT, e de 7 KB em todas as outras plataformas. No exemplo acima, 1.819 pacotes * 64 KB * 2 buffers = 227 MB apenas para buffers.
Como o número de buffers é escalonado linearmente com o número de AssetBundles, a solução simples para reduzir a memória é ter menos pacotes carregados em tempo de execução. No entanto, evitamos anteriormente carregar pacotes grandes para evitar que ativos indesejados persistam na memória. Então, como podemos reduzir o número de pacotes mantendo a granularidade?
Uma primeira etapa sólida seria agrupar os ativos com base em seu uso no aplicativo. Se você puder fazer suposições inteligentes com base no seu aplicativo, poderá agrupar ativos que você sabe que sempre serão carregados e descarregados juntos, como os ativos de grupo com base no nível de jogo em que se encontram.
Por outro lado, talvez você esteja em uma situação em que não possa fazer suposições seguras sobre quando seus ativos serão necessários ou não. Se você estiver criando um jogo de mundo aberto, por exemplo, não poderá simplesmente agrupar tudo do bioma da floresta em um único pacote de ativos, pois os jogadores poderão pegar um item da floresta e carregá-lo entre os biomas. O pacote inteiro da floresta permanece na memória porque o jogador ainda precisa de um ativo da floresta.
Felizmente, há uma maneira de reduzir o número de pacotes e, ao mesmo tempo, manter o nível desejado de granularidade. Vamos ser mais inteligentes sobre como deduplicar nossos pacotes.
A regra de análise de deduplicação integrada que executamos detecta todos os ativos que estão em vários pacotes e os move com eficiência para um único Addressables Group. Ao definir esse grupo como Pack Separately (Empacotar separadamente), acabamos com um ativo por pacote. No entanto, há alguns ativos duplicados que podem ser agrupados com segurança sem introduzir problemas de memória. Considere o diagrama abaixo:

Sabemos que as texturas "Sword_N" e "Sword_D" são dependências dos mesmos pacotes (Pacote 1 e Pacote 2). Como essas texturas têm os mesmos pais, podemos embalá-las juntas com segurança sem causar problemas de memória. Ambas as texturas de espada devem sempre ser carregadas ou descarregadas. Nunca há a preocupação de que uma das texturas possa persistir na memória, pois nunca há um caso em que usamos especificamente uma textura e não a outra.
Podemos implementar essa lógica de deduplicação aprimorada em nossa própria regra Addressables Analyze Rule. Trabalharemos com base na regra CheckForDupeDependencies.cs existente. Você pode ver o código de implementação completo no exemplo do Inventory System. Nesse projeto simples, apenas reduzimos o número total de pacotes de sete para cinco. Mas imagine um cenário em que seu aplicativo tenha centenas, milhares ou até mais ativos duplicados em Addressables. Ao trabalhar com a Unknown Worlds Entertainment em um contrato de serviços profissionais para o jogo Subnautica, o projeto inicialmente tinha um total de 8.718 pacotes depois de usar a regra de análise de deduplicação integrada. Reduzimos esse número para 5.199 pacotes depois de aplicar a regra personalizada para agrupar ativos desduplicados com base nos pais dos pacotes. Saiba mais sobre nosso trabalho com a equipe nesta história de caso.
Isso representa uma redução de 40% no número de pacotes, embora ainda tenham o mesmo conteúdo e mantenham o mesmo nível de granularidade. Essa redução de 40% no número de pacotes também reduziu o tamanho do SerializedFile em tempo de execução em 40% (de 311 MB para 184 MB).
O uso de Addressables pode reduzir significativamente o consumo de memória. Você pode obter mais redução de memória organizando seus AssetBundles de acordo com seu caso de uso. Afinal, as regras de análise incorporadas são conservadoras para se adequarem a todos os aplicativos. A criação de suas próprias regras de análise pode automatizar o layout do pacote e otimizá-lo para seu aplicativo. Para detectar problemas de memória, continue a criar perfis com frequência e verifique a janela Analyzer para ver quais ativos estão explícita e implicitamente incluídos em seus pacotes. Consulte a documentação do Addressables Asset System para obter mais práticas recomendadas, um guia para ajudá-lo a começar e a documentação ampliada da API.
Se quiser obter mais ajuda prática para saber como melhorar o gerenciamento de conteúdo com o Addressables Asset System, entre em contato com o departamento de vendas para saber sobre um curso de treinamento profissional.