Command Palette

Search for a command to run...

Hectal
Course outline
Topic 12.7Advanced ~59 min · step 7 of 7

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.

0/7 · 0%

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. MeshPhysicalMaterial draws it with its own smooth normal, so the flakes stay underneath.
chunk
A named piece of three.js's shader text, written #include <name>. onBeforeCompile finds 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. 1

    Bigger flakes

    In the live example change flakeScale = 140 to 40 and run. The flakes are now about 2.5 cm and look like confetti: blocky streaks along the tubes.

  2. 2

    Flakes too small

    Change flakeScale = 140 to 400. 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. 3

    No flakes at all

    Change flakeDensity = 0.45 to 0. 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

The showroom after Phase 12 New tab

Every project file of this phase, joined into one (local imports cannot run in the preview). Orbit the bike and watch the flakes glint.

idle

Console

Run the scene to see its console.

Expected console output

frame meshes painted: 9 | paint: lumen-paint

Your project files

Your lumen-3d project after this topic. Click a file to see it in full.

src/materials/paint.glsl.jsjs

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>".

terminal
$ Run
── what you'll see ──
[error] Uncaught Error: paint.glsl.js: #include <normal_fragment_map> is not in this version of three.js's shader
The message repeats, and the six frame parts disappear from the picture: wheels and chain still draw, the tubes do not. Loud and obvious, which is the point.

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.

  • ▸

    customProgramCacheKey makes 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 reads nonPerturbedNormal, so the coat stays smooth: that is what real paint looks like.

  • ▸

    If the console shows warning X4122 ... cannot be represented accurately in double precision when 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. 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 MeshStandardMaterial has neither. This is this phase's part, the paint.

  2. 2

    **Why onBeforeCompile and not ShaderMaterial.** The paint must still react to the key light, the shadow, the environment and the tone mapping. A ShaderMaterial has none of that: you would write the whole lighting yourself (Topic 12.6). So createPaint() makes a MeshPhysicalMaterial (it has clearcoat) 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. 3

    What it adds. src/materials/paint.glsl.js with createPaint(options); bike.js gains paintFrame(bike, paint), which swaps the material on the six frame parts and disposes the old one; main.js calls both after the model loads, and also calls loadEnvironment(scene, renderer) again: Phase 6's call was dropped from main.js in an earlier phase, and without an environment the clear coat has nothing to reflect.

  4. 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 with material.clone()) keeps its flakes, because createPaint() copies onBeforeCompile into 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

01

Why does the paint use MeshPhysicalMaterial and onBeforeCompile instead of a ShaderMaterial?

02

Why are the flakes computed in object space?

Practice

01

Add a flakeColour uniform that tints the sparkle gold.

02

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