Skip to content
bgHub
Gallery

Cyber Teal Car

Three.jsHeavy

Dozens of small car assembled from Three.js primitives, under coloured rim light.

#3d#three#object#model#car#cyber-teal
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":"#04070a","colors":["#22d3ee","#0ea5e9","#67e8f9"],"light":{"bg":"#eff8fc","colors":["#0e7490","#0369a1","#0891b2"]},"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