Command Palette

Search for a command to run...

Hectal
Course outline
Topic 15.6Advanced ~37 min · step 6 of 6

Build: Glow and Outline

In one line

The showroom gets an effect chain: the headlight glows (and nothing else does), the part under the pointer gets an outline, and the bike gets its pedals. A button turns the effects off.

0/6 · 0%

Before you start

You will learn

  • Add an effect chain with bloom and an outline to the showroom
  • Make only the headlight glow
  • Outline the part under the pointer
  • Add the pedals to the bike

You write code in

In your lumen-3d project (npm run dev) and in the editor on this page

Files you touch

  • src/world/composer.js
  • src/world/loop.js
  • src/objects/bike.js
  • src/systems/picking.js
  • src/ui/panel.js
  • src/main.js
  • index.html

Think of it like this

The studio lights go on. Until now the showroom was a clean render. Now the headlight shines, and when a visitor points at the fork a thin white line traces it, like a salesperson's finger on the real bike.

Words you'll meet

New words in this topic, in plain English. Come back here whenever one feels fuzzy.

highlight
Setting the outline pass's selectedObjects: the pass draws a line round them.
keepLook
A flag in a mesh's userData that tells the picker not to swap its material on hover or selection; Phase 13 sets it on the headlight lens, and this build makes the picker obey it.
effects.enabled
A switch on the chain: when false, the loop draws with the plain renderer.
selectedObjects
The list of objects an OutlinePass draws a line round.

Step by step

01Add composer.js

Copy src/world/composer.js. It exports createEffects(renderer, scene, camera), which returns { enabled, passes, render, resize, highlight }.

02Let the loop draw through it

In loop.js, createLoop takes a fourth argument draw that defaults to the plain renderer.render. main.js passes effects.render.

03Add the parts

Copy the new createPedals into bike.js, the one-line change in addRide, and the line in main.js that adds the pedals to the bike.

04Connect the pointer

The picker's onChange already runs when the hovered part changes. It now also calls effects.highlight(object).

05Test

Run npm run dev. The headlight glows. Move the mouse over the pedals, the fork or the wheels: a white outline follows. On a touch screen, tap a part (or use the parts list): the chosen part is outlined. Press Glow and outline to switch the effects off and back on.

Try it yourself

  1. 1

    Turn the glow off

    In the live example set effects.enabled = false before loop.start(). The headlight is a plain pale disc, the outline disappears too, and the backdrop is a little lighter (no tone mapping on the background): the plain renderer takes over.

  2. 2

    Make everything glow

    In composer.js change the bloom threshold from 2.5 to 1. The lit stand is brighter than 1, so it glows: a white haze covers the floor and washes out the wheels. This is the second breakIt of Topic 15.3 on the real bike.

Code & diagrams

The showroom with the effect chain New tab

The project's changed files joined into one module, because local imports cannot run in the preview, with short stand-ins for the renderer, scene, lights and camera. The pedals are outlined from code, as the picker would do on hover. Look at the status bar: the chain adds draw calls.

idle

Console

Run the scene to see its console.

Expected console output

bike parts: down-tube, top-tube, seat-tube, chain-stays, seat-stays, fork, front-wheel, rear-wheel, handlebar, crankset, pedals, headlight
effect chain: RenderPass > OutlinePass > UnrealBloomPass > OutputPass
outlined part: pedals
glow round the headlight lens: yes, bloom adds light round the lens

Your project files

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

src/world/composer.jsjs

The whole file after this topic. If yours differs, you can copy this one and carry on.

import * as THREE from "three";
import { EffectComposer } from "three/addons/postprocessing/EffectComposer.js";
import { RenderPass } from "three/addons/postprocessing/RenderPass.js";
import { OutlinePass } from "three/addons/postprocessing/OutlinePass.js";
import { UnrealBloomPass } from "three/addons/postprocessing/UnrealBloomPass.js";
import { OutputPass } from "three/addons/postprocessing/OutputPass.js";

