Unity Rendering Principle (4) Unity Shader Basic Concepts

The basic content of the rendering pipeline has been introduced in front, and then the parts that we can program are introduced. Before we start, we need to introduce some basic concepts and briefly explain some of the properties.

Shader

Shader Type

Shaders in Unity are divided into three large types, each used for different purposes:

  • Shader: It is part of the rendering pipeline and the most commonly used type of Shader, which is used to calculate the pixel value of each point on the screen. This shader is divided into various types
    • Vertex shader
    • Element shader
    • Surface shaders (Unity specific, vertex shaders and slice shaders are compiled)
  • Compute Shader: The calculation is performed in the GPU, but the process is not in the graphics pipeline.
  • Ray Tracing Shader: Performs calculations related to ray tracing.

Glossary

  • Shader/Shader Program: A program that runs on the GPU, unless otherwise specified, refers to the ordinary Shader that is part of the rendering pipeline.
  • Shader Object: An instance of a Shader Class that encapsulates the Shader Program and other information.
  • Shader Lab: a dedicated Shader language packaged by Unity
  • Shader Graph: A tool for visually creating Shaders
  • Shader asset: A file in the Unity project with the extension “.shader” that defines a Shader Object
  • Shader Graph asset: a file in the Unity project that defines a Shader Object

Shader

In Unity, when we use a Shader as part of a pipeline, we are using an instance of a Shader Class, the Shader Object.
Shader Object is a way of working with Shader Programs for Unity. It encapsulates Shader Programs and other related information. It can define multiple Shader Programs in the same file and tell Unity how to use them.

Shader

A Shader Object contains the Shader Program, instructions to modify GPU settings (i.e. render state), and information about how Unity uses them.

There are two ways to create a new Shader Object:

  • Create a Shader asset by writing code (a text file with the extension “.shader”)
  • Create a Shader Graph asset using Shader Graph
    Either way, the result is the same.

Shader

** A Shader Object has a nested structure that organizes the Shader Program into Shader Variants and the internal information into structures called SubShader and Pass. **

A Shader Object contains:
Information about itself, such as its name

  • Optional fallback Shader Object for Unity to call when it is not available
  • One or more SubShaders

We can also define some additional information, such as shared Shader code, etc

SubShader

SubShader allows us to split the Shader Object into multiple parts to accommodate different hardware, pipeline, and runtime settings

A SubShader contains:

  • SubShader applicable hardware, pipeline and runtime settings
  • SubShader Tag, a set of Attributes that provide information about SubShader - Value Pair
    One or more passes.

We can also define some additional information, such as the render state that applies to all affiliated passes.

Pass

A pass includes:

  • Pass Tag, Attribute that provides Pass-related information - Value Pair
  • Command to update the render state before running the Shader program to which Pass belongs
  • Shader Program, which is then organized into Shader variants
    We can also define some additional information, such as the name of the Pass

Shader

Shader Programs in Pass are organized into Shader Variants, which share common code but have different capabilities when enabling or disabling a given keyword.

The number of shader variants in Pass depends on the keywords and target platform defined in the shader code. Each Pass contains at least one variant

The call flow of Shader during rendering

Before using a Shader Object, Unity will create a list of SubShaders in the Shader Object, including self-defined and fallback

When Unity renders geometry for the first time or the LOD value of the Shader changes or the rendering pipeline settings change, you need to decide which SubShader to take from the Shader Object:

  • Unity will first traverse all SubShaders in the list to find the SubShader that is suitable for the current device, less than or equal to the current LOD value, and matches the pipeline settings
  • If there are multiple SubShaders that meet the above requirements, then choose the first one that meets the criteria
  • If there is no eligible SubShader, then
    • If it meets the requirements of the device but does not meet the requirements of LOD or pipeline settings, then select one of these SubShaders
    • If any requirements are not met, then an error will be reported
      Unity will recognize geometries using the same Shader variant and then package them into batches for rendering
  • Unity determines which pass in the current SubShader it should render, and at which point in the frame. This behavior varies depending on the render pipeline
  • Each pass it will render:
    • If the current render state is different from the render state in Pass, change it to the render state in Pass
    • GPU renders with associated Shader Variants

