Course outline
Build: The World Modules
In one line
Split the showroom's single main.js into small modules (renderer, scene, camera, lights, loop), add the stand module and the bike's seat tube, and make the canvas follow its container.
Before you start
You will learn
- Split the showroom into world modules with one job each
- Run a loop with delta time and a resize that follows
#app - Add the seat tube to the bike
You write code in
In your lumen-3d project: add or replace these files, then run npm run dev
Files you touch
- index.html
- src/main.js
- src/world/renderer.js
- src/world/scene.js
- src/world/camera.js
- src/world/lights.js
- src/world/loop.js
- src/objects/showroom.js
- src/objects/bike.js
Think of it like this
A workshop where every tool has its place on the wall. At first you work with a few tools in a heap on the bench. As the job grows you hang each one in its spot, so anyone can find it and put it back. Today the showroom gets its wall.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- module
- A JavaScript file that exports functions or values. Other files
importthem by path. - updatable
- In this course: any object with a
tick(delta)method that the loop calls once per frame. - turntable
- A group holding the stand and the bike, so rotating one object turns them both.
- seat tube
- The frame tube that runs from the bottom bracket up to the seat. This phase's bike part.
Step by step
01Create the files
In lumen-3d/src/ make a folder world with renderer.js, scene.js, camera.js, lights.js and loop.js, and put showroom.js next to bike.js in objects. Paste each file below. The code is the Phase 2 main.js, cut at its seams. index.html is listed so you have the whole project: it is the same as in Phase 1.
02Replace main.js
The new src/main.js imports the creators, checks WebGL 2 (from Phase 1), creates the pieces, groups the stand and bike on a turntable, starts the loop and observes the container.
03Update bike.js
createBike() adds one more call to tubeBetween: the seat tube, named "seat-tube". Open the scene tree and look for it under bike.
04Run it
npm run dev, open the address and resize the window. The bike turns on the stand and stays in proportion. The live example below shows 5 draw calls: the stand, its mark and three tubes.
Try it yourself
- 1
Add a second updatable
In the live example add
loop.add({ tick: () => { camera.position.y = 1.2 + Math.sin(performance.now() / 1000) * 0.1; camera.lookAt(0, 0.4, 0); } });afterloop.add(turntable);. The camera bobs gently: anything with atick(delta)can join the loop without a new loop. - 2
Stop the loop
After
loop.start();addsetTimeout(() => loop.stop(), 500);and two lines that logrenderer.info.render.frameafter 800 ms and after 1200 ms. Both print the same number:stop()calledsetAnimationLoop(null), so no frame is drawn any more and the stand freezes. Use it when the canvas scrolls out of view.
Code & diagrams
Local imports cannot run in the preview, so this example has the code of every project file inline, each under a comment with its path. The files below are the same code, split.
Console
Run the scene to see its console.Expected console output
bike parts: down-tube, top-tube, seat-tube
draw calls: 5 | triangles: 460Your 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.
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Lumen Bikes · 3D showroom</title>
<style>
html, body { margin: 0; height: 100%; background: #0b1118; color: #e6edf6; font-family: system-ui, sans-serif; }
#app { position: fixed; inset: 0; }
canvas { display: block; }
#fallback { position: fixed; inset: 0; display: grid; place-content: center; justify-items: center; gap: 12px; padding: 16px; text-align: center; background: #0b1118; }
#fallback[hidden] { display: none; }
#fallback img { max-width: min(100%, 420px); max-height: 60%; border-radius: 8px; }
</style>
</head>
<body>
<div id="app"></div>
<div id="fallback" hidden>
<p>Your browser can't draw 3D here, so here is a picture of the Lumen bike instead.</p>
<img src="/data/3d/showroom-poster.png" alt="The Lumen Bikes showroom: an orange e-bike on a round display stand" />
</div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
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
A missing export
In your src/world/camera.js remove the word export in front of resizeCamera, then save.
Myth vs fact
Myth
More files make the code slower.
Fact
Vite joins modules for the browser in production, and the code that runs is the same. Files are for people.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
Keep creators free of side effects beyond what they say:
createRenderer(container)appends the canvas (its job);createLights(scene)adds to the scene (its job). A creator that also starts the loop or readswindowmakes tests harder (Phase 19). - ▸
updatablesis a plain array:addpushes, and there is noremove(the practice question adds one). Add it only when something must leave the loop.
Remember this
- 1
The problem this solves: the story's trouble came from one file where nothing had an owner. Kofi could not change the pixel ratio without reading the loop. Every later phase adds more (controls, a panel, loading); without a split
main.jswould become a thousand lines nobody dares touch. - 2
What this phase adds. In
src/world/:renderer.js(createRenderer,resizeRenderer),scene.js,camera.js(createCamera,resizeCamera),lights.jsandloop.js(createLoopreturningstart(),stop(),add(updatable));src/objects/showroom.js(createStand); and the bike's seat tube, from the bottom bracket (0, 0.30, 0) to the seat cluster (−0.10, 0.82, 0), radius 0.020, named"seat-tube".main.jsbecomes wiring. - 3
How it plugs in. The stand moves into
showroom.jsunchanged.tubeBetween, thedown-tubeand thetop-tubekeep their points, which now sit insidecreateBikeas local constants instead of Phase 2's exportedPOINTS(nothing imports it). Phase 1'ssupportsWebGL2(),#fallbackandstartShowroom()stay. The stand and bike share aturntablegroup whosetick(delta)turns 0.25 radians a second: one turn in about 25 s, the same speed as Phase 2'stime / 4000, now at any refresh rate. - 4
Resize and delta. The loop uses a connected
THREE.Timer(Topic 3.2) and passesdeltato everything added withloop.add.main.jsobserves#appwith aResizeObserver(Topic 3.3), skips a 0-pixel box, and the pixel ratio is capped at 2. - 5
What the showroom can do now: every part has a home; the loop reports a real delta; the canvas keeps its proportions in any container; a hidden tab does not make the stand jump. Still missing: the rear triangle (Phase 4), real materials and light (Phase 5), wheels (Phases 6–7), controls (Phase 8). Today the frame is three orange tubes on a stand.
Explain it without notes
Why do renderer.js and camera.js each export a resize function instead of one shared resize()?
What does the loop's add(updatable) buy compared with calling stand.rotation.y += ... in the loop itself?
Practice
Give createLoop a way to remove an updatable. Write the new method.
Make the turntable turn at one full turn per 10 seconds.
Done when you can
Your project has the five
src/world/files,showroom.jsand the updatedbike.jsThe stand and bike turn together at the same speed on any screen
The canvas keeps its proportions when you resize or hide and show it
The scene tree shows
down-tube,top-tubeandseat-tubeunderbike