Course outline
Build: Camera Views
In one line
The showroom gets orbit controls with limits, three preset views (side, front, rear wheel) on buttons that work with a keyboard, a camera that refits when the screen changes shape, and the bike's rear wheel.
Before you start
You will learn
- Add orbit controls with limits and three preset views
- Compute each view from the bike's size and the screen's shape
- Add the rear wheel to the bike
You write code in
In your lumen-3d project: add or replace these files, then run npm run dev. Copy the texture rubber_tiles_diff_1k.jpg from this site into public/data/3d/textures/
Files you touch
- index.html
- src/main.js
- src/world/camera.js
- src/systems/controls.js
- src/objects/bike.js
- public/data/3d/textures/rubber_tiles_diff_1k.jpg
Think of it like this
A shop window with a rotating plinth is turned by an assistant. When a customer says "show me the back wheel", the assistant turns it gently to that spot and stops if the customer starts turning it themselves. This build gives the showroom that assistant.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- preset view
- A camera position and target saved under a name, such as "side", that a button can fly to.
- refit
- Computing a preset again after the screen changed shape, if the visitor has not moved the camera since.
- tick
- A method the loop calls every frame with the time since the last frame, in seconds (Topic 3.2).
- hub
- The centre of a wheel, where the axle goes. The detail view looks at the rear wheel's hub.
Step by step
01Views are computed, not typed in
viewFor(name, camera, bike) measures the bike with Box3, then returns a { position, target }. Side: from +z at the distance that fits the bike's length. Front: from +x. Detail: close to the rear wheel's hub. It returns null for an empty bike, so the buttons do nothing instead of sending the camera to NaN.
02Controls with limits
createControls() sets minDistance 0.8, maxDistance 6, maxPolarAngle π / 2, no panning and damping. maxPolarAngle stops the camera at the horizon; the bike's middle is above the floor, so the camera stays above it too.
03Fly, cut or cancel
goTo(name) starts a 1.2 s flight, using the arc of Topic 7.5. With reduced motion, or { instant: true }, it sets the camera at once. A start event from the controls cancels the flight and forgets the preset, so refit() leaves a camera the visitor placed alone.
04Wire the buttons and the shape
index.html has three buttons with a data-view each. main.js shows them only after the support check, so they do not appear on the no-WebGL fallback. The ResizeObserver calls controls.refit() after setting the aspect.
05The rear wheel
createWheel(name, x) builds a tyre (a torus with the tread texture), a rim, a hub and 18 spokes as one InstancedMesh, so a wheel costs 4 draw calls. The bike scene now makes 18 draw calls: 9 for the frame tubes, stays and fork legs, 8 for the two wheels and 1 for the stand.
Try it yourself
- 1
A view that is too close
In the example change
new THREE.Vector3(0.35, 0.35, 0.75)inviewFortonew THREE.Vector3(0.35, 0.35, 0.3)and press "Rear wheel". The offset is 0.58 m, closer thanminDistance, so the controls push the camera back: it stops 0.80 m from the hub. - 2
A faster flight
Set
FLIGHT_SECONDSto0.3. The buttons now almost cut. Turn on "reduce motion" in your operating system and press a button: the camera cuts at once.
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 of Phases 3, 5 and 6 are shortened stand-ins. Press the three buttons; drag, scroll or pinch. The tread texture loads from /data/3d/textures.
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
draw calls: 18 | triangles: 7968Your 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; }
#views { position: fixed; left: 50%; bottom: 16px; transform: translateX(-50%); display: flex; gap: 8px; }
#views[hidden] { display: none; }
#views button { min-height: 44px; padding: 0 18px; border: 1px solid #3b4757; border-radius: 22px; background: #121b26; color: inherit; font: inherit; cursor: pointer; }
#views button:hover { border-color: #ff6b2c; }
#views button:focus-visible { outline: 3px solid #2a9df4; outline-offset: 2px; }
#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>
<nav id="views" aria-label="Camera views" hidden>
<button type="button" data-view="side">Side</button>
<button type="button" data-view="front">Front</button>
<button type="button" data-view="detail">Rear wheel</button>
</nav>
<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
Forget the aspect on resize
In main.js remove the line resizeCamera(camera, width, height); from resize().
Myth vs fact
Myth
Preset views can be fixed numbers.
Fact
Fixed numbers fit one screen shape. Computed views refit for a phone held upright, which needs more than three times the distance.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
The detail view reads the wheel's world position with
getWorldPosition(). When Phase 9 spins the wheels, the hub stays where it is, so the view stays valid. If the whole bike moved, the view would have to be computed again each time. - ▸
maxDistancecaps the refit. On a window narrower than about 0.43 (width divided by height), such as a folded phone's cover screen, the side view would need more than 6 m. The controls clamp it to 6 m (measured: 5.48 m at 0.47, 5.99 m at 0.43, 6.00 m at 0.40) and the bike's ends are cropped. RaisemaxDistanceif you must support such screens. - ▸
Every wheel shares one tyre material, so the texture is on the GPU once. The two wheels are separate groups, not clones of one mesh, because later phases need them as separate parts to name, pick and paint.
Remember this
- 1
The problem this solves: Lena's two bugs are fixed. Her swipe is no longer a surprise: the showroom fills the whole screen, so a swipe on it is meant to turn the bike and there is no page to scroll (a page that scrolls around a canvas needs the
pan-yfix of Topic 7.3). The camera can no longer go under the floor or through the bike, because of limits. Ama's request is met: one tap on "Rear wheel" flies to a close view of it. - 2
What it adds.
src/world/camera.jsgainsfitDistance()andviewFor()(each preset computed from the bike's size and the screen's shape). The newsrc/systems/controls.jswrapsOrbitControls(damping, limits, no pan), flies between views on an eased arc, stops when the visitor grabs the controls, and cuts when reduced motion is on.bike.jsgetscreateWheel()and the rear wheel ("rear-wheel");index.htmlthree buttons. - 3
How it plugs in.
controlshas atick(delta), soloop.add(controls)is all the loop needs.main.jscreates the bike first, because the views are measured from it. The old turntable is gone: the camera now does the moving. A gentle turntable returns in Phase 9, with a pause button. - 4
What the showroom can do now: orbit, zoom and tilt with mouse or touch, within limits; jump to the side, front or rear wheel with buttons (Tab, then Enter or Space); stay fitted when a phone turns; show both wheels. Still missing: clicking a part (Phase 8), keys that orbit the camera (Topic 8.4), colours (Phase 12) and the real model (Phase 10).
- 5
Starting point. This build starts from your files after Phase 6. The live example below shortens the files this phase does not change, so it runs on its own; if your files differ in detail, keep yours and add what is new. These
main.jsandbike.jsare compact: they keep the same function and part names, but leave out Phase 6'sloadEnvironment(scene, renderer)call, the tyre's normal map and the shadow flags. Keep those lines from your own files if you want the reflections and shadows.
Explain it without notes
Why does the build compute viewFor when you click, instead of when the page loads?
What happens when the visitor drags the camera and then turns the phone?
Practice
Add a fourth preset, "top", that looks straight down at the bike from 2 m above. What limit gets in the way, and what do you change?
Make a key press, 1, 2 or 3, jump to a preset. Where does the listener go?
Done when you can
Wire OrbitControls with limits and a target into the loop
Compute preset views from the bike's size and the aspect
Cancel flights on user input and respect reduced motion
Say which files this phase added or changed and why