DEV Community

Attilio Carotenuto
Attilio Carotenuto

Posted on • Edited on

Unity Shader Variants Optimisation and Troubleshooting

Note: This blogpost is also available on Unity's official blog: https://unity.com/blog/engine-platform/shader-variants-optimization-troubleshooting-tips

When writing shaders in Unity, we conveniently have the ability to include multiple features, passes and branching logic in a single source file. At build time, shader source files are compiled into shader programs, which contain one or more variants. A variant is a version of that shader following a single set of conditions, resulting (in most cases) in a linear execution path without static branching conditionals.

The reason why we use variants, as opposed to keeping the branching paths all in one shader, is because GPUs are great at parallelising code that is predictable and always follow the same path, resulting in higher throughput. If conditionals are present in the compiled shader program, the GPU will need to spend resources doing predictive tasks, waiting for the other paths to be completed and so on, introducing inefficiencies.

While this leads to significantly better GPU performance compared to dynamic branching, it also has some downsides. Build times will get longer as the number of variants increases, sometimes even by multiple hours per build. The game will also take longer to boot, as it will need to spend more time loading and prewarming shaders. Finally, you might notice significant runtime memory usage from shaders if variants are not properly managed, sometimes over 1GB.

The amount of variants generated increases depending on a variety of factors, including keywords and properties defined, Quality Settings, Graphics Tiers, enabled Graphics APIs, post-processing effects, the active Rendering pipeline, Lighting and Fog modes, and whether XR is enabled, among others. Shaders that result in a large number of variants are often called Uber Shaders. Unity then loads at runtime the variant that matches the required settings and keywords, as we’ll cover later.

This is particularly impactful when you consider that we often see shaders with over 100 keywords, leading to an unmanageable number of resulting variants, often referred to as Shader Variants Explosion. It’s not unusual to see shaders with an initial variant space in the millions, before any filtering is applied.

To alleviate this, Unity will try and reduce the amount of variants generated based on a few filtering passes. For example, if XR is not enabled, variants that are needed for that will normally be stripped. Unity then takes into account what features you’re actually using in your scenes, such as lighting modes, fog and so on. These are particularly tricky to detect, as developers and artists could introduce seemingly safe changes, which actually leads to a significant increase in shader variants, without any obvious way to detect it unless you put some safeguards in place as part of your deployment pipeline.

While this is helpful, this process is not perfect and there is a lot we can do to help Unity strip as many variants as possible without affecting the visual quality of your game.

Here, I’d like to share a few practical tips on how to handle variants, how to understand where they are coming from, and some effective ways to reduce them. Your project build time and memory footprint will greatly benefit as a result.

Understanding Keywords impact on variants

Shader variants are generated, among other factors, based on all possible combinations of shader_feature and multi_compile keywords used in your shader. Keywords marked as multi_compile are always included in your build, while those marked as shader_feature will be included if they are referenced by any material in your project. For this reason, you should use shader_feature whenever possible.

To see what keywords are defined in a shader, you can select it and the Inspector:

Image description

As you can see, keywords are divided into Overridable and Not Overridable. Local Keywords (the ones you define in the actual shader file) with a Global scope can be overridden by a global shader keyword with a matching name. If instead they are defined at a local scope (by using multi_compile_local or shader_feature_local), they can’t be overridden and will show up in the “Not overridable” section underneath. Global shader keywords are provided by the Unity engine, and are overridable. As they can be added at any point in the build process, not all global keywords might show up in this list.

Keywords can be defined in mutually exclusive groups, called sets, by defining them in the same directive. By doing this, you avoid generating variants for combinations of keywords that will never be enabled at the same time (such as two different types of lighting or fog).

#pragma shader_feature LIGHT_LOW_Q LIGHT_HIGH_Q

To reduce the amount of keywords per platform, you can use preprocessor macros to define them only for the relevant platform, for example:

#ifdef SHADER_API_METAL
   #pragma shader_feature IOS_FOG_FEATURE
#else
   #pragma shader_feature BASE_FOG_FEATURE
#endif
Enter fullscreen mode Exit fullscreen mode

Note that these expressions with macros cannot depend on other keywords or features that are not only related to the build target.

Keywords can also be limited to a specific pass, reducing the amount of potential combinations. To do so, you can add one of the following suffixes to the directive:
_vertex
_fragment
_hull
_domain
_geometry
_raytracing

For example:
#pragma shader_feature_fragment FRAG_FEATURE_1 FRAG_FEATURE_2

This can behave differently depending on the renderer you’re using. For example, on OpenGL, OpenGL ES and Vulkan the suffixes will be ignored.

You can use the directive #pragma skip_variants to define keywords that should be excluded when generating variants for that specific shader. When making your player build, all shader variants for that shader containing one of those keywords will be skipped.

You can also optionally define keywords using the #pragma dynamic_branch directive, which will force Unity to rely on dynamic branching and not generate variants for those keywords. While this reduces the amount of resulting variants, it can lead to weaker GPU performance depending on the shader and game content, so it’s recommended to profile accordingly when using it.

