Course outline
Build: The Metal-Flake Paint
In one line
The frame gets the Lumen paint: a metallic orange with tiny flakes that glint as the bike turns, under a glossy clear coat. It is one material, still lit by the scene's lights and environment.
Before you start
You will learn
- Write a paint material with flakes and a clear coat
- Apply one material to many parts
- Fail loudly when three.js changes
You write code in
In your lumen-3d project: add src/materials/paint.glsl.js and change src/objects/bike.js and src/main.js
Files you touch
- src/materials/paint.glsl.js
- src/objects/bike.js
- src/main.js
Think of it like this
A car painter does not mix a new colour for every car. They spray the same base, scatter the same flakes, then add a clear coat. Here the paint is one recipe, a function, and you spray it on every frame tube.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- flake
- One tiny cell of the paint with its own random tilt. A cell whose tilt faces the light and the camera lights up.
- clear coat
- A thin glossy layer on top of the paint.
MeshPhysicalMaterialdraws it with its own smooth normal, so the flakes stay underneath. - chunk
- A named piece of three.js's shader text, written
#include <name>.onBeforeCompilefinds a chunk by its name. - object space
- Positions measured from the mesh's own origin. Flakes use it so they stay stuck to the frame when the bike turns.
Step by step
01One recipe
createPaint() makes a metallic, glossy MeshPhysicalMaterial and three uniforms (uFlakeScale, uFlakeDensity, uFlakeStrength). They are stored in paint.userData.flake so a UI can change them.
02Four small edits
onBeforeCompile adds declarations after #include <common> in both shaders, saves the object-space position after #include <begin_vertex>, and tilts the normal after #include <normal_fragment_maps>.
03Flakes stay on the bike
Each flake is a cube cell of the object's own space, with a random tilt from a hash (Topic 12.4). The tilt is turned into view space with the object's axes, so the flakes glint when the bike or the camera moves.
04Spray the frame
paintFrame() walks the six frame parts of the loaded model, sets one shared material on every mesh and disposes the old one. Wheels and chain keep their own.
Try it yourself
- 1
Bigger flakes
In the live example change
flakeScale = 140to40and run. The flakes are now about 2.5 cm and look like confetti: blocky streaks along the tubes. - 2
Flakes too small
Change
flakeScale = 140to400. A flake is now 2.5 mm, smaller than a pixel at this size. The paint turns into a fine grain that shimmers when the bike turns: this is aliasing, not smooth metal. Real fixes fade the flakes out with distance or lower the scale. - 3
No flakes at all
Change
flakeDensity = 0.45to0. Every cell is empty: the frame is an even metallic orange with the glossy clear coat on top. The flakes are the only difference from a plain physical material.
Code & diagrams
Every project file of this phase, joined into one (local imports cannot run in the preview). Orbit the bike and watch the flakes glint.
Console
Run the scene to see its console.Expected console output
frame meshes painted: 9 | paint: lumen-paintYour project files
Your lumen-3d project after this topic. Click a file to see it in full.
The whole file after this topic. If yours differs, you can copy this one and carry on.
import * as THREE from "three";
// The Lumen paint: a metallic base with tiny tilted flakes, under a clear glossy coat.
// It is a MeshPhysicalMaterial, so the scene's lights, shadows, environment and tone mapping all keep working.
// onBeforeCompile edits three.js's own shader text before it is compiled (Topic 12.6 says why that is fragile).
/** Puts `extra` after `token` in a three.js shader. Throws if the token is gone, because three.js changed its chunks. */
function insertAfter(source, token, extra) {
if (!source.includes(token)) throw new Error("paint.glsl.js: " + token + " is not in this version of three.js's shader");
return source.replace(token, token + "\n" + extra);
}
const VERTEX_DECLARATIONS = `
varying vec3 vFlakePos; // where on the object this pixel is (object space)
varying vec3 vToViewX; varying vec3 vToViewY; varying vec3 vToViewZ; // the object's axes in view space`;
const VERTEX_SET = `
vFlakePos = position;
vToViewX = normalMatrix * vec3(1.0, 0.0, 0.0);
vToViewY = normalMatrix * vec3(0.0, 1.0, 0.0);
vToViewZ = normalMatrix * vec3(0.0, 0.0, 1.0);`;
const FRAGMENT_DECLARATIONS = `
uniform float uFlakeScale; // flakes per metre
uniform float uFlakeDensity; // 0 to 1: the share of cells that hold a flake
uniform float uFlakeStrength; // how far a flake tilts the surface
varying vec3 vFlakePos;
varying vec3 vToViewX; varying vec3 vToViewY; varying vec3 vToViewZ;
vec3 flakeHash(vec3 p) { // a repeatable random vec3 from 0 to 1 for a cell
p = fract(p * vec3(0.1031, 0.1030, 0.0973));
p += dot(p, p.yxz + 33.33);
return fract((p.xxy + p.yxx) * p.zyx);
}`;
// Runs right after three.js has worked out the surface normal. The clear coat has its own normal, so it stays smooth.
const FRAGMENT_FLAKES = `
vec3 flakeCell = floor(vFlakePos * uFlakeScale);
float flakePresent = step(flakeHash(flakeCell + 7.0).x, uFlakeDensity);
vec3 tilt = flakeHash(flakeCell) * 2.0 - 1.0; // a random direction in object space
vec3 tiltView = tilt.x * vToViewX + tilt.y * vToViewY + tilt.z * vToViewZ;
normal = normalize(normal + uFlakeStrength * flakePresent * tiltView);
roughnessFactor *= mix(1.0, 0.5, flakePresent); // a flake is smoother than the paint round it`;
export function createPaint({ color = "#ff6b2c", flakeScale = 140, flakeDensity = 0.45, flakeStrength = 0.7 } = {}) {
const paint = new THREE.MeshPhysicalMaterial({ color, metalness: 0.75, roughness: 0.4, clearcoat: 1, clearcoatRoughness: 0.05 });
paint.name = "lumen-paint";
const uniforms = {
uFlakeScale: { value: flakeScale },
uFlakeDensity: { value: flakeDensity },
uFlakeStrength: { value: flakeStrength },
};
paint.userData.flake = uniforms; // so a UI can change them later: paint.userData.flake.uFlakeScale.value = 200
paint.onBeforeCompile = (shader) => {
Object.assign(shader.uniforms, uniforms); // share the same objects, so later changes reach the GPU
shader.vertexShader = insertAfter(shader.vertexShader, "#include <common>", VERTEX_DECLARATIONS);
shader.vertexShader = insertAfter(shader.vertexShader, "#include <begin_vertex>", VERTEX_SET);
shader.fragmentShader = insertAfter(shader.fragmentShader, "#include <common>", FRAGMENT_DECLARATIONS);
shader.fragmentShader = insertAfter(shader.fragmentShader, "#include <normal_fragment_maps>", FRAGMENT_FLAKES);
};
paint.customProgramCacheKey = () => "lumen-paint-1"; // one program for every paint made here, whatever its colour
// material.clone() (Phase 8's glow uses it) does not copy onBeforeCompile, so a clone would lose its flakes: copy it by hand
paint.clone = function () {
const copy = THREE.MeshPhysicalMaterial.prototype.clone.call(this);
copy.onBeforeCompile = this.onBeforeCompile;
copy.customProgramCacheKey = this.customProgramCacheKey;
return copy;
};
return paint;
}
Break it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
A chunk that is not there
Change "#include <normal_fragment_maps>" to "#include <normal_fragment_map>".
Myth vs fact
Myth
Custom paint needs a ShaderMaterial.
Fact
Only if you want to write the lighting yourself. Editing a built-in material keeps lights, shadows, environment and tone mapping for free.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
customProgramCacheKeymakes every paint share one program whatever its colour: colour is a uniform. Without it, three.js keys the cache on the text of the function, which is the same here, so it would share anyway: the key makes the intent explicit and survives minification. - ▸
The flake normal is only added to the base layer.
MeshPhysicalMaterial's clear coat readsnonPerturbedNormal, so the coat stays smooth: that is what real paint looks like. - ▸
If the console shows
warning X4122 ... cannot be represented accurately in double precisionwhen the showroom loads on Chrome for Windows, it is the harmless Direct3D compiler note explained in Topic 12.6, not a bug in the paint.
Remember this
- 1
The problem this solves: Ama's last note on the showroom was "the orange looks like plastic". Real frame paint has flakes that catch the light and a glossy layer on top. A plain
MeshStandardMaterialhas neither. This is this phase's part, the paint. - 2
**Why
onBeforeCompileand notShaderMaterial.** The paint must still react to the key light, the shadow, the environment and the tone mapping. AShaderMaterialhas none of that: you would write the whole lighting yourself (Topic 12.6). SocreatePaint()makes aMeshPhysicalMaterial(it hasclearcoat) and adds only the flakes, by editing its shader text. The price is fragility: if a future three.js renames one of the four chunks we search for,insertAfter()throws a clear error instead of silently drawing no flakes. Phase 13 builds the same paint in TSL, which does not have this problem. - 3
What it adds.
src/materials/paint.glsl.jswithcreatePaint(options);bike.jsgainspaintFrame(bike, paint), which swaps the material on the six frame parts and disposes the old one;main.jscalls both after the model loads, and also callsloadEnvironment(scene, renderer)again: Phase 6's call was dropped frommain.jsin an earlier phase, and without an environment the clear coat has nothing to reflect. - 4
What the showroom can do now: show a painted frame with flakes that glint as the camera moves, with a clear coat that reflects the studio image, and let any code change the flakes with
paint.userData.flake. A selected part (Phase 8 glows it withmaterial.clone()) keeps its flakes, becausecreatePaint()copiesonBeforeCompileinto its clones by hand:clone()leaves it out. Still missing: the colour picker (Phase 11 does it in React), a TSL version (Phase 13) and a performance budget for phones (Phase 17: a Physical material costs more than Standard).
Explain it without notes
Why does the paint use MeshPhysicalMaterial and onBeforeCompile instead of a ShaderMaterial?
Why are the flakes computed in object space?
Practice
Add a flakeColour uniform that tints the sparkle gold.
Make paintFrame also accept the fork only, by passing a list of names.
Done when you can
Your frame shows flakes that glint as you orbit
A wrong chunk name throws a readable error
The paint is one material shared by all frame meshes
Your three files match this topic