🌐 Translate:
Table of contents

Toon rendering, also called NPR or toon shading, may be the rendering style most commonly used by Unity developers besides Unity’s built-in Standard shader. I wrote a toon-rendering shader a few months ago; below, I divide its key features into the following areas.

  • Ramp-based diffuse lighting
  • Stylized specular highlights
  • Rim lighting
  • Outline effects
  • Specular highlights on hair
  • Halftone overlay
  • Hatching overlay
  • Custom shadow color
  • A custom material editor
  • Other details

Ramp-based diffuse lighting

Among the many common lighting models, the Lambert and Half-Lambert models are the most commonly used for diffuse lighting. The result of dot(normal, lightDirection)—which I will call NdotL—can give an object’s surface a basic diffuse-lighting response.

Lambert model and Half-Lambert model{:.blog-post-md-img-half}

For toon rendering, however, we want more control over the color produced by light on the object’s surface. Ramp-based diffuse lighting is one solution. Instead of using NdotL only as the intensity of the light on the surface, use NdotL as the UV coordinate for sampling a ramp texture. This lets the color gradient in the texture completely control the result of the original diffuse lighting.

{:.blog-post-md-img-15}Four-step ramp texture, a gradient ramp texture, and a ramp texture on Team Fortress 2{:.blog-post-md-img-70}

Simply replacing the ramp texture can produce completely different styles. By extending Unity’s editor, we can also let users draw a different ramp texture directly with Unity’s Gradient editor, quickly trying out the desired rendering result with immediate visual feedback.

The simplest and most common toon shading approach on the internet is two-step tone. In a shader it can be expressed with step(_Threshold, saturate(NdotL)).

If needed, we can blend Unity PBR rendering with the ramp-lighting result. The following image directly interpolates PBR diffuse and ramp lighting with lerp.

Stylized specular highlights

A specular highlight is the bright spot produced when light is perfectly reflected by an object’s surface. A common calculation is the NdotH value from the Blinn-Phong model.

// Source: [Unity Graphics Repo](https://github.com/Unity-Technologies/Graphics/blob/master/Packages/com.unity.render-pipelines.universal/ShaderLibrary/Lighting.hlsl#L30)

float3 halfVec = normalize(float3(lightDir) + float3(viewDir));
half NdotH = saturate(dot(normal, halfVec));
half modifier = pow(NdotH, smoothness);
half3 specularReflection = specular.rgb * modifier;
return lightColor * specularReflection;

A more complex approach is Unity’s Standard shader, which is based on a BRDF implementation. If you are interested in the paper, see Moving Mobile Graphics — SIGGRAPH 2015 Course.

I personally like Unity’s BRDF specular, so I copied that code and added extra parameters. By sharpening and smoothing the edge falloff after the calculation, we can create a more cartoon-like specular highlight than the original realistic rendering.

// Code for sharpening and smoothing the falloff
// Source: [https://github.com/ChiliMilk/URP_Toon/blob/master/Assets/ChiliMilkToonShader/Include/ToonFunction.hlsl](https://github.com/ChiliMilk/URP_Toon/blob/master/Assets/ChiliMilkToonShader/Include/ToonFunction.hlsl)

half StepFeatherToon(half Term,half maxTerm,half step,half feather)
{
    return saturate((Term/maxTerm-step)/feather)*maxTerm;
}

{:.blog-post-md-img-30} {:.blog-post-md-img-30} From the original PBR specular on the left to specular with completely sharp edges on the right{:.blog-post-md-img-30}

Rim lighting

{:.blog-post-md-img-half} Both the Zelda and TF2 characters have rim light; Zelda’s is sharper{:.blog-post-md-img-half}

Rim lighting is another effect often seen in games. It appears in titles such as The Legend of Zelda and Team Fortress 2. There are two implementations I know of: one using a depth texture and one using the Fresnel effect. I am more accustomed to Fresnel, so this section covers that version.

