← All work
Archived · runnableExperiments

Real-Time City Scene

A WebGL city that ran behind this homepage. I optimised it hard, measured it honestly, and cut it when the measurement said it was not worth the cost.

My role
Built the scene, the camera system and the optimisation pass. Then removed it.
Stack
Three.jsReact Three FiberWebGLTypeScriptPerformance profiling

The problem

The homepage needed presence. The answer at the time was a real-time 3D city behind the content: an animated skyline with a five-shot camera sequence driven by scroll position. The interesting problem was not making it look right; it was making a scene of that size hold a stable frame rate on a mid-range phone without turning the homepage into a graphics demo.

What I built

A React Three Fiber scene of roughly 3,100 lines across nine modules: nineteen buildings with per-window emissive materials that respond to scroll, plus water, smoke, vehicles, infrastructure, landmarks and a particle rain. A camera system moves through five composed shots, smoothstep easing between them, damped mouse parallax on top. Around thirty-six per-frame loops drive it. Nothing about the renderer is left at its default: antialiasing, stencil and shadows off, device pixel ratio capped, and adaptive performance allowed to drop resolution rather than frame rate under load.

The hard part

Everything expensive in a scene like this is per-frame, and the two worst offenders stay invisible until you go looking. The first is layout thrash. Reading scroll position inside the render loop forces the browser to recompute layout on every frame, so scroll is read once per scroll event and cached at module level instead. The second is frame-rate-dependent animation. An easing factor times a fixed step runs faster on a 144Hz monitor than a 60Hz one, so every interpolation is damped against delta time. Beyond that: instanced meshes, so a thousand particles cost one draw call, and a fog range matched to the camera's far plane, so distant geometry fades exactly where it is culled instead of popping.

Evidence

  • Run it hereThe scene itself, on this page, loaded only if you click, with its frame rate measured live.
  • Source at the commit before removalRestrictedThe full scene as it shipped, in this site's history. Private repository.
  • Measured cost, both buildsOld and new homepages built and loaded in the same browser. The numbers below are from that comparison, not an estimate.

The scene

Still runs. Just not on the homepage.

The scene as it shipped, driven by the same five-shot camera sequence, with scroll position on the old homepage, a scrubber here. Frame rate is measured live, not quoted.

Technical decisions

What was chosen, and what it cost.

Scroll position is read once per scroll event, never inside the frame loop.

WhyReading scrollHeight during render forces the browser to recompute layout on every single frame. Cached at module level, the frame loop only reads a number.

CostThe cached value is one event stale, which is invisible at this scale but would matter for anything requiring exact scroll sync.

Every interpolation is damped against delta time, not a fixed step.

WhyA fixed easing factor makes the camera move more than twice as fast on a 144Hz display as on a 60Hz one. Damping against delta makes the motion identical on both.

CostSlightly more arithmetic per frame, and easing constants stop being intuitive.

Device pixel ratio capped at 1.25, and at 1.0 for coarse pointers.

WhyAn uncapped canvas on a 3x phone screen renders nine times the pixels for no perceptible gain, and fill rate is the first thing to break on mobile.

CostSlightly softer edges on high-density displays. Accepted, since antialiasing is off anyway.

Adaptive performance drops resolution rather than frame rate.

WhyA scene at 60fps and reduced resolution reads as smooth. The same scene at full resolution and 24fps reads as broken. Given the choice, sharpness is the thing to give up.

CostVisible resolution shifts on a struggling device.

Removed it from the homepage entirely.

WhyIt cost 903 KB of JavaScript and pushed largest contentful paint from 0.66s to 2.5s, to render decoration behind the text that actually makes the case. It was not evidence for anything the site claims.

CostThe homepage lost its most distinctive visual. That is a real loss, and the measurement still says it was the right trade.

Constraints

  • Had to hold a usable frame rate on a mid-range phone, not just a desktop GPU.
  • Could never block or delay the content it sat behind.
  • Decorative by definition, so it had to be inert to assistive technology and to anyone who prefers reduced motion.

How it is verified

  • Both homepages were built and loaded in the same browser at the same viewport, and the payloads compared directly.
  • Old build: 1,542 KB of JavaScript, of which 898 KB was three.js. New build: 638 KB, none of it three.js.
  • Largest contentful paint measured at 2,496 ms with the scene and 656 ms without it.
  • The demo on this page reports its own frame rate live, so the performance claim is observable rather than asserted.

Outcome

  • 903 KB of JavaScript removed from every homepage visit, 59% of the page's script weight.
  • Largest contentful paint improved roughly 3.8x.
  • The scene still runs, in its own chunk, loaded only by people who ask to see it.

What I would tell someone building this

  • The optimisation notes committed alongside this scene described a version cut down to eight buildings, four lights and half the modules. The code at that same commit had nineteen buildings, seven lights and every module still rendering. A document describing an optimisation is not the same as an optimisation, and only one of them shows up in a measurement.
  • The expensive parts of a real-time scene are almost never the parts that look expensive. Geometry count was never the problem; a layout read inside the frame loop was.
  • Knowing how to build something and knowing whether it should ship are different skills, and the second one is rarer. This is on the site because deciding to cut it was better engineering than building it.
  • Gating a heavy feature behind an explicit click, with its cost written on the button, turns an unacceptable payload into an acceptable one. Nothing about the scene changed except who pays for it.

Questions about how this was built, or want something like it?