Command Palette

Search for a command to run...

Hectal
Course outline
Topic 13.6Advanced ~68 min · step 6 of 6

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.

0/6 · 0%

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, WebGPU or WebGL 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 = 0 and 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.

src/main.jsadd to filejs
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. 1

    Run the WebGPU path

    Open your project with ?webgpu at the end of the address. The console prints backend: WebGPU in Chrome with a GPU. Then add forceWebGL: true to the WebGPURenderer options: it prints backend: WebGL 2 (fallback) with no warning, the WebGPU path on the WebGL 2 backend. Remove the option to go back.

  2. 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 its emissiveIntensity instead.)

  3. 3

    Change the flakes

    On the WebGPU path, call createPaintTSL({ flakeScale: 60, flakeStrength: 1 }) in main.js. The flakes become fewer and bigger.

Code & diagrams

The showroom after Phase 13 New tab

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

idle

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, headlight

Your project files

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

src/world/renderer.jsjs

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.

terminal
$ Run with ?webgpu
── what you'll see ──
backend: WebGL 2
[error] Uncaught (in promise) TypeError: renderer.setAnimationLoop is not a function
Measured in Chrome with WebGPU. The first line is wrong: `backendName` got a promise, which has no `isWebGPURenderer`, so it answered as if this were the WebGL renderer.

Break #2

A missing HDR file

In scene.js change studio_small_08_1k.hdr to missing.hdr.

terminal
$ Run
── what you'll see ──
[warn] HDR environment failed, using RoomEnvironment: fetch for "https://.../data/3d/hdr/missing.hdr" responded with 404: Not Found
(the showroom still loads, with the room's reflections)

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.z squared 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 need normalLocal.x instead, and a real light source (SpotLight) for the light on the floor. That is a visual choice, not a limit.

  • ▸

    paintFrame disposes 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. 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, which WebGPURenderer doesn'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. 2

    What it adds. src/world/renderer.js keeps createRenderer() (WebGL 2) and adds createWebGPURenderer() (a WebGPURenderer, await init()) and backendName(renderer). src/world/scene.js gives the HDR image straight to scene.environment (both renderers prepare it themselves) and picks the right PMREMGenerator for the fallback room. src/materials/paint.tsl.js holds createPaintTSL() and createGlow(). src/objects/bike.js gains createHeadlight(lensMaterial) and createLens(). src/ui/panel.js prices the headlight. src/main.js reads ?webgpu, creates the matching renderer and paint, prints the backend, and adds the headlight.

  3. 3

    How it plugs in. Every other module keeps import * as THREE from "three": the classes are the same ones. Only renderer.js, scene.js and paint.tsl.js import from three/webgpu and three/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 by WebGPURenderer, so on WebGL 2 the lens is a classic emissive material. And the WebGPU path loads with import() only when asked for: measured with vite build, the main chunk is 135.60 kB (39.22 kB gzipped) instead of 821.75 kB, because three/webgpu (675.53 kB) and paint.tsl.js become separate files that ?webgpu visitors download.

  4. 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. 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 EffectComposer doesn't run on WebGPURenderer, so that path keeps plain rendering until the effects are ported to nodes). Phase 18 handles the loss of the GPU.

  6. 6

    Same function, two paints. paintFrame (Phase 12) doesn't care which paint it gets: createPaint() and createPaintTSL() take the same options and return a material named lumen-paint. That is the trick of a gradual migration: the code around the swap doesn't change.

Explain it without notes

01

What changed in main.js and renderer.js to support WebGPU with fallback, and what did not need to change?

02

Why does loadEnvironment no longer use PMREMGenerator from three?

Practice

01

Add a brightness control: write the line that sets the lens glow to 4 once the model is loaded.

02

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