Fresnel reflection: when you stand beside a lake and look straight down, the water looks clear. When you look toward the far surface, it behaves like a mirror and it becomes difficult to see below the water. This phenomenon is the Fresnel effect.

Tenaya Lake, Yosemite National Park

In a shader, the strength of this Fresnel reflection can be found from the dot product of the view direction and the vertex normal direction. The larger the angle between these vectors, the stronger the Fresnel reflection. For example, when we look toward the far surface of a lake, the angle between the vector from a point on the surface to our eyes and the surface normal is larger.

// Source: [Unity Shader Graph Documentation](https://docs.unity3d.com/Packages/com.unity.shadergraph@10.4/manual/Fresnel-Effect-Node.html)

fresnel = pow((1.0 - saturate(dot(normalize(Normal), normalize(ViewDir)))), Power);

Interpolating with NdotL lets us control the rim-light intensity on the lit and unlit sides. We can also use smoothstep to sharpen or smooth the edge falloff.

{:.blog-post-md-img-30} {:.blog-post-md-img-30} Left and center: adjust edge smoothing. Right: lerp between intensity and the backlight direction.{:.blog-post-md-img-30}

Outline effects

There are many ways to implement outlines: redraw the object in a second pass, extruding the normals and culling the front faces; use edge detection; use Kage-mura lines; or draw the outline directly into a texture.

{:.blog-post-md-img-30} {:.blog-post-md-img-30} Left: Brawl Stars (two passes). Center: Ni no Kuni (edge detection). Right: Guilty Gear (two passes plus Kage-mura lines).{:.blog-post-md-img-30}

I was deeply impressed by the outline effect in the PS4 version of Ni no Kuni. I originally used edge detection (a camera depth texture, vertex colors, and a camera-normal texture), but after trying and reading many online articles, I felt that these three textures alone were not enough to produce a perfect outline. Controlling the outline through edge detection probably requires more textures or vertex data. I therefore settled for the two-pass plus normal-extrusion approach. Extruding in different spaces produces different effects; I perform the extrusion in clip space.

{:.blog-post-md-img-half} Use an extra pass to slightly extrude the current object’s position in the vertex shader, render the object as black (or an outline color) in the pixel shader, cull the front faces, and overlay the result on the original object.{:.blog-post-md-img-half}

In the Built-in Render Pipeline, adding another pass directly inside the subshader should be enough. In URP, we need to write a Renderer Feature to insert a custom pass. By using a shader-tag ID to find the additional outline pass, we can render every renderer with that outline pass at the selected point in the render process.

For more outline implementations, see 5 Ways to Draw an Outline.

Specular highlights on hair

Hair specular highlights are different from the highlights on a smooth plane. miHoYo shared this implementation in Unite 2018 | High-Quality Toon Rendering in Unity for Honkai Impact 3rd. It uses the Kajiya-Kay model, an anisotropic lighting model; the source and a detailed explanation are in this 2004 paper.

Ordinary specular lighting is calculated with NdotH. The Kajiya-Kay model instead replaces the lighting normal with a tangent vector and assumes that the surface normal lies in the plane formed by the tangent and view direction. Combining the result with a noise texture produces specular light on strands of hair.

