Search Unity

Unity Shader Variants Optimization & Troubleshooting Tips

May 28, 2024 in Engine & platform | 15 min. read
A desert oasis scene from Unity’s URP 3D sample
A desert oasis scene from Unity’s URP 3D sample
Share

Is this article helpful for you?

Thank you for your feedback!

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 we use variants, as opposed to keeping the branching paths all in one shader, is because GPUs are great at parallelizing code that is predictable and always follows 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, since 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. At runtime, Unity loads 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 spot, since developers and artists could introduce seemingly safe changes that actually lead to a significant increase in shader variants, without any obvious way to detect 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 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, 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, based on all possible combinations of shader_feature and multi_compile keywords used in your shader, among other factors. 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 which keywords are defined in a shader, you can select it and check the Inspector.

Keywords from the Shader Inspector view
Keywords from the Shader Inspector view

As you can see, keywords are divided into Overridable and Not Overridable. Local keywords (the ones defined 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 they are overridable. Since 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

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, the OpenGL ES and Vulkan 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 optimizations.

Keywords from the Shader Inspector view
Keywords from the Shader Inspector view

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 display a temp file with some additional debug information about 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 Render 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.

Show generated code option for a surface shader Alt text: Enabling the Show generated code option for a surface shader
Show generated code option for a surface shader

Determining which 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 the 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, since 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 which 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:

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

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, 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)

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

If you are using the Universal Render Pipeline (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.

Setting Log Level in the URP Global Settings
Setting Log Level in the URP Global Settings

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

Determining which variants are used at runtime

In order to understand which shaders are actually compiled for the GPU at runtime, you can enable the Log Shader Compilation option, under Project Settings > Graphics.

Enabling Log Shader Compilation in the Graphics Project Settings
Enabling Log Shader Compilation in the Graphics Project Settings

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

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 which 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 optimizing.

Overview of shaders in the Memory Profiler
Overview of shaders in the Memory Profiler

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 Render Pipeline or URP.

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

Graphics Shader Stripping Settings
Graphics Shader Stripping Settings

Setting them to Automatic lets Unity determine which 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 URP, some of these options will be hidden. Instead, you’ll be able to define what features your game requires directly in the Pipeline Settings asset.

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

URP provides more granular control on which features you want to include in your game, potentially resulting in more optimized builds with fewer unused variants.

Stripping based on graphics tiers

Note: This is only relevant when using the Built-in Render 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.

Graphics tier settings
Graphics tier settings

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

UNITY_HARDWARE_TIER1

UNITY_HARDWARE_TIER2

UNITY_HARDWARE_TIER3

It then generates shader variants for each of the graphics tiers defined. If you are not using graphics tiers and want to avoid the related variants for them, you need to ensure that all graphics tiers are set to exactly the same settings so that 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 

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, disable 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.

Disable Auto Graphics API to select your preferred APIs
Disable Auto Graphics API to select your preferred APIs

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 to ensure that Unity only tries to load the exact match for the combination of local and global keywords you need.

Enable Strict Shader Variant Matching in Project Settings
Enable Strict Shader Variant Matching in Project Settings

If not found, it will use the Error Shader and 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 in the Editor, Unity keeps track of which shaders and variants are currently in use in your scene and allows you to export that into a collection. To do so, 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 engage 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.

The Save to asset button
The Save to asset button

Shader Variants Collections are assets containing a list of shaders and related variants. They are commonly used to predefine which variants you want included in your build and to prewarm shaders.

Adding a shader to a Shader Variants Collection
Adding a shader to a Shader Variants Collection

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 (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 in 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:

Script to strip all variants containing the DEBUG shader keyword
Script to strip all variants containing the DEBUG shader keyword

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

Visit the Graphics-Shaders forum discussion to learn more.

May 28, 2024 in Engine & platform | 15 min. read

Is this article helpful for you?

Thank you for your feedback!

Related Posts