Consejos para optimizar y solucionar problemas de las variantes de sombreado de Unity

Al escribir sombreadores en Unity, tenemos la posibilidad de incluir convenientemente múltiples características, pases y lógica de ramificación en un solo archivo fuente. En el momento de la compilación, los archivos fuente del sombreador se compilan en programas de sombreador, que contienen una o más variantes. Una variante es una versión de ese sombreador que sigue un único conjunto de condiciones, lo que da como resultado (en la mayoría de los casos) una ruta de ejecución lineal sin condicionales de ramificación estática.
La razón por la que usamos variantes, en lugar de mantener todas las rutas de ramificación en un solo sombreador, es porque las GPU son excelentes para paralelizar código que es predecible y siempre sigue la misma ruta, lo que resulta en un mayor rendimiento. Si hay condicionales presentes en el programa shader compilado, la GPU necesitará gastar recursos realizando tareas predictivas, esperando que se completen las otras rutas, etc., lo que generará ineficiencias.
Si bien esto produce un rendimiento de la GPU significativamente mejor en comparación con la ramificación dinámica, también tiene algunas desventajas. Los tiempos de compilación serán más largos a medida que aumente el número de variantes, a veces incluso varias horas por compilación. El juego también tardará más en iniciarse, ya que necesitará dedicar más tiempo a cargar y precalentar los shader. Finalmente, es posible que notes un uso significativo de memoria en tiempo de ejecución de los sombreadores si las variantes no se administran correctamente, a veces más de 1 GB.
La cantidad de variantes generadas aumenta dependiendo de una variedad de factores, incluidas las palabras clave y propiedades definidas, la configuración de calidad, los niveles de gráficos, las API de gráficos habilitadas, los efectos de posprocesamiento, la tubería de renderizado activa, los modos de iluminación y niebla, y si XR está habilitado, entre otros. Los sombreadores que dan como resultado una gran cantidad de variantes a menudo se denominan sombreadores superiores. En tiempo de ejecución, Unity carga la variante que coincide con las configuraciones y palabras clave requeridas, como veremos más adelante.
Esto es particularmente impactante si tenemos en cuenta que a menudo vemos sombreadores con más de 100 palabras clave, lo que genera una cantidad inmanejable de variantes resultantes, a menudo denominada explosión de variantes de sombreador. No es inusual ver sombreadores con un espacio de variantes inicial de millones antes de que se aplique cualquier filtrado.
Para aliviar esto, Unity intentará reducir la cantidad de variantes generadas en función de unos pocos pases de filtrado. Por ejemplo, si XR no está habilitado, las variantes necesarias para ello normalmente se eliminarán. Luego, Unity tiene en cuenta qué características estás usando realmente en tus escenas, como modos de iluminación, niebla, etc. Estos son particularmente difíciles de detectar, ya que los desarrolladores y artistas podrían introducir cambios aparentemente seguros que en realidad conducen a un aumento significativo en las variantes de sombreado, sin ninguna forma obvia de detectarlo a menos que implemente algunas medidas de seguridad como parte de su proceso de implementación.
Si bien esto es útil, este proceso no es perfecto y hay mucho que podemos hacer para eliminar la mayor cantidad posible de variantes sin afectar la calidad visual del juego.
Aquí me gustaría compartir algunos consejos prácticos sobre cómo manejar las variantes, comprender de dónde provienen y algunas formas efectivas de reducirlas. Como resultado, el tiempo de construcción de su proyecto y el consumo de memoria se beneficiarán enormemente.
Las variantes de sombreador se generan en función de todas las combinaciones posibles de palabras clave shader_feature y multi_compile utilizadas en el sombreador, entre otros factores. Las palabras clave marcadas como multi_compile siempre se incluyen en su compilación, mientras que las marcadas como shader_feature se incluirán si se hace referencia a ellas en cualquier material de su proyecto. Por este motivo, debes utilizar shader_feature siempre que sea posible.
Para ver qué palabras clave están definidas en un sombreador, puede seleccionarlo y consultar el Inspector.

