Otimização de Variantes de Shader do Unity & Dicas de Solução de Problemas

Quando escrevendo shaders no Unity, temos convenientemente a capacidade de incluir múltiplos recursos, passes e lógica de ramificação em um único arquivo de origem. No momento da construção, os arquivos de origem do shader são compilados em programas de shader, que contêm uma ou mais variantes. Uma variante é uma versão desse shader seguindo um único conjunto de condições, resultando (na maioria dos casos) em um caminho de execução linear sem condicionais de ramificação estáticas.
A razão pela qual usamos variantes, em vez de manter os caminhos de ramificação todos em um shader, é porque as GPUs são ótimas em paralelizar código que é previsível e sempre segue o mesmo caminho, resultando em maior rendimento. Se condicionais estiverem presentes no programa de shader compilado, a GPU precisará gastar recursos realizando tarefas preditivas, esperando que os outros caminhos sejam concluídos, e assim por diante, introduzindo ineficiências.
Embora isso leve a um desempenho significativamente melhor da GPU em comparação com ramificação dinâmica, também tem algumas desvantagens. Os tempos de construção ficarão mais longos à medida que o número de variantes aumenta, às vezes até mesmo por várias horas por construção. O jogo também levará mais tempo para inicializar, já que precisará gastar mais tempo carregando e pré-aquecendo shaders. Finalmente, você pode notar um uso significativo de memória em tempo de execução dos shaders se as variantes não forem gerenciadas adequadamente, às vezes mais de 1GB.
A quantidade de variantes geradas aumenta dependendo de uma variedade de fatores, incluindo palavras-chave e propriedades definidas, configurações de qualidade, níveis gráficos, APIs gráficas habilitadas, efeitos de pós-processamento, o pipeline de renderização ativo, modos de iluminação e neblina, e se XR está habilitado, entre outros. Shaders que resultam em um grande número de variantes são frequentemente chamados de uber shaders. Em tempo de execução, o Unity carrega a variante que corresponde às configurações e palavras-chave necessárias, como abordaremos mais adiante.
Isso é particularmente impactante quando você considera que frequentemente vemos shaders com mais de 100 palavras-chave, levando a um número incontrolável de variantes resultantes, frequentemente referido como explosão de variantes de shader. Não é incomum ver shaders com um espaço inicial de variantes na casa dos milhões antes que qualquer filtragem seja aplicada.
Para aliviar isso, o Unity tentará reduzir a quantidade de variantes geradas com base em alguns passes de filtragem. Por exemplo, se XR não estiver habilitado, variantes que são necessárias para isso normalmente serão removidas. O Unity então leva em conta quais recursos você está realmente usando em suas cenas, como modos de iluminação, neblina, e assim por diante. Esses são particularmente difíceis de identificar, já que desenvolvedores e artistas podem introduzir mudanças aparentemente seguras que na verdade levam a um aumento significativo nas variantes de shader, sem uma maneira óbvia de detectar, a menos que você coloque algumas salvaguardas como parte do seu pipeline de implantação.
Embora isso seja útil, esse processo não é perfeito, e há muito que podemos fazer para remover o máximo de variantes possível sem afetar a qualidade visual do seu jogo.
Aqui, gostaria de compartilhar algumas dicas práticas sobre como lidar com variantes, entender de onde elas estão vindo e algumas maneiras eficazes de reduzi-las. O tempo de construção do seu projeto e a pegada de memória se beneficiarão muito como resultado.
Para mais informações sobre a remoção de variantes de shader, consulte Reduzindo variantes de shader no Manual do Unity.
As variantes de shader são geradas com base em todas as combinações possíveis de shader_feature e multi_compile palavras-chave usadas no seu shader, entre outros fatores. Palavras-chave marcadas como multi_compile estão sempre incluídas na sua construção, enquanto aquelas marcadas como shader_feature serão incluídas se forem referenciadas por qualquer material no seu projeto. Por essa razão, você deve usar shader_feature sempre que possível.
Para ver quais palavras-chave estão definidas em um shader, você pode selecioná-lo e verificar o Inspetor.

