🌐 Translate:
Table of contents

The toon-rendering shader I made on a whim has been published for a while, so I am writing these notes to help organize my thoughts.

Most Chinese-language resources about custom lighting shaders in Unity stop at the Built-in Render Pipeline. There are fewer resources for the Universal Render Pipeline in the SRP family, but because SRP is open source, tracing the source code makes it fairly easy to understand the entire rendering process of a lit shader.

In this post I will write a custom lighting shader that casts and receives shadows and supports multiple lights. I will also look through com.unity.render-pipelines.universal/ShaderLibrary/Lighting.hlsl to help us understand some details of how Unity’s lit and simple-lit shaders perform forward rendering. Deferred rendering can wait for another post.

A custom lighting shader

Unity’s documentation provides a basic Unlit shader example, but to make a complete shader we also need to add passes with different pass tags. This code is a simple template. (It is too long to paste here, so I am linking it.)

After assigning this shader to a material and assigning the material to a mesh renderer in the scene, we get the following result.

The fragment shader currently only returns _BaseColor, shown on a material in the scene

Several UsePass entries appear at the bottom of the shader code because the render pipeline selects and renders these passes at different times according to their LightMode tags:

  • ShadowCaster draws the object’s depth into the shadow map from the light’s point of view while the pipeline renders the shadow map. This pass is required for an object to cast shadows.

  • Depth Only draws the object’s depth into the depth texture from the current camera’s point of view. URP’s depth prepass also uses it.

  • The Meta pass is used for light baking; Unity automatically removes this pass while building the project.

  • Universal2D is used by URP’s 2D renderer and 2D lighting, although I have not investigated it in depth.

Using UsePass here is a shortcut. The SRP Batcher will be disabled if the constant buffers do not match those of the other shaders. In a real implementation, we should write the corresponding pass for each pass tag ourselves, but the code would be much longer.

Once these passes are present, the object has all the elements it needs to render in a scene (depth, shadow-map writing, and baking). Only the object’s color and lighting are missing. The pass at the top of the template with LightMode set to UniversalForward is the part we need to write ourselves.

This shader currently renders only a solid color. Before calculating a lighting model, we need the main light and the normal in world space.

Convert the object normal to a world-space normal in the vertex shader before passing it to the fragment shader

With the main-light information, we can write the simplest Lambert lighting model (NdotL). Multiplying NdotL by the light color and albedo color gives us Lambert lighting.

(Left) Three objects using the current shader. (Right) They currently do not receive shadows from other objects.

The shader does not include shadows yet. In the code above, when calling GetMainLight() we also pass a shadow-coordinate parameter. When that parameter is supplied, Unity samples the current shadow map through MainLightRealtimeShadow(). We can read the result from shadowAttenuation in the light struct. Multiplying the final lighting result by shadowAttenuation completes the shadowed part of the object.

float lambert = ndotl * mainLight.color * mainLight.shadowAttenuation;

Consider shadow attenuation when calculating the lighting model

The custom shader receives shadows, but unlike the built-in shader the shadow is clearly pure black

About shadow maps: Unity has both single shadow maps and CSM (cascaded shadow maps). When the shadow distance is limited, games usually enable CSM to extend the shadow-drawing distance while keeping nearby shadows high quality. A detailed explanation and implementation can be found at LearnOpenGL’s CSM article.

Back in the scene, the shader can now receive shadows, but the shadow is much darker than the one from the built-in Lit shader (the floor in the image). We can look at URP’s Simple Lit shader to see why.

Simple Lit calculates lighting with UniversalFragmentBlinnPhong from ShaderLibrary/Lighting.hlsl

Unity’s Simple Lit uses the Blinn-Phong lighting model. Its Lambert calculation is the same as the custom shader’s, but notice that the diffuseColor variable includes inputData.bakedGI before Lambert lighting is calculated. Our shader lacks bakedGI, so its final shadow output is zero (pure black).

This baked-GI value contains ambient light from the skybox and indirect light from light probes. Unity’s Lit and Simple Lit shaders set it in their fragment shaders through the SAMPLE_GI macro.

inputData.bakedGI = SAMPLE_GI(input.lightmapUV, input.vertexSH, inputData.normalWS);

To keep the code simple and ignore lightmaps, we can call SampleSH directly as the GI color.

Add bakedGI to the Lambert lighting result

Internally, SAMPLE_GI performs spherical-harmonic lighting calculations for the skybox and indirect light (light probes). The theory involves a lot of mathematical derivation; see this article and Wikipedia’s spherical harmonic lighting page.

Back in the scene, the custom lighting result is now almost identical to Simple Lit (although it has no specular). Because SampleSH supplies a base color, the shadowed areas are no longer pure black.

At this point we can try different lighting models (Oren-Nayar, Blinn-Phong, PBR, or toon shading). Before applying them, however, we need to consider additional lights. The shader currently calculates only the main light.

Four point lights are placed in the scene. Only Unity’s built-in Lit shader shows their colors; the custom shader does not.

To calculate additional lights (point lights, spotlights, and so on), we likewise need their light color, direction, and attenuation. Looking through Lighting.hlsl reveals the following code:

The part of UniversalFragmentBlinnPhong that calculates multiple lights

In URP’s default forward rendering, each URP object can receive data for at most eight additional lights. Therefore, for each fragment of each lit object, this loop runs at most eight times.

After copying the additional-light code into the current shader, add #pragma multi_compile _ _ADDITIONAL_LIGHTS to the current pass, calculate the lighting contribution of every additional light in the loop, and add the result to diffuseColor.

Add the multiple-light code to the custom shader

The final result is an object that supports multiple lights.

The completed template code is available here.

Trying different lighting models

Applying step(0.5, ndotl) to the NdotL result creates a simple toon shader. Replace every saturate(dot(normalWS, mainLight.direction)) with step(0.5, saturate(dot(normalWS, mainLight.direction))) and return to the scene:

A step(0.5, ndotl) toon shader, with the base color set to green (left) and white (right)

We have created a toon shader in URP that can cast shadows, receive shadows, and support multiple lights.

Here are the key points:

  • A ShadowCaster pass is required to create shadows.
  • Remember to add a DepthOnly pass so the object’s depth is written into _CameraDepthTexture; many shader effects use it.
  • To receive shadows, pass the shadow coordinate when getting the light so light.shadowAttenuation contains the sampled shadow-map value.
  • Lighting shaders in Unity must also account for indirect and ambient light; most of this is handled inside SAMPLE_GI.
  • For multiple lights, refer to ShaderLibrary/Lighting.hlsl.

URP’s ShaderLibrary/Lighting.hlsl also contains code for BRDF specular lighting and ordinary NdotH specular lighting. It is worth referring to it when writing custom lighting.