Writing Shader Code for the Universal RP

2020/09 10 14:09

HLSL

The actual shader code is written in HLSL (High level shading language) inside each ShaderLab Pass.

Sometimes you’ll also see it referred to as “cg” as well. Both Cg and HLSL (DX9-style at least) are treated basically as the same language, but cg is deprecated and has been for several years. Even so, in built-in pipeline shaders you’ll commonly still see code between CGPROGRAM and ENDCG (and CGINCLUDE). These tags automatically include some of the Builtin-Include files, like HLSLSupport.cginc and UnityShaderVariables.cginc.

However, If you use these CG tags in URP they will cause conflicts with the URP ShaderLibrary as many of the variables and functions will be defined twice. URP should use HLSLPROGRAM/HLSLINCLUDE and ENDHLSL instead as they don’t include these files.

SCALAR VARIABLES

Variables in HLSL commonly consist of these scalar data types :

  • bool – true or false.
  • float – 32 bit floating point number. Generally used for world space positions, texture coordinates, or scalar computations involving complex functions such as trigonometry or power/exponentiation.
  • half – 16 bit floating point number.  Generally used for short vectors, directions, object space positions, colours.
  • double – 64 bit floating point number. Cannot be used as inputs/outputs, see note here.
  • fixed – Used in built-in shaders only, not supported in URP, use half instead.
  • real – Used in URP only? I think this just defaults to half (assuming they are supported on the platform), unless the shader specifies “#define PREFER_HALF 0″, then it will use float precision.
  • int – 32 bit signed integer
  • uint – 32 bit unsigned integer (except GLES2, where this isn’t supported, and is defined as an int instead).
VECTOR

A vector can be produced by appending an integer from 1 to 4 to one of these scalar data types. For example :

  • float4 – (A vector containing 4 floats)
  • half3
  • int2, etc

In order to get one of the components of a vector, we can use .x, .y, .z, or .w (as well as .r, .g, .b, .a instead, which might make more sense for colours). We can also obtain another vector by using these multiple times, even rearranged. This is refereed to as swizzling:

float3 vector = float3(1, 2, 3);
float3 a = vector.xyz;  // (1, 2, 3), same as vector.rgb
float3 b = vector3.zyx; // (3, 2, 1), vector.bgr
float3 c = vector.xxx;  // (1, 1, 1), vector.rrr
float2 d = vector.zy;   // (3, 2), vector.bg
float4 e = vector.xxzz; // (1, 1, 3, 3), vector.rrbb
float f = vector.y;     // 2, vector.g
 
// Note that something like "vector.rx" is not allowed.
// Use vector.rr or vector.xx instead.
MATRIX

A matrix can be produced by appending two integers between 1 and 4 to the scalar, separated by an “x”. The first integer is the number of rows, while the second is the number of columns in the matrix :

  • float4x4 – 4 rows, 4 columns
  • int4x3 – 4 rows, 3 columns
  • half2x1 – 2 rows, 1 column
  • float1x4 – 1 row, 4 columns

A vector of one of the rows in the matrix can be obtained via [index], and obtaining one of the components of that vector can also be obtained via [index] again.

float3x3 matrix = {0,1,2,
                   3,4,5,
                   6,7,8};
float3 row0 = matrix[0]; // (0, 1, 2)
float3 row1 = matrix[1]; // (3, 4, 5)
float3 row2 = matrix[2]; // (6, 7, 8)
float row1column2 = matrix[1][2]; // 5
// Note we could also do
float row1column2 = matrix[1].z;

Matrices are commonly used for transformation between different coordinate spaces. To do this we need to do matrix multiplication, which can be done using the mul function (rather than the * operator which won’t work for a matrix and vector type). For example, transforming from Object space to World space is achieved by :

mul(GetObjectToWorldMatrix(), float4(positionOS, 1.0)).xyz;
// Where GetObjectToWorldMatrix() returns "UNITY_MATRIX_M"
// Which is the model matrix that Unity passes in for the object

This is what the TransformObjectToWorld function does. Note that the order of the matrix and vector is important in the mul(x, y) function. If the first input is a vector, it treats it as a row vector (1 row, n columns), while in the second input it treats it as a column vector (n rows, 1 column). For example, a float3 in the first mul slot, is the same as a float1x3. However in the second slot, it would be the same as a float3x1.

The inputs must also have the same number of columns in the first input as rows in the second, and the dimensions of the result can vary depending on the inputs. The result will have as many rows in the first input and columns as the second input, so for mul(float4x4, float4 (second input, so column vector, float4x1)), the result is a float4x1, aka a float4.

ARRAYS

An array can be specified in a shader, although they aren’t supported in the Shaderlab Properties or material inspector and must be set from a C# script. The size of the array must be specified in the shader, and it should remain constant to prevent issues. If you don’t know the size the array needs to be, you need to set a maximum and pass in the array padding with 0s. You can specify another float as a length for when you need to loop over the array, like the example here.

float _Array[10]; // Float array
float4 _Array[10]; // Vector array
float4x4 _Array[10]; // Matrix array

When setting the float array, use material.SetFloatArray or Shader.SetGlobalFloatArray. There is also SetVectorArray and SetMatrixArray and their global versions too.

OTHER TYPES

HLSL also includes other types such as Textures and Samplers, which can be defined using the following macros in URP :

TEXTURE2D(textureName);