Como você pode ver, as palavras-chave são divididas em Sobrescrevíveis e Não Sobrescrevíveis. Palavras-chave locais (as definidas no arquivo de shader real) com um escopo global podem ser sobrescritas por uma palavra-chave de shader global com um nome correspondente. Se, em vez disso, forem definidas em um escopo local (usando multi_compile_local ou shader_feature_local), não podem ser sobrescritas e aparecerão na seção Não sobrescrevível abaixo. Palavras-chave de shader globais são fornecidas pelo motor Unity, e elas são sobrescritas. Como podem ser adicionadas em qualquer ponto do processo de construção, nem todas as palavras-chave globais podem aparecer nesta lista.
As palavras-chave podem ser definidas em grupos mutuamente exclusivos, chamados conjuntos, definindo-as na mesma diretiva. Ao fazer isso, você evita gerar variantes para combinações de palavras-chave que nunca serão ativadas ao mesmo tempo (como dois tipos diferentes de iluminação ou neblina).
#pragma shader_feature LIGHT_LOW_Q LIGHT_HIGH_QPara reduzir a quantidade de palavras-chave por plataforma, você pode usar macros de pré-processador para defini-las apenas para a plataforma relevante, por exemplo:
#ifdef SHADER_API_METAL
#pragma shader_feature IOS_FOG_FEATURE
#else
#pragma shader_feature BASE_FOG_FEATURE
#endif
Observe que essas expressões com macros não podem depender de outras palavras-chave ou recursos que não estão apenas relacionados ao alvo de construção.
As palavras-chave também podem ser limitadas a um passe específico, reduzindo a quantidade de combinações potenciais. Para fazer isso, você pode adicionar um dos seguintes sufixos à diretiva:
- _vertex
- _fragment
- _hull
- _domain
- _geometry
- _raytracing
Por exemplo:
#pragma shader_feature_fragment FRAG_FEATURE_1 FRAG_FEATURE_2Isso pode se comportar de maneira diferente dependendo do renderizador que você está usando. Por exemplo, no OpenGL, OpenGL ES e Vulkan, os sufixos serão ignorados.
Você pode usar a diretiva #pragma skip_variants para definir palavras-chave que devem ser excluídas ao gerar variantes para aquele shader específico. Ao fazer a construção do seu jogador, todas as variantes de shader para aquele shader que contiver uma dessas palavras-chave serão puladas.
Você também pode opcionalmente definir palavras-chave usando a diretiva #pragma dynamic_branch, que forçará a Unity a depender de ramificação dinâmica e não gerar variantes para essas palavras-chave. Embora isso reduza a quantidade de variantes resultantes, pode levar a um desempenho mais fraco da GPU dependendo do shader e do conteúdo do jogo, por isso é recomendado fazer um perfil adequado ao usá-lo.
Para mais informações sobre palavras-chave de shader, consulte Alterando como os shaders funcionam usando palavras-chave no Manual da Unity.
Normalmente, as variantes de shader não serão compiladas até que você realmente construa o jogo. Usando esta opção, você pode inspecionar as variantes de shader resultantes para uma plataforma de construção específica ou API gráfica. Isso permite que você verifique erros com antecedência. Além disso, você pode colar o código gerado em ferramentas de análise de desempenho de shader GPU, como PVRShaderEditor, para otimizações adicionais.

Na parte inferior, você notará uma entrada dizendo quantas variantes estão incluídas, com base nos materiais presentes na cena atualmente aberta, sem qualquer remoção scriptável aplicada. Se você clicar no botão Mostrar, ele exibirá um arquivo temporário com algumas informações adicionais de depuração sobre quais palavras-chave foram usadas ou removidas em várias plataformas, incluindo o número de variantes de estágio de vértice.
A caixa de seleção Apenas Pré-processar acima permite que você alterne entre o código do shader compilado e a fonte do shader pré-processada para depuração mais fácil e rápida.
Se você estiver usando o Pipeline de Renderização Integrado e trabalhando com um shader de superfície, você tem a opção de verificar o código gerado que a Unity usará para substituir sua fonte de shader simplificada quando você construir. Você pode então opcionalmente substituir a fonte do seu shader pelo código gerado, se desejar modificar a saída.
Para mais informações, consulte Verifique quantas variantes de shader você tem no Manual do Unity.

