Course outline
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.
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
userDatathat 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
OutlinePassdraws 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
Turn the glow off
In the live example set
effects.enabled = falsebeforeloop.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
Make everything glow
In
composer.jschange the bloom threshold from2.5to1. 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 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.
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 lensYour 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";
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.
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
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
What it adds.
src/world/composer.jsbuilds the chain Render, Outline, Bloom, Output on a half-float target with 4 samples, so the edges stay smooth.createLoopgets adrawfunction so the loop paints through the chain.main.jscreates the chain, resizes it with the canvas, and callseffects.highlight(...)when the picker's state changes: the hovered part, or, when nothing is hovered (touch screens never hover), the chosen part. The new#fxbutton turns the effects off and on; its label stays the same andaria-pressedtells a screen reader the state. The outline does not pulse (pulsePeriodis 0 by default), so nothing flashes and the reduced-motion rule needs no change. - 3
The bike part of this phase: the pedals.
createPedals()adds two pedal groups to the bike, namedpedals. Each hangs on the end of a crank arm and stays level as the crankset turns.addRidetells 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
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.jsnow skips meshes markeduserData.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?webgpupathcreateEffectsreturns the plain renderer:EffectComposeris WebGL-only. - 5
How it plugs in. The chain is optional by design. If the GPU cannot draw into half-float pictures,
createEffectsreturns a plain object, the page renders as before, and#fxstays hidden. The button switches the chain off, and the plain renderer takes over on the next frame. - 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
?webgpupath (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
Starting point. The showroom as Phase 14 left it (the bike loads from
lumen-bike-draco.glb). The same edits work on the code-builtcreateBike().
Explain it without notes
Why does the build keep effects.render and the plain renderer both available?
Why is the bloom selective by brightness rather than by layers here?
Practice
Add a second #fx-style toggle that lowers the pixel ratio of the chain to 1 (effects.resize with composer.setPixelRatio(1)).
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