This chapter links to the course’s interactive demos and uses MathJax for its formulas. Read it through the course website (or download the file and open it in a browser); a Canvas inline preview will reach neither the demos nor the math. All chapters.

Unity Shaders: ShaderLab, the Two Programs, and the Data Between Them

Pat Hanrahan and Jim Lawson's 1990 shading language for RenderMan made a surface's appearance a program; programmable shaders reached consumer graphics cards about a decade later. Hanrahan and Lawson, A Language for Shading and Lighting Calculations, SIGGRAPH 1990

A shader is the program the GPU runs at two stages of the pipeline of the rasterization chapter: once per vertex, to place it, and once per covered pixel, to color it. The mathematics of what those programs compute is in the texture and illumination chapters. This chapter is about the container: the ShaderLab file that wraps the two programs, the semantics that name their inputs and outputs, the built-in matrices and the values a script uploads, and the fixed-function state (culling, depth, blending) set around them. It reads the class-example projects’ own shader, a no-cull unlit shader that grows a texture, then a second texture, then a checker computed from the coordinates, then a light, and works its arithmetic at one vertex and one pixel. It closes with the errors that produce a magenta object, a black object, or a silently wrong normal, and what each means.

Contents
  1. Where the shader sits
  2. A shader file, outside in
  3. The two programs and their semantics
  4. The matrices: built in, and your own
  5. Getting values in: properties and globals
  6. Textures in the shader
  7. Lighting in the shader, and the normal
  8. Render state: culling, depth, blending
  9. When it goes wrong
  10. Exercises

1 · Where the shader sits

The engine sends each mesh to the GPU as its vertex arrays and a material. The GPU runs the material’s vertex program on every vertex, producing a clip-space position and any values the pixel stage will need; the fixed rasterizer clips, divides, finds the covered pixels and interpolates those values across each triangle with the barycentric weights of the rasterization chapter; the material’s fragment program runs on every covered pixel, producing a color; and the fixed depth test and blend stage writes it. A shader file holds both programs and the fixed-function settings around them, and a material is that file plus values for its parameters. The class-example projects use the Built-in Render Pipeline and write the programs in Cg/HLSL, which is what this chapter shows; Unity’s Universal Render Pipeline uses HLSL with different include files and the same two stages.

A data-flow diagram: the Mesh feeds vertex attributes into vert; the Material feeds uniforms into both vert and frag; a C# script feeds globals through Shader.SetGlobal; Unity feeds the built-in matrices per draw; vert outputs the clip position and a v2f struct that the rasterizer interpolates into frag; frag outputs SV_Target, the framebuffer color.
What reaches the two programs, and from where. Attributes arrive per vertex, uniforms per draw, and the rasterizer carries the vertex program’s outputs to the fragment program.

2 · A shader file, outside in

A listing of a minimal ShaderLab file with its parts colored: the Properties block in orange, the SubShader and Pass braces, the render-state line Cull Off ZWrite On in gray, the CGPROGRAM block in purple with its pragmas and include, the appdata and v2f structs and vert in blue, frag in green; arrows to the right name what each part feeds.
The structure every class-example shader shares. Only the two functions and the structs are code; the rest is declaration.
Shader "CSS551/UnlitTextured" {
    Properties {
        _MainTex ("Texture", 2D) = "white" {}     // a slot in the Inspector, and a uniform
        _Tint    ("Tint", Color) = (1, 1, 1, 1)
    }
    SubShader {
        Cull Off                                   // draw both faces (the class examples' "NoCull")
        Pass {
            CGPROGRAM
            #pragma vertex   vert                  // which function is the vertex program
            #pragma fragment frag                  // which is the fragment program
            #include "UnityCG.cginc"               // UnityObjectToClipPos, TRANSFORM_TEX, ...

            sampler2D _MainTex;                    // the same names as in Properties
            float4    _MainTex_ST;                 // tiling (xy) and offset (zw), filled by Unity
            float4    _Tint;

            struct appdata {                       // per-vertex input, from the mesh
                float4 vertex : POSITION;
                float3 normal : NORMAL;
                float2 uv     : TEXCOORD0;
            };
            struct v2f {                           // vertex output, interpolated to the pixel
                float4 pos : SV_POSITION;          // clip-space position (required)
                float2 uv  : TEXCOORD0;
            };

            v2f vert (appdata v) {
                v2f o;
                o.pos = UnityObjectToClipPos(v.vertex);      // P · V · M · vertex
                o.uv  = TRANSFORM_TEX(v.uv, _MainTex);       // uv * tiling + offset
                return o;
            }
            fixed4 frag (v2f i) : SV_Target {
                return tex2D(_MainTex, i.uv) * _Tint;        // one sample, tinted
            }
            ENDCG
        }
    }
}
Definition (the parts) Shader “name” is the path under which the material picker lists it. Properties declares the parameters an artist or a script can set, each with a type (2D, Color, Float, Vector) and a default. SubShader is one implementation (a file may hold several for different hardware); its top-level lines are render state. Pass is one draw of the geometry; a multi-pass shader draws the mesh more than once. CGPROGRAM … ENDCG encloses the programs; the #pragma lines name which functions are which, and the #include brings in the helper functions and matrix names of Section 4.