Ao construir o jogo, o Unity determinará o espaço de variantes para cada shader com base em todas as possíveis permutações de seus recursos, configurações do motor e outros fatores. Essas combinações são então passadas para os pré-processadores para várias passagens de remoção. Isso pode ser estendido usando callbacks IPreprocessShaders para criar lógica personalizada para remover mais variantes da construção, conforme abordado abaixo.
Shaders que estão incluídos como parte da lista de Shaders sempre incluídos (sob Configurações do Projeto > Gráficos) terão todas as suas variantes incluídas na construção. Por essa razão, é melhor usar isso apenas quando estritamente necessário, pois pode facilmente levar a um grande número de variantes sendo geradas.
Finalmente, o pipeline de construção passará por um processo chamado deduplicação, identificando variantes idênticas dentro do mesmo Pass e garantindo que elas apontem para o mesmo bytecode. Isso resultará em um tamanho reduzido no disco, mas variantes idênticas ainda afetarão negativamente o tempo de construção, o tempo de carregamento e o uso de memória em tempo de execução, portanto, não é um substituto para a remoção adequada de variantes.
Após uma construção bem-sucedida, podemos olhar o arquivo Editor.log para coletar algumas informações úteis sobre quais variantes de shaders foram incluídas na construção. Para fazer isso, pesquise o arquivo de log por “Compilando shader” e o nome do seu shader. Aqui está, por exemplo, como isso se parece:
Compiling shader "GameShaders/MyShader" pass "Pass 1" (vp)
Full variant space: 608
After settings filtering: 608
After built-in stripping: 528
After scriptable stripping: 528
Processed in 0.00 seconds
starting compilation...
finished in 0.02 seconds. Local cache hits 528 (0.16s CPU time), remote cache hits 0 (0.00s CPU time), compiled 0 variants (0.00s CPU time), skipped 0 variants
Em certos casos, você pode ver a quantidade de variantes aumentar após a etapa de filtragem de configurações, por exemplo, se seu projeto tiver XR habilitado.
Se seu jogo suportar várias APIs Gráficas, você também encontrará informações para cada renderizador suportado:
Serialized binary data for shader GameShaders/MyShader in 0.00s
gles3 (total internal programs: 290, unique: 193)
vulkan (total internal programs: 290, unique: 193)Finalmente, você verá esses logs de compressão que lhe darão uma indicação do tamanho final, no disco, do shader para uma API Gráfica específica:
Compressed shader 'GameShaders/MyShader' on vulkan from 1.35MB to 0.19MBSe você estiver usando o Universal Render Pipeline (URP), pode selecionar se deseja que os logs sejam gerados apenas a partir de shaders SRP, de todos os shaders ou desativar logs. Para fazer isso, selecione o Nível de Log em Configurações do Projeto > Gráficos > Configurações Globais do URP.

Além disso, se você selecionar a opção Exportar Variantes de Shader abaixo, um arquivo JSON será gerado após sua compilação que contém um relatório das compilações de variantes de shader. Isso está disponível no Unity 2022.2 ou mais recente.
Para entender quais shaders são realmente compilados para a GPU em tempo de execução, você pode habilitar a opção Log de Compilação de Shader, nas Configurações do Projeto > Gráficos.

Isso fará com que seu jogo imprima nos logs do jogador sempre que um shader for compilado enquanto você joga. Isso funcionará apenas em builds de desenvolvimento e no modo Debug, conforme descrito na dica de ferramenta.
O formato é assim:
Compiled Shader: Folder/ShaderName, pass: PASS_NAME, stage: STAGE_NAME, keywords ACTIVE_KEYWORD_1 ACTIVE_KEYWORD_2Tenha em mente que algumas plataformas, como Android, irão armazenar em cache shaders compilados. Por essa razão, você pode precisar desinstalar e reinstalar o jogo antes de fazer um teste para capturar todos os shaders compilados.
Finalmente, você pode usar o pacote Memory Profiler para tirar um instantâneo do seu jogo enquanto ele está em execução, e então ter uma visão geral de quais shaders estão atualmente carregados na memória e seu tamanho. Classificar por tamanho normalmente dá uma boa indicação de quais shaders estão trazendo mais variantes e valem a pena otimizar.