Shader compiling

Every time you build a project, the Unity editor compiles all the shaders needed to build: for each desired graphics API, compile each desired shader variant.
When you work in the Unity editor, the editor does not compile everything in advance. This is because it can take a long time to compile each variant for each graphics API.
Instead, the Unity editor does this:

  • When importing a shader resource, some minimal processing is performed (such as surface shader generation).
  • It checks the Library/ShaderCache folder when shader variants need to be displayed.
  • If a previously compiled shader variant using the same source code is found, that shader variant will be used.
  • If no match is found, compile the desired shader variant and save the result to the cache.
    Note: If you enable asynchronous shader compile, it does this in the background and displays the placeholder shader at the same time.
    Shader compile uses a process called UnityShaderCompiler. Multiple UnityShaderCompiler compile processes can be started (usually one for each CPU core in the machine), so that shader compile can be completed in parallel when the player is built. When the editor does not compile the shader, the compiler process does not perform any operations and does not consume computer resources.
    If there are many shaders that change frequently, the Shader Cache folder can become very large. Deleting this folder is safe; it will only cause Unity to recompile the shader variants.
    When the player builds, all shader variants that are “not yet compiled” will be compiled, so they will exist in the game data even if the editor will not use them.

Stripping at build time

When building a game, Unity may detect that the game does not use certain internal shader variants and exclude (“strip”) them from the build data. Stripping at build time will be used for the following:

  • For shaders shader_feature with #pragma, Unity automatically checks if a variant is used. If none of the materials in the build use a variant, the variant will not be included in the build. See the internal shader variant doc. Standard shaders use this feature.
  • Shader variants that handle fog and lightmap modes that are not used by any scene will not be included in the game data. If you want to override this behavior, see the Graphics window.
    You can also manually identify variants and use the OnProcessShader API to tell Unity to exclude them from the build.
    The combination of the above usually greatly reduces the shader data size. For example, a fully compiled standard shader will occupy several hundred megabytes, but in a typical project, it usually ends up occupying only a few megabytes (and is usually further compressed by the application packaging process).

Shader compile asynchronously

A Shader object can compile hundreds of Shader Variants. If Unity had to compile all of them every time it loaded a Shader object, the editor might get stuck. And actually, Unity compiles the Variants on demand, using a Placeholder Shader object.

The working principle of asynchronous shader compile is:

  1. When the editor first encounters an uncompiled shader variant, it adds the shader variant to the compile queue on the job thread. The Progress Bar in the lower right corner of the editor displays the status of the compile queue.
  2. When loading the shader variant, the editor renders the geometry using a placeholder shader, which appears as pure cyan.
  3. When the editor has finished compiling the shader variants, it will use the shader variants to render the geometry.

There are also some specific methods to switch this asynchronous compile through settings and code, which will not be repeated. You can take a look.异步着色器编译的官方文档

Branches, Variants and Keywords in Shader

Sometimes, we want the same shader to behave differently in different situations. When this happens, you use conditions to define different behaviors for different hardware.

This section of the manual contains information about the color fixer variants and keywords that work and when and how to use them.

PageDescription
Conditionals in shadersAn introduction to conditionals in shaders, including information on the different types of conditional, and when to use which one.
Shader branchingAn introduction to static and dynamic branching in shaders.
Shader variantsAn introduction to shader variants, and information on how to understand and control how many shader variants Unity compiles.
Shader keywordsAn introduction to shader keywords, and information on how to use them.
Using shader keywords with C# scriptsWorking with shader keywords in C# scripts.
Using shader keywords with the material InspectorWorking with shader keywords in the Unity Editor, using the material Inspector.
着色器变体集合An introduction to shader variant collections, and information on how to use them.

Conditionals