3 · The two programs and their semantics

Definition (semantics) A semantic is the label after a colon on a struct field or a function that tells the pipeline what the value is. Inputs to the vertex program: POSITION (the mesh’s vertex, in object space), NORMAL, TEXCOORD0, TEXCOORD1, … (the mesh’s uv, uv2 arrays), COLOR. Outputs of the vertex program and inputs of the fragment program: SV_POSITION, the clip-space position the rasterizer needs, and TEXCOORDn for anything else you want interpolated (coordinates, a world position, a normal, a color). Output of the fragment program: SV_Target, the color written to the framebuffer.

The types are the small vector types of shading languages: float, float2, float3, float4, with half and fixed as lower precision variants (the class examples use fixed4 for colors), and float4x4 for a matrix. Swizzles read and reorder components (v.xyz, c.rgb, p.xy), and the arithmetic operators act component-wise. mul(M, v) is a matrix-vector product, dot, cross, normalize, length, saturate (clamp to \([0,1]\)), lerp and smoothstep are the vector chapter’s operations under their shading names.

Worked example (one vertex through the vertex program) The vertex \((0.5, 0.5, 0.5)\) of a unit cube whose model matrix is \(T(0, 0.5, 0)\,R_y(30^\circ)\), under the demo camera of the viewing chapter, becomes the clip position that chapter computes step by step and the pixel \((702.4, 320.9)\) on a \(1280\times720\) view. UnityObjectToClipPos(v.vertex) does the whole chain in one call: mul(UNITY_MATRIX_MVP, float4(v.vertex.xyz, 1)), which is \(P\,V\,M\) applied to the homogeneous point. The divide by \(w\) and the viewport mapping happen after the program returns, in the fixed stage, so o.pos is the four-component clip vector, not the pixel.

Everything in v2f other than pos is interpolated with perspective correction (the rasterization chapter) before the fragment program sees it. A normal interpolated this way is no longer unit length, which is why a lit fragment program normalizes it first; a world position interpolates exactly, which is why the class-example shaders pass one to compute the light direction per pixel.

4 · The matrices: built in, and your own

name in the programwhat it isthe text’s symbol
UNITY_MATRIX_M (also unity_ObjectToWorld)the object’s world matrix from its Transform hierarchy\(W\) of the scene-graph chapter, \(M\) of the viewing chain
UNITY_MATRIX_Vworld to camera\(V\)
UNITY_MATRIX_Pcamera to clip\(P\)
UNITY_MATRIX_MVP, _MV, _VPthe products, precomputed per draw\(PVM\), \(VM\), \(PV\)
unity_WorldToObjectthe inverse of the world matrix\(M^{-1}\)
_WorldSpaceCameraPosthe eye\(\mathbf{e}\)

These are filled by the engine for every draw from the object’s Transform and the camera. The class-example projects that teach matrices do something different on purpose: they compute a matrix in C# and upload it under their own name, so that the shader multiplies by the student’s matrix and nothing else. The scene-graph project sets each node’s composite as material.SetMatrix("MyXformMat", m), and the viewing project uploads its own \(V\) and \(P\) as globals:

