Course outline
Build: The Headlight in TSL
In one line
The showroom gains a WebGPU path: with ?webgpu in the address it runs on WebGPURenderer (with its own WebGL 2 fallback) and paints the frame with the TSL twin of Phase 12's paint, src/materials/paint.tsl.js. WebGL 2 stays the default. Both paths get this phase's part, the headlight.
Before you start
You will learn
- Add a WebGPU path behind ?webgpu, with its own fallback
- Use the TSL twin of the paint on that path
- Add the headlight with a glowing lens
You write code in
In your lumen-3d project: add src/materials/paint.tsl.js, and change renderer.js, scene.js, bike.js, panel.js and main.js
Files you touch
- src/world/renderer.js
- src/world/scene.js
- src/materials/paint.tsl.js
- src/objects/bike.js
- src/ui/panel.js
- src/main.js
Think of it like this
A shop installs a new till next to the old one and lets a few staff use it for a month before the old one goes. Customers are served either way. The showroom does the same: the WebGPU path runs for whoever opts in with ?webgpu, and the proven WebGL 2 path serves everyone else.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- paint.tsl.js
- The project file that builds the Lumen paint and the headlight lens from TSL nodes.
- backendName
- A small helper that returns
WebGL 2,WebGPUorWebGL 2 (fallback)for the console and bug reports. - opt-in path
- A new way of running that only users who ask for it get (here
?webgpu), while everyone else keeps the proven one. - equirectangular texture
- A panorama image stored as a flat rectangle. Assigned to
scene.environment, it lights and reflects. - emissive
- Light a surface gives off itself, whatever lights are in the scene. The headlight lens uses it.
- glow uniform
- The number that sets the lens brightness. Set
glow.value = 0and the lamp is off.
Step by step
01Add the second renderer
createWebGPURenderer builds a WebGPURenderer with the same pixel ratio, shadows and tone mapping (shared in setUp), then waits for init(). main.js uses it only when the address has ?webgpu, so startShowroom became async.
const WEBGPU = new URLSearchParams(location.search).has("webgpu");
const renderer = WEBGPU ? await createWebGPURenderer(app) : createRenderer(app);02Load the environment without PMREM
loadEnvironment gives the HDR texture an equirectangular mapping and assigns it to scene.environment: both renderers prepare it themselves. If the file fails, the room fallback uses the PMREMGenerator that matches the renderer, so metal never goes black.
03Add paint.tsl.js
Copy the file from the list below. createPaintTSL is the metal-flake paint as a MeshPhysicalNodeMaterial. createGlow is the WebGPU-path lens: dark glass with an emissiveNode that is brightest where the surface faces the camera.
04Add the headlight and swap the paint
createHeadlight(lensMaterial) returns a group named headlight at (0.50, 0.86, 0): a dark housing and a flat lens. After the model loads, main.js paints the frame (createPaintTSL() or createPaint()) and adds the headlight with createGlow() on the WebGPU path or the default createLens().
05Price the new part
panel.js has a PARTS table of labels and prices. Add headlight: { label: "Headlight", price: 45 } so the new button appears and shows a price. A part with no entry would show ask us.
Try it yourself
- 1
Run the WebGPU path
Open your project with
?webgpuat the end of the address. The console printsbackend: WebGPUin Chrome with a GPU. Then addforceWebGL: trueto theWebGPURendereroptions: it printsbackend: WebGL 2 (fallback)with no warning, the WebGPU path on the WebGL 2 backend. Remove the option to go back. - 2
Switch the lamp off
On the WebGPU path, after the model loads, add
bike.getObjectByName("headlight").children[1].material.userData.glow.value = 0;. The lens turns dark glass: no new shader, only a number changed. (On WebGL 2 the lens is a classic material: set itsemissiveIntensityinstead.) - 3
Change the flakes
On the WebGPU path, call
createPaintTSL({ flakeScale: 60, flakeStrength: 1 })inmain.js. The flakes become fewer and bigger.
Code & diagrams
Every project file of this phase, joined into one (local imports cannot run in the preview). It runs the default WebGL 2 path; change `WEBGPU` to `true` to run the WebGPU path (measured: `backend: WebGPU` in Chrome with a GPU, the fallback warning and `backend: WebGL 2 (fallback)` without).
Console
Run the scene to see its console.Expected console output
backend: WebGL 2
parts: down-tube, top-tube, seat-tube, chain-stays, seat-stays, fork, front-wheel, rear-wheel, handlebar, crankset, chain, headlightYour 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";
// Two renderers since Phase 13. WebGL 2 stays the default: Phase 12's GLSL paint (and Phase 15's effects) need it.
// Add ?webgpu to the address to run the WebGPU path (WebGPURenderer and the TSL paint), which falls back to WebGL 2 itself.
export function createRenderer(container) {
const renderer = new THREE.WebGLRenderer({ antialias: true });
setUp(renderer, container);
return renderer;
}
/** Phase 13: the WebGPU path. Async: the browser must be asked for a GPU before the first frame. */
export async function createWebGPURenderer(container) {
const { WebGPURenderer } = await import("three/webgpu"); // downloaded only on this path: about 676 kB
const renderer = new WebGPURenderer({ antialias: true });
setUp(renderer, container);
await renderer.init(); // picks the backend; render() before this finishes throws
return renderer;
}
function setUp(renderer, container) {
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); // sharp, but never more than 4x the pixels of ratio 1
renderer.shadowMap.enabled = true; // Phase 5: draw shadow maps
renderer.toneMapping = THREE.NeutralToneMapping; // Phase 5: keeps Lumen orange orange when the light is bright
container.append(renderer.domElement);
}
/** "WebGPU", "WebGL 2 (fallback)" or "WebGL 2": what the console says, and what to put in a bug report. */
export function backendName(renderer) {
if (!renderer.isWebGPURenderer) return "WebGL 2";
return renderer.backend.isWebGPUBackend ? "WebGPU" : "WebGL 2 (fallback)";
}
export function resizeRenderer(renderer, width, height) {
renderer.setSize(width, height); // also sets the canvas's CSS size
}
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
Forget the await
In main.js change await createWebGPURenderer(app) to createWebGPURenderer(app), and open the page with ?webgpu.
Break #2
A missing HDR file
In scene.js change studio_small_08_1k.hdr to missing.hdr.
Myth vs fact
Myth
Switching to WebGPU means rewriting the whole showroom.
Fact
Only three files changed their imports. The scene, loader, controls, picker and loop are the same code, because classes are shared and classic materials are converted.
Myth
A glow needs a light.
Fact
An emissive material gives off its own colour, with or without lights. A real light that shines on the scene is a different thing, and the lens does not light the bike.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
The headlight lens uses
normalView.zsquared for its falloff, which depends on the camera, not on the bike's direction: it looks like a bulb from every side. A directional beam would neednormalLocal.xinstead, and a real light source (SpotLight) for the light on the floor. That is a visual choice, not a limit. - ▸
paintFramedisposes the old materials after swapping, as in Phase 12. With node materials this also frees the compiled programs when nothing else uses them. The picker's highlight copies (material.clone()) are separate materials and are cached per colour.
Remember this
- 1
Why the showroom needs this now. Tomás asked whether to move to WebGPU. The honest answer is "not everything at once": Phase 12's paint uses
onBeforeCompile, whichWebGPURendererdoesn't support (Topic 13.4), and the effect chain coming in Phase 15 is WebGL-only too. So the team ships WebGPU as an opt-in path, the way real migrations go: both paths stay working, and the WebGPU one gets the TSL paint. And the bike gets a headlight, which Ama asked for so the evening view looks finished. - 2
What it adds.
src/world/renderer.jskeepscreateRenderer()(WebGL 2) and addscreateWebGPURenderer()(aWebGPURenderer,await init()) andbackendName(renderer).src/world/scene.jsgives the HDR image straight toscene.environment(both renderers prepare it themselves) and picks the rightPMREMGeneratorfor the fallback room.src/materials/paint.tsl.jsholdscreatePaintTSL()andcreateGlow().src/objects/bike.jsgainscreateHeadlight(lensMaterial)andcreateLens().src/ui/panel.jsprices the headlight.src/main.jsreads?webgpu, creates the matching renderer and paint, prints the backend, and adds the headlight. - 3
How it plugs in. Every other module keeps
import * as THREE from "three": the classes are the same ones. Onlyrenderer.js,scene.jsandpaint.tsl.jsimport fromthree/webgpuandthree/tsl. The picker, the controls and the loop did not change. The lens is the one material that differs per path: a node material can only be drawn byWebGPURenderer, so on WebGL 2 the lens is a classic emissive material. And the WebGPU path loads withimport()only when asked for: measured withvite build, the main chunk is 135.60 kB (39.22 kB gzipped) instead of 821.75 kB, becausethree/webgpu(675.53 kB) andpaint.tsl.jsbecome separate files that?webgpuvisitors download. - 4
What the showroom can do now that it could not before. With
?webgpu, run on WebGPU where the browser has it (falling back to WebGL 2 without any code of ours) and paint the frame from a node graph with uniforms (paint.userData.flake.density.value); without it, run exactly as before. Both show a headlight that glows, and the console names the backend. - 5
What is still missing. The paint colour is fixed: a customer can't pick one yet. Phase 14 adds the rear light with a small raw-GPU overlay. Phase 15 adds bloom so the headlight glows on the WebGL 2 path (its
EffectComposerdoesn't run onWebGPURenderer, so that path keeps plain rendering until the effects are ported to nodes). Phase 18 handles the loss of the GPU. - 6
Same function, two paints.
paintFrame(Phase 12) doesn't care which paint it gets:createPaint()andcreatePaintTSL()take the same options and return a material namedlumen-paint. That is the trick of a gradual migration: the code around the swap doesn't change.
Explain it without notes
What changed in main.js and renderer.js to support WebGPU with fallback, and what did not need to change?
Why does loadEnvironment no longer use PMREMGenerator from three?
Practice
Add a brightness control: write the line that sets the lens glow to 4 once the model is loaded.
Write the PARTS line for a part named rear-light with the label Rear light and a price of 30 (Phase 14 will add the part).
Done when you can
Run the showroom on both paths and read the backend
Explain why only three files changed their imports
Add a node-material part (the lens) and change it with a uniform
Name what is still missing and which phase adds it