Sometimes, we want to make the same Shader behave differently in different situations, for example, configuring different settings for different materials, defining different functions for different hardware, or dynamically modifying the Shader’s functions at runtime. Or in some cases avoid running complex calculations, such as texture reading.

We have three different ways to make GPUs behave differently in different situations.

  • Static branching: generate different shaders according to different conditions during the compile stage of the shader
  • Dynamic branching: GPU selects different branches based on conditions at runtime
  • Shader variants: Unity uses static branches to compile the source code of Shader into multiple shader codes. Unity selects different shader codes according to different conditions at runtime

These three can actually be divided into two types, static conditions and dynamic conditions, because the Shader variant actually uses static conditions, but the static branch eliminates the unused code according to the static conditions in the compile stage, and the variant compiles a variant of each different condition in the compile stage

How to choose between these three methods, you can take a look官方文档

Simply put, if you know the conditions that need to be met in advance, use static branching. If you don’t know, choose one of the dynamic branching or shaer variants. Dynamic branching is selected during GPU runtime, so it will affect GPU performance. Shader variants It will increase the package size.

Shader

Shader variants, sometimes called shader permutations, are a way to introduce conditional behavior into shader code. Unity compiles shader source files into shader programs. Each compiled shader program has one or more variants: different shader program versions apply to different conditions. At runtime, Unity uses variants that match the current requirements. You can use shader keywords to configure variables.

Shaders with a large number of variants are called mega shaders or uber shaders. Unity’s standard shader is an example of such a shader.

Number of variants generated

At build time, Unity compiles a set of shader variants for each graph API of the current build target. The number of variants per graph API and build target combination depends on your shader source files and your use of shader keywords.

  1. Unity compiles a set of shader variants for each graph API in the current build target list. Shaders are different for each combination of build target and graph API; for example, Unity compiles different shaders for Metal on iOS and macOS. Some shaders or keywords may only target a given graph API or a given build target, so the total number of variants for each combination of graph API and build target may differ; however, the process of compiling these variants is the same.

  2. Unity must decide how many Shader Programs to compile for the current build target and graphics API combination. For each shader source file included in your build, Unity determines how many unique Shader Programs it defines:

  • A Computational Shader Asset defines a separate Shader Program.
  • In a hand-coded shader, the number of Shader Programs depends on your code. The total includes:
    • all shader stages in all channels in the source file itself. For example, each vertex stage defines a Shader Program;
    • Define a Shader Program for each fragment phase; this includes all fallback shaders, as well as all channels included using the UsePass command
  • In Shader Graph Shader, the number of Shader programs depends on the code Unity generates from your graph. To view the shader code generated by Unity, click on the shader Graph asset and select see generated code. You can then determine the total number of shader programs, just like manually coding shaders.
  1. keywords affect the number of variants
    ** When Unity determines how many shader programs it must compile for the current build target and graphics API, it determines how many shader variants it must compile for each shader program. **

For each shader program, Unity decides on the combination of shader keywords that produce different variants. This includes:

For example, this set contains three shader variant keywords:

COLOR_RED
COLOR_GREEN
COLOR_BLUE
This set contains four shader variant keywords:

QUALITY_LOW
QUALITY_MEDIUM
QUALITY_HIGH
QUALITY_ULTRA
A shader program affected by those shader variant keywords will result in the following twelve variants:

COLOR_RED and QUALITY_LOW
COLOR_RED and QUALITY_MEDIUM
COLOR_RED and QUALITY_HIGH
COLOR_RED and QUALITY_ULTRA
COLOR_GREEN and QUALITY_LOW
COLOR_GREEN and QUALITY_MEDIUM
COLOR_GREEN and QUALITY_HIGH
COLOR_GREEN and QUALITY_ULTRA
COLOR_BLUE and QUALITY_LOW
COLOR_BLUE and QUALITY_MEDIUM
COLOR_BLUE and QUALITY_HIGH
COLOR_BLUE and QUALITY_ULTRA