Como puedes ver, las palabras clave se dividen en Anulables y No Anulables. Las palabras clave locales (las definidas en el archivo shader real) con un alcance global pueden ser reemplazadas por una palabra clave shader global con un nombre coincidente. Si, en cambio, se definen en un ámbito local (mediante multi_compile_local o shader_feature_local), no se pueden anular y aparecerán en la sección No anulable debajo. Las palabras clave del sombreador global las proporciona el motor Unity y se pueden anular. Dado que se pueden agregar en cualquier punto del proceso de compilación, es posible que no todas las palabras clave globales aparezcan en esta lista.
Las palabras clave se pueden definir en grupos mutuamente excluyentes, llamados conjuntos, definiéndolas en la misma directiva. Al hacer esto, evitas generar variantes para combinaciones de palabras clave que nunca se habilitarán al mismo tiempo (como dos tipos diferentes de iluminación o niebla).
#pragma shader_feature LIGHT_LOW_Q LIGHT_HIGH_Q
Para reducir la cantidad de palabras clave por plataforma, puede utilizar macros de preprocesador para definirlas solo para la plataforma relevante, por ejemplo:
#ifdef SHADER_API_METAL
#pragma shader_feature IOS_FOG_FEATURE
#else
#pragma shader_feature BASE_FOG_FEATURE
#endif
Tenga en cuenta que estas expresiones con macros no pueden depender de otras palabras clave o características que no estén relacionadas únicamente con el objetivo de la compilación.
Las palabras clave también se pueden limitar a un pase específico, reduciendo la cantidad de combinaciones potenciales. Para ello, puede agregar uno de los siguientes sufijos a la directiva:
- _vertex
- _fragmento
- _hull
- _domain
- _geometry
- _raytracing
Por ejemplo:
#pragma shader_feature_fragment FRAG_FEATURE_1 FRAG_FEATURE_2
Esto puede comportarse de manera diferente según el renderizador que estés usando. Por ejemplo, en OpenGL, se ignorarán los sufijos OpenGL ES y Vulkan.
Puede utilizar la directiva #pragma skip_variants para definir palabras clave que deben excluirse al generar variantes para ese sombreador específico. Al crear la compilación de tu reproductor, se omitirán todas las variantes de sombreador para ese sombreador que contengan una de esas palabras clave.
También puede definir opcionalmente palabras clave utilizando la directiva #pragma dynamic_branch, lo que obligará a Unity a confiar en la ramificación dinámica y no generar variantes para esas palabras clave. Si bien esto reduce la cantidad de variantes resultantes, puede generar un rendimiento más débil de la GPU según el sombreador y el contenido del juego, por lo que se recomienda crear perfiles en consecuencia al usarlo.
Normalmente, las variantes de sombreado no se compilarán hasta que realmente construyas el juego. Con esta opción, puede inspeccionar las variantes de sombreado resultantes para una plataforma de compilación o API de gráficos específicas. Esto le permite comprobar si hay errores con antelación. Además, puedes pegar el código generado en herramientas de análisis del rendimiento del sombreador de GPU, como PVRShaderEditor, para realizar optimizaciones adicionales.

En la parte inferior, verás una entrada que indica cuántas variantes están incluidas, en función de los materiales presentes en la escena abierta actualmente, sin aplicar ninguna eliminación programable. Si presiona el botón Mostrar, se mostrará un archivo temporal con información de depuración adicional sobre qué palabras clave se usaron o eliminaron en varias plataformas, incluida la cantidad de variantes de la etapa de vértice.
La casilla de verificación Solo preprocesar arriba le permite alternar entre el código de sombreado compilado y el código fuente de sombreado preprocesado para una depuración más fácil y rápida.
Si está utilizando el canal de renderizado integrado y está trabajando con un sombreador de superficie, tiene la opción de verificar el código generado que Unity utilizará para reemplazar su fuente de sombreador simplificado cuando construya. Luego, puedes reemplazar opcionalmente la fuente del sombreador con el código generado, si deseas modificar la salida.

