Course outline
Build: The Rear Light
In one line
The showroom gets its rear light: a small red lamp on the seat tube, and a pulsing glow drawn by your own GPU code on a transparent canvas over the scene (WebGPU where available, WebGL 2 elsewhere). It follows the lamp, fades from the front, and stops pulsing with the pause button or reduced motion.
Before you start
You will learn
- Add the rear light to the bike
- Draw its glow with your own WebGL 2 and WebGPU code
- Place a 2D effect over a 3D point every frame
You write code in
In your lumen-3d project: add the three glow files, change bike.js, panel.js, main.js and index.html, then npm run dev
Files you touch
- src/world/glow-webgl2.js
- src/world/glow-webgpu.js
- src/systems/rear-glow.js
- src/objects/bike.js
- src/ui/panel.js
- src/main.js
- index.html
Think of it like this
A sticker on a shop window lined up with something inside. The window is a separate layer; move your head and you must move the sticker.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- overlay canvas
- A second, transparent canvas laid exactly over the 3D canvas, drawn by its own code.
- projection to screen
- Turning a world position into canvas pixels with
vector.project(camera)and the canvas size. - premultiplied alpha
- Colours stored already multiplied by their opacity, so blending is
src + dst × (1 − srcAlpha). - fallback chain
- Try the best option first, then the next: WebGPU, then WebGL 2, then nothing.
Step by step
01The drawer on its own
The first example runs glow-webgl2.js alone and reads pixels back: 255, 31, 20, 255 at the centre at full brightness, alpha about 60 at 25 px out, 0 outside the 50 px radius. Then it pulses.
02Pick a backend
createRearGlow() awaits createGlowWebGPU(), which resolves to null without an adapter, then calls createGlowWebGL2(). Both return the same shape, so nothing else asks which one it got. With no adapter, Chrome prints No available adapters. once, and the fallback runs. Read back from the canvas, both drawers give the same pixel: 255, 31, 20, 255 at the centre (WebGPU's canvas stores it as 20, 31, 255, 255, blue first).
03Follow the lamp
Each frame: world position, project(camera) to −1…1, then CSS pixels ((x + 1) / 2 × width, (1 − y) / 2 × height). Size: 0.3 m in pixels at that distance. Brightness: the pulse × how much the lamp's −x direction faces the camera.
04Run the showroom
The second example is every project file in one module. Press Front: the glow fades out. Side: a dim halo. Pause animation: it holds steady.
Try it yourself
- 1
Walk round the bike
Press Front, then Side, then Rear wheel. Measured facing values: 0 from the front (no glow), about 0.55 from the side (a dim halo), 0.85 from the rear-wheel view, where the lamp is above the top of the frame, so you see only the glow's lower edge, or nothing.
- 2
Pause it
Press Pause animation: the button says
Resume animationand the glow holds at 0.8. With reduced motion on in your system settings, your project starts steady and the button saysPlay animation. - 3
Flash faster
In the first example, change
1.5to4inglowBrightness. The cycle line becomes0.65, 0.35, 0.95, 0.65, 0.35: four pulses a second, past the three-flashes-per-second limit of WCAG 2.3.1 (small or dim flashes can pass its thresholds, but staying at three or fewer is the safe rule).
Code & diagrams
The real `glow-webgl2.js`, inlined. Edge pixels are rounded to the nearest 20: soft edges can differ by a step or two between GPUs.
Console
Run the scene to see its console.Expected console output
brightness 1: centre 255, 31, 20, 255 | 25 px out alpha about 60 | 60 px out 0, 0, 0, 0
brightness 0.4: centre 102, 12, 8, 102 | 25 px out alpha about 20 | 60 px out 0, 0, 0, 0
brightness 0: centre 0, 0, 0, 0 | 25 px out alpha about 0 | 60 px out 0, 0, 0, 0
pulse over one cycle: 0.65, 1.00, 0.65, 0.30, 0.65
motion off (pause or reduced motion): 0.8Every project file, joined into one (local imports can't run in the preview). This site's test browser has no WebGPU, so the glow uses the WebGL 2 drawer here; Chrome with a GPU uses WebGPU.
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, rear-light, headlight
rear light at -0.12, 0.74, 0.00Your 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.
// Phase 14: the rear light's glow, drawn with raw WebGL 2 on a transparent canvas laid over the 3D one.
const VERTEX = `#version 300 es
in vec2 corner; // a corner of a square from -1 to 1
uniform vec2 uCentre; // the glow's centre, in canvas pixels from the bottom left
uniform float uSize; // its radius, in canvas pixels
uniform vec2 uViewport; // the canvas size, in pixels
out vec2 vLocal;
void main() {
vLocal = corner;
vec2 pixel = uCentre + corner * uSize;
gl_Position = vec4(pixel / uViewport * 2.0 - 1.0, 0.0, 1.0);
}`;
const FRAGMENT = `#version 300 es
precision mediump float;
in vec2 vLocal;
uniform float uBrightness;
out vec4 colour;
void main() {
float glow = uBrightness * pow(1.0 - smoothstep(0.0, 1.0, length(vLocal)), 2.0);
colour = vec4(vec3(1.0, 0.12, 0.08) * glow, glow); // premultiplied: colour already times alpha
}`;
function compile(gl, type, source) {
const shader = gl.createShader(type);
gl.shaderSource(shader, source);
gl.compileShader(shader);
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) throw new Error("Glow shader: " + gl.getShaderInfoLog(shader));
return shader;
}
/** Returns { backend, resize(width, height), draw(x, y, size, brightness), dispose() }, or null without WebGL 2. */
export function createGlowWebGL2(container) {
const canvas = document.createElement("canvas");
canvas.className = "glow";
canvas.setAttribute("aria-hidden", "true"); // decoration: the parts list says the bike has a rear light
const gl = canvas.getContext("webgl2", { alpha: true, premultipliedAlpha: true, antialias: false });
if (!gl) return null;
container.append(canvas);
const program = gl.createProgram();
gl.attachShader(program, compile(gl, gl.VERTEX_SHADER, VERTEX));
gl.attachShader(program, compile(gl, gl.FRAGMENT_SHADER, FRAGMENT));
gl.linkProgram(program);
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) throw new Error("Glow program: " + gl.getProgramInfoLog(program));
const u = (name) => gl.getUniformLocation(program, name);
const uniforms = { centre: u("uCentre"), size: u("uSize"), viewport: u("uViewport"), brightness: u("uBrightness") };
const vao = gl.createVertexArray();
gl.bindVertexArray(vao);
const buffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1]), gl.STATIC_DRAW); // a strip of 2 triangles
const corner = gl.getAttribLocation(program, "corner");
gl.enableVertexAttribArray(corner);
gl.vertexAttribPointer(corner, 2, gl.FLOAT, false, 0, 0);
gl.bindVertexArray(null);
gl.enable(gl.BLEND);
gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA); // premultiplied alpha: overlapping glow adds up softly
let ratio = 1;
return {
backend: "WebGL 2",
resize(width, height) {
ratio = Math.min(window.devicePixelRatio, 2);
canvas.width = Math.round(width * ratio);
canvas.height = Math.round(height * ratio);
gl.viewport(0, 0, canvas.width, canvas.height);
},
// x, y in CSS pixels from the top left (like the mouse); size in CSS pixels
draw(x, y, size, brightness) {
gl.clearColor(0, 0, 0, 0);
gl.clear(gl.COLOR_BUFFER_BIT);
if (brightness <= 0) return;
gl.useProgram(program);
gl.bindVertexArray(vao);
gl.uniform2f(uniforms.centre, x * ratio, canvas.height - y * ratio); // WebGL counts y from the bottom
gl.uniform1f(uniforms.size, size * ratio);
gl.uniform2f(uniforms.viewport, canvas.width, canvas.height);
gl.uniform1f(uniforms.brightness, brightness);
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
},
dispose() {
gl.deleteBuffer(buffer);
gl.deleteVertexArray(vao);
gl.deleteProgram(program);
canvas.remove();
},
};
}
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
Let the overlay take the clicks
Remove pointer-events: none; from the .glow rule in index.html.
Myth vs fact
Myth
An effect drawn over the canvas is part of the 3D scene.
Fact
It is a picture on glass with no depth: the glow draws over tubes in front of the lamp. Effects that hide behind objects belong in the renderer (Phase 15).
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
Each canvas loses its own context on a context loss. The overlay should listen for
webglcontextlost(or WebGPU'sdevice.lost) and rebuild; Phase 18 adds that. - ▸
Requesting a WebGPU adapter starts or wakes the GPU process. A page that never needs WebGPU shouldn't probe for it at start-up "just in case".
Remember this
- 1
The problem this solves: Ama's wish: a rear light that glows and pulses like a real one. A red box alone looks flat, and the glow is a small 2D job, where raw GPU code is reasonable (Topic 14.5).
- 2
What it adds.
bike.jsgainscreateRearLight(): a mesh namedrear-lightat (−0.12, 0.74, 0), behind the seat tube. Two drawers share one API,{ backend, resize(width, height), draw(x, y, size, brightness), dispose() }:src/world/glow-webgl2.js(Topic 14.1's steps, a VAO, premultiplied-alpha blending) andsrc/world/glow-webgpu.js(Topic 14.3's steps, a 24-byte uniform struct as in Topic 14.4).src/systems/rear-glow.jstries WebGPU, falls back to WebGL 2, and each frame projects the lamp to the screen (Vector3.project, Topic 8.3), fades it by how much it faces the camera, sizes it from the distance, and draws.panel.jsgains the price,index.htmlthe.glowstyle,main.jsthe lamp and the glow. - 3
Motion and accessibility. The pulse is a smooth 1.5 Hz wave, under WCAG 2.3.1's three flashes per second. It uses Phase 9's rule: when
motion.mayMove()is false (pause button, or reduced motion), the glow holds at 0.8. The overlay hasaria-hidden="true"(the panel lists the rear light as a real part) andpointer-events: none(clicks reach the 3D canvas). - 4
What the separate canvas costs. A second GPU context (Chromium keeps at most 16 WebGL contexts per page, Topic 18.2), and no depth: the glow draws over tubes in front of the lamp. Its buffer is capped at pixel ratio 2, so a ratio-3 phone does not pay 9 times the pixels, and a
ResizeObserverresizes it with the page. Light that hides behind geometry needs three.js's own pipeline: bloom, Phase 15. - 5
What the showroom can do now: everything before, plus a rear light that glows in the right place from every view, pulses gently, respects pause and reduced motion, and works with or without WebGPU. Still missing: the test ride (Phase 16), the performance budget (Phase 17), production fallbacks and context-loss recovery for both canvases (Phase 18).
Explain it without notes
Why does the build use a separate canvas instead of three.js's context?
How does the glow know where to draw, every frame?
Practice
Make the glow amber in both drawers.
Skip drawing when the lamp is well outside the screen.
Done when you can
Your bike has a rear-light part with a price in the panel
The glow follows the lamp, fades from the front, and pauses
It uses WebGPU where available and WebGL 2 otherwise
Clicks still pick parts through the overlay
Your files match the seven files of this topic