Gallery
Vapor Car
Three.jsHeavy
Dozens of small car assembled from Three.js primitives, under coloured rim light.
#3d#three#object#model#car#vapor
Live preview
1 file
$
npm i three/*
* Models (React + react-three-fiber is not needed here: this is plain three)
*
* Setup with Vite:
* npm create vite@latest my-app -- --template react-ts
* cd my-app
* npm i three
*
* Next.js App Router: add "use client" as the first line of the file.
* Render <Models /> inside a container with position: relative.
*/
// @ts-nocheck
import { useEffect, useRef } from "react";
import * as THREE from "three";
const opts = {"variant":"car","present":"scatter","bg":"#08060c","colors":["#f0abfc","#67e8f9","#a5b4fc"],"light":{"bg":"#faf5fd","colors":["#a21caf","#0e7490","#4338ca"]},"speed":1,"density":1};
export function Models() {
const ref = useRef(null);
useEffect(() => {
const canvas = ref.current;
if (!canvas) return;
/* Both schemes ship in opts; `light` is absent in the live preview, which
resolves the scheme itself, so nothing is observed there. */
const schemeMq =
opts.light && typeof matchMedia === "function"
? matchMedia("(prefers-color-scheme: light)")
: null;
const schemeNow = () => (schemeMq && schemeMq.matches ? opts.light : opts);
/** The scheme the scene or particle set was first built from. */
const SCHEME_BUILT = schemeNow();
let COLORS = SCHEME_BUILT.colors, BG = SCHEME_BUILT.bg;
const onScheme = () => {
const s = schemeNow();
COLORS = s.colors; BG = s.bg;
if (typeof seed === "function") seed();
if (typeof applyScheme === "function") applyScheme();
};
if (schemeMq) schemeMq.addEventListener("change", onScheme);
function schemeMap(c) {
const s = schemeNow();
const i = SCHEME_BUILT.colors.indexOf(c);
if (i >= 0) return s.colors[i] || c;
if (c === SCHEME_BUILT.bg) return s.bg;
return c;
}
const SPEED = opts.speed || 1, DENSITY = opts.density || 1;
const V = opts.variant, PRESENT = opts.present || "scatter";
const rnd = (a, b) => a + Math.random() * (b - a);
const pick = (a) => a[(Math.random() * a.length) | 0];
const TAU = Math.PI * 2;
const hex = (c) => new THREE.Color(c);
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
const scene = new THREE.Scene();
scene.background = hex(BG);
scene.fog = new THREE.Fog(hex(BG), 12, 34);
const camera = new THREE.PerspectiveCamera(45, 1, 0.1, 100);
camera.position.set(0, 0, 9);
/**
* Stylised on purpose: emissive rim plus metal reads well in any palette.
*
* Every material in every scene comes through here, which is what makes the
* scheme swap possible at all: a scene graph is built once and cannot be rebuilt
* cheaply, so instead each material remembers which palette slot it was given
* and `applyScheme` re-points it. Colours that are not from the palette (a white
* light, a literal grey) map to themselves and so stay put, which is right.
*/
const schemeMats = [];
function mat(color, o) {
o = o || {};
const m = new THREE.MeshStandardMaterial({
color: hex(color),
roughness: o.rough != null ? o.rough : 0.34,
metalness: o.metal != null ? o.metal : 0.5,
emissive: hex(o.emissive || color),
emissiveIntensity: o.glow != null ? o.glow : 0.16,
flatShading: !!o.flat,
});
schemeMats.push({ m: m, color: color, emissive: o.emissive || color });
return m;
}
/**
* Re-points the scene's palette-driven colours at the active scheme.
*
* A family that builds palette-coloured materials outside `mat()` (a
* LineBasicMaterial, a PointsMaterial) can join in by pushing
* `{ m: material, color: c }` onto `schemeMats`; `emissive` is optional and
* skipped when the material has none.
*/
function applyScheme() {
scene.background = hex(BG);
if (scene.fog) scene.fog.color = hex(BG);
rim1.color = hex(schemeMap(SCHEME_BUILT.colors[0]));
rim2.color = hex(schemeMap(SCHEME_BUILT.colors[1] || SCHEME_BUILT.colors[0]));
rim3.color = hex(schemeMap(SCHEME_BUILT.colors[2] || SCHEME_BUILT.colors[0]));
for (const e of schemeMats) {
if (e.m.color && e.color) e.m.color.set(schemeMap(e.color));
if (e.m.emissive && e.emissive) e.m.emissive.set(schemeMap(e.emissive));
}
}
scene.add(new THREE.AmbientLight(0xffffff, 0.5));
const keyLight = new THREE.DirectionalLight(0xffffff, 1.4);
keyLight.position.set(4, 6, 7);
scene.add(keyLight);
const rim1 = new THREE.PointLight(hex(COLORS[0]), 70, 34);
rim1.position.set(-7, 3, 4);
scene.add(rim1);
const rim2 = new THREE.PointLight(hex(COLORS[1] || COLORS[0]), 50, 34);
rim2.position.set(7, -4, 3);
scene.add(rim2);
const rim3 = new THREE.PointLight(hex(COLORS[2] || COLORS[0]), 36, 34);
rim3.position.set(0, 6, -7);
scene.add(rim3);
const mouse = { x: 0, y: 0 };
function resize() {
const w = canvas.clientWidth || 1, h = canvas.clientHeight || 1;
renderer.setSize(w, h, false);
camera.aspect = w / h;
camera.updateProjectionMatrix();
}
function buildObject() {
const g = new THREE.Group();
const bodyMat = mat(COLORS[0], { rough: 0.3, metal: 0.6 });
const trimMat = mat(COLORS[1] || COLORS[0], { rough: 0.36 });
const glowMat = mat(COLORS[2] || COLORS[0], { glow: 1.6, rough: 0.5 });
const tyreMat = mat(BG, { rough: 0.85, metal: 0.1 });
const body = new THREE.Mesh(new THREE.BoxGeometry(0.9, 0.5, 2), bodyMat);
body.position.y = -0.11;
g.add(body);
const skirt = new THREE.Mesh(new THREE.BoxGeometry(0.96, 0.16, 1.6), trimMat);
skirt.position.y = -0.3;
g.add(skirt);
const cabin = new THREE.Mesh(new THREE.BoxGeometry(0.78, 0.42, 1), bodyMat);
cabin.position.set(0, 0.35, -0.12);
g.add(cabin);
const glass = new THREE.Mesh(new THREE.BoxGeometry(0.82, 0.26, 0.86), mat(BG, { rough: 0.15, metal: 0.9 }));
glass.position.set(0, 0.38, -0.12);
g.add(glass);
for (const s of [-1, 1]) {
const lamp = new THREE.Mesh(new THREE.BoxGeometry(0.2, 0.1, 0.06), glowMat);
lamp.position.set(s * 0.29, -0.05, 1.02);
g.add(lamp);
}
const wheels = [];
for (const sx of [-1, 1]) {
for (const sz of [-1, 1]) {
const wheel = new THREE.Group();
wheel.position.set(sx * 0.47, -0.31, sz * 0.62);
wheel.rotation.z = Math.PI / 2;
const tyre = new THREE.Mesh(new THREE.CylinderGeometry(0.24, 0.24, 0.17, 14), tyreMat);
wheel.add(tyre);
const hub = new THREE.Mesh(new THREE.CylinderGeometry(0.12, 0.12, 0.19, 10), trimMat);
wheel.add(hub);
const spoke = new THREE.Mesh(new THREE.BoxGeometry(0.05, 0.2, 0.4), glowMat);
wheel.add(spoke);
g.add(wheel);
wheels.push(wheel);
}
}
g.userData.wheels = wheels;
return g;
}
function tagParts(o) {
for (const m of o.userData.wheels || []) m.userData.role = "wheel";
for (const m of o.userData.spin || []) m.userData.role = "spin";
delete o.userData.wheels;
delete o.userData.spin;
}
function collectParts(o) {
const wheels = [], spin = [];
o.traverse((m) => {
const role = m.userData && m.userData.role;
if (role === "wheel") wheels.push(m);
else if (role === "spin") spin.push(m);
});
return { wheels, spin };
}
const proto = buildObject();
tagParts(proto);
const count = Math.max(6, Math.min(26, Math.round(14 * DENSITY)));
const items = [];
for (let i = 0; i < count; i++) {
const o = i === 0 ? proto : proto.clone(true);
// Sized and spread for a 300px gallery card, where the earlier smaller,
// wider scatter read as a few specks.
o.scale.setScalar(rnd(0.5, 0.95));
o.position.set(rnd(-6, 6), rnd(-3.6, 3.6), rnd(-6.5, 2.5));
o.rotation.set(rnd(0, TAU), rnd(0, TAU), rnd(-0.4, 0.4));
// Set after cloning, so these never go through the userData JSON round-trip.
o.userData.spd = rnd(0.12, 0.42) * (Math.random() < 0.5 ? -1 : 1);
o.userData.bob = rnd(0, TAU);
o.userData.drift = rnd(0.1, 0.35);
o.userData.parts = collectParts(o);
scene.add(o);
items.push(o);
}
function frame(t) {
for (const o of items) {
o.rotation.y += o.userData.spd * 0.016;
o.rotation.x += o.userData.spd * 0.006;
o.position.y += Math.sin(t * 0.8 + o.userData.bob) * 0.004;
o.position.x -= o.userData.drift * 0.012;
if (o.position.x < -8.5) o.position.x = 8.5;
for (const wh of o.userData.parts.wheels) wh.rotation.x = t * 4;
for (const s of o.userData.parts.spin) s.rotation.y = t * 2.4;
}
}
const ro = new ResizeObserver(resize);
ro.observe(canvas);
resize();
const onMove = (e) => {
const r = canvas.getBoundingClientRect();
mouse.x = ((e.clientX - r.left) / r.width - 0.5) * 2;
mouse.y = ((e.clientY - r.top) / r.height - 0.5) * 2;
};
const onLeave = () => { mouse.x = 0; mouse.y = 0; };
canvas.addEventListener("pointermove", onMove);
canvas.addEventListener("pointerleave", onLeave);
const clock = new THREE.Clock();
const home = camera.position.clone();
let raf = 0;
function loop() {
const t = clock.getElapsedTime() * SPEED;
frame(t);
camera.position.x += (home.x + mouse.x * 0.7 - camera.position.x) * 0.05;
camera.position.y += (home.y - mouse.y * 0.5 - camera.position.y) * 0.05;
camera.lookAt(0, 0, 0);
renderer.render(scene, camera);
raf = requestAnimationFrame(loop);
}
loop();
return () => {
cancelAnimationFrame(raf);
ro.disconnect();
canvas.removeEventListener("pointermove", onMove);
canvas.removeEventListener("pointerleave", onLeave);
if (schemeMq) schemeMq.removeEventListener("change", onScheme);
scene.traverse((o) => {
if (o.geometry) o.geometry.dispose();
if (o.material) {
const list = Array.isArray(o.material) ? o.material : [o.material];
for (const m of list) m.dispose();
}
});
renderer.dispose();
};
}, []);
return (
<canvas ref={ref} style={{ position: "absolute", inset: 0, width: "100%", height: "100%", display: "block" }} />
);
}Copy or download any file. The HTML build loads Three.js from a CDN via an import map, so it runs by just opening index.html. The React build is react-three-fiber; setup notes live at the top of each file.
Variants
20 of this pattern · page 1 of 2