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 abaseMaterialShader().modify()hook using the p5.strands DSL — no raw GLSL strings, no hand-written vertex shader.createPanelwires 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:
vNormalis eye-space and arrives already transformed byuNormalMatrix— no extra matrix multiplication needed.components.baseColoris always[0,0,0]in user modify shaders —fill()does not pushuMaterialColorto them, so colour must come from a custom uniform (u_colorhere).fill()is not needed for geometry submission —modify()shaders are recognised as material shaders, so p5 submits geometry unconditionally.- The
uniformsmap both declares the GLSLuniformline and registers it sosetUniform()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, andpush()/pop()restores it correctly. Unlike the oldp5.treeglpipeline which callednoStroke()implicitly, you must call it yourself before drawing with a custom shader.resetShader()at the end ofdraw()returns p5 to its default shading so gizmos (axes, grid) drawn outside the shader block render correctly.
References
- p5.strands tutorial — Luke Plowden
- Writing shaders in JavaScript — Dave Pagurek
- Cel shading — Wikipedia