// C#, once per frame in the camera's script (the viewing class example, HW4)
Matrix4x4 r = ...;                                      // the basis, built from cross products
Matrix4x4 t = Matrix4x4.TRS(-transform.position, Quaternion.identity, Vector3.one);
Shader.SetGlobalMatrix("CameraViewMatrix", r * t);      // V = R · T(−eye)
Matrix4x4 p = Matrix4x4.Perspective(c.fieldOfView, c.aspect, c.nearClipPlane, c.farClipPlane);
Shader.SetGlobalMatrix("CameraProjMatrix", p);

// the shader, reading them instead of UNITY_MATRIX_V and _P
uniform float4x4 CameraViewMatrix;
uniform float4x4 CameraProjMatrix;
uniform float4x4 MyXformMat;
v2f vert (appdata v) {
    v2f o;
    float4 world = mul(MyXformMat, v.vertex);           // your M
    o.pos = mul(CameraProjMatrix, mul(CameraViewMatrix, world));   // your P · V
    return o;
}
Worked example (the uploaded projection) With the demo camera’s parameters, field of view \(45^\circ\), aspect \(16/9\), near 1, far 8, Matrix4x4.Perspective returns rows \((1.358, 0, 0, 0)\), \((0, 2.414, 0, 0)\), \((0, 0, -1.286, -2.286)\), \((0, 0, -1, 0)\): entry for entry the \(P\) of the viewing chapter and of the library’s perspective(45, 16/9, 1, 8). The engine uses the same matrix for its own UNITY_MATRIX_P on most platforms and a flipped or remapped one on others (Direct3D’s depth range is \([0, 1]\), and its framebuffer is upside down), which GL.GetGPUProjectionMatrix accounts for; the class-example shader that reads the uploaded matrix directly is correct wherever the engine’s choice matches, which it does in the editor on the platforms the course uses.

mul(M, v) with v a column is the text’s \(M\mathbf{v}\); the float4(v.vertex.xyz, 1) is the homogeneous lift of the affine chapter, and a direction is lifted with 0 instead so that the translation drops out.

5 · Getting values in: properties and globals

A value the program reads that is the same for every vertex and pixel of a draw is a uniform. There are two routes in.

// PointLight.cs (the illumination class example, HW5): the light is an ordinary object, and a script
// hands its transform and color to every shader as globals, once per frame
public void LoadLightToShader() {
    Shader.SetGlobalVector("LightPosition",  transform.localPosition);
    Shader.SetGlobalColor ("LightColor",     LightColor);
    Shader.SetGlobalFloat ("LightNear",      Near);
    Shader.SetGlobalFloat ("LightFar",       Far);
    Shader.SetGlobalVector("LightDirection", DirLight.up);        // the spot's aim
    Shader.SetGlobalVector("SlightPos",      DirLight.localPosition);
}
// and in the shader:
uniform float4 LightPosition;  uniform float4 LightColor;  uniform float3 LightDirection;

Names must match exactly between the C# string and the shader declaration, and a mismatch is not an error: the shader reads zero. That is the first thing to check when a light has no effect.

6 · Textures in the shader

A texture property arrives as a sampler2D, and tex2D(sampler, uv) is the sampling of the texture chapter: wrap mode and filtering are set on the texture asset in the Inspector (Repeat or Clamp; Point, Bilinear or Trilinear; mipmaps on or off), and the sampler applies them. The engine also fills a companion float4 named _MainTex_ST with the material’s tiling in xy and offset in zw; TRANSFORM_TEX(uv, _MainTex) expands to uv * _MainTex_ST.xy + _MainTex_ST.zw, the scale-then-offset placement that the texture class example does by hand in C# and that the texture chapter writes as a \(3\times3\) matrix.

