Skip to content
bgHub
Gallery

Aurora Borealis Car Glyph

Three.jsHeavy

Dozens of small car glyph extruded from a coded silhouette, under coloured rim light.

#3d#three#object#icon#car#aurora-borealis
Live preview
1 file
$npm i three
/*
 * Icon3D (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 <Icon3D /> 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":"#03080a","colors":["#34d399","#22d3ee","#818cf8"],"light":{"bg":"#eff9f8","colors":["#047857","#0e7490","#4338ca"]},"speed":1,"density":1};

export function Icon3D() {
  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 buildShape() {
const s = new THREE.Shape();
s.moveTo(-0.92, -0.5);
s.lineTo(0.92, -0.5);
s.quadraticCurveTo(1.0, -0.5, 1.0, -0.42);
s.lineTo(1.0, -0.02);
s.quadraticCurveTo(1.0, 0.1, 0.88, 0.12);
s.lineTo(0.52, 0.16);
s.quadraticCurveTo(0.34, 0.2, 0.24, 0.5);
s.lineTo(-0.18, 0.5);
s.quadraticCurveTo(-0.32, 0.48, -0.46, 0.16);
s.lineTo(-0.86, 0.12);
s.quadraticCurveTo(-1.0, 0.1, -1.0, -0.02);
s.lineTo(-1.0, -0.42);
s.quadraticCurveTo(-1.0, -0.5, -0.92, -0.5);
const front = new THREE.Path();
front.absarc(0.55, -0.22, 0.17, 0, Math.PI * 2, true);
const rear = new THREE.Path();
rear.absarc(-0.55, -0.22, 0.17, 0, Math.PI * 2, true);
s.holes.push(front, rear);
return s;
}

const EXTRUDE = { depth: 0.34, bevelEnabled: true, bevelThickness: 0.05, bevelSize: 0.05, bevelSegments: 3, curveSegments: 12 };

function buildObject() {
  const geo = new THREE.ExtrudeGeometry(buildShape(), EXTRUDE);
  geo.center();
  const body = new THREE.Mesh(geo, mat(COLORS[0], { metal: 0.62, rough: 0.28 }));
  // EdgesGeometry, not a wireframe material: a wireframe draws every triangle,
  // which exposed the tessellation of the bevel and the curves as noise. This
  // keeps only edges where faces meet at more than 30 degrees, i.e. the outline
  // and the creases a viewer would expect to see.
  const edgeColor = COLORS[COLORS.length - 1];
  const edgeMat = new THREE.LineBasicMaterial({
    color: hex(edgeColor),
    transparent: true,
    opacity: 0.5,
  });
  // A line material never goes through mat(), so it registers itself. One entry
  // covers the whole scatter: clones share the material rather than copying it.
  schemeMats.push({ m: edgeMat, color: edgeColor });
  const edge = new THREE.LineSegments(new THREE.EdgesGeometry(geo, 30), edgeMat);
  const g = new THREE.Group();
  g.add(body);
  g.add(edge);
  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