STRATAFIELD NOTES · THE BUILD
How this was made

A black void, and how it became deep time.

STRATA is a site for a fictional institute that studies geological deep time. Its hero is a real WebGL terrain — a plane displaced by a simplex-noise shader into a basalt ridgeline under a molten horizon. This is the field log: the decisions, the shader, and the three screenshot-critique passes it took to get there — including the one where the terrain rendered as nothing at all.

Site01 · strata
StackSingle HTML file · Three.js r160 · zero build step
TypeFraunces italic · Space Grotesk
Assets100% procedural — no images
§ 01 — The brief & the concept

Make deep time something you can feel underfoot.

The brief asked for an institute of geological deep time: a full-viewport Three.js scene with procedural displaced terrain, a dark basalt palette, ember-orange veins glowing through fog, slow camera drift, and mouse parallax — then, below the fold, a horizontal strata timeline where each era expands with facts. The reference energy was National Geographic meets WebGL.

The concept I settled on treats the whole page as a core sample read from the top down. You begin at the surface — a molten ridgeline at dusk — and descend through a stratigraphic column of the six eras, newest first, all the way to the Hadean ember at the bottom. A single metaphor, "the ground is a ledger," runs from the headline to the footer. Everything else serves it.

§ 02 — Design decisions

Type carries the meaning; the palette carries the heat.

Type. The display face is Fraunces set in italic at low weight, with optical sizing on — its high-contrast, slightly literary curves give the headline a stone-carved, old-institution gravity that a grotesk never would. Everything structural — nav, labels, era ages, body copy — is Space Grotesk, whose tabular figures matter for the geological dates (539 Ma, 2.5 Ga). Two voices, cleanly divided: serif says, sans labels.