Worked example (tiling and offset, and two samplers) With tiling \((2, 2)\) and offset \((0.25, 0)\), the coordinate \((0.5, 0.5)\) becomes \((0.5\cdot2 + 0.25,\; 0.5\cdot2 + 0) = (1.25, 1.0)\), which a Repeat texture reads at \((0.25, 0)\). The multi-texturing class-example shader samples two textures with two coordinate sets and blends them, 0.2 * tex2D(_MainTex, i.uv) + 0.8 * tex2D(_SecTex, i.uv1): for texels \((0.9, 0.2, 0.2)\) and \((0.2, 0.4, 0.9)\) the result is \((0.34, 0.36, 0.76)\), the weighted mix per channel. The second coordinate set comes from the mesh’s uv2 array through a TEXCOORD1 input.
Worked example (a texture with no image) The synthesized-texture class example computes the checker in the fragment program from the coordinate alone: int su = int(i.uv.x * URepeat); int sv = int(i.uv.y * VRepeat); then Color1 if su % 2 == sv % 2, else Color2. At \((0.31, 0.62)\) with 8 repeats, \(su = \lfloor 2.48\rfloor = 2\), \(sv = \lfloor 4.96\rfloor = 4\), both even, so Color1: the fourth-column, fifth-row check of the board, the same parity rule the course’s uv-placement demo bakes into its 8×8 texture.

7 · Lighting in the shader, and the normal

The class-example shaders do their own lighting rather than the engine’s. The vertex program carries the world position and the world normal to the fragment program; the fragment program builds \(\mathbf{L}\) from the light’s global position, clamps \(\mathbf{N}\cdot\mathbf{L}\), and multiplies. It is the illumination chapter’s diffuse term, in the language of Section 3:

struct v2f { float4 pos : SV_POSITION; float3 normal : TEXCOORD0; float3 vertexWC : TEXCOORD1; };

v2f vert (appdata v) {
    v2f o;
    o.pos      = UnityObjectToClipPos(v.vertex);
    o.vertexWC = mul(UNITY_MATRIX_M, v.vertex).xyz;          // the point, in world space
    // "this is not pretty but we don't have access to inverse-transpose ..."
    float3 p   = v.vertex.xyz + v.normal;                    // a point one step along the normal
    p          = mul(UNITY_MATRIX_M, float4(p, 1)).xyz;      // that point, in world space
    o.normal   = normalize(p - o.vertexWC);                  // the difference: a world-space direction
    return o;
}
float ComputeDiffuse (v2f i) {
    float3 l = normalize(LightPosition.xyz - i.vertexWC);   // direction to the light, per pixel
    return clamp(dot(normalize(i.normal), l), 0, 1);        // max(0, N·L)
}
fixed4 frag (v2f i) : SV_Target {
    return _Color * LightColor * ComputeDiffuse(i);
}
Pitfall The finite-difference normal is exact for a rigid model matrix and wrong under a non-uniform scale, which is the trap of the illumination chapter. Take the point \((0.707, 0.707, 0)\) on a unit circle with normal \((0.707, 0.707, 0)\), and scale the object \(2\times\) in \(x\). The stepped point \((1.414, 1.414, 0)\) maps to \((2.828, 1.414, 0)\), the point to \((1.414, 0.707, 0)\), and their difference normalized is \((0.894, 0.447, 0)\); the correct normal, \((M^{-1})^{\top}\mathbf{n}\) normalized, is \((0.447, 0.894, 0)\). The two are \(36.9^\circ\) apart, and the hack’s vector has a dot of \(-0.6\) with the surface’s tangent where the correct one has 0. For the rotation-plus-translation matrices of the class-example scenes the two agree to every printed digit, which is why the shortcut works there; the engine’s own UnityObjectToWorldNormal(v.normal) multiplies by the inverse transpose and works everywhere. The class example’s spot-light shader steps by 10 * v.normal instead of v.normal, which changes nothing in exact arithmetic and reduces rounding when the model is small.

The spot cone, the attenuation and the specular term of the illumination chapter are each a few more lines in ComputeDiffuse: acos(dot(l, LightDirection)) for the angle to the axis, smoothstep for the soft edge, reflect(-l, n) for the mirror direction, pow(saturate(dot(r, v)), shininess) for the highlight, with v = normalize(_WorldSpaceCameraPos - i.vertexWC). Every one of them is a dot product of two unit vectors computed per pixel, which is Phong shading in the sense of the meshes chapter; moving the computation into the vertex program and interpolating the result would be Gouraud shading.

