Command Palette

Search for a command to run...

Hectal
Course outline
Topic 14.6Advanced ~82 min · step 6 of 6

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.

0/6 · 0%

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

    Pause it

    Press Pause animation: the button says Resume animation and the glow holds at 0.8. With reduced motion on in your system settings, your project starts steady and the button says Play animation.

  3. 3

    Flash faster

    In the first example, change 1.5 to 4 in glowBrightness. The cycle line becomes 0.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 glow overlay on its own (WebGL 2) New tab

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.

idle

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.8
The showroom after Phase 14 New tab

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

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, rear-light, headlight
rear light at -0.12, 0.74, 0.00

Your project files

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

src/world/glow-webgl2.jsjs

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.

terminal
$ document.elementFromPoint(x, y) at the lamp
── what you'll see ──
element at the light: CANVAS glow
Clicking a part does nothing: no highlight, no price.

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's device.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. 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. 2

    What it adds. bike.js gains createRearLight(): a mesh named rear-light at (−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) and src/world/glow-webgpu.js (Topic 14.3's steps, a 24-byte uniform struct as in Topic 14.4). src/systems/rear-glow.js tries 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.js gains the price, index.html the .glow style, main.js the lamp and the glow.

  3. 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 has aria-hidden="true" (the panel lists the rear light as a real part) and pointer-events: none (clicks reach the 3D canvas).

  4. 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 ResizeObserver resizes it with the page. Light that hides behind geometry needs three.js's own pipeline: bloom, Phase 15.

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

01

Why does the build use a separate canvas instead of three.js's context?

02

How does the glow know where to draw, every frame?

Practice

01

Make the glow amber in both drawers.

02

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