Gallery
Magma City Skyline
Three.jsHeavy
A slow city skyline of instanced towers with hashed heights. A Three.js scene.
#3d#three#city#skyline#magma
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":"#0a0503","colors":["#fbbf24","#f97316","#ef4444"],"light":{"bg":"#fdf6ef","colors":["#b45309","#c2410c","#b91c1c"]},"speed":0.7,"density":0.9};
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