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 Uint8Array(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 Uint8Array(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;
}

The table is array of unsigned bytes. WebGL accepts an array of this type as a source of pixel data for a texture. However, 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 table also only has one component per texel instead of four components for the red, green, blue, and alpha intensities. That means we'll need to configure the texture so that it only expects one byte per texel that will be dropped in the texture's red channel:

function createToonTexture(table) {
  gl.activeTexture(gl.TEXTURE0);

  const texture = gl.createTexture();
  gl.bindTexture(gl.TEXTURE_2D, texture);
  gl.texImage2D(gl.TEXTURE_2D, 0, gl.R8, table.length, 1, 0, gl.RED, gl.UNSIGNED_BYTE, table);
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
  gl.generateMipmap(gl.TEXTURE_2D);

  return texture;
}
function createToonTexture(table) {
  gl.activeTexture(gl.TEXTURE0);

  const texture = gl.createTexture();
  gl.bindTexture(gl.TEXTURE_2D, texture);
  gl.texImage2D(gl.TEXTURE_2D, 0, gl.R8, table.length, 1, 0, gl.RED, gl.UNSIGNED_BYTE, table);
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
  gl.generateMipmap(gl.TEXTURE_2D);

  return texture;
}

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:

uniform sampler2D table;
const vec3 lightDirection = normalize(vec3(1.0, 1.0, 1.0));
const vec3 albedo = vec3(1.0, 1.0, 1.0);
in vec3 mixNormal;
out vec4 fragmentColor;

void main() {
  vec3 normal = normalize(mixNormal);
  float litness = max(0.0, dot(normal, lightDirection));
  float level = texture(table, vec2(litness, 0.0)).r;
  fragmentColor = vec4(albedo * level, 1.0);
}
uniform sampler2D table;
const vec3 lightDirection = normalize(vec3(1.0, 1.0, 1.0));
const vec3 albedo = vec3(1.0, 1.0, 1.0);
in vec3 mixNormal;
out vec4 fragmentColor;

void main() {
  vec3 normal = normalize(mixNormal);
  float litness = max(0.0, dot(normal, lightDirection));
  float level = texture(table, vec2(litness, 0.0)).r;
  fragmentColor = vec4(albedo * level, 1.0);
}

The texture coordinate must be a vec2 since the texture is technically a 2D texture. The s-coordinate is the litness. The t-coordinate is set to 0, but it could really be any number since the texture's height is 1 and the coordinates are clamped.

← BillboardingNormal Mapping →