Palette. Committed to basalt and ember. The ground is near-black (#0b0a0c ink, #141218 basalt); the heat is a two-step ember (#ff5a1f#c23a12) with a hotter highlight for accents. Text is a warm bone rather than pure white, so nothing feels clinical. Each era in the timeline gets its own sediment tint — sand, moss, slate, rust, terracotta, magma — so the column reads as real rock.

Motion. Restrained and continuous rather than flashy. The camera drifts on layered sines; ember sparks rise and fade; the horizon glow breathes. Scroll reveals use a single custom ease (cubic-bezier(.16,1,.3,1)). The page's one clever trick is a marginalia depth readout that counts from 0 to 14,000 metres as you scroll — the descent made literal. All of it respects prefers-reduced-motion by freezing to a single legible frame instead of collapsing the layout.

§ 03 — Signature techniques

The terrain is a shader, the fog is a plane, the depth is the scroll.

fBm-displaced plane

A 260×260 plane pushed into basalt ridges by fractal simplex noise, entirely in the vertex shader — no geometry baked on the CPU.

Billboard ember haze

The "fog glow" isn't fog at all — it's a transparent gradient plane behind the ridgeline whose alpha hugs the horizon and breathes.

Scroll = depth

A fixed marginal readout maps scroll progress to metres of descent, turning a page-scroll into a geological one.

The heart of it is the vertex shader. Each vertex samples a five-octave fractal-Brownian-motion stack of simplex noise, sharpens the crests by subtracting an absolute-valued layer (the trick that makes ridges instead of rolling hills), then lifts the far field so peaks rise into frame:

index.html — terrain vertex shader (displacement)
float fbm(vec3 p){              // 5 octaves of simplex noise
  float a=0.5,f=1.0,s=0.0;
  for(int i=0;i<5;i++){ s+=a*snoise(p*f); f*=2.03; a*=0.5; }
  return s;
}
// … in main():
vec3 np = vec3(p.x*0.11, p.y*0.11, uTime*0.028);
float h = fbm(np) * 5.2;
h = h - abs(fbm(np*1.3 + 4.0))*0.9;   // sharpen into ridges
h += smoothstep(6.0, -46.0, p.y) * 5.0;    // lift the far ridgeline
p.z += h;

The "ember veins glowing through fog" turned out to read best as two cooperating tricks: linear fog that melts the far field to black, plus a separate transparent plane behind the terrain whose alpha is shaped to a thin band just above the horizon. That plane is what makes the sky look molten without flooding the whole frame red:

index.html — horizon haze (fragment) + fog
// linear fog: crisp foreground, far field dissolves to ink
scene.fog = new THREE.Fog("#0b0a0c", 14, 62);

// haze plane fragment: glow hugs the horizon, then falls off upward
float band = smoothstep(0.02, 0.20, vUv.y)
           * (1.0 - smoothstep(0.24, 0.62, vUv.y));
float breathe = 0.78 + 0.22*sin(uTime*0.5);
gl_FragColor = vec4(uEmber, band * 0.62 * breathe);

And the descent metaphor is just arithmetic on scroll position — clamp progress to [0,1], scale to the institute's fourteen kilometres of catalogued core, and format it. Batching the update behind requestAnimationFrame keeps the scroll handler cheap:

index.html — scroll-driven depth readout
const MAX_DEPTH = 14000;   // 14 km of catalogued core
function updateDepth(){
  const max = document.documentElement.scrollHeight - innerHeight;
  const p = max > 0 ? Math.min(scrollY / max, 1) : 0;
  depthEl.textContent = Math.round(p * MAX_DEPTH).toLocaleString("en-US");
}
§ 04 — Assets & provenance

Nothing here is a photograph.

Honest accounting: this site ships zero image assets. The mountain range is generated at runtime by the noise shader — reload the page and the ridgeline is subtly different. The molten sky, the rising sparks, and the sediment striations in the timeline are all CSS or GLSL. The only "asset" is the favicon, an inline SVG data-URI of five stacked strata bands drawn by hand in markup.

That was a deliberate constraint, not a shortcut: a procedural hero can't go stale, weighs nothing to download, and animates for free. The trade is that it has to be tuned rather than shot — which is exactly what the three passes below were for.

§ 05 — The three iteration passes

Screenshot, critique like a hostile art director, fix, repeat.

Each pass followed the same loop: render the page in headless Chrome, read the actual pixels back, write down everything that looked wrong or AI-default, fix it, and add one deliberate upgrade. Here is what each pass actually caught.

Pass 1The black void

What the screenshot showed

The hero was empty black. No console error, no crash — WebGL had initialized fine, but the terrain was simply invisible. The typography and timeline, by contrast, already read beautifully, which made the void even more glaring.

Diagnosis & fix

Three compounding darkness sources, not a bug: exponential fog at 0.055 density fogged the terrain to near-black within fifteen units; the rock albedo was almost black to begin with; and the camera sat too flat to catch any displaced crest against the sky. The realization that it was a tuning problem, not a rendering one, set up the whole next pass.

Pass 2Making it cinematic

What the screenshot showed

With linear fog, brighter ridge tones, and a reframed camera, the terrain appeared — but as a flat grey-purple silhouette with the ember barely present. Then, adding the haze plane, an early version flooded the entire sky red like a stock sunset, fighting the headline.

The upgrade

Added derivative-based Lambert lighting so ridge crests catch light, pushed the emissive veins, and — the signature move — introduced the billboarded ember-haze plane, then tightened its alpha falloff so the glow hugs the horizon and the top of the frame returns to basalt-black. That single change turned a horizon band into a genuine stop-scroll.

Pass 3Detail & life

What the screenshot showed

The hero was strong, but the timeline bands looked inert at rest — flat color fields that only came alive on hover, so a static screenshot of the un-hovered column felt lifeless.

The upgrades

Added fine sediment-striation texture to every band so they read as layered rock even unhovered; scattered rising ember sparks through the 3D scene for atmosphere; and wired the scroll-depth readout so the marginalia counts 0→14,000 m on the way down. Final check: zero console errors, no horizontal scroll at 1440 or 390.

§ 06 — Reproduce this

A short checklist to build a WebGL hero that survives headless.

Start with the type and layout, not the 3D. If the page is beautiful with a black hero, the terrain is pure upside — and you'll never mistake a layout flaw for a shader flaw.

Displace in the vertex shader. An fBm stack of simplex noise on a subdivided plane; subtract an abs() octave to sharpen hills into ridges. Keep geometry off the CPU.

Merge the fog uniforms. A ShaderMaterial with fog:true needs UniformsLib.fog merged in and the <fog_vertex>/<fog_fragment> chunks — and its vertex output must be named mvPosition.

Reach for linear fog before exponential. Fog(near, far) is far easier to reason about for a horizon; dense FogExp2 is how you accidentally paint everything black.

Fake the atmosphere with a plane. A transparent gradient billboard behind the terrain reads as glowing haze for a fraction of the cost of volumetrics — and it's trivially tunable.

Screenshot and actually read it. Render headless, read the pixels back, and critique like you're trying to reject the work. The black void was invisible to logs and obvious to eyes.

Always ship a fallback. Wrap init in try/catch, detect a dead GL context, and paint a 2D-canvas terrain so the hero is never empty — even where the GPU isn't.