Toon shading, or cel shading, is a non-photorealistic rendering technique that gives 3D graphics a cartoon-like appearance, commonly seen in various forms of visual media, such as video games and animations. The outlined toon shader achieves the effect signature flat look by quantizing diffuse reflection into a finite number of discrete shades. The makeShader function parses the fragment shader source code to create a vertex shader and an interactive user interface, returning a toon p5.Shader that applyShader then uses for interactive real-time rendering of the scene.

code
const toon_shader = `#version 300 es
precision highp float;

uniform vec4 uMaterialColor;
uniform vec3 lightNormal; // eye space
uniform vec4 ambient4; // 'gold'
uniform float shades; // 2, 10, 5, 1

in vec3 normal3; // eye space
out vec4 outputColor;

void main() {
  // Compute lighting intensity based on light direction and surface normal
  float intensity = max(0.0, dot(normalize(-lightNormal), normalize(normal3)));  
  // Quantize intensity into discrete shades to create a banded effect
  float shadeSize = 1.0 / shades;
  float k = floor(intensity / shadeSize) * shadeSize;
  // Calculate the final color using the stepped intensity and ambient influence
  vec4 material = ambient4 * uMaterialColor;
  outputColor = vec4(k * material.rgb, material.a);
}`

let models, modelsDisplayed
let toon
const depth = -0.4 // [1-, 1]

function setup() {
  createCanvas(600, 400, WEBGL)
  toon = makeShader(toon_shader, Tree.pmvMatrix, { x: 10, y: 10 })
  showUniformsUI(toon)
  colorMode(RGB, 1)
  noStroke()
  setAttributes('antialias', true)
  // suppress right-click context menu
  document.oncontextmenu = function () { return false }
  let trange = 200
  models = []
  for (let i = 0; i < 100; i++) {
    models.push(
      {
        position: createVector((random() * 2 - 1) * trange,
                               (random() * 2 - 1) * trange,
                               (random() * 2 - 1) * trange),
        angle: random(0, TWO_PI),
        axis: p5.Vector.random3D(),
        size: random() * 50 + 16,
        color: color(random(), random(), random())
      }
    )
  }
  // gui
  modelsDisplayed = createSlider(1, models.length, int(models.length / 2), 1)
  modelsDisplayed.position(width - 125, 15)
  modelsDisplayed.style('width', '120px')
}

function draw() {
  orbitControl()
  background('#1C1D1F')
  push()
  stroke('green')
  axes({ size: 175 })
  grid({ size: 175 })
  pop()
  applyShader(toon, {
    scene: () => {
      for (let i = 0; i < modelsDisplayed.value(); i++) {
        push()
        fill(models[i].color)
        translate(models[i].position)
        rotate(models[i].angle, models[i].axis)
        let radius = models[i].size / 2
        i % 3 === 0 ? cone(radius) : i % 3 === 1 ? 
                      sphere(radius) : torus(radius, radius / 4)
        pop()
      }
    },
    uniforms: {
      lightNormal: [-(mouseX / width - 0.5) * 2, 
                    -(mouseY / height - 0.5) * 2,
                    depth]
    }
  })
}

Toon shader

The toon shader computes the diffuse reflection intensity as the dot product of the light direction and the surface normal in eye space, and then quantizes this intensity into discrete bands with the given number of shades to achieve a cartoon-like effect. The final color is obtained by multiplying the quantized intensity with the material’s color and ambient light, yielding a stepped shading on the rendered scene.

const toon_shader = `#version 300 es
precision highp float;

uniform vec4 uMaterialColor;
uniform vec3 lightNormal;
uniform vec4 ambient4; // 'gold
uniform float shades; // 2, 10, 5, 1

in vec3 normal3;
out vec4 outputColor;

void main() {
  // Compute lighting intensity based on light direction and surface normal
  float intensity = max(0.0, dot(normalize(-lightNormal), normalize(normal3)));  
  // Quantize intensity into discrete shades to create a banded effect
  float shadeSize = 1.0 / shades;
  float k = floor(intensity / shadeSize) * shadeSize;
  // Calculate the final color using the stepped intensity and ambient influence
  vec4 material = ambient4 * uMaterialColor;
  outputColor = vec4(k * material.rgb, material.a);
}`

Several uniform variables are declared in the toon shader to control its real-time behavior and appearance, each being manipulated through different user interactions or p5 elements. The table below outlines these variables, their data types, and how they are handled:

typevariablehandled by
vec4uMaterialColorfill
vec3lightNormalmouse interaction
vec4ambient4color picker p5.Element
floatshadesslider p5.Element

The normal3 varying variable defines the fragment’s surface normal in eye space, interpolated from vertex data using barycentric interpolation.

Setup

The setup function calls makeShader to instantiate the toon shader and display its user controls as p5.Elements at the screen position 10, 10.

let toon;

function setup() {
  createCanvas(600, 400, WEBGL);
  toon = makeShader(toon_shader, Tree.pmvMatrix, { x: 10, y: 10 });
  showUniformsUI(toon);
  // scene models instantiation
}

To initialize the toon p5.Shader, the makeShader function performs two key tasks:

  1. It parses the toon_shader string source and matrices params to infer a vertex shader.
  2. It parses uniform variable comments to create a uniformsUI object to interactively setting the shader uniform variable values.

Vertex Shader

When makeShader(toon_shader, Tree.pmvMatrix) is executed, it triggers a parser that:

  1. Searches for varying variables within the fragment shader source code (toon_shader) to generating corresponding vertex shader varying variables, adhering to this naming convention.
  2. Identifies mask bit fields within the matrices param (Tree.pmvMatrix) to define the vertex projection onto clip space.

This process results in the creation of the following vertex shader:

#version 300 es
precision highp float;
in vec3 aPosition;
in vec3 aNormal;
uniform mat3 uNormalMatrix; // used to compute normal3
uniform mat4 uModelViewProjectionMatrix; // used for vertex projection
out vec3 normal3;
void main() {
  normal3 = normalize(uNormalMatrix * aNormal); // computed in eye space
  gl_Position = uModelViewProjectionMatrix * vec4(aPosition, 1.0);
}

This shader is then logged to the console, coupled with the fragment shader, and returned as a p5.Shader instance alongside it.

uniformsUI

The makeShader(toon_shader, Tree.pmvMatrix) function also parses the fragment shader’s uniform variable comments to create a uniformsUI object, mapping uniform variable names to p5.Element instances for interactive setting their values:

const toon_shader = `#version 300 es
precision highp float;

uniform vec4 ambient4; // 'gold'
uniform float shades; // 2, 10, 5, 1

// ...`

maps ambient4 to a color picker, preset to gold, and shades to a slider defined in [2..10] with a default value of 5.

Draw

Rendering is achieved with applyShader which defines the scene geometry and sets the value of the uniforms which are not defined in uniformsUI:

function draw() {
  // add orbitCOntrol; render axes and grid hints
  applyShader(toon, {
    scene: () => {
      for (let i = 0; i < modelsDisplayed.value(); i++) {
        push()
        fill(models[i].color)
        translate(models[i].position)
        rotate(models[i].angle, models[i].axis)
        let radius = models[i].size / 2
        i % 3 === 0 ? cone(radius) : i % 3 === 1 ? 
                      sphere(radius) : torus(radius, radius / 4)
        pop()
      }
    },
    uniforms: {
      lightNormal: [-(mouseX / width - 0.5) * 2, 
                    -(mouseY / height - 0.5) * 2,
                    depth]
    }
  })
}