You 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, you 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, painterly 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, you 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 you see in the renderer above, you 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:
You could make your 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;
}
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. You can 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 you'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;
}
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);
}
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.