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.


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
UsePasshere 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.


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.


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.



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.

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.
To keep the code simple and ignore lightmaps, we can call SampleSH directly as the GI color.

Internally,
SAMPLE_GIperforms 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.


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:

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.

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:


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.shadowAttenuationcontains 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.