The Guide · a teaching page
How to write weather in one fragment shader — and set a poem inside it.
HARMATTAN is ten lines of verse inside a single full-viewport WebGL2 fragment shader: fractal noise advected along a wind field, graded from haze to stars as you scroll. This page explains the whole machine — the math, the GLSL, the typography, the accessibility engineering — generously enough that you could rebuild it. Read the process page for the poem itself and the thinking behind the piece.
Weather, not noise
Every visible thing on the poem page — haze, dust, the pale sun, the ember dusk, the stars — is computed per pixel, per frame, by one fragment shader. There are no images, no video, no textures, no libraries. That is the rule for the whole 26-site collection this piece belongs to: every visual is code-drawn. Constraint, turned into a thesis.
The trap with shader backgrounds is that FBM noise straight out of the box looks like lava lamps and marble, not weather. Weather has anisotropy — wind smears things in a direction; depth — haze far away behaves differently from grit near your face; and a story — light changes over time. Those three ideas structure everything below.
The palette is the poem's arc. Five authored colours, and the scroll position grades between them — the page literally darkens as the season's day burns down:
- haze #E8E2D6
- dry sky #B9C4C9
- dust rose #C9977B
- ember #8A3B22
- night #211D1B
One variable font — Epilogue, weight 100 to 900 in a single 35 KB woff2 — does every piece of type on the site, and its weight axis becomes a display of wind.
One triangle, one shader
The canvas setup is deliberately minimal: raw WebGL2, no vertex buffers at all. A single
triangle, generated from gl_VertexID, covers the whole screen (a triangle avoids the
diagonal seam a two-triangle quad can show). Everything else happens in the fragment stage.
#version 300 es
void main(){
// ids 0,1,2 → (0,0),(2,0),(0,2) → clip-space triangle covering the viewport
vec2 v = vec2(float((gl_VertexID << 1) & 2), float(gl_VertexID & 2));
gl_Position = vec4(v * 2.0 - 1.0, 0.0, 1.0);
}
The fragment shader receives nine uniforms — the full interface between page and weather:
| Uniform | Meaning | Driven by |
|---|---|---|
| u_prog | poem progress 0→1 | scroll position |
| u_wind | wind energy | smoothed scroll velocity |
| u_flow | advection clock | ∫ wind dt (integrated on the CPU) |
| u_time | wall clock | rAF timestamp — shimmer, twinkle |
| u_mouse / u_gust | gust centre & heat | pointer position & speed |
| u_liney / u_lineon | active line's screen band | layout measurement |
| u_res | canvas size | resize, DPR-capped |
Value noise & FBM — the raw material of dust
The base ingredient is 2-D value noise: hash a lattice of integer points to random values, then interpolate smoothly between them. Cheap, and good enough — dust hides the difference between value noise and fancier gradient noise.
float h12(vec2 p){ // 2-in, 1-out hash
vec3 q = fract(vec3(p.xyx) * 0.1031);
q += dot(q, q.yzx + 33.33);
return fract((q.x + q.y) * q.z);
}
float vn(vec2 p){ // value noise: smooth lattice blend
vec2 i = floor(p), f = fract(p);
f = f * f * (3.0 - 2.0 * f); // smoothstep fade curve
return mix(mix(h12(i), h12(i + vec2(1,0)), f.x),
mix(h12(i + vec2(0,1)), h12(i + 1.0), f.x), f.y);
}
FBM — fractional Brownian motion — stacks octaves of that noise, each one roughly twice the frequency (lacunarity ≈ 2) and half the amplitude (gain = 0.5) of the last. Low octaves give billowing masses; high octaves give grit. Each octave is also rotated by a fixed matrix so the lattice grids never align — without the rotation you get visible squareness.
const mat2 RM = mat2(0.8, -0.6, 0.6, 0.8); // rotate ~37° each octave
float fbm(vec2 p, int oct){
float v = 0.0, a = 0.5;
for (int i = 0; i < 5; i++){
if (i >= oct) break;
v += a * vn(p);
p = RM * p * 2.03 + 17.7; // lacunarity 2.03, decorrelating offset
a *= 0.5; // gain
}
return v;
}
Octave count is a budget decision. HARMATTAN never spends five octaves everywhere: the far haze uses 3, the mid dust 4, the near grit 3 — because the layering (next section) creates more apparent detail than any single expensive FBM would.
The wind field — giving the noise a direction to live in
Plain FBM drifts; wind pours. The harmattan is a trade wind — it has a prevailing direction (from the Sahara: screen upper-right toward lower-left) that wanders slowly. So the wind is modelled as an angle field: a base angle plus a low-frequency FBM meander,
vec2 wdir(vec2 p, float t, float settle){
// base ≈ 192° (left & slightly down) + slow FBM meander of ±33°
float a = 3.35 + (fbm(p * 0.55 + vec2(t * 0.04, -t * 0.026), 3) - 0.5) * 1.15;
// as the poem ends, the field tips toward 270° — straight down: dust settling
a = mix(a, 4.712, settle * 0.85);
return vec2(cos(a), sin(a));
}
Every layer asks this field two things: which way do I travel, and which way am I smeared. The second is the trick that makes it read as wind at a glance:
Anisotropic streaking
Build a local coordinate frame aligned with the wind — one axis along the flow
(dot(q, d)), one across it (dot(q, n)) — then sample the FBM with the
along-wind axis compressed and the cross-wind axis stretched. Features elongate
along the flow like smeared chalk. The same asymmetry is applied to the domain warp
(vec2(1.5, 0.65)), which keeps the warp from curling the streaks back into
lava-lamp swirls.
float dust(vec2 uv, float t, float sc, float sp, float streak, int oct, float settle){
vec2 d = wdir(uv * 0.7, t, settle);
vec2 n = vec2(-d.y, d.x); // perpendicular
vec2 q = uv - d * t * sp; // ADVECTION: sample upstream
vec2 lp = vec2(dot(q, d) * (1.0 - streak * 0.72), // squash along wind
dot(q, n) * (1.0 + streak * 1.6)); // stretch across it
vec2 w = vec2(fbm(lp * sc * 0.5 + t * 0.06, 3), // domain warp,
fbm(lp * sc * 0.5 + 7.31 - t * 0.05, 3));
return fbm(lp * sc + (w - 0.5) * vec2(1.5, 0.65), oct); // warped, wind-biased FBM
}
Three depths
The function is called three times with different scales, speeds and streak factors — far haze (scale 1.5, slow, soft), mid dust (3.1), near grit (6.3, fast, strongly streaked). Because speed scales with "closeness", scrolling produces parallax for free: the near layer tears past while the far haze barely breathes. Depth without geometry.
Advection & the integrated clock — the bug worth teaching
Advection here is the cheap classic: instead of moving dust forward, move the sampling
point upstream — q = uv − d · t · speed. The pattern appears to travel
with the wind.
The subtle bug: if scroll velocity multiplied t directly
(offset = dir · time · windSpeed), then changing the wind speed rescales
all of history — the whole sky lurches, as if time-travelling. The fix is to integrate a
flow clock on the CPU and hand the shader the integral, not the product:
// JS, once per frame — u_flow only ever moves forward, at the current wind speed
flow += dt * (0.045 + wind * 0.16);
Now a gust accelerates the weather from this moment on, and calm lets it coast to a drift. This one-line integral is most of why the piece feels physical.
Scroll is the wind
// px/s of scroll → wind energy target, with fast attack and slow release
const target = Math.min(v / 2400, 1.5);
scrollV += (target - scrollV) * (target > scrollV ? 0.28 : 0.045);
wind = 0.12 + scrollV; // 0.12 = the season never goes fully still
Asymmetric smoothing matters: wind should hit quickly (attack 0.28) and die slowly (release 0.045), like a real gust. The same energy value drives the type's weight axis, so the letters and the dust answer the same wind.
The cursor is a hot gust
Pointer speed charges a gust value (decaying exponentially, ~1 s half-life); the
shader turns it into three local effects, all falling off with a Gaussian
exp(−d²·9) around the pointer:
- Displacement — sampling coordinates are pushed radially away from the pointer, so the dust visibly shoves aside;
- Shimmer — a small high-frequency FBM wobble near the pointer, like heat-haze over asphalt;
- Heat — the local colour warms toward ember-orange, so the gust reads hot, not just windy.
vec2 dm = uv - m;
float g = u_gust * exp(-dot(dm, dm) * 9.0); // Gaussian falloff
vec2 guv = uv + normalize(dm + 1e-4) * g * 0.17 // radial shove
+ g * 0.35 * (fbm2(uv * 7.0 ± u_time) - 0.5); // heat shimmer
...
col = mix(col, hotOrange, g * 0.45); // heat tint
On touch screens the same math runs from pointermove, so a dragging thumb gusts too.
Grading the poem — scroll as a colour timeline
Scroll progress u_prog is remapped into four overlapping phase factors with
smoothstep ramps, tuned so each poem line lands inside its intended light:
| Factor | Ramp (u_prog) | Poem lines | Light |
|---|---|---|---|
| roseF | 0.18 → 0.38 | I–III → IV–VI | haze white to dust rose |
| emberF | 0.60 → 0.72 | VII–VIII | ember dusk; text flips to light |
| night | 0.80 → 0.92 | IX–X | night settles in |
| settle / stars | 0.84 → 0.97 | IX–X | dust falls; stars arrive |
Sky, dust-shadow and dust-light colours are each a chain of mix() calls through the
five palette stops, so everything — the gradient, the particles' lit and shaded sides,
even the gust's heat colour — moves through the same season together. The sun is drawn last:
a soft disc plus two exponential glows, positioned by progress so it slides down the sky,
reddening from milk-coin to old coal, veiled by whatever mid-layer dust happens to pass in
front of it, and gone by u_prog ≈ 0.88.
Dust to stars — the ending is a phase change
The last two lines needed the shader to perform the poem's final image: the wind lies down, and the dust becomes stars. Three coordinated moves:
- Settling — a
settlefactor multiplies dust density toward ~0.12 of its peak, and (inwdir) rotates the whole wind field toward straight-down. The dust doesn't fade out; it falls. - Stars — a hashed cell grid: each cell of
uv · 34rolls one hash; 18% of cells get a star at a hashed offset, sized by a tightsmoothstep, twinkling on a per-star phase ofu_time. Stars are dimmed by any remaining foreground dust, so they emerge exactly as the air clears. - Grade — the sky chains land on night (#211D1B), slightly warmer at the horizon, and the vignette holds the corners.
vec2 cell = floor(uv * 34.0);
float ch = h12(cell);
vec2 co = vec2(h12(cell + 71.3), h12(cell + 113.7)); // star's offset in its cell
float sdd = length(fract(uv * 34.0) - 0.3 - co * 0.4);
float tw = 0.55 + 0.45 * sin(u_time * (0.6 + ch * 2.4) + ch * 41.0);
float st = smoothstep(0.16, 0.0, sdd) * step(0.82, ch) * tw;
col += starF * st * vec3(0.85, 0.88, 1.0) * (1.0 - dustHere);
Type in the weather — a variable font as an anemometer
The poem lines are ordinary HTML — real, selectable, screen-readable text — sitting over the canvas. Two mechanisms put them inside the weather rather than on top of it:
Blend modes
Through the light phases the lines are night-ink with mix-blend-mode: multiply;
once u_prog passes 0.645 (as line VII's ember arrives) the body flips a class and the
lines become haze-white with mix-blend-mode: screen. Multiply lets bright dust eat
faintly into dark strokes; screen lets the letters glow with whatever passes behind them. The
flip is timed to the gap between lines VI and VII so it is never seen mid-read.
Two caveats learned the hard way: an ancestor that creates a stacking context silently turns
blend modes off (put the blend on the sticky element itself), and legibility needs help —
the shader receives the active line's screen band (u_liney) and thins the near dust
inside it, a clearing that reads as the wind parting around the words.
The wind in the letterforms
Each word is wrapped in a span with a deterministic phase. Every frame, the same wind
energy that drives u_flow writes two CSS custom properties per word — a weight for the
variable axis and a small vertical drift — plus a letter-spacing on the line:
/* CSS — the font listens to the wind through custom properties */
.line .w {
font-variation-settings: 'wght' var(--ww, 420);
transform: translateY(calc(var(--wy, 0) * 1px));
}
// JS — per word, per frame; ph is the word's fixed random phase
const n = Math.sin(now * 0.001 * (1.1 + ph * 1.6) + ph * 47.0);
const wt = clamp(330 + (n * 0.5 + 0.5) * (80 + windEnergy * 460), 100, 900);
w.style.setProperty('--ww', Math.round(wt));
w.style.setProperty('--wy', (n * windEnergy * 3.2).toFixed(2));
Calm air leaves the line murmuring between weights 330 and 430; a hard scroll gusts words up toward 900 and stretches the tracking. Only the one or two visible lines are animated — weight changes cause layout, so the work is scoped to at most ~15 words a frame.
Accessibility & performance — first-class, not fallback
Four ways to read one poem
- The weather — full motion experience; the poem is real DOM text throughout, in correct reading order.
- Read as text — a prominent header toggle swaps to a still typographic setting of all ten lines. The choice is remembered.
- Reduced motion —
prefers-reduced-motiongets the text poem plus a gallery of five shader frames rendered once at fixed (progress, time) pairs — one per phase of the season — into small canvases. No loop ever starts. - No WebGL2 — the text setting is the piece. Nothing breaks; the toggle hides.
Keyboard
Arrow keys, space and page keys step scene by scene; Home/End jump to cover and coda. Each step announces the line to assistive tech via a polite live region.
Performance discipline
- Device pixel ratio capped at 2, and total canvas pixels capped (~2.3 MP) — 4K screens render slightly under native and nobody can tell through the haze.
- Adaptive resolution: a smoothed frame-time average; if it stays over 40 ms, the render scale steps down (0.72×) with a cooldown — weak GPUs get the same weather, softer.
- rAF pauses when the tab is hidden, when "read as text" is active, and the shader skips drawing once the coda section fully covers the canvas.
- Layers budget octaves (3/4/3), and the whole page ships with zero JS dependencies — the shader, engine and typography are ~21 KB of hand-written code, unminified; the heaviest asset on the site is the 35 KB font.
The iteration log — three passes, honestly kept
-
Pass 1 — design critique
- Screenshots caught the poem lines rendering at 24% opacity: the line's 62vh top margin was collapsing out of its scene, shifting every measured centre by 558px. Moved the offset to scene padding, which cannot collapse.
- Sticky centring silently failed twice —
overflow-x: hiddenon body made body the scroll container (fixed withoverflow-x: clip), then a zero-slack wrapper gave the sticky line no room to stick (gave holds full scene height). - The dust read as lava-lamp swirls in the rose phase; biased the domain warp along the wind axis (1.5, 0.65) and strengthened streaking so flow reads directional.
- QA harness itself was lying: screenshots fired mid smooth-scroll under the software rasterizer, so every frame lagged a phase. Forced instant scrolling in the test rig before trusting any art judgement.
-
Pass 2 — elevation
- Warmed the haze phase (it photographed grey, not cream) and deepened the mid-phase dust contrast so the rose scenes have visible weather layers, not a flat wash.
- Gave the finale more night: stars now emerge through thinning dust rather than appearing on a clean background, and the settle factor tips the wind field downward so the last dust visibly falls.
- Pushed the cover's kinetic title — the wordmark breathes through the weight axis even before the first scroll, teaching the interaction wordlessly.
- Added the legibility clearing band (u_liney/u_lineon) after contrast checks on the rose phase — the wind parting around the active line is both a fix and a moment.
-
Pass 3 — ship quality
- Verified zero console errors across /, /guide and /process at mobile, tablet and desktop; fonts load from one 35 KB woff2; every link resolves to a real route, the hub, or a live sibling site.
- Reduced-motion verified: five stills render once, no animation loop starts, keyboard stepping switches to instant scrolls.
- Read-as-text toggle exercised in both directions, preference persistence checked, and the WebGL2-free path confirmed by forcing context creation to fail.
- Proofread every word of poem, guide and process; checked the dark-phase text flip lands between lines VI and VII at all three viewport heights.
Toolchain & deploy
Direction: Hannah Kwakye — concept, poem, palette, taste, and the many rounds of "that's noise, not weather yet". Engineering: Fable 5 as designer-engineer, writing the shader, the engine and these pages under that direction. The collection's working thesis in practice: AI doesn't replace taste — it amplifies the designer who has it.
Stack: hand-authored static HTML, CSS and JavaScript. No build step, no framework, no dependencies. One self-hosted variable font. Raw WebGL2. The heaviest asset on the site is the font.
Deploy: Netlify, CI-driven from the collection's repository. The site is a
folder of static files with a netlify.toml that sets immutable cache headers for
hashed-safe assets and conservative security headers (nosniff, same-origin framing,
strict referrer). Nothing on the page makes a network request beyond its own files.
QA ran through a headless-Chromium screenshot rig (SwiftShader running the real WebGL2 pipeline in software), reading rendered frames at every poem phase — judging the shader the way you'd judge a photograph, then editing the math.