8 · Render state: culling, depth, blending

line in the SubShader or Passdefaultwhat it does
Cull Back / Cull Front / Cull OffBackskips triangles whose winding faces away; the class examples’ 451NoCullShader sets Off so a hand-built triangle with the wrong winding is still visible (the meshes chapter)
ZWrite On / OffOnwhether the pass writes the depth buffer; transparent objects usually turn it off
ZTest LEqual / Always / …LEqualthe depth comparison of the rasterization chapter
Blend SrcAlpha OneMinusSrcAlphaoffthe over operator with straight alpha; Blend One OneMinusSrcAlpha for premultiplied; Blend One One is additive
Tags { "Queue" = "Transparent" }Geometrydraws after the opaque queue, back to front, so blending sees the opaque scene

A blended pass with ZWrite On hides everything behind it that draws later; a blended pass in the opaque queue draws in an order that has nothing to do with depth. Both are common first transparency bugs, and both are settings, not code.

9 · When it goes wrong

what you seewhat it meanswhere to look
the object is solid magentathe shader failed to compile, or the material references a missing shaderselect the shader asset in Project: the Inspector lists the compile errors with line numbers
the object is blackthe fragment program returned zero: a uniform not set (name mismatch), a light behind the surface, a texture missingreturn a constant color first; then return fixed4(i.normal * 0.5 + 0.5, 1) to see the normals as colors, the class examples’ own debugging trick
the object is invisibleculled (wrong winding with Cull Back), clipped (outside near or far), or a wrong matrix sending it off screenCull Off to test; print the uploaded matrix from C#; check the camera’s planes
the texture is flipped or shiftedthe \(v\) origin convention, or tiling and offset on the materialthe texture chapter; the material’s Inspector
lighting is right at rest and wrong when the object is scaledthe normal transformed by \(M\) instead of the inverse transposeSection 7
shading changes when the mesh is subdividedlighting computed per vertex and interpolatedmove the computation to the fragment program
Hands-on
  1. Normals as colors. In the diffuse-illumination class-example project, change the fragment program’s return to return fixed4(i.normal * 0.5 + 0.5, 1);. Expected: the sphere shows a smooth rainbow, red where the normal points along \(+x\), green along \(+y\), blue toward the camera’s forward; rotate the object and the colors turn with it because the normal is in world space. Restore the line.
  2. The mismatch that reads zero. In PointLight.cs, misspell one global name (for instance "LightPosision") and Play. Expected: no error anywhere, and the surface goes dark, because the shader’s LightPosition is now zero and every \(\mathbf{L}\) points at the origin.
  3. The cull switch. In the mesh class example’s single-triangle project, change Cull Off to Cull Back in the shader and Play, then reverse the index triple in the C# from (0, 1, 2) to (0, 2, 1). Expected: the triangle disappears at the first change and reappears at the second.
  4. The projection, checked. Print the uploaded projection from the viewing class example with Debug.Log(p) and compare with the library:
    node -e "import('./lib/core/xform.js').then(x=>{const m=x.perspective(45,16/9,1,8);for(let r=0;r<4;r++)console.log([m[r],m[4+r],m[8+r],m[12+r]].map(v=>+v.toFixed(3)))})"
    [ 1.358, 0, 0, 0 ]
    [ 0, 2.414, 0, 0 ]
    [ 0, 0, -1.286, -2.286 ]
    [ 0, 0, -1, 0 ]
Further reading Unity’s manual sections “ShaderLab” and “Writing vertex and fragment shaders” document every keyword used here, and “Built-in shader variables” lists the matrices of Section 4; the Universal Render Pipeline’s equivalents are under “Writing custom shaders” in its package documentation. The class-example shaders are in the Topic 8 and Topic 9 projects on Canvas.

10 · Exercises