SAMPLER(sampler_textureName);

There is also Buffers, though I’ve never actually used them so am not that familiar with how they are used. They are set from C# using material.SetBuffer or Shader.SetGlobalBuffer.

#ifdef SHADER_API_D3D11
StructuredBuffer<float3> buffer;
#endif
// I think this is only supported in Direct3D 11?
// and also require #pragma target 4.5 or higher?
// see https://docs.unity3d.com/Manual/SL-ShaderCompileTargets.html

You may also want to look into other parts of HLSL such as flow control (if, for, while, etc), But the syntax is basically the same as C# if you are familiar with that. You can also find a list of all operators supported by HLSL here

FUNCTIONS

Declaring functions in HLSL is fairly similar to C#. Here’s an example :

float3 example(float3 a, float3 b){
    return a * b;
}

Where float3 is the return type, example is the function name and inside the brackets are the parameters passed into the function. In the case of no return type, void is used. You can also specify output parameters using “out” before the parameter type, or “inout” if you want it to be an input that you can edit and pass back out.

You may also see “inline” before the function return type. This is the default modifier and is the only modifier a function can actually have, so it’s not important to specify it. It means that the compiler will generate a copy of the function for each call. This is done to reduce the overhead of calling the function.

You may also see functions like :

#define EXAMPLE(x, y) ((x) * (y))

This is called a macro. Macros are handled before compiling the shader and when used are replaced with the definition with the parameters substituted. For example :

float f = EXAMPLE(3, 5);
float3 a = float3(1,1,1);
float3 f2 = EXAMPLE(a, float3(0,1,0));
 
// becomes :
float f = ((3) * (5));
float a = float(1,1,1);
float3 f2 = ((a) * (float3(0,1,0)));
// then the shader is compiled.
 
// Note that the macro has () around x and y.
// This is because we could do :
float b = EXAMPLE(1+2, 3+4);
// becomes :
float b = ((1+2) * (3+4)); // 3 * 7, so 21
// If those () wasn't included, it would instead be :
float b = (1+2*3+4)
// which equals 11 due to * taking precedence over +

They can also do some things that functions can’t do. For example :

#define TRANSFORM_TEX(tex,name) (tex.xy * name##_ST.xy + name##_ST.zw)
 
// Usage :
OUT.uv = TRANSFORM_TEX(IN.uv, _MainTex)
 
// becomes :
OUT.uv = (IN.uv.xy * _MainTex_ST.xy + _MainTex_ST.zw);

The “##” operator is a special case where macros can be useful. It allows us to concatenate the name and _ST parts, resulting in _MainTex_ST for this usage input. If the ## part was left out, it would just produce “name_ST”, resulting in an error since that hasn’t be defined. (Of course, _MainTex_ST still also needs to be defined, but that’s the intended behaviour, as appending _ST to the texture name is how Unity handles the tiling and offset values for a texture).

BEGINNING THE SHADER

Shaders commonly consist of two stages, a vertex shader and fragment shader. In short, a vertex shader runs for each vertex in the mesh, while fragment runs for every pixel that will end up on the screen. Some fragments can be discarded, so don’t become actual pixels, e.g. In alpha clip/cutout shaders & stencil shaders. There are also additional shaders such as hull/domain (for tessellation) and geometry shaders, but I won’t be going over them here – as far as I can tell they work the same way as in the built-in pipeline.

In the Shaderlab example, we had a HLSLINCLUDE which automatically includes the code in every Pass inside the Subshader. Using this isn’t required, but is helpful as we can ensure the UnityPerMaterial CBUFFER is the same for every pass – which is required to make the shader compatible with the SRP Batcher. This CBUFFER needs to include all of the exposed properties (same as in the Shaderlab Properties block). It cannot include other variables that aren’t exposed, and textures don’t need to be included.

Note : While variables don’t have to be exposed to set them via the C# material.SetColor/SetFloat/SetVector etc, if multiple material instances have different values, this can produce glitchy behaviour as the SRP Batcher will still batch them together when on screen. If you have variables that aren’t exposed – always set them using Shader.SetGlobalColor/Float/Vector etc, so that they remain constant for all material instances. If they need to be different per material, expose them via the Shaderlab Properties block and add them to the CBUFFER instead.

HLSLINCLUDE
    #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
 
    CBUFFER_START(UnityPerMaterial)
    float4 _BaseMap_ST;
    float4 _BaseColor;
    //float4 _ExampleDir;
    //float _ExampleFloat;
    CBUFFER_END
ENDHLSL

We are also including Core.hlsl from the URP ShaderLibrary using the #include as shown above. This is the URP-equivalent of the built-in pipeline UnityCG.cginc. Core.hlsl (and other ShaderLibrary files it automatically includes) contain a bunch of useful functions and macros (which are similar to functions, but are handled before the shader is compiled).

One of the first things we need to do in our HLSLPROGRAM is specify the vertex and fragment shaders. We’ll put names for each of our functions here, commonly “vert” and “frag” is used, but they can be anything.

HLSLPROGRAM
 
#pragma vertex vert
#pragma fragment frag
 
...
 
ENDHLSL

Before we define these functions, we’ll need some structs, which I’ll be going over in the next section.

There are also some ShaderLibrary files which aren’t included, that we might want to include, such as Lighting.hlsl. For now this example is an Unlit shader so we won’t be needing that, but in a later section we’ll go over a Lit example too.