A single-page, interactive car showroom built with React Router 7, React Three Fiber and Tailwind CSS 4. The concept is a private garage after dark: you start the car, the room wakes up around it, and the car is the brightest thing in it. The UI stays quiet so the car does the talking.
- Black screen, thin loading line.
- The gate. Letterbox bars and a silhouette of the car, edged by a rim light while the camera drifts slowly. The only control is a push-to-start button ("Enter silently" skips the sound;
Enteralso works). - Ignition. The camera holds on the car while the starter cranks and the engine catches with one short jolt and a rev. The headlights and taillights double-flash on.
- Pull back, lights up. Once the revs settle, the camera eases back to the hero shot while the ceiling lights fade up one after another, rippling out from above the car, each with a switch clunk.
- Light falls on the car. A shaft of light pours down from the ceiling onto the car while a title card plays, then the letterbox bars retract and the page rises in. The ceiling settles to a dim glow.
All shaders are compiled while the gate is showing, and quality/resolution never change during the entry, so nothing stalls or flashes black mid-sequence.
- Rev it. Tap REV or press
Spacefor a throttle blip: sound, needle, and a squat of the body. Idling is still.Estops or restarts the engine. - Camera angles. 360°, Front, Side, Rear and Top (right edge on desktop, a row above the paints on phones; keys
1–5). Each one is framed for that angle and keeps the car centred. Orbit toggles the slow turntable drift. - Change the paint. Seven lacquered dots, bottom right. A scanner gantry sweeps nose to tail and repaints the car as it passes, with a glowing seam on the bodywork and a laser line on the floor.
- Customize. The popover next to the paints holds the garage (Hex, Neon, Concrete, Carbon), the floor (gloss epoxy, polished concrete, checker tiles) and the light level. The paint reflects the room.
- Orbit. Drag to rotate and scroll or pinch to zoom. Once you let go it slowly orbits again.
- Specs, test drive, reserve. Demo only; nothing is sent anywhere.
All audio is synthesized with WebAudio in app/lib/engine-audio.ts, with no audio files. It includes the V8 start-up and lumpy idle, throttle blips, light-switch clunks and a scanner whoosh. The engine sound, rev counter, headlights, body motion and camera shake all read one rpm curve from app/lib/experience.ts, so they stay in sync. A header toggle mutes it.
app/lib/stage-state.ts holds a small mutable store (gate/intro times, room power, engine level, rpm, paint-scan time) that scene components read inside useFrame. Per-frame animation therefore never goes through React state. INTRO in that file and ENTRY in routes/home.tsx set the choreography.
public/models/car.glbis compressed with glTF-Transform (Meshopt + WebP textures): 27.6 MB → 5.9 MB. Material names are preserved, since the configurator targets thecarpaintmaterial.- The paint has no normal map. The source model tiled a noise normal map over the body, which showed up as spots in the reflections. The encode script below removes it, and
car-model.tsxalso clears it at runtime. - Normals are stored at 14 bits, not the default 8. With 8-bit normals, clearcoat reflections break up into blocks.
- Reflections come from an unlit copy of the garage baked into a cube map once per theme or paint change. The brightness slider only changes
environmentIntensity, so dragging it never re-bakes. PerformanceMonitorlowers the pixel ratio on slower GPUs and falls back to a plain floor without bloom or real-time shadows if the frame rate stays low. Add?quality=lowto the URL to force this mode.
To re-encode a new model (run from a folder with @gltf-transform/core, @gltf-transform/extensions, @gltf-transform/functions, meshoptimizer and sharp installed):
import { NodeIO } from "@gltf-transform/core";
import { ALL_EXTENSIONS, EXTMeshoptCompression } from "@gltf-transform/extensions";
import { dedup, flatten, join, prune, quantize, reorder, textureCompress, weld } from "@gltf-transform/functions";
import { MeshoptDecoder, MeshoptEncoder } from "meshoptimizer";
import sharp from "sharp";
await MeshoptEncoder.ready;
const io = new NodeIO()
.registerExtensions(ALL_EXTENSIONS)
.registerDependencies({ "meshopt.encoder": MeshoptEncoder, "meshopt.decoder": MeshoptDecoder });
const doc = await io.read("input.glb");
for (const m of doc.getRoot().listMaterials()) if (m.getName() === "carpaint") m.setNormalTexture(null);
await doc.transform(
dedup(), flatten(), join(), weld(), prune(),
textureCompress({ encoder: sharp, targetFormat: "webp" }),
reorder({ encoder: MeshoptEncoder }),
quantize({ quantizePosition: 16, quantizeNormal: 14, quantizeTexcoord: 14 }),
);
doc.createExtension(EXTMeshoptCompression).setRequired(true)
.setEncoderOptions({ method: EXTMeshoptCompression.EncoderMethod.QUANTIZE });
await io.write("public/models/car.glb", doc);Don't merge materials (no palette()), or the paint can no longer be targeted.
app/
lib/showroom.ts paints, camera views, specs data
lib/garage.ts garage themes, floor finishes, room size
lib/stage-state.ts shared animation state and intro timeline
components/scene/ everything inside the <Canvas>
showroom-stage.tsx canvas, lights, floor finishes, bloom, reflections, view offset
garage-room.tsx procedural garage: walls, ceiling fixtures (ripple power-on), slats, neon
effects.tsx intro director, light cones, dust, paint-scanner gantry, stage ring
car-lights.tsx headlights, beams, taillights, engine idle motion
camera-rig.tsx camera controls, view transitions, auto-rotate, stays inside the room
car-model.tsx model loading, sizing, paint-scanner shader
components/ui/ DOM overlay: entry, header, title, view rail, paint bar + customize, ignition, specs, dialog, loader
lib/experience.ts engine state and the shared rpm timeline
lib/engine-audio.ts WebAudio engine, switch and scanner sounds
routes/home.tsx state and wiring
yarn install
yarn dev # http://localhost:5173
yarn typecheck
yarn build && yarn startA Dockerfile is included for container deployments:
docker build -t car-model-demo .
docker run -p 3000:3000 car-model-demo