source: [Hair rendering and shading (2004)](https://web.engr.oregonstate.edu/~mjb/cs519/Projects/Papers/HairRendering.pdf)

float3 ShiftTangentHair(float3 Tangent, float3 N, float shift)
{
    float3 shiftedT = Tangent + (shift * N);
    return normalize(shiftedT);
}

float3 StrandSpecular(float3 T, float3 V, float3 L, float exponent)
{
     float3 H = normalize(L + V);
     float dotTH = dot(T,H);
     float sintTH = sqrt(1.0-dotTH*dotTH);
     float dirAtten = smoothstep(-1.0,0.0,dot(T,H));
     return dirAtten * pow(sintTH,exponent);
}

float3 AnistropicColor(float3 tangent, float3 normal, float3 viewVec, float3 lightVec, float2 uv)
{
    float shiftValue = tex2d(tSpecShift, uv).r;
    float3 t1 = ShiftTangentHair(tangent, normal, shiftValue);
    float spec = StrandSpecular(t1, viewVec, lightVec, power);
    return _SpecularColor.rgb * spec;
}

Applying smoothstep to sharpen or smooth the falloff of the calculation lets us tune a stylized hair specular highlight.

{:.blog-post-md-img-30} {:.blog-post-md-img-30} {:.blog-post-md-img-30}

miHoYo’s implementation also uses a curve to control the width and jitter of the reflected light, plus an extra map to make the specular discontinuous. I tried implementing this, but it was outside my needs at the time, so I left it out. The approach is to export the result of Unity’s animation curve as a texture and sample it while calculating hair specular to control the width.

Halftone overlay

Halftone was first used in newspapers. It is a long-established printing effect that simulates the appearance of a continuous-tone image by changing the size or frequency of ink dots to represent changes in brightness.

{:.blog-post-md-img-half} {:.blog-post-md-img-half}

I later remembered that the characters in Spider-Man: Into the Spider-Verse seem to have a similar effect applied over them, so I decided to try implementing it. The method is simple: use a signed-distance-field image as the dot shape, then control each dot’s size from the strength of NdotL. Because it is an SDF, the size is easy to control and it includes antialiasing. If we do not want to use an SDF image, we can calculate procedural SDFs (circles, squares, and rectangles) in the pixel shader.

There are two ways to sample the SDF: use the mesh’s own UVs or use screen position. Mesh UVs make the halftone shape deform along with the object’s UVs—in other words, it deforms with the object’s shape.

With screen space, the result resembles comic dots or newspaper halftone. The drawback is that because it is in screen space, the halftone size on the surface does not change as the object moves away from the camera. I handle this by scaling once more using the distance between the camera and the mesh root. As the object moves closer to or farther from the camera, the sampled screen-space UV also grows or shrinks, keeping the halftone density on the surface constant.

The difference between sampling the halftone shape with mesh UVs and with screen position

Hatching overlay

This is an effect often seen in NPR shader exercises. Prepare N tone-art maps (TAMs), each a sketch-stroke texture with a different density, and choose the TAM to sample according to NdotL (for example, use the TAM with denser strokes in darker areas).

For the traditional approach, see this paper and this repo. I am extremely lazy and did not want to prepare so many textures, so I used a method similar to traditional hatching. Prepare one noise texture, stretch it, and put it inside a for loop (assuming there would originally be eight TAMs, loop eight times). On each loop, offset the sampled noise UV and scale slightly, then add the results together. At runtime, one stretched noise texture can imitate N stroke textures with different densities. The final loop result is the densest stroke texture, and NdotL controls how many iterations are included.

{:.blog-post-md-img-half} {:.blog-post-md-img-half}

Custom material editor

After putting a large number of properties into a shader, the material inspector becomes messy. Add an editor script that inherits from BaseShaderGUI to customize the material-editor UI. While writing it, I referred to the editor scripts for Unity’s Lit and Simple Lit shaders. They contain EditorGUI handling for many types of properties, so I reused what I could and wrote my own material drawers for missing types.

{:.blog-post-md-img-30} {:.blog-post-md-img-30} {:.blog-post-md-img-30}

For a custom material drawer, see Unity Material Property Drawer — Custom Material Editor.

Other details

Some rendered results

When I seriously tried to write a Unity shader that actually worked, I discovered many details that needed attention. Besides real-time lighting, for example, we also need to account for baked GI and light-probe contributions. Unity’s official URP shader code changes in this area every three months, which makes it exhausting to keep up.

This post only lists the features used by my shader. A shader focused more specifically on an anime style has many additional details that are not covered here, such as eyebrows, eyes, blush, and special handling for facial shadows in toon rendering. There is still a long road of learning ahead; I can only keep learning by doing.