Course outline
Build: the Turntable and the Pedalling Crank
In one line
The bike pretends to ride on its stand: both wheels and the new crankset turn together at a real speed, the loop caps its delta, and a pause button plus the reduced-motion rule decide whether anything moves.
Before you start
You will learn
- Make the wheels and crankset turn at a real speed
- Cap the delta in the loop
- Add a pause button that respects reduced motion
You write code in
In your lumen-3d project: replace or add the six files (panel.js only gains the crankset's price), then run npm run dev
Files you touch
- src/world/loop.js
- src/ui/a11y.js
- src/objects/bike.js
- src/main.js
- index.html
- src/ui/panel.js
Think of it like this
A bike on a shop's display stand with its back wheel spinning slowly. It looks ready to go. The shop keeps a switch within reach, so a customer who finds it distracting can stop it.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- crankset
- The pedal arms and the big front gear (chainring) that turn together on the bottom bracket.
- chainring
- The big toothed front gear. Ours has 44 teeth.
- gear ratio
- Teeth on the chainring divided by teeth on the rear gear: 44 / 16 = 2.75 wheel turns for each pedal turn.
- updatable
- Any object with a
tick(delta)method, added to the loop.
Step by step
01loop.js: cap the delta
One new line: Math.min(timer.getDelta(), MAX_DELTA) with MAX_DELTA = 0.1. The timer already gives 0 while the tab is hidden.
02bike.js: the crankset
createCrankset() builds the chainring from a cylinder with 44 sides and a radius of 44 × 12.7 mm ÷ 2π = 88.9 mm, a spindle along z and two 170 mm arms 180° apart, just outside the chain stays.
03bike.js: tick
bike.tick(delta) adds speed / WHEEL_RADIUS * delta radians to each wheel (minus: clockwise) and the same times 16 / 44 to the crankset.
04a11y.js and main.js
createMotion(button) returns { mayMove }. main.js adds the updatable that calls bike.tick when the rule allows, and shows the button when WebGL 2 works.
05Run it
The example is the changed files inlined. It steps the bike 60 times by 1/60 s, so the printed turns never depend on the clock.
Try it yourself
- 1
Change the speed
In the build example set
bike.userData.speed = 5(metres a second, 18 km/h) before the 60 steps. After 1 s the wheel has turned 5 / 0.352 / 2π = 2.26 turns. - 2
Press the button
Press Pause: the wheels and the crank stop together and the button reads "Resume animation". The page's camera controls keep working.
Code & diagrams
This is the exact code of loop.js, a11y.js and bike.js below (import and export words removed, because local imports cannot run in the preview), plus short stand-ins for the other files. The button is the page's own. Open the scene tree: the bike now holds a `crankset`.
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
speed: 1.667 m/s, after 1 s: wheel 0.754 turns, crankset 0.274 turns
may move on its own: true | button says: Pause animationYour 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";
const MAX_DELTA = 0.1; // seconds: one slow frame (a debugger stop, a heavy page) must not throw the world forward
// The render loop. Anything with a tick(delta) method can be added; delta is in seconds.
export function createLoop(renderer, scene, camera) {
const timer = new THREE.Timer();
timer.connect(document); // zero delta while the tab is hidden, so nothing jumps when it comes back
const updatables = [];
function frame(time) {
timer.update(time);
const delta = Math.min(timer.getDelta(), MAX_DELTA); // Phase 9: never more than 0.1 s per frame
for (const item of updatables) item.tick(delta);
renderer.render(scene, camera);
}
return {
start() { renderer.setAnimationLoop(frame); },
stop() { renderer.setAnimationLoop(null); },
add(updatable) { updatables.push(updatable); },
};
}
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
Update the crankset by frame
In bike.tick, replace the crankset line with crankset.rotation.z -= 0.05;.
Myth vs fact
Myth
The pedals and the wheel turn at the same speed.
Fact
With a 44-tooth chainring and a 16-tooth rear gear, the wheel turns 2.75 times for one pedal turn.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
A real crank turns the wheel only while you push. On a free-wheel hub the wheel can spin on while the pedals stop. Our pretend ride links them to keep the picture simple.
- ▸
The wheel's rotation grows without limit. After an hour at 4.7 rad/s it is about 17,000 radians. Float64 holds that without visible error, but keep it small (
% (2 * Math.PI)) if the angle is also sent to a shader. - ▸
createMotionreadsmatchMediaeach time the rule is asked (query.matches), so a changed setting takes effect on the next frame without any extra code.
Remember this
- 1
The problem this solves: the story's two complaints. Lena's phone and Ama's monitor now show the same speed, because everything uses delta time. And there is a pause button, plus a still bike for visitors who ask the system to reduce motion.
- 2
What it adds. A new bike part, the crankset (a group named
cranksetat the bottom bracket: a 44-sided chainring, a spindle and two arms).bike.tick(delta)turns both wheels atv / rwithv= 1.667 m/s (6 km/h), and turns the crankset slower by 16/44 (the rear gear has 16 teeth, the chainring 44), so the pedals go round 2.75 times slower than the wheel.src/world/loop.jscaps the delta at 0.1 s.src/ui/a11y.jsis new:createMotion(button)keeps the rule from Topic 9.5.index.htmlgets the button, andmain.jswires them. - 3
How it plugs in.
createLoopkeeps its API.main.jsadds one updatable that callsbike.tick(delta)only whenmotion.mayMove(). The visitor's own camera moves (controls from Phase 7) are another updatable and are never blocked. Phase 8's picking and panel keep working: the crankset is a new child of the bike, so it is pickable at once, andpanel.jsgains its label and price. - 4
What the showroom can do now that it could not before: pretend to ride at a real, frame-rate-independent speed, show a turning crankset, survive a stalled frame, pause on request and stay still for a visitor who reduced motion.
- 5
What is still missing: the chain that joins the chainring to the rear gear (Phase 10), and an idle camera turntable. The camera orbit has no automatic turn yet, so the only automatic motion is the pedalling. Pedals (Phase 15), brakes (Phase 16) and the rest of the bike come later.
Explain it without notes
Why does main.js call bike.tick only when motion.mayMove(), but call the controls always?
How is the crank's angle related to the wheel's angle, and where in the code does the 2.75 come from?
Practice
Add a bike.userData.speed = 0 branch so the bike stands still when speed is 0, without special code.
Make the loop log a warning when a frame needed clamping.
Done when you can
Turn wheels at v / r with delta time
Add a part that follows another with a ratio
Add a pause button that obeys reduced motion
Say what the showroom still lacks (the chain, Phase 10)