Al crear el juego, Unity determinará el espacio de variantes para cada sombreador en función de todas las posibles permutaciones de sus características, configuraciones del motor y otros factores. Estas combinaciones luego pasan a los preprocesadores para múltiples pasadas de eliminación. Esto se puede ampliar usando devoluciones de llamadas IPreprocessShaders para crear una lógica personalizada para eliminar más variantes de la compilación, como se explica a continuación.
Los sombreadores que se incluyen como parte de la lista de sombreadores siempre incluidos (en Configuración del proyecto > Gráficos) tendrán todas sus variantes incluidas en la compilación. Por este motivo, es mejor utilizar esto solo cuando sea estrictamente necesario, ya que puede conducir fácilmente a la generación de una gran cantidad de variantes.
Finalmente, el flujo de compilación pasará por un proceso llamado deduplicación, que identifica variantes idénticas dentro del mismo pase y garantiza que apunten al mismo código de bytes. Esto dará como resultado un tamaño reducido en el disco, pero las variantes idénticas seguirán afectando negativamente el tiempo de compilación, el tiempo de carga y el uso de memoria en tiempo de ejecución, por lo que no reemplaza la eliminación adecuada de variantes.
Después de una compilación exitosa, podemos consultar el archivo Editor.log para recopilar información útil sobre qué variantes de sombreadores se incluyeron en la compilación. Para ello, busque en el archivo de registro “Compiling shader” y el nombre de su shader. Así es como se ve, por ejemplo:
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
En ciertos casos, es posible que notes que la cantidad de variantes aumenta después del paso de filtrado de configuración, por ejemplo, si tu proyecto tiene XR habilitado.
Si tu juego admite varias API de gráficos, también encontrarás información para cada renderizador compatible:
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, verás estos registros de compresión que te darán una indicación del tamaño final, en disco, del sombreador para una API de gráficos específica:
Compressed shader 'GameShaders/MyShader' on vulkan from 1.35MB to 0.19MB
Si está utilizando Universal Render Pipeline (URP), puede seleccionar si desea que los registros se generen solo desde los sombreadores SRP, desde todos los sombreadores o deshabilitar los registros. Para ello, seleccione el Nivel de registro en Configuración del proyecto > Gráficos > Configuración global de URP.

Además, si selecciona la opción Exportar variantes de sombreado a continuación, se generará un archivo JSON después de la compilación que contiene un informe de las compilaciones de variantes de sombreado. Esto está disponible en Unity 2022.2 o más reciente.
Para comprender qué sombreadores se compilan realmente para la GPU en tiempo de ejecución, puede habilitar la opción Compilación de sombreadores de registro, en Configuración del proyecto > Gráficos.

Esto hará que tu juego se imprima en los registros del jugador cada vez que se compile un sombreador mientras juegas. Solo funcionará en compilaciones de desarrollo y en modo de depuración, como se describe en la información sobre herramientas.
El formato se ve así:
Compiled Shader: Folder/ShaderName, pass: PASS_NAME, stage: STAGE_NAME, keywords ACTIVE_KEYWORD_1 ACTIVE_KEYWORD_2
Tenga en cuenta que algunas plataformas, como Android, almacenarán en caché los sombreadores compilados. Por este motivo, es posible que necesites desinstalar y reinstalar el juego antes de realizar una prueba para capturar todos los sombreadores compilados.
Por último, puedes usar el paquete Memory Profiler para tomar una instantánea de tu juego mientras se está ejecutando y luego tener una descripción general de qué sombreadores están cargados actualmente en la memoria y su tamaño. Ordenar por tamaño normalmente da una buena indicación de qué sombreadores incorporan más variantes y vale la pena optimizarlos.