Como parte dos processos de remoção, o Unity removerá variantes de shader relacionadas a recursos gráficos que seu jogo não está usando. O processo muda ligeiramente se você estiver usando o Pipeline de Renderização Integrado ou o URP.
Para definir isso, vá para Configurações do Projeto > Gráficos. A partir daqui, enquanto usa o Pipeline de Renderização Integrado, você pode selecionar quais modos de Lightmap e Nebulosa seu jogo suporta.

Defini-los como Automático permite que o Unity determine quais variantes remover com base nas cenas incluídas na sua compilação.
Se você não tiver certeza de quais recursos está usando, também pode usar o botão Importar da Cena Atual para deixar o Unity descobrir quais recursos você precisa. Claro que isso só é útil se todas as suas cenas estiverem usando as mesmas configurações, então certifique-se de selecionar uma cena representativa ao usar esta opção.
Se você estiver usando o URP, algumas dessas opções estarão ocultas. Em vez disso, você poderá definir quais recursos seu jogo requer diretamente no ativo Configurações do Pipeline.
Por exemplo, desabilitar Buracos de Terreno fará com que todas as variantes do Shader de Buracos de Terreno sejam removidas, reduzindo também o tempo de compilação.
O URP fornece um controle mais granular sobre quais recursos você deseja incluir em seu jogo, resultando potencialmente em compilações mais otimizadas com menos variantes não utilizadas.
Observação: Isso é relevante apenas ao usar o Pipeline de Renderização Integrado. Essas configurações serão ignoradas ao usar um pipeline de renderização scriptável, como o URP.
Os níveis gráficos são usados para aplicar diferentes configurações gráficas com base no hardware em que seu jogo está sendo executado (não confundir com as Configurações de Qualidade). Quando o jogo começa, o Unity determinará o nível gráfico do seu dispositivo com base nas capacidades de hardware, API gráfica e outros fatores.
Eles podem ser definidos em Configurações do Projeto > Gráficos > Configurações de Nível.

Com base nisso, o Unity adiciona essas três palavras-chave a todos os shaders:
UNITY_HARDWARE_TIER1
UNITY_HARDWARE_TIER2
UNITY_HARDWARE_TIER3
Em seguida, gera variantes de shader para cada um dos níveis gráficos definidos. Se você não estiver usando níveis gráficos e quiser evitar as variantes relacionadas a eles, precisa garantir que todos os níveis gráficos estejam configurados exatamente com as mesmas configurações para que o Unity pule essas variantes.
Como mencionado anteriormente, o Unity tentará deduplicar variantes que são idênticas, então, se, por exemplo, dois dos três níveis tiverem as mesmas configurações, isso levará a uma redução no tamanho em disco, mesmo que todas as variantes ainda sejam geradas. Você pode opcionalmente forçar o Unity a gerar variantes de nível para um shader e API de renderizador gráfico específicos, usando os variants_tier_hardware conforme mostrado abaixo:
// Direct3D 11/12
#pragma hardware_tier_variants d3d11 Para mais informações, consulte Níveis gráficos no Pipeline de Renderização Integrado no Manual do Unity.
Unity compila um conjunto de variantes de shader para cada API gráfica incluída na sua compilação, então, em alguns casos, é benéfico selecionar manualmente as APIs e excluir aquelas que você não precisa.
Para fazer isso, vá para Configurações do Projeto > Player. Por padrão, a API Gráfica Automática está selecionada, e o Unity incluirá um conjunto de APIs gráficas integradas e escolherá uma em tempo de execução dependendo das capacidades do dispositivo. Por exemplo, no Android, o Unity tentará usar Vulkan primeiro, e se o dispositivo não suportar, o motor recuará para GLES3.2, GLES3.1 ou GLES3.0 (as variantes serão idênticas nessas versões do GLES).
Em vez disso, desative a API Gráfica Automática para a plataforma relevante e selecione manualmente as APIs que você gostaria de incluir. O Unity dará prioridade à primeira na lista.

A desvantagem é que você pode limitar a quantidade de dispositivos que suportam seu jogo, então certifique-se de saber o que está fazendo ao mudar isso e teste em uma variedade de dispositivos.
Normalmente, em tempo de execução, o Unity tenta carregar a variante que é mais próxima do conjunto de palavras-chave solicitadas se uma correspondência exata não estiver disponível ou foi removida da compilação do jogador. Embora isso seja conveniente, também oculta problemas potenciais com a configuração das suas palavras-chave de shader.
A partir do Unity 2022.3, você pode selecionar Correspondência Estrita de Variantes de Shader em Configurações do Projeto > Player para garantir que o Unity só tente carregar a correspondência exata para a combinação de palavras-chave locais e globais que você precisa.