When you add more shader variable keyword sets, the number of variables in Unity compile can grow rapidly. The term for this rapid growth is combinatorial explosion. For example, consider a fairly typical use case where a shader has many shader variant keyword sets, each containing two keywords (< feature name > ON and < feature name > OFF). If the shader has two such keyword sets, this will result in four variables. If the shader has 10 sets of such keywords, this will result in 1024 variants

Deduplication of shader variants

After compiling, Unity automatically recognizes the same variables in the same Pass and ensures that these same variables point to the same bytecode. This is called deduplication. Deduplication prevents the same variables in the same Pass from increasing the file size; however, the same variant can still cause wasteful work during compile and increase memory usage and shader load time at runtime. With this in mind, it is best to remove unwanted variables.

How to optimize pack volume from a Shader variant perspective

There is an article well written, record it: https://blog.unity.com/technology/stripping-scriptable-shader-variants

Shader

The Shader keyword allows us to use conditional behavior code in shader code. We can create some common code, but the behavior and function of these codes will vary depending on the keyword.

We can define keywords in a collection, a collection contains keywords that contain mutual exclusion, for example:

  • COLOR_RED
  • COLOR_GREEN
  • COLOR_BLUE

The way we define the shader keyword affects a few things:

  • type: affects how unity creates shader variants for keywords
  • scope: will affect whether the keyword is local or global, which determines whether we can modify this keyword at runtime
  • stage: the stage that affects the influence of the shader keyword (vertex shading stage, slice element shading stage, etc.)

type:multi

Type is divided into two types, one is multi compile and the other is shader feature. Unity uses these to create a collection of keywords to work with shader variants. These keywords are used within Unity to create ‘#define’ preprocessors

  • multi compile: defines a collection of keywords to use with shader variants, unity compiles variants for each keyword in the collection
  • shader feature: defines a set of keywords to be used with shader variants, and also instructs the compiler to compile variants when no keyword is enabled (unity will detect the state of each keyword at packaging time, And compile variants only for the keyword used. Whether a keyword is used depends on whether the material using it has enabled the keyword)

The choice of “multi compile” or “shader feature” depends on how the keyword is used. If you use the keyword to configure material in your project and do not change its value from a C #script at runtime, you should use “shader feature” to reduce the number of shader keywords and variants in your project. If you use a C #script to enable and disable keywords at runtime, you should use “Multi Compile” to prevent variants from being stripped.

Note: If you put a shader in "Always

scope:local

When you declare a set of keywords, you choose whether the keywords in the collection have local or global scope. This determines whether you can use the global shader keyword to override the state of this keyword at runtime.

By default, you can declare a keyword using global scope. This means that you can override the state of this keyword at runtime using global shader keywords. If you declare a keyword with local scope, this means that you cannot override the state of this keyword at runtime using global shader keywords

If a keyword with the same name exists in the shader source file and its dependencies, the scope of the keyword in the source file overrides the scope in the dependencies

stage

By default, Unity generates keyword variants for each stage of the shader. For example, if your shader contains a vertex stage and a fragment stage, Unity generates variants for each keyword combination for both the vertex and fragment shader program. If only one set of keywords is used in one stage, it will result in the same variant for the other stage.
Unity automatically recognizes and repeats denaturation to avoid increasing build size, but still results in wasted compile time, increased shader load time, and increased runtime memory usage.

To avoid this problem, when you declare a set of keywords in a hand-coded shader, you can instruct Unity to compile only at a given shader stage. You are then responsible for ensuring that keywords are only used in the specified shader stage.

The graphics API does not fully support stage-specific keywords. In OpenGL and Vulkan, Unity automatically converts all stage-specific keyword directives into regular keyword directives when compiling. In Metal, any keyword targeting the vertex stage will also affect the tessellation stage and vice versa.

shader

We can set the keyword enable or disable through code or material inspector. For details, please refer to the official doc:Using shader keywords with C# scripts, Using shader keywords with the Material Inspector