Como parte de los pases de eliminación, Unity eliminará las variantes de sombreado relacionadas con las características gráficas que tu juego no utiliza. El proceso cambia ligeramente si está utilizando el canal de renderizado integrado o URP.
Para definirlos, vaya a Configuración del proyecto > Gráficos. Desde aquí, mientras usas el canal de renderizado integrado, puedes seleccionar qué modos Lightmap y Fog son compatibles con tu juego.

Al configurarlos en Automático, Unity puede determinar qué variantes eliminar en función de las escenas incluidas en su compilación.
Si no está seguro de qué funciones está utilizando, también puede usar el botón Importar desde la escena actual para permitir que Unity descubra qué funciones necesita. Por supuesto, esto solo es útil si todas las escenas usan la misma configuración, así que asegúrese de seleccionar una escena representativa cuando use esta opción.
Si está utilizando URP, algunas de estas opciones estarán ocultas. En su lugar, podrás definir qué características requiere tu juego directamente en el activo Configuración de Pipeline.
Por ejemplo, al deshabilitar Terrain Holes se eliminarán todas las variantes del sombreador Terrain Holes, lo que también reducirá el tiempo de compilación.
URP proporciona un control más granular sobre qué características quieres incluir en tu juego, lo que potencialmente da como resultado compilaciones más optimizadas con menos variantes sin usar.
Nota: Esto solo es relevante cuando se utiliza el pipeline de renderizado integrado. Estas configuraciones se ignorarán cuando se utilice un canal de renderizado programable como URP.
Los niveles de gráficos se utilizan para aplicar diferentes configuraciones de gráficos según el hardware en el que se ejecuta el juego (no debe confundirse con la configuración de calidad). Cuando comienza el juego, Unity determinará el nivel gráfico de tu dispositivo según las capacidades del hardware, la API de gráficos y otros factores.
Se pueden configurar en Configuración del proyecto > Gráficos > Configuración de niveles.

En base a esto, Unity agrega estas tres palabras clave a todos los sombreadores:
UNITY_HARDWARE_TIER1
UNITY_HARDWARE_TIER2
UNITY_HARDWARE_TIER3
Luego genera variantes de sombreado para cada uno de los niveles de gráficos definidos. Si no está utilizando niveles de gráficos y desea evitar las variantes relacionadas con ellos, debe asegurarse de que todos los niveles de gráficos estén configurados exactamente con la misma configuración para que Unity omita estas variantes.
Como se mencionó anteriormente, Unity intentará deduplicar variantes que sean idénticas, por lo que si, por ejemplo, dos de los tres niveles tienen la misma configuración, esto generará una reducción en el tamaño del disco, aunque se seguirán generando todas las variantes. Opcionalmente, puede forzar a Unity a generar variantes de nivel para una API de renderizador de gráficos y sombreador determinada, utilizando hardware_tier_variants como se muestra a continuación:
// Direct3D 11/12
#pragma hardware_tier_variants d3d11
Unity compila un conjunto de variantes de sombreador para cada API de gráficos incluida en su compilación, por lo que en algunos casos es beneficioso seleccionar manualmente las API y excluir las que no necesita.
Para hacerlo, vaya a Configuración del proyecto > Reproductor. De forma predeterminada, se selecciona la API de gráficos automáticos y Unity incluirá un conjunto de API de gráficos integradas y elegirá una en tiempo de ejecución según las capacidades del dispositivo. Por ejemplo, en Android, Unity intentará usar Vulkan primero y, si el dispositivo no lo admite, el motor recurrirá a GLES3.2, GLES3.1 o GLES3.0 (aunque las variantes serán idénticas en esas versiones de GLES).
En su lugar, deshabilite la API de gráficos automáticos para la plataforma correspondiente y seleccione manualmente las API que desea incluir. Unity entonces dará prioridad al primero de la lista.

