Skip to content
bgHub
Gallery

Sunset City Skyline

Three.jsHeavy

A flowing city skyline of instanced towers with hashed heights. A Three.js scene.

#3d#three#city#skyline#sunset
Live preview
1 file
$npm i three
/*
 * City (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 <City /> inside a container with position: relative.
 */
// @ts-nocheck
import { useEffect, useRef } from "react";
import * as THREE from "three";

const opts = {"variant":"skyline","bg":"#0a0505","colors":["#f59e0b","#f43f5e","#8b5cf6"],"light":{"bg":"#fdf5f2","colors":["#b45309","#be123c","#6d28d9"]},"speed":1.4,"density":1.3};

export function City() {
  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();
}
const SPREAD = 15, MAXH = 3.4, BW = 0.42, SKIP = 0.12, FLOW = 1.5;
/**
 * Camera height is load-bearing: towers scroll toward the camera and recycle
 * behind it, so a camera below MAXH gets a tower driven through the frame. Every
 * variant sits above its own MAXH with room for the pointer parallax dip.
 *
 * Hashed heights, not random ones. Wrapping recycles a tower to the far end of
 * the grid every lap; if its height were drawn once at random that would still
 * work, but hashing on the cell also keeps the skyline stable across reloads and
 * lets the falloff below be evaluated anywhere.
 */
function h2(x, y) {
  const s = Math.sin(x * 127.1 + y * 311.7) * 43758.5453;
  return s - Math.floor(s);
}

const NX = Math.max(12, Math.min(28, Math.round(22 * DENSITY)));
const GAP = SPREAD / NX;
// Deeper than it is wide, so the recycle line can sit behind the camera. Wrap a
// tower while it is still on screen and you see it wink out of existence.
const NZ = Math.round(NX * 1.6);
const DEPTH = NZ * GAP;
const COUNT = NX * NZ;
// Near edge of the wrap window, comfortably behind the camera at z=9.
const Z0 = 11 - DEPTH * 0.5;

// Reused across every instance: one geometry, one material, one draw call.
const boxGeo = new THREE.BoxGeometry(1, 1, 1);
const city = new THREE.InstancedMesh(
  boxGeo,
  mat(COLORS[0], { metal: 0.55, rough: 0.4, glow: 0.1, flat: true }),
  COUNT,
);
scene.add(city);

const bx = new Float32Array(COUNT);
const bz = new Float32Array(COUNT);
const bh = new Float32Array(COUNT);
const m4 = new THREE.Matrix4();
const tint = new THREE.Color();

let n = 0;
for (let i = 0; i < NX; i++) {
  for (let j = 0; j < NZ; j++) {
    const x = (i - (NX - 1) / 2) * GAP;
    const z = (j - (NZ - 1) / 2) * GAP;
    let hh = h2(i, j);
    // Falloff across x makes a downtown band, tall in the middle of the frame
    // and low at the left and right edges. The wave along z rolls two clusters
    // of towers past the camera per lap; two whole periods, so the profile is
    // continuous across the wrap.
    const d = Math.min(1, Math.abs(x) / (SPREAD * 0.5));
    const wave = 0.6 + 0.4 * Math.sin((j / NZ) * TAU * 2);
    hh = Math.pow(hh, 1.7) * (1 - d * d * 0.7) * wave * MAXH + 0.18;
    if (h2(i * 3.7, j * 1.3) < SKIP) hh = 0;
    bx[n] = x; bz[n] = z; bh[n] = hh;
    n++;
  }
}

/**
 * The tower tints are per-instance, which puts them in instanceColor rather
 * than in a material, out of reach of the scheme registry. The prelude re-runs
 * seed() whenever the scheme changes, so the colour pass lives here on its own
 * and reads the heights the build already settled: the palette moves, the
 * skyline does not.
 */
function seed() {
  const cLow = hex(COLORS[0]), cMid = hex(COLORS[1] || COLORS[0]), cHigh = hex(COLORS[2] || COLORS[0]);
  for (let i = 0; i < n; i++) {
    // Colour by height, low to mid to high, so the towers read as a gradient.
    const k = Math.min(1, bh[i] / MAXH);
    tint.copy(cLow).lerp(cMid, Math.min(1, k * 2)).lerp(cHigh, Math.max(0, k * 2 - 1));
    city.setColorAt(i, tint);
  }
  city.instanceColor.needsUpdate = true;
}
seed();

/** The ground, which fog turns into a horizon for free. */
const ground = new THREE.Mesh(
  new THREE.PlaneGeometry(SPREAD * 5, SPREAD * 5),
  mat(BG, { metal: 0.2, rough: 0.9, glow: 0.06, emissive: COLORS[0] }),
);
ground.rotation.x = -Math.PI / 2;
ground.position.y = -0.01;
scene.add(ground);

/** Wraps a coordinate into the window ending at Z0, so the city never runs out. */
function wrapZ(z) {
  return ((z - Z0 + DEPTH * 0.5) % DEPTH + DEPTH) % DEPTH - DEPTH * 0.5 + Z0;
}

/**
 * Rewrites every instance matrix for the current scroll. A tower that passes the
 * camera jumps to the back of the grid, which is invisible because that end is
 * past the fog. Costs GRID*GRID cheap matrix writes and keeps one draw call.
 */
function layout(scroll) {
  for (let k = 0; k < n; k++) {
    const hh = bh[k];
    // Zero-height towers are parked far below the ground plane rather than
    // scaled to 0, which would give a degenerate matrix.
    if (hh <= 0) {
      m4.makeScale(0.001, 0.001, 0.001);
      m4.setPosition(0, -50, 0);
    } else {
      m4.makeScale(BW, hh, BW);
      m4.setPosition(bx[k], hh * 0.5, wrapZ(bz[k] + scroll));
    }
    city.setMatrixAt(k, m4);
  }
  city.instanceMatrix.needsUpdate = true;
}
camera.position.set(0, 5.2, 9.5);
// Tight enough that the far end of the grid has fully dissolved into the
// background before it runs out, which is what makes the horizon.
scene.fog = new THREE.Fog(hex(BG), 8, 26);

function frame(t) {
  layout(t * FLOW);
}
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

40 of this pattern · page 1 of 4