Course outline
Build: The Test Ride
In one line
The showroom gets a Test ride button. Rapier loads only when it is pressed; then the bike rides a track with a ramp, driven by held keys or on-screen buttons, followed by the camera, reset when it falls, with brake pads that glow while braking. The brakes are this phase's part.
Before you start
You will learn
- Load a physics engine only when it is needed
- Turn the showroom into a small ride with keys and buttons
- Add the brakes, and show the input on the bike
You write code in
In your lumen-3d project: npm install @dimforge/rapier3d-compat@0.21.0, add src/systems/physics.js, change bike.js, panel.js, main.js and index.html
Files you touch
- src/systems/physics.js
- src/objects/bike.js
- src/ui/panel.js
- src/main.js
- index.html
Think of it like this
A shop that lets you take the bike round the block. The bike stays on the stand for everyone else; only when you ask does someone open the gate.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- dynamic import
import("…")at run time: the code is downloaded only when that line runs, not with the page.- createTestRide
- The build's function that sets up Rapier, the track and the bike's body, and returns
{ input, step, tick, reset, end }. - hold button
- A button that acts while pressed (pointerdown) and stops on pointerup, pointerleave or pointercancel.
- starting flag
- A variable that is true while something loads, so a second press can't start it twice.
Step by step
01Load on demand
await import("@dimforge/rapier3d-compat") inside createTestRide: nothing of Rapier is downloaded until the first press. In your project, install it with npm install @dimforge/rapier3d-compat@0.21.0.
02Build the world
A 40 m strip of floor (top at y = 0, like the stand), a 7° ramp 7 m out, and the bike's body: a box and two balls, pitch only. The track is also drawn, so the visitor sees where to ride.
03Wire input and camera
setInput(name, on) writes ride.input and lights the brake pads. The loop gets one extra updatable, ride?.tick(delta), which runs the fixed steps and moves the camera with the bike.
04End cleanly
End ride calls ride.end(): the bike goes back to the stand, the track's geometries and material are disposed, world.free() frees the WebAssembly memory, and the camera returns to the side view.
Try it yourself
- 1
Ride it
In the showroom example, press Test ride, then hold Pedal (or click the page and hold W). The bike leaves the stand along the track and the camera follows; hold Brake (or Space) and the pads glow red (measured: emissive intensity 1.5 while held, 0 after). End ride puts the bike back and the views return.
- 2
A steeper ramp
In
physics.js, change the ramp'srotation.zfrom0.12to0.25. Measured with the scripted ride: the bike still makes it over, with a higher hop (0.18 m instead of 0.12), and arrives a little slower (4.19 m/s instead of 4.31).
Code & diagrams
The real `createBrakes()`, inlined. The pads switch on and off every 45 frames so you can see the glow.
Console
Run the scene to see its console.Expected console output
brakes: 2 calipers, at 0.56, 0.69 and -0.50, 0.68
pad glow released: 0
pad glow braking: 1.5 | one material, so both calipers glow together: trueEvery project file, joined into one (local imports can't run in the preview). Press Test ride, then hold W (click the page first so it has the keyboard) or the Pedal button.
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, pedals, brakes, rear-light, headlightYour 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 STEP = 1 / 60; // physics always steps by 1/60 s (Topic 16.2)
const PEDAL_FORCE = 30; // newtons at the wheel: an easy pedal for a 20 kg bike (the rider is left out)
const ROLLING_DAMPING = 0.3; // linear damping while rolling: top speed = force / (mass × damping) = 30 / (20 × 0.3) = 5 m/s
const BRAKE_DAMPING = 4; // linear damping while braking
const MAX_CATCH_UP = 0.25; // seconds: never try to simulate more than this after a stall
/**
* Phase 16: the test ride. Rapier is loaded only when the visitor starts a ride (import() keeps it out of the first
* download). The bike is one rigid body: a box for the frame and two balls for the wheels. It may pitch over the ramp
* (rotation round z) but not roll or steer. Its pose is copied to `root` (the loaded model) after every step.
*/
export async function createTestRide({ root, bike, scene, camera, controls }) {
const { default: RAPIER } = await import("@dimforge/rapier3d-compat");
await RAPIER.init();
const world = new RAPIER.World({ x: 0, y: -9.81, z: 0 });
world.timestep = STEP;
// the track: a long strip of floor along +x (its top at y = 0, like the stand) and a low ramp 7 m out
const track = new THREE.Group();
track.name = "track";
const grey = new THREE.MeshStandardMaterial({ color: "#3a4250", roughness: 0.9 });
const strip = new THREE.Mesh(new THREE.BoxGeometry(40, 0.02, 1.6), grey);
strip.position.set(18, -0.011, 0);
const ramp = new THREE.Mesh(new THREE.BoxGeometry(1.6, 0.04, 1.2), grey);
ramp.position.set(7, 0.07, 0); // its low edge sits just below the floor, so a wheel rolls on without a lip
ramp.rotation.z = 0.12; // about 7 degrees
track.add(strip, ramp);
scene.add(track);
world.createCollider(RAPIER.ColliderDesc.cuboid(20, 0.1, 3).setTranslation(18, -0.1, 0));
world.createCollider(RAPIER.ColliderDesc.cuboid(0.8, 0.02, 0.6).setTranslation(7, 0.07, 0).setRotation(new THREE.Quaternion().setFromEuler(ramp.rotation)));
const start = root.position.clone();
const body = world.createRigidBody(
RAPIER.RigidBodyDesc.dynamic().setTranslation(start.x, start.y, start.z).setLinearDamping(ROLLING_DAMPING).setAngularDamping(0.5).enabledRotations(false, false, true),
);
// colliders in the bike's own space (metres, Phase 4's geometry): low, heavy frame; frictionless wheels that "roll"
world.createCollider(RAPIER.ColliderDesc.cuboid(0.45, 0.12, 0.08).setTranslation(0, 0.55, 0).setDensity(450), body); // about 15.6 kg
for (const x of [-0.55, 0.58]) world.createCollider(RAPIER.ColliderDesc.ball(0.35).setTranslation(x, 0.35, 0).setFriction(0).setFrictionCombineRule(RAPIER.CoefficientCombineRule.Min).setDensity(13), body); // 2.3 kg each; Min: 0 wins over the floor's 0.5 (the default would average them to 0.25)
const input = { pedal: false, brake: false };
const before = new THREE.Vector3();
let accumulator = 0, flippedFor = 0;
function reset() {
body.setTranslation(start, true);
body.setRotation({ x: 0, y: 0, z: 0, w: 1 }, true);
body.setLinvel({ x: 0, y: 0, z: 0 }, true);
body.setAngvel({ x: 0, y: 0, z: 0 }, true);
flippedFor = 0;
sync();
}
function sync() {
before.copy(root.position);
const p = body.translation(), q = body.rotation();
root.position.set(p.x, p.y, p.z);
root.quaternion.set(q.x, q.y, q.z, q.w);
// the follow camera: move the camera and its target by the bike's movement, so the view angle stays the same
const moved = root.position.clone().sub(before);
camera.position.add(moved);
controls.orbit.target.add(moved);
}
/** One fixed physics step with the current input. Public, so tests can drive the ride without a clock. */
function step() {
body.resetForces(true);
if (input.pedal) body.addForce({ x: PEDAL_FORCE, y: 0, z: 0 }, true);
body.setLinearDamping(input.brake ? BRAKE_DAMPING : ROLLING_DAMPING);
world.step();
bike.userData.speed = body.linvel().x; // Phase 9's tick turns the wheels and the crank at this speed
const up = new THREE.Vector3(0, 1, 0).applyQuaternion(root.quaternion);
flippedFor = up.y < 0.3 || body.translation().y < -2 ? flippedFor + STEP : 0;
if (flippedFor > 1) reset(); // on its side (or off the track) for a second: put it back
sync();
}
return {
input,
body, // the Rapier body, for tests and tools
step,
reset,
get speed() { return body.linvel().x; },
get distance() { return body.translation().x - start.x; },
tick(delta) {
accumulator += Math.min(delta, MAX_CATCH_UP);
while (accumulator >= STEP) { accumulator -= STEP; step(); }
},
end() {
reset();
scene.remove(track);
track.traverse((o) => o.geometry?.dispose());
grey.dispose();
world.free(); // frees the WebAssembly memory of the whole world
bike.userData.speed = 1.667; // back to Phase 9's gentle showroom speed
},
};
}
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
Trust the disabled button alone
In main.js, delete if (starting) return; and press Test ride twice quickly from code (or a test tool).
Myth vs fact
Myth
Adding physics makes the whole page heavier.
Fact
Only if it loads with the page. Loaded with import() on the first press, it costs nothing for visitors who never ride.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
Measure the real cost: in your project,
npm run buildlists Rapier as its own chunk because of the dynamic import; check its size there and in the Network panel when you press Test ride, and decide whether a smaller engine or the non-compat build (a separate.wasm) is worth it (Topic 18.4). - ▸
world.free()releases the WebAssembly memory; the JavaScriptworldobject can't be used afterwards. Forgetting it leaks the whole simulation's memory on every ride.
Remember this
- 1
The problem this solves: Tomás's "let them feel it", without making the showroom slower for the many visitors who never ride. Rapier is a large download (its WebAssembly is inside the JavaScript), so it loads with
import()only on the first press, and its world is freed when the ride ends. - 2
What it adds.
src/systems/physics.js:createTestRide({ root, bike, scene, camera, controls })builds the world, the track and the ramp, the bike's body, the fixed-steptick, the watchdog andend().bike.jsgainscreateBrakes(): two calipers above the rims, whose pads glow red throughuserData.show(on).panel.jsprices the brakes.index.htmlgains the Test ride button and the Pedal, Brake and Reset buttons.main.jswires them: astartingflag (a second press while Rapier loads must not start a second ride), held keys and held pointer buttons writing the ride'sinput, and the camera views hidden while the follow camera runs. - 3
Keyboard, mouse and touch. Hold W or the up arrow to pedal; S, the down arrow or Space to brake; R resets. The on-screen buttons work with pointer events, so mouse, touch and pen behave the same, and they use
touch-action: none. Space on a focused button keeps its normal job. Ablurhandler lets go of both inputs when the window loses focus: a key released in another window never sendskeyuphere, and (measured with a scripted blur while W was held) the bike would otherwise keep pedalling. If Rapier fails to load (offline, a blocked file), the button says "Test ride unavailable. Try again", logs the error, and the showroom carries on as before. The ride is started by the visitor, so it is not "motion on its own"; the pause button still stops the showroom's own motion. - 4
What the showroom can do now that it could not before: let a visitor ride the bike along a track and over a ramp, with real momentum and braking, on any input device, and recover from falls. Still missing: a budget that keeps all of this fast on a cheap phone (Phase 17), fallbacks, context-loss recovery and the accessibility pass on the whole page (Phase 18).
Explain it without notes
Why does the build load Rapier with import(), and what does end() clean up?
How do keyboard, mouse and touch all drive the same ride?
Practice
Show the current speed in km/h next to the ride buttons.
Add the brakes' price to panel.js as 65 instead of 50.
Done when you can
Your showroom has a Test ride that loads Rapier only when pressed
Keys and on-screen buttons pedal and brake, and the pads glow
The camera follows, a fallen bike resets, and End ride cleans up
Your files match the five files of this topic