Inspecting generated Shader code

Normally, shader variants won’t be compiled until you actually build the game. Using this option, you can inspect the resulting shader variants for a specific build platform or graphics API. This allows you to check for errors ahead of time. In addition, you can paste the generated code into GPU shader performance analysis tools, such as PVRShaderEditor, for further optimisations.

Image description

At the bottom you will notice an entry saying how many variants are included, based on the materials present in the currently open scene, without any scriptable stripping applied. If you hit the Show button, it will show a temp file with some additional debug information on which keywords were used or stripped on various platforms, including the number of vertex stage variants.

The “Preprocess only” checkbox above allows you to toggle between compiled shader code and preprocessed shader source, for easier and faster debugging.

If you are using the Built-in Rendering Pipeline and working with a Surface Shader, you have the option to check the generated code that Unity will use to replace your simplified shader source when you build. You can then optionally replace your shader source with the generated code, if you’d like to modify the output.

Image description

Determining what Variants are generated at build time

When building the game, Unity will determine the variant space for each shader based on all possible permutations of its features, engine settings and other factors. These combinations are then passed to the preprocessors for multiple stripping passes. This can be extended using IPreprocessShaders callbacks, to create custom logic to strip more variants from build, as covered below.

Shaders that are included as part of Always-included shaders list, under Project Settings - Graphics, will have all their variants included in the build. For this reason, it’s best to use this only when strictly necessary, as it can easily lead to a large number of variants being generated.

Finally, the build pipeline will go through a process called Deduplication, identifying identical variants within the same Pass, and ensuring that they point to the same bytecode. This will result in reduced size on disk, but identical variants will still negatively affect build time, loading time and runtime memory usage, so it’s not a replacement for proper variants stripping.

After a successful build, we can look into the Editor.log file to collect some useful information on what shaders variants were included in the build. To do so, search the log file for “Compiling shader” and the name of your shader. Here’s for example how it looks like on Unity 2021:

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
Enter fullscreen mode Exit fullscreen mode

In certain cases, you might see the amount of variants increase after the settings filtering step, for example if your project has XR enabled.

If your game supports multiple Graphics APIs, as mentioned in the above section, you’ll also find information for each supported renderer:

Serialized binary data for shader GameShaders/MyShader in 0.00s
    gles3 (total internal programs: 290, unique: 193)
    vulkan (total internal programs: 290, unique: 193)
Enter fullscreen mode Exit fullscreen mode

Finally, you’ll see these compression logs that will give you an indication of the final size, on disk, of the shader for a specific Graphics API:

Compressed shader 'GameShaders/MyShader' on vulkan from 1.35MB to 0.19MB
Enter fullscreen mode Exit fullscreen mode

If you are using URP, you can select whether to have logs generated only from SRP shaders, from all Shaders, or to disable logs. To do so, select the Log Level from Project Settings - Graphics - URP Global Settings, as shown below:

Image description

In addition, if you select the “Export Shader Variants” option below, a JSON file will be generated after your build, containing a report of the shader variants compilations. This is available on Unity 2022.2 or newer.

Determining what Variants are used at runtime

In order to understand what shaders are actually compiled for the GPU, at runtime, you can enable the “Log Shader Compilation” option, under Project Settings - Graphics, as shown below:

Image description

This will cause your game to print in the player logs whenever a shader is compiled while you play. It will only work on Development builds and Debug mode, as described in the tooltip.

The format looks like this:

Compiled Shader: Folder/ShaderName, pass: PASS_NAME, stage: STAGE_NAME, keywords ACTIVE_KEYWORD_1 ACTIVE_KEYWORD_2
Enter fullscreen mode Exit fullscreen mode

Keep in mind that some platforms, such as Android, will cache compiled shaders. For this reason, you might need to uninstall and reinstall the game before doing a test pass to catch all compiled shaders.

Finally, you can use the Memory Profiler package to take a snapshot of your game while it’s running, and then have an overview of what Shaders are currently loaded in memory, and their size. Sorting by size normally gives a good indication of which Shaders are bringing in the most variants, and are worth optimising.

Image description

Stripping based on Graphics Settings

As part of the stripping passes, Unity will remove shader variants related to graphics features your game is not using. The process changes slightly if you are using the Built-in Rendering Pipeline, or the Universal Rendering Pipeline (URP).

To define those, Go to Project Settings - Graphics. From here, while using the Built-in Rendering Pipeline, you can select what Lightmap and Fog modes your game supports.

Image description

Setting them to Automatic will let Unity determine what variants to strip based on the scenes included in your build.

If you are unsure what features you are using, you can also use the “Import from Current Scene” button to let Unity figure out what features you need. Of course this is only helpful if all your scenes are using the same settings, so make sure to select a representative scene when using this option.

If you are using the Universal Rendering Pipeline (URP), some of those options will not be used and will be hidden. Instead, you’ll be able to define what features your game requires directly in the Pipeline Settings asset.

Image description

For example, unchecking “Terrain Holes” will cause all Terrain Holes Shader variants to be stripped, reducing build time as well.

