A practical introduction to toon shading. We'll build a stylized lighting model and combine it with post-processing to create a classic cartoon rendering effect.
Cel shading (also known as toon shading) is a form of non-photorealistic rendering (NPR) used in films and video games to create a cartoon-like visual style. Some of the best-known games that make use of this technique include The Legend of Zelda, Prince of Persia, and Borderlands:
Generally speaking, there is no single "correct" way to implement this technique, and every project adopts its own approach. That said, most toon shading implementations consist of three main components:
In this article, we'll focus only on the last two. Model preparation is not a programming task—it falls under the responsibility of artists, technical artists, and art directors. As in the other computer graphics articles on this site, we'll use Unity Engine so that we don't have to spend time on problems that have already been solved countless times, such as loading textures or accessing the scene depth buffer.
Let's start from the beginning. Consider the most basic lighting model used in computer graphics: Lambertian shading. As is well known, the illumination of a point is calculated using a very simple principle: the larger the angle between the surface normal and the direction from the point to the light source, the less illuminated that point on the surface is.
Below is a fragment shader that computes lighting according to the Lambertian model, along with the resulting image. In the following sections, we will modify this shader step by step to achieve the desired toon shading effect.
fixed4 frag(fragment_data i) :COLOR
{
float3 L = normalize(_WorldSpaceLightPos0 - i.pos_world);
float3 N = normalize(i.normal);
float lambert = max(dot(L, N) + _Ambient, 0.0);
float3 color = tex2D(_DiffTex, i.uv);
return float4(color * lambert, 1.0);
}You can read more about Lambertian shading and other basic lighting models here.
Lambertian shading produces a smooth lighting function because it is based on the cosine function, which is itself continuous. This is the first thing we need to change: cel shading is characterized by discrete lighting levels, where the transitions between different illumination levels are clearly visible.
Let's modify the Lambertian model by introducing a constant, steps, that controls the number of lighting levels. We then compute the lighting increment for each level (step_size) and determine which illumination level (brightness_level) corresponds to the current surface point.
fixed4 frag(fragment_data i): COLOR
{
float3 L = normalize(_WorldSpaceLightPos0 - i.pos_world);
float3 N = normalize(i.normal);
float lambert = pow(max(dot(L, N) + _Ambient, 0.0), 0.8);
int steps = 3;
float step_size = 1.0 / steps;
int brightness_level = round(lambert / step_size);
float result = brightness_level * step_size;
float3 color = tex2D(_DiffTex, i.uv);
return float4(color * result, 1.0);
}Here and below, all parameters are hardcoded for the sake of clarity.
In production code, they should of course be exposed as shader parameters.
The images below show the results of the stepped lighting function for different values of the steps parameter:
The stepped lighting function described above has one drawback: the lighting levels are distributed uniformly, and the transitions between them are always sharp. A more flexible approach, without significantly increasing the computational cost, is to use LUT (lookup table) textures. Examples of such textures are shown below. In this case, the output of the Lambertian lighting function is used as the texture coordinate.
Thus, if the angle between the surface normal and the direction to the light source is zero (the point is fully illuminated), the Lambertian function evaluates to 1, and we sample the rightmost point of the LUT texture.
Conversely, if the surface normal is perpendicular to the direction to the light source (the point receives no direct illumination), the Lambertian function evaluates to 0, and we sample the leftmost point of the LUT texture.
The shader below implements this approach, followed by the results obtained using several different LUT textures.
fixed4 frag(fragment_data i): COLOR
{
float3 L = normalize(_WorldSpaceLightPos0 - i.pos_world);
float3 N = normalize(i.normal);
float lambert = max(dot(L, N), 0.0);
float result = tex2D(_LUTTex, float2(lambert, 0.5));
float3 color = tex2D(_DiffTex, i.uv);
return float4(color * lambert, 1.0);
}Using LUT textures has an important advantage: the lighting can be adjusted without modifying the shader source code.
Let's add specular highlights to our lighting model. We'll take the specular component from the Phong lighting model, but modify it. In the original Phong model, the highlight is computed from the angle between the view direction and the light vector reflected about the surface normal, producing a smooth, soft highlight.
For our purposes, we need sharper highlights, so we'll use the built-in GLSL function smoothstep. This function takes three arguments (the lower bound, the upper bound, and the input value) and behaves as follows:
0.1.0 and 1, using smooth (non-linear) interpolation.In our case, if the difference between the lower and upper bounds is small, the resulting highlight will have very narrow, yet still soft, edges.
The fragment shader below adds the specular component. It is followed by screenshots showing the specular highlights on their own (for clarity) and combined with the stepped lighting function.
float3 V = normalize(_WorldSpaceCameraPos - i.pos_world); // вектор взгляда
float3 R = reflect(-L, N); // отражённый вектор света
int specularPower = 100;
float phongSpec = pow(saturate(dot(R, V)), specularPower);
float specularSmooth = smoothstep(0.005, 0.01, phongSpec);
result += specularSmooth;Another effect commonly used in toon rendering is rim lighting. From an implementation standpoint, it is fairly straightforward: we use the cosine of the angle between the surface normal and the view direction. As expected, this value changes smoothly and becomes negative on the back-facing side of the model.
By applying the smoothstep function and adjusting the interval between its lower and upper bounds, we can highlight the region near the visible silhouette of the object.
The screenshots below show the rim lighting component by itself, followed by the final result of the complete lighting model.
float4 rim = 1 - dot(V, N);
float rimAmount = 0.8;
float rimIntensity = smoothstep(rimAmount - 0.01, rimAmount + 0.01, rim);
result += rimIntensity;Choosing the lower and upper bounds for the
smoothstepfunction, both here and in the previous section, is largely a matter of preference. In practice, these values should be exposed as shader parameters and tuned to match the current scene or the game's visual style.
Now that we've covered all the components of the lighting model, let's look at the final version of the shader. In this version, all constants have been exposed as shader parameters, and both the vertex and fragment shaders are included. In other words, this shader can be used as-is.
It's worth noting that you don't have to include every lighting component discussed above. For example, your project may not require specular highlights or rim lighting. Ultimately, these choices depend on the desired visual style and artistic direction.
Shader "karonator/Shading/Toon"
{
Properties
{
_DiffTex("Diffuse texture", 2D) = "white" {}
_StepsCount("Steps count", Int) = 4
_LUTTex("Toon LUT texture", 2D) = "white" {}
_Ambient("Ambient coeff", Range(0, 1)) = 0.1
_RimAmount("Rim Amount", Range(0, 1)) = 0.8
_SpecMin("Specular range min", Range(0, 1)) = 0.02
_SpecMax("Specular range max", Range(0, 1)) = 0.07
}
SubShader
{
Pass
{
Tags { "LightMode" = "ForwardAdd" }
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "UnityCG.cginc"
sampler2D _DiffTex;
int _StepsCount;
sampler2D _LUTTex;
float _Ambient;
float _RimAmount;
float _SpecMin;
float _SpecMax;
struct vertex_data
{
float4 vertex: POSITION;
float3 normal: NORMAL0;
float2 uv: TEXCOORD0;
};
struct fragment_data
{
float4 pos: POSITION;
float4 pos_world: TEXCOORD1;
float3 normal: NORMAL0;
float2 uv : TEXCOORD0;
};
fragment_data vert(vertex_data v)
{
fragment_data o;
o.pos = UnityObjectToClipPos(v.vertex);
o.pos_world = mul(unity_ObjectToWorld, v.vertex);
o.uv = v.uv;
o.normal = normalize(mul(float4(v.normal, 0.0), unity_WorldToObject).xyz);
return o;
}
fixed4 frag(fragment_data i) :COLOR
{
float3 L = normalize(_WorldSpaceLightPos0 - i.pos_world);
float3 N = normalize(i.normal);
float3 V = normalize(_WorldSpaceCameraPos - i.pos_world);
float lambert = max(dot(L, N) + _Ambient, 0.0);
// stepped lambert shading (variant 1)
float step_size = 1.0 / _StepsCount;
float result = round(lambert / step_size) * step_size;
// using lut texture (variant 2)
// float result = tex2D(_LUTTex, float2(lambert, 0.5));
// specular
float3 R = reflect(-L, N);
float spec = pow(saturate(dot(R, V)), 100);
float specularSmooth = smoothstep(_SpecMin, _SpecMax, spec);
result += specularSmooth;
// rim
float4 rim = 1 - dot(V, N);
float rimIntensity = smoothstep(_RimAmount - 0.01, _RimAmount + 0.01, rim);
result += rimIntensity;
float3 color = tex2D(_DiffTex, i.uv);
return float4(color * result, 1.0);
}
ENDCG
}
}
Fallback "VertexLit"
}The last topic I'd like to cover in this article is the post-processing stage applied to the image produced by the lighting model. One of the effects commonly used in cel shading is outlining, which makes objects and characters appear as if they were drawn with a pencil.
This is a very effective effect, and it's surprisingly easy to implement: we simply apply the Sobel operator to the scene's depth map. Let me explain.
Simply put, an operator is an algorithm that produces a new image from an existing one. The Sobel operator is an edge detection operator. A depth map, in turn, is an image in which the brightness of each pixel corresponds to the distance between the object at that point in the 3D scene and the camera (see the screenshots below).
All we need to do is apply the Sobel operator to the depth map (thereby extracting the object edges) and then overlay those edges onto the rendered image.
To obtain the depth map, we'll use Unity's built-in _CameraDepthTexture.
The Sobel operator is computed as follows: for each pixel, two gradient components, Gx and Gy, are calculated using the neighboring pixels and the following convolution kernels:
In the formulas below, A denotes the color value of the pixel being processed.
After computing the Gx and Gy components, we calculate the Sobel operator value for the current pixel using the following formula:
If the explanation above isn't entirely clear, take a look at the post-processing shader code below. The implementation is actually much simpler than it may sound.
fixed4 frag (v2f inp): SV_Target
{
float3x3 KernelX = { -1, 0, 1, -2, 0, 2, -1, 0, 1 };
float3x3 KernelY = { -1, -2, -1, 0, 0, 0, 1, 2, 1 };
float dx = 1.0 / _ScreenParams.x;
float dy = 1.0 / _ScreenParams.y;
float GX = 0;
float GY = 0;
for (int i = 0; i < 3; i++) {
for (int j = 0; j < 3; j++) {
float2 tex_coord = float2(inp.uv.x + (i - 1) * dx, inp.uv.y + (j - 1) * dy);
float depth = tex2D(_CameraDepthTexture, tex_coord).r;
depth = Linear01Depth(depth);
GX += KernelX[i][j] * depth;
GY += KernelY[i][j] * depth;
}
}
float sobel_result = sqrt(pow(GX, 2) + pow(GY, 2));
float3 color = tex2D(_MainTex, inp.uv);
return float4(color * (1.0 - sobel_result), 1.0);
}Note that before applying the Sobel operator, we don't use the raw values from the depth map directly. Instead, we pass them through the
Linear01Depthfunction, which converts the depth values to the[0, 1]range, where1corresponds to the farthest parts of the scene and0to the closest.
Final result — lighting model with outlines:
smoothstep function specification