// Phase 15: the effect chain. Render > Outline > Bloom > Output, in that order.
// Bloom is selective by brightness: only pixels brighter than 2.5 glow. The lit stand and paint stay below it; the headlight lens (6) does not.
export function createEffects(renderer, scene, camera) {
  const plain = { enabled: false, render: () => renderer.render(scene, camera), resize() {}, highlight() {}, passes: [] };
  // EffectComposer is WebGL-only (Topic 13.4): the ?webgpu path draws without effects until they are ported to nodes
  if (renderer.isWebGPURenderer) return plain;
  // The chain draws into half-float pictures. A GPU that cannot do that keeps the plain renderer: no glow, but a working page.
  if (!renderer.extensions.has("EXT_color_buffer_float") && !renderer.extensions.has("EXT_color_buffer_half_float")) return plain;

  // samples: 4 keeps the smooth edges that antialias: true gave the canvas, but a composer's pictures do not have
  const target = new THREE.WebGLRenderTarget(1, 1, { type: THREE.HalfFloatType, samples: 4 });
  const composer = new EffectComposer(renderer, target);
  const size = renderer.getSize(new THREE.Vector2());

  const outline = new OutlinePass(size, scene, camera);
  outline.edgeStrength = 4;
  outline.edgeThickness = 1.5;
  outline.visibleEdgeColor.set("#ffffff");
  outline.hiddenEdgeColor.set("#ffffff"); // the same line where the part hides behind another

  composer.addPass(new RenderPass(scene, camera));
  composer.addPass(outline);
  composer.addPass(new UnrealBloomPass(size, 0.6, 0.4, 2.5)); // strength, radius, threshold
  composer.addPass(new OutputPass()); // tone mapping and the sRGB curve, once, at the end

  const effects = {
    enabled: true,
    passes: composer.passes,
    // The visitor can turn the effects off (a slow phone, a preference): the plain renderer takes over at once.
    render(delta) { return effects.enabled ? composer.render(delta) : renderer.render(scene, camera); },
    resize(width, height) {
      composer.setPixelRatio(renderer.getPixelRatio()); // the pictures must match the canvas's real pixels
      composer.setSize(width, height);
    },
    highlight(object) { outline.selectedObjects = object ? [object] : []; },
  };
  return effects;
}

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

Pointer tint on the lens

In picking.js remove || o.userData.keepLook from the traverse line, then hover the headlight.

terminal
$ Hover the headlight
── what you'll see ──
the glow goes out while the pointer is over it, and comes back when it leaves

Myth vs fact

Myth

The outline is part of the picker.

Fact

The picker only decides which part is hovered. The chain draws the line, so turning effects off removes the outline but not the hover tint.

Pro corner

Extra depth for experienced readers. New to this? Skip it for now and come back later.

  • ▸

    The outline pass renders the selected objects again into its own masks, so it adds draw calls in proportion to the selected part's meshes. Selecting one part at a time keeps this small.

  • ▸

    A bigger showroom would keep the effect settings in one config object so Phase 17's budget can lower them on slow devices.

Remember this

  1. 1

    The problem this solves: the headlight (Phase 13) is a lens with no glow round it, and hover only tints a part (Phase 8). Ama wants the bike to feel lit and wants every part to say "yes, I am the one you pointed at", even a thin one like the pedal axle.

  2. 2

    What it adds. src/world/composer.js builds the chain Render, Outline, Bloom, Output on a half-float target with 4 samples, so the edges stay smooth. createLoop gets a draw function so the loop paints through the chain. main.js creates the chain, resizes it with the canvas, and calls effects.highlight(...) when the picker's state changes: the hovered part, or, when nothing is hovered (touch screens never hover), the chosen part. The new #fx button turns the effects off and on; its label stays the same and aria-pressed tells a screen reader the state. The outline does not pulse (pulsePeriod is 0 by default), so nothing flashes and the reduced-motion rule needs no change.

  3. 3

    The bike part of this phase: the pedals. createPedals() adds two pedal groups to the bike, named pedals. Each hangs on the end of a crank arm and stays level as the crankset turns. addRide tells them the crank's angle every frame. They are found by name, so they work on the loaded model as well as the code-built one.

  4. 4

    The headlight. Phase 13's lens on the WebGL 2 path has emissive intensity 6. Only the lens is brighter than the bloom threshold (2.5). A first try with a threshold of 1 made the whole stand glow, because the lit stand is brighter than 1 in linear light: the threshold must sit above every lit surface. picking.js now skips meshes marked userData.keepLook (Phase 13 marks the lens), because the hover tint would lower its emissive and the glow would go out. Phase 14's rear light (emissive 1.5) stays below the threshold on purpose: it keeps its own glow from Phase 14, so bloom does not touch it. Because the whole picture now passes through tone mapping, the dark backdrop looks a little darker than before (Topic 15.2). On the ?webgpu path createEffects returns the plain renderer: EffectComposer is WebGL-only.

  5. 5

    How it plugs in. The chain is optional by design. If the GPU cannot draw into half-float pictures, createEffects returns a plain object, the page renders as before, and #fx stays hidden. The button switches the chain off, and the plain renderer takes over on the next frame.

  6. 6

    What the showroom can do now that it could not before: glow from the lights, an outline for the hovered part, pedals that turn with the crank. Still missing: effects on the ?webgpu path (they need the node pipeline), a physics test ride (Phase 16), a frame-time budget so the chain can drop itself on a slow phone (Phase 17), accessibility work on the whole page (Phase 18).

  7. 7

    Starting point. The showroom as Phase 14 left it (the bike loads from lumen-bike-draco.glb). The same edits work on the code-built createBike().

Explain it without notes

01

Why does the build keep effects.render and the plain renderer both available?

02

Why is the bloom selective by brightness rather than by layers here?

Practice

01

Add a second #fx-style toggle that lowers the pixel ratio of the chain to 1 (effects.resize with composer.setPixelRatio(1)).

02

Change the headlight's price in panel.js to 55.

Done when you can

  • Add an effect chain with a plain-renderer fallback to the showroom

  • Glow only the headlight, using brightness

  • Outline the hovered or chosen part from the picker's change event

  • Add parts to a loaded model by name