Toon Shading

How to 3D

Chapter 10: Texture Effects

Toon Shading

We can also use a texture to produce a cartoon-like shading effect. The standard Blinn-Phong lighting model produces smooth gradients across a surface. In toon shading or cel shading, we see more discrete bands of illumination, as is shown in this renderer:

Toon shading mimics the practice of cartoon animators who painted animated foreground images on sheets of celluloid. The sheets or cels were transparent and could be placed atop static and artistically-painted backgrounds. Since an animation required many cels, animators saved time by using few colors and no gradients when painting the foreground cels.

In standard diffuse shading, we modulate the surface's albedo by the degree of alignment between the normal and the light vector. The alignment is computed as the cosine of the angle between the two vectors, which makes for a smooth gradient between full illumination and darkness. To achieve a discrete dropoff as we see in the renderer above, we use the dot product as a texture coordinate into a 1D lookup table that gives a small set of litness values. The lookup table might look like this texture, which has 16 texels but only 4 illumination levels:

We could make our lookup table in an image editor, but it's also possible to synthesize it programmatically. Here's a function that creates a table of width 128 with five discrete illumination levels:

function generateToonTable() {
  const table = new Uint8ClampedArray(128);

  for (let i = 0; i < table.length; i += 1) {
    if (i < 20) {
      table[i] = 0;
    } else if (i < 30) {
      table[i] = 50;
    } else if (i < 70) {
      table[i] = 128;
    } else if (i < 120) {
      table[i] = 200;
    } else {
      table[i] = 255;
    }
  }

  return table;
}
function generateToonTable() {
  const table = new Uint8ClampedArray(128);

  for (let i = 0; i < table.length; i += 1) {
    if (i < 20) {
      table[i] = 0;
    } else if (i < 30) {
      table[i] = 50;
    } else if (i < 70) {
      table[i] = 128;
    } else if (i < 120) {
      table[i] = 200;
    } else {
      table[i] = 255;
    }
  }

  return table;
}

Our other textures have been 2D, but this table is 1D. WebGL doesn't allow 1D textures like its cousin OpenGL. We work around this limitation by creating a 2D texture with a height of 1.

The fragment shader indexes into this texture using the litness value. It pulls out just the red channel, which will be one of the five levels, and applies it to the albedo.

The texture coordinates must be a vec2 since the texture is technically a 2D texture. Since our table is wide rather than tall, we use the litness value as the s-coordinate. The t-coordinate could be any number since it will be wrapped, but 0 is a canonical choice.

← BillboardingNormal Mapping →