La desventaja es que podrías limitar la cantidad de dispositivos compatibles con tu juego, así que asegúrate de saber lo que estás haciendo al cambiar esto y prueba en una variedad de dispositivos.
Normalmente, en tiempo de ejecución, Unity intenta cargar la variante más cercana al conjunto de palabras clave solicitadas si no hay una coincidencia exacta disponible o se ha eliminado de la compilación del reproductor. Si bien esto es conveniente, también oculta posibles problemas con la configuración de las palabras clave del sombreador.
Desde Unity 2022.3, puedes seleccionar Coincidencia estricta de variantes de sombreador en Configuración del proyecto > Reproductor para garantizar que Unity solo intente cargar la coincidencia exacta para la combinación de palabras clave locales y globales que necesitas.

Si no se encuentra, utilizará el Shader de errores e imprimirá un error en la consola que contiene el shader, el índice del subshader, el pase real y las palabras clave solicitadas. Esto es muy útil cuando necesitas rastrear variantes faltantes que realmente necesitas. Como es habitual con el stripping, esto sólo funciona en el reproductor y no tiene ningún impacto en el editor.
Mientras juegas en el Editor, Unity realiza un seguimiento de qué sombreadores y variantes están actualmente en uso en tu escena y te permite exportarlos a una colección. Para hacerlo, navegue a Configuración del proyecto > Gráficos. En la parte inferior, verás una sección de Carga de sombreadores, que muestra cuántos sombreadores están actualmente rastreados como activos.
Asegúrate de presionar Borrar de antemano para tener una muestra más precisa, luego ingresa al modo Jugar e interactúa con tu escena, asegurándote de encontrar todos los elementos del juego que requieren sombreadores específicos. Esto aumentará los contadores rastreados. Luego, presione el botón “Guardar en activo…” para guardar todos ellos en un activo de colección.

Las Collections de variantes de sombreador son activos que contienen una lista de sombreadores y variantes relacionadas. Se utilizan comúnmente para predefinir qué variantes desea incluir en su compilación y para precalentar los sombreadores.

Un enfoque utilizado en algunos proyectos es ejecutar esto para cada nivel del juego, guardando una colección para cada uno de ellos y luego eliminando cualquier variante que no esté presente en ninguna de esas listas mediante un script IPreprocessShaders (cubierto en la siguiente sección). Si bien esto es conveniente, en mi experiencia también es bastante propenso a errores. Es difícil garantizar que encuentres todas las variantes necesarias en una sola partida, y algunas de las características podrían cargarse solo en el dispositivo y en casos específicos, lo que da como resultado una lista que no es necesariamente precisa. A medida que el juego cambia y se agregan nuevos elementos a los niveles o cambian los materiales, será necesario actualizar las colecciones. Por este motivo, lo utilizaría principalmente para fines de depuración e investigación, en lugar de integrarlo directamente en su proceso de compilación.
Cada vez que un sombreador esté a punto de compilarse en la compilación de tu juego, Unity enviará una devolución de llamada. Esto sucede tanto en las compilaciones de paquetes de jugadores como de paquetes de activos. Podemos escucharlos convenientemente usando IPreprocessShaders.OnProcessShader y IPreprocessComputeShaders.OnProcessComputeShader (para sombreadores de cómputo) y agregar lógica personalizada para eliminar variantes innecesarias. De esta manera, podemos reducir en gran medida el tiempo de compilación, el tamaño de la compilación y la cantidad total de variantes que entran en la compilación.
Para ello, cree un script que implemente la interfaz IPreprocessShaders y luego escriba su lógica de eliminación dentro de OnProcessShader. Por ejemplo, aquí hay un script que eliminará todas las variantes que contengan la palabra clave del sombreador DEBUG en las compilaciones de lanzamiento:
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);
}
}
}
}
El orden de devolución de llamada le permite definir qué script de preprocesamiento debe ejecutarse primero, lo que le permite crear pases de eliminación de varios pasos. Los scripts con menor prioridad se ejecutarán primero.
Visita el foro de discusión Graphics-Shaders para obtener más información.