Exercise 1 (semantics) A vertex program needs the mesh’s second coordinate set and wants to pass the world-space position and a normal to the fragment program. Write the two structs.
Solution struct appdata { float4 vertex : POSITION; float3 normal : NORMAL; float2 uv : TEXCOORD0; float2 uv1 : TEXCOORD1; }; and struct v2f { float4 pos : SV_POSITION; float2 uv : TEXCOORD0; float2 uv1 : TEXCOORD1; float3 worldPos : TEXCOORD2; float3 normal : TEXCOORD3; };. The TEXCOORD indices on the output are labels for interpolation slots and need not match the input’s.
Exercise 2 (the chain in one line) Expand UnityObjectToClipPos(v.vertex) into explicit multiplications by the three matrices, in the right order.
Solution mul(UNITY_MATRIX_P, mul(UNITY_MATRIX_V, mul(UNITY_MATRIX_M, float4(v.vertex.xyz, 1)))): model first, then view, then projection, read right to left as in every chapter.
Exercise 3 (a direction through M) Why does the world-normal hack lift the stepped point with \(w = 1\) and subtract, rather than transforming float4(v.normal, 0) directly?
Solution Both give \(M\)’s linear part applied to the normal, since the translation cancels in the difference and is dropped by \(w = 0\); they are the same wrong answer under non-uniform scale. The class example’s comment explains the choice as the author’s way of getting a direction without knowing the \(w = 0\) rule. The correct answer is the inverse transpose either way.
Exercise 4 (tiling) A material has tiling \((4, 1)\) and offset \((0, 0.5)\). Where does the coordinate \((0.3, 0.3)\) sample, on a Repeat texture and on a Clamp texture?
Solution \((1.2, 0.8)\). Repeat reads \((0.2, 0.8)\); Clamp reads \((1.0, 0.8)\), the right edge column.
Exercise 5 (the checker) With 6 repeats, which color does the checker shader return at \((0.5, 0.5)\), and at \((0.5, 0.34)\)?
Solution \((3, 3)\): both odd, equal parity, Color1. \((3, 2)\): parities differ, Color2. Crossing \(v = 1/3\) flips the color, the boundary between row 1 and row 2.
Exercise 6 (blending) A shader draws a 50 % transparent red quad over a white background with Blend SrcAlpha OneMinusSrcAlpha. What pixel results? And with Blend One One?
Solution Over: \(0.5\,(1, 0, 0) + 0.5\,(1, 1, 1) = (1, 0.5, 0.5)\), pink. Additive: \((1, 0, 0)\cdot\text{(alpha ignored)} + (1, 1, 1) = (2, 1, 1)\), clamped to white with a red tint lost; additive blending over white saturates.
Exercise 7 (globals) Two lights are needed. What must change in the C# and the shader to pass both?
Solution Distinct global names for each (LightPosition0, LightPosition1, and so on) set by each light’s script, matching declarations in the shader, and a fragment program that evaluates ComputeDiffuse for each and sums, adding the ambient once, as the illumination chapter prescribes.
Exercise 8 (magenta) A material turns magenta after a script edit. Name three edits to a .shader file that cause it and one to a .cs file that cannot.
Solution A syntax error in the program, a semantic misspelled (SV_POSITON), a variable used without declaration; a C# error stops Play entirely rather than turning anything magenta, since C# compiles before the scene runs.
Exercise 9 (by code: Gouraud from Phong) Move the diffuse computation of Section 7 into the vertex program and pass the result as a float in v2f. Run it on a coarse sphere and on a fine one; what differs, and why?
Solution The coarse sphere shows visible shading facets and a highlight-free look; the fine one approaches the per-pixel result. Lighting evaluated at vertices and interpolated is Gouraud shading; its error is the difference between a linear interpolation of the cosine and the cosine of the interpolated normal, which shrinks with the triangle size (the meshes chapter).
Exercise 10 (by code: the view, uploaded) Replace UnityObjectToClipPos in a working shader by the explicit product using the two globals of Section 4 uploaded from a script, and confirm the picture does not change. Then upload the identity as the view matrix and describe what you see.
Solution With the correct \(V\) and \(P\) the frame is identical to the engine’s. With \(V = I\) the camera is at the world origin looking down \(-z\): objects near the origin fill the screen, those behind it vanish, and the class example’s ViewMatrixWrong mode is exactly this experiment.