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 discreteshades
. The makeShader function parses the fragment shader source code to create a vertex shader and an interactive user interface, returning atoon
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:
type | variable | handled by |
---|---|---|
vec4 | uMaterialColor | fill |
vec3 | lightNormal | mouse interaction |
vec4 | ambient4 | color picker p5.Element |
float | shades | slider 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:
- It parses the
toon_shader
string source andmatrices
params to infer a vertex shader. - 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:
- Searches for varying variables within the fragment shader source code (
toon_shader
) to generating corresponding vertex shader varying variables, adhering to this naming convention. - 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]
}
})
}