Se não encontrado, ele usará o Shader de Erro e imprimirá um erro no console contendo o shader, o índice do subshader, a passagem real e as palavras-chave solicitadas. Isso é bastante útil quando você precisa rastrear variantes ausentes que você realmente precisa. Como de costume com a remoção, isso só funciona no Player e não tem impacto no Editor.
Enquanto joga o jogo no Editor, o Unity mantém o controle de quais shaders e variantes estão atualmente em uso na sua cena e permite que você exporte isso para uma coleção. Para fazer isso, navegue até Configurações do Projeto > Gráficos. Na parte inferior, você notará uma seção de Carregamento de Shader, mostrando quantos shaders estão atualmente rastreados como ativos.
Certifique-se de clicar em Limpar antes para ter uma amostra mais precisa, depois entre no modo de Jogo e interaja com sua cena, garantindo que você encontre todos os elementos do jogo que requerem shaders específicos. Isso aumentará os contadores rastreados. Em seguida, pressione o botão “Salvar como ativo…” para salvar todos eles em um ativo de coleção.
Para mais informações, consulte Criar uma coleção de variantes de shader no Manual do Unity.

Coleções de Variantes de Shader são ativos que contêm uma lista de shaders e variantes relacionadas. Eles são comumente usados para predefinir quais variantes você deseja incluir em sua compilação e para pré-aquecer shaders.

Uma abordagem usada em alguns projetos é executar isso para cada nível do jogo, salvando uma coleção para cada um deles, e depois remover quaisquer variantes que não estão presentes em nenhuma dessas listas usando um script IPreprocessShaders (coberto na próxima seção). Embora isso seja conveniente, na minha experiência, também é bastante propenso a erros. É difícil garantir que você encontre todas as variantes necessárias em uma única jogada, e alguns dos recursos podem ser carregados apenas no dispositivo e em casos específicos, resultando em uma lista que não é necessariamente precisa. À medida que seu jogo muda e novos elementos são adicionados aos níveis ou materiais mudam, as coleções precisarão ser atualizadas. Por essa razão, eu usaria isso principalmente para fins de depuração e investigação, em vez de integrá-lo diretamente em seu pipeline de compilação.
Para mais informações, consulte Criar uma coleção de variantes de shader no Manual do Unity.
Sempre que um shader estiver prestes a ser compilado em sua compilação de jogo, o Unity enviará um callback. Isso acontece tanto em builds de Player quanto em Asset Bundles. Podemos ouvir isso convenientemente usando IPreprocessShaders.OnProcessShader e IPreprocessComputeShaders.OnProcessComputeShader (para shaders de computação), e adicionar lógica personalizada para remover variantes desnecessárias. Dessa forma, podemos reduzir significativamente o tempo de compilação, o tamanho da compilação e o número total de variantes que entram em sua compilação.
Para fazer isso, crie um script que implemente a interface IPreprocessShaders, e então escreva sua lógica de remoção dentro de OnProcessShader. Por exemplo, aqui está um script que removerá todas as variantes contendo a palavra-chave DEBUG no shader em builds de release:
public class StripDebugVariantsPreprocessor : IPreprocessShaders
{
public int callbackOrder => 0;
ShaderKeyword keywordToStrip;
public StripDebugVariantsPreprocessor()
{
keywordToStrip = new ShaderKeyword("DEBUG");
}
public void OnProcessShader(Shader shader, ShaderSnippetData snippet, IList<ShaderCompilerData> data)
{
if (EditorUserBuildSettings.development)
{
return;
}
for (int i = data.Count - 1; i >= 0; i--)
{
if (data[i].shaderKeywordSet.IsEnabled(keywordToStrip))
{
data.RemoveAt(i);
}
}
}
}A ordem de callback permite que você defina qual script de pré-processamento deve ser executado primeiro, permitindo que você crie passes de remoção em várias etapas. Scripts com uma prioridade mais baixa serão executados primeiro.
Visite o fórum de Gráficos-Shaders para saber mais.
Para mais informações, consulte as seguintes seções no Manual do Unity:
