Toon shading, or cel shading, gives 3D geometry a flat, cartoon-like look by quantizing diffuse reflection into a finite number of discrete shades. In this p5.js v2 version the shader is written as a baseMaterialShader().modify() hook using the p5.strands DSL — no raw GLSL strings, no hand-written vertex shader. createPanel wires the color and shades controls to the shader automatically each frame.


Toon shader

The combineColors hook in baseMaterialShader().modify() receives the eye-space vNormal varying directly — no vertex shader boilerplate required. Diffuse intensity is the dot product of the surface normal and the (normalized) light direction; it is then snapped into discrete bands to produce the cel-shading look.

toon = baseMaterialShader().modify({
  uniforms: {
    'float u_shades':   5,
    'vec3  u_tint':     [1, 0.84, 0],
    'vec3  u_lightDir': [0, 0, 1],
    'vec3  u_color':    [1, 1, 1]
  },
  'vec4 combineColors': `(ColorComponents components) {
    float intensity = max(0.0, dot(normalize(vNormal), normalize(u_lightDir)));
    float shadeSize = 1.0 / clamp(u_shades, 1.0, 10.0);
    float k         = floor(intensity / shadeSize) * shadeSize;
    k = max(k, shadeSize * 0.5);
    return vec4(k * u_tint * u_color, 1.0);
  }`
})

A few things worth noting about the modify() path:

  • vNormal is eye-space and arrives already transformed by uNormalMatrix — no extra matrix multiplication needed.
  • components.baseColor is always [0,0,0] in user modify shaders — fill() does not push uMaterialColor to them, so colour must come from a custom uniform (u_color here).
  • fill() is not needed for geometry submission — modify() shaders are recognised as material shaders, so p5 submits geometry unconditionally.
  • The uniforms map both declares the GLSL uniform line and registers it so setUniform() works.

Uniform panel

createPanel binds schema keys to DOM controls and makes their values readable in draw(). No manual setUniform is needed for panel-owned uniforms — for the rest, we call setUniform directly:

panel = createPanel({
  u_shades: { min: 1, max: 10, value: 5, step: 1 },
  u_tint:   { value: '#ffd700' }
}, { x: 10, y: 10, labels: true, color: 'white' })
// in draw():
toon.setUniform('u_shades',   panel.u_shades.value())
toon.setUniform('u_tint',     [...panel.u_tint.value()])

Passing a target: toon option would push all schema keys automatically each frame — useful when uniform names match exactly and no per-model variation is needed.


First-person light

The light direction tracks the mouse in eye space and is converted to a normalized direction vector before being passed to the shader. Since the light and the normal (vNormal) are already in the same eye space, no additional transform is needed on the GPU side.

const lx  = (mouseX / width  - 0.5) * 2
const ly  = (mouseY / height - 0.5) * 2
const len = Math.sqrt(lx*lx + ly*ly + depth*depth)
toon.setUniform('u_lightDir', [lx/len, ly/len, depth/len])

Notes on p5 v2 shader state

Two things to keep in mind when using custom shaders in p5 v2:

  • noStroke() is required — p5’s default stroke state is enabled, and push()/pop() restores it correctly. Unlike the old p5.treegl pipeline which called noStroke() implicitly, you must call it yourself before drawing with a custom shader.
  • resetShader() at the end of draw() returns p5 to its default shading so gizmos (axes, grid) drawn outside the shader block render correctly.

References