As you can see, URP provides more granular control on what features you want to include in your game, potentially resulting in more optimised builds with fewer unused variants.

Stripping based on Graphics Tiers

Note: This is only relevant when using the Built-in Rendering Pipeline. These settings will be ignored when using a scriptable rendering pipeline such as URP.

Graphics Tiers are used to apply different graphics settings based on the hardware your game is running on (not to be confused with the Quality Settings). When the game starts, Unity will determine your device Graphic Tier based on hardware capabilities, Graphics API and other factors.

They can be set in Project Settings - Graphics - Tier Settings, as shown below:

Image description

Based on these, Unity adds these three keywords to all shaders:

UNITY_HARDWARE_TIER1
UNITY_HARDWARE_TIER2
UNITY_HARDWARE_TIER3

Then it generates shader variants for each of the Graphics tier defined. If you are not using Graphics tiers and want to avoid the related variants for them, you need to ensure all Graphics Tiers are set to exactly the same settings. Then, Unity will skip these variants.

As mentioned earlier, Unity will attempt to deduplicate variants that are identical, so if for example two of the three Tiers have the same settings, this will lead to a reduction in size on disk, even though all variants will still be generated.

You can optionally force Unity to generate tier variants for a given shader and graphics renderer API, using the hardware_tier_variants as shown below:

// Direct3D 11/12
#pragma hardware_tier_variants d3d11 
Enter fullscreen mode Exit fullscreen mode

Stripping based on Graphics APIs

Unity compiles one set of shader variants for each graphics API included in your build, so in some cases it is beneficial to manually select the APIs and exclude the ones you don’t need.

To do so, go to Project Settings - Player. By default, Auto Graphics API is selected, and Unity will include a set of built-in graphics APIs and pick one at runtime depending on the device capabilities. For example, on Android, Unity will try to use Vulkan first, and if the device does not support it, the engine falls back to GLES3.2, GLES3.1 or GLES3.0 (the variants will be identical on those GLES versions though).

Instead, uncheck Auto Graphics API for the relevant platform and manually select the APIs you’d like to include. Unity will then give priority to the first one in the list.

Image description

The downside is that you might limit the amount of devices that support your game, so make sure you know what you’re doing when changing this, and test on a variety of devices.

Strict Shader Variant Matching

Normally, at runtime Unity tries to load the variant that is closest to the set of keywords requested, if an exact match is not available or has been stripped from the player build. While this is convenient, it also hides potential issues with your shader keywords setup.

From Unity 2022.3, you can select Strict Shader Variant Matching in Project Settings - Player. This will ensure Unity only tries to load the exact match for the combination of local and global keywords you need.

Image description

If not found, it will use the Error Shader, and it will print an error in the Console, containing the shader, the subshader index, the actual pass and keywords requested. This is pretty handy when you need to track down missing variants that you actually need. As usual with stripping, this only works in the Player and has no impact in the Editor.

Exporting used variants into a Shader Variants Collection

While playing the game within the Editor, Unity keeps track of what shaders and variants are currently in use in your scene, and allows you to export that into a collection. To do that, navigate to Project Settings - Graphics. At the bottom you’ll notice a Shader Loading section, showing how many shaders are currently tracked as active.

Make sure to hit Clear beforehand to have a more accurate sample, then enter Play Mode and play around with your scene, ensuring that you encounter all game elements that require specific shaders. This will increase the tracked counters. Then, press the “Save to asset…” button to save all of those in a collection asset.

Image description

Shader Variants Collections are assets containing a list of shaders and related variants. They are commonly used to pre-define what variants you want included in your build, and to pre-warm shaders.

Image description

One approach used in some projects is to run this for every level of the game, saving a collection for each of them, then stripping any variants that are not present in any of those lists by using a IPreprocessShaders script (as covered in the next section). While this is convenient, in my experience it’s also fairly prone to errors. It’s hard to ensure that you encounter all required variants in a single playthrough, and some of the features might only be loaded on device and on specific cases, resulting in a list that is not necessarily accurate. As your game changes and new elements are added to the levels, or materials change, the collections will need to be updated. For this reason, I would use this mainly for debugging and investigation purposes, rather than integrating it into your build pipeline directly.

Scriptable Shader Variants Stripping

Whenever a shader is about to be compiled into your game build, Unity will dispatch a callback. This happens both on Player and Asset Bundles builds. We can conveniently listen to these using IPreprocessShaders.OnProcessShader and IPreprocessComputeShaders.OnProcessComputeShader (for Compute Shaders), and add custom logic to strip unnecessary variants. This way, we can greatly reduce build time, build size, and the total number of variants that get into your build.

To do so, create a script that implements the IPreprocessShaders interface, then write your stripping logic within OnProcessShader. For example, here is a script that will strip all variants containing the DEBUG shader keyword, on release builds:

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);
         }
      }
   }
}
Enter fullscreen mode Exit fullscreen mode

The callback order allows you to define which preprocessing script should run first, letting you create multi-steps stripping passes. Scripts with a lower priority will be executed first.

Top comments (0)