01The idea
If you came here from the front page at night, you've seen it: a dark sea heaving under a storm deck, a castaway on a raft holding a lantern, and every few seconds a branching bolt that lights the clouds from inside. When a flash lands close enough, the letters of my name show up in the cloud for a moment. Come back in daylight and the same sea is an inked postcard with a palm island and a castaway sunbathing under a parasol; come back on an October night and it's a haunted shore with a harvest moon and a ghost on the raft. Everything on that page is a character in a monospace grid. There are no images.
ASCII rendering is an old idea, and the usual version is a webcam filter: take each pixel's brightness, look it up in a string like " .:-=+*#%@", print the character. That works, and it looks exactly like what it is. I wanted something closer to how a patient person would draw the scene with a keyboard: a wave crest drawn with slashes, a horizon with underscores, a lightning channel as a line.
The storm is where it started, so most of this post is the storm: three render passes in one WebGL context with three.js, targeting 60 frames per second and stepping its own quality down on machines that can't keep up. Sections 09 to 14 cover what came after: the engine that runs the other worlds, the weather, the transition between worlds, and the sprites. The figures here run smaller copies of the same shaders, so you can poke at each stage.
02Render a real scene first
The most important decision was the first one: don't generate ASCII directly. Render an ordinary 3D scene with ordinary lighting into an ordinary texture, then treat the conversion to characters as a separate post-process. Every frame is three passes:
scene pass sky dome + ocean mesh
│ → texture: cols×2 by rows×3, RGB colour + material id in A
▼
cell pass one fragment per character cell
│ → texture: cols by rows, glyph code + stroke flag
│ (ping-pong: also reads last frame's glyphs)
▼
screen pass one fragment per screen pixel
→ draws the glyph from a font atlas, colours it,
layers rain, sparks and text on top
The scene itself is just two meshes. A sky dome, a sphere 4,000 units across, whose fragment shader raymarches a volumetric cloud deck and also draws the lightning, the rain shafts and my name. And an ocean mesh, displaced in the vertex shader by five sharpened sine waves plus a small GPU ripple simulation that rain, clicks and the bot's words disturb.
Keeping the scene "real" pays off everywhere. The flash lights the cloud from inside because there is a light at the channel; the bolt's reflection lands in the right place because there is a reflected ray. And the scene and the character conversion can be tuned separately: a dev-only lab page shows the raw frame before conversion, and I had it open next to the real thing for most of the project.
One tax: the raft has to ride the waves, and a click has to know where it hit the water, so the CPU needs the same wave function as the GPU. Rather than maintain two copies by hand, the GLSL is generated from the TypeScript table:
// kx, kz, omega, amp, sharpness
const WAVES = [
[0.012, 0.021, 0.75, 5.0, 1.6],
[-0.028, 0.017, 1.1, 2.6, 1.4],
[0.05, 0.036, 1.7, 1.3, 1.2],
[-0.083, 0.11, 2.5, 0.6, 1.0],
[0.14, -0.085, 3.4, 0.3, 1.0],
];
const swellGLSL = `
float swell(vec2 p) {
float h = 0.0;
${WAVES.map(([kx, kz, w, a, k]) =>
`h += wave(p, vec2(${f(kx)}, ${f(kz)}), ${f(w)}, ${f(a)}, ${f(k)});`).join("\n ")}
return h * uSwell;
}`;
The exponent on each sine sharpens crests and flattens troughs, which is most of what makes a sum of sines read as a storm swell instead of a lake.
03Six samples per character
The scene doesn't render at screen resolution. It renders at exactly 2×3 texels per character cell, with nearest filtering, so each texel is one sample.
Cells are 11 CSS pixels tall on a typical desktop (13 on screens wider than 1,600 px, 9 on phones) and 0.625 times as wide as they are tall. At 1440×900 that's 210 × 82 = 17,220 cells, so the scene target is 420 × 246, about 103,000 texels. The canvas at a device pixel ratio of 2 is 5.2 million pixels. The expensive shader, the cloud raymarch, runs on about one fiftieth of the pixels it would at native resolution. The ASCII look isn't a cost the scene has to pay for. It's the reason the sky can be volumetric at all.
Why 2×3? With a 5:8 cell, a 2×3 grid gives nearly square samples (0.31 × 0.33 of the cell height). It's also the smallest grid that can tell left from right and top from middle from bottom, and that turns out to be most of what distinguishes one punctuation mark from another. A comma and an apostrophe have the same amount of ink; the difference is which row it's in.
04Why a brightness ramp looks muddy
The classic approach averages a cell's brightness and indexes into a ramp of characters ordered by ink density. Mine is the ten characters " .:-=+*#%@". The trouble is that a ramp only encodes how much ink a cell should have, not where it goes.
Take a cell on the top edge of a wave: the top half is lit crest, the bottom half is dark water. Its mean is mid-grey, so the ramp gives it = or +, a character whose ink sits in the middle, which is the one place the image has none. Every edge dissolves this way into a band of medium-density characters, and with only 17,000 cells on screen, edges are most of what makes the scene legible.
The ramp still has a job, though: flat cells. If all six samples in a cell are within 0.16 of each other, there's no edge to match, and a 2×3 grid genuinely can't tell a thin centred bar from an even wash of the same total brightness. Shape matching on flat cells picks arbitrary glyphs, and they change as the image drifts. So flat cells use the ramp, with a fixed dither:
const int RAMP[10] = int[10](32, 46, 58, 45, 61, 43, 42, 35, 37, 64);
// Per-cell noise from the cell's coordinate only: it never changes over
// time, so it breaks up banding without crawling.
float dn = fract(sin(dot(cell, vec2(12.9898, 78.233))) * 43758.5453) - 0.5;
if (mx - mn < uEdge) { // uEdge = 0.16
float t = clamp(pow(mean, 0.85) * 1.1 + dn * uDither, 0.0, 0.999);
gl_FragColor = vec4(float(RAMP[int(t * 10.0)]) / 255.0, 0.0, 0.0, 1.0);
return;
}
Without the dither, a smooth gradient across the cloud deck quantises into ten visible contour bands; ±0.06 of static noise breaks them into a fine stipple.
05Shape vectors and nearest-glyph search
The fix for edges comes from Alex Harri's "ASCII characters are not pixels", which is the best write-up on this I know of and worth reading in full. The idea: describe each glyph by where its ink is, sample the image the same way, and pick the nearest glyph.
At startup, atlas.ts draws every printable ASCII character in IBM Plex Mono into a 40×64 slot on a canvas. For the 29 glyphs the matcher is allowed to use, it splits each slot into the same 2×3 grid and measures the fraction of each region covered by ink. That's a six-number vector per glyph:
// Glyphs the matcher may choose from: symbols only, so the scene
// reads as texture rather than text.
const SHAPE_SET = " .,:;'`\"-_~=+*^<>/\\|()[]{}#%@";
for (let y = 0; y < GH; y++) {
const r = Math.min(2, Math.floor((y / GH) * 3)); // row 0..2
for (let x = 0; x < GW; x++) {
const k = r * 2 + (x < GW / 2 ? 0 : 1); // region 0..5
v[k] += img[(y * GW + x) * 4] / 255; // ink coverage
cnt[k]++;
}
}
The vectors are normalised by the largest coverage of any region of any glyph, so the densest region of any glyph is 1.0, and uploaded as a tiny 29×2 float texture. Letters are left out on purpose. Once the matcher can choose n and o, the sky starts spelling things, and your eye reads them instead of the picture.
On the GPU, the cell pass runs one fragment per cell. It reads the six texels, converts them to luminance with a gain of 1.4, sharpens the contrast within the cell, and then brute-forces the distance to every glyph:
// Contrast within the cell: darker samples get darker relative to the
// brightest, pushing soft edges toward the clean shapes glyphs have.
s0 = pow(s0 / mx, uContrast) * mx; // ...same for s1..s5, uContrast = 1.7
float best = 1e9, bestCode = 32.0, prevD = 1e9;
for (int i = 0; i < 96; i++) {
if (float(i) >= uCount) break;
float u = (float(i) + 0.5) / uCount;
vec4 a = texture2D(tShapes, vec2(u, 0.25)); // regions 0-3
vec4 b = texture2D(tShapes, vec2(u, 0.75)); // regions 4-5, char code
vec4 da = a - vec4(s0, s1, s2, s3);
vec2 db = b.xy - vec2(s4, s5);
float d = dot(da, da) + dot(db, db); // squared distance in 6-D
if (d < best) { best = d; bestCode = b.z; }
if (abs(b.z - prev) < 0.5) prevD = d; // remember last frame's glyph
}
That's about 170 multiply-adds per cell. Even if every one of the 17,000 cells were an edge, it would be about three million per frame, which a GPU doesn't notice. At this size brute force is the right data structure; there's nothing to gain from a k-d tree or a lookup table.
The contrast step is a simplified version of the contrast enhancement in Harri's post. A soft cloud edge gives a vector that slopes gently, say (0.5, 0.5, 0.3, 0.3, 0.2, 0.2), whose nearest glyphs tend to have ink everywhere. With an exponent of 1.7 it becomes (0.5, 0.5, 0.21, 0.21, 0.1, 0.1): ink on top, little below, and glyphs like " and ^ win. The underside of a lit cloud comes out as a row of ' and ", a diagonal crest as /, the horizon as _.
Update. The storm, the calm night and the Halloween shore no longer use this rule. A small neural net trained to beat it now picks the glyphs, from twelve samples per cell instead of six. How it was trained and graded. The rule is still one URL away: ?matcher=classic.
06Hysteresis
Shape matching looked great in screenshots and bad in motion. The camera sways like a boat, the clouds drift, the swell rolls toward you, so the image under each cell moves a little every frame, and a cell near the boundary between two glyphs flips between them. Ramps have this problem too, but mildly, because neighbouring ramp entries look alike. Neighbouring shape matches don't: / to ( to | is a big jump. The whole picture shimmered, and the shimmer read as noise rather than motion.
The fix is hysteresis, the same idea as a thermostat that won't switch back on until the room is clearly too cold. The cell pass renders into two targets and swaps them each frame, so every cell can read the glyph it chose last frame. The search loop above already records that glyph's distance (prevD). The decision is one line:
// Keep last frame's glyph unless the new best beats it by a margin.
float code = prevD <= best + uHyst ? prev : bestCode; // uHyst = 0.06
The margin is in squared-distance units over six components between 0 and 1, so 0.06 is what you'd get if every component moved by 0.1 (6 × 0.1² = 0.06). Smaller than that and the shimmer comes back on slow drifts. Much larger and glyphs visibly lag the image: a crest that has flattened drags its old / along for a few frames. The cost is one extra texture read and a compare per cell.
It can't get stuck on a glyph from another path: if last frame's glyph came from the ramp or the lightning code below, it isn't in the shape set, prevD stays at 109, and the cell takes the new best immediately. The ramp doesn't need hysteresis, because its dither depends on position, not time.
I haven't seen temporal hysteresis described in the write-ups I learned from, and of everything here it made the single biggest difference to how the scene feels. It's the kind of thing you only find by watching the output move.
07Materials in the alpha channel
The screen pass gives each cell two colours: ink, from the average of its six texels, and a faint background wash at 13% of the scene colour so the gaps between glyphs aren't flat black. That covers most of the sky and sea. But some things should look different from what their colour implies: foam should churn, lightning should be white-hot, stars belong only in empty sky.
Nothing in the scene is blended, so its alpha channel is free, and it carries a material id: 0.3 sky, 0.5 water, 0.75 whitecap, 1.0 lightning channel. The sky is 0.3 rather than 0 because 0 means "clear sky" in the site's other scenes, where the screen pass sprinkles twinkling stars. There are no stars under a storm deck.
The one subtlety: the screen pass reads the material from a single texel (the middle-left one), never an average of the six. Material ids aren't quantities. Average a lightning texel (1.0) with a water texel (0.5) and you get 0.75, which is a whitecap, so the edge of every reflected bolt would boil with foam. Colour gets averaged; ids get sampled.
Whitecaps then get special handling. Their ink turns pale with a slight teal tint, a dim bioluminescent glow appears behind them, and the brightest foam cells swap their glyph for a random pick from %&@#*o+, re-rolled about eight times a second with a per-cell phase offset. It's the one place where I deliberately add the flicker I spent a section removing.
08The sky and the lightning
The cloud deck
The sky shader marches each view ray through a slab of cloud between 5 and 13 units above the eye. Noise sags the base into pouches. Density is five octaves of 3D value noise plus a detail term, pushed through a steep threshold so the cloud has edges instead of fog. Ambient light is a cheap stand-in for multiple scattering: thin wisps glow, dense cores stay dark, 0.015 + 0.26·(1 − d)³.
The march uses 18 steps, down from 26, offset by a per-pixel random jitter. Jitter turns banding into noise, and at six samples per character that noise is absorbed by the glyph choice. The ray stops early once transmittance drops below 3%. The step count is a uniform, so it can be traded for speed at runtime.
The expensive part is the flash. Each sample needs light from the channel, shadowed by the cloud between the sample and the channel. The first version took two extra full-density samples toward the channel, which doubled the cost of the sky during a strike. Now it's tiered by distance:
vec3 tl = fpos - q; // toward the channel
float dl = length(tl);
if (dl < 45.0) { // beyond 45: negligible, skip
float occ = 2.0 * d; // far: own density as proxy
if (dl < OCC_NEAR) { // near (18): one coarse sample
vec3 qa = q + tl / dl * 2.0;
occ = 2.0 * cloudDLite(qa, cloudBase(qa));
}
lc += vec3(0.75, 0.8, 1.0) * uFlash * 3.2 / (1.0 + dl * dl * 0.03) * exp(-occ * 1.6);
}
cloudDLite keeps only the lowest octave and the fine detail term, which carry most of the occlusion, and replaces octaves two through five with their mean (0.234). Together these took the calm sky from 0.32 ms to 0.14 ms a frame, and the extra cost during a flash from 2.0× to 1.3×.
Last comes a tone curve with a hard black point, pow(max(c·2.4 − 0.045, 0), 1.3). It crushes dim washes to zero so they become empty cells, and only real features get ink. In ASCII, black is the most important colour. The empty cells are what give the glyphs their contrast.
The bolt lives in tangent space
The lightning channel isn't geometry. It's a 2D function evaluated in the sky shader, in the tangent plane of the view direction: t = d.xy / d.z. That makes it fixed in the world (the camera sways; the bolt stays put), and anything with a direction can look it up. The channel wanders by fractal noise plus piecewise-linear "jag" noise, which gives straight runs with sharp kinks, and four branches fork off it. Distance is corrected for the local slope, so steep zigzags stay as thick as vertical runs.
float boltAt(vec3 rd) {
if (uBoltVis < 0.01 || rd.z < 0.2) return 0.0;
// Evaluated at 1/BK scale so the channel is ~BK times thicker on screen
// (wide enough to land on the pass's material texel) with the same shape.
const float BK = 1.35;
vec2 t = rd.xy / rd.z;
float bottom = uBoltPin > 0.5 ? uBoltEnd.y : 0.0;
float lean = uBoltPin * (uBoltEnd.x - uBoltX);
return boltL(t / BK, boltTop() / BK, bottom / BK, 1.0 / BK, lean / BK, uBoltPin) * uBoltVis;
}
The reflection is the same function. The sea shader computes the reflected ray, maps it into the same space, and calls boltAt, then breaks the result up with noise so it shatters across the waves. For a strike you call down yourself (hold the cursor still, then click), the sea instead extends the view ray to the strike's depth, mirrors that point below the surface, and looks up the channel along that ray, so the reflection runs down the water toward you from the point of impact.
The BK factor in that excerpt is a fix for a real problem. At its natural width the channel is thinner than a scene texel in places, and since the screen pass reads the material from one texel per cell, stretches of the bolt would simply not register as lightning. Evaluating it at 1/1.35 scale makes it 35% thicker with exactly the same shape.
The channel's shape depends only on a random seed per strike, but the first version re-derived all that noise per pixel, per frame, in both the sky and the sea. Now the CPU tabulates it once per strike into a 1024×5 float texture (the main channel's wander and slope, plus one row per branch), and the shader does two texelFetches and a mix. One wrinkle: the JavaScript hash runs in double precision, so a seed gives a different channel than the GPU version would. Seeds are random anyway, so nobody can tell.
Stroke glyphs
Even with shape matching, the bolt looked wrong. A vertical channel matches | nicely, but a diagonal channel crossing cell corners came out as a mess of ', ,, ( and [, because six samples of a line clipping a corner look like a blob. And where the channel filled a whole cell, there was no shape left to match.
Acerola's ASCII shader (AcerolaFX, and his video about it) showed the alternative: on edges, don't match, draw. Work out the edge direction and pick from - / | \. My version applies that only to the lightning core. A cell with any texel above alpha 0.9 measures the second moments of the core mask over a 6×9-texel window, its own six texels plus its eight neighbours', so even a channel that fills the cell still has a direction:
for (int j = -3; j < 6; j++) {
for (int i = -2; i < 4; i++) {
float w = step(0.9, texelFetch(tHi, clamp(base + ivec2(i, j), ivec2(0), lim), 0).a);
vec2 p = (vec2(float(i), float(j)) - vec2(0.5, 1.0)) * vec2(0.3125, 0.33333);
n += w; m += w * p; q += w * vec3(p.x * p.x, p.y * p.y, p.x * p.y);
}
}
m /= n;
vec3 c = q / n - vec3(m.x * m.x, m.y * m.y, m.x * m.y); // covariance
float ani = length(vec2(c.x - c.y, 2.0 * c.z)) / (c.x + c.y + 1e-5);
float th = 0.5 * atan(2.0 * c.z, c.x - c.y); // principal axis
That's a principal-axis fit in nine lines. ani is the difference between the two eigenvalues over their sum: 0 for a round blob, 1 for a perfect line. Above 0.35, and with fewer than 40 of the 54 texels lit, the cell is a stroke. The angle is binned: under 22° or over 158° is - (or _ if the line sits low in the cell), 22–68° is /, 68–112° is |, and the rest is \. Forks and point-blank strikes fail the test and fall through to the shape matcher.
That alone gave doubled lines, because a channel three texels wide straddles two cells and both drew a |. So each core cell walks across the channel to find the run's extent, and only the cell holding its midpoint draws: one cell per row for a steep line, one per column for a shallow one, like Bresenham's algorithm run backwards. The stroke cell gets white-hot ink over a faint violet-blue background; its neighbours draw a space flagged "glow only" and keep a dimmed wash of light, which becomes the halo. A run wider than six texels is a solid body and goes back to the matcher. Strokes get their own hysteresis too: a cell keeps last frame's stroke while the angle is within 12° of its bin.
All of this costs about 4 µs a frame. Only core cells take the branch, a small fraction of the screen even mid-strike, and every other cell skips it after a single compare.
- _ / | \, and thins the result to one cell per row or column.The name
My name is a 5×7 pixel font rasterised into a mask at scene resolution. The sky shader adds it to the cloud before the tone curve, as extra lit cloud with noise texture, but only near the last flash, with a Gaussian falloff whose reach grows with the flash's brightness. Most strikes show a fragment; one right underneath shows it all. Because it's part of the scene, the glyph matcher draws the letters' edges like any other cloud, so the name belongs to the weather instead of sitting on top of it.
09One engine, four skies
The storm was written as one file. Adding a second world meant deciding what belongs to the engine and what belongs to a world. The split that stuck: the engine owns the renderer, the ripple simulation, the character grid, the pointer, and where the castaway and the bottles sit on screen. A theme owns its 3D world, its pass chain and its sprite art, and hands the engine a ThemeScene: the world, a sprite layer, the castaway's animation, the bottle frames, what a click does ("strike" or "splash"), and prepare / render / compile / dispose.
The one signature that matters is render(renderer, target). With target = null a theme draws to the canvas; with a render target it draws its finished frame into a texture. That's what makes the transition in section 13 possible without any theme knowing it exists.
There are four themes but three worlds, because a calm night and a storm are the same world in different weather:
const kindOf = (t: Theme): Kind => (t === "night" ? "storm" : t);
const LOADERS: Record<Exclude<Kind, "storm">, () => Promise<ThemeFactory>> = {
day: () => import("../themes/day.ts").then((m) => m.createDayTheme),
halloween: () => import("../themes/halloween.ts").then((m) => m.createHalloweenTheme),
};
The storm world ships in the main bundle, because night is the most common theme and the intro runs on it. Day and Halloween are separate chunks, fetched when the clock (or the header's picker) asks for them; if a chunk fails to load mid-deploy, the engine falls back to the storm. When a transition finishes, the old world is parked if it's the storm (it'll be back tonight) and disposed if it's one of the lazy ones.
The shader warm-up from section 15 had to learn about this too. three.js keys a compiled program partly on the output colour space of the target it's drawn into, so a screen pass linked against the canvas gets linked again, blocking, the first time it draws into a texture for a transition. Every theme now compiles its screen pass twice up front: once against the canvas, once against a 1×1 offscreen probe.
Which world you get is a rule, not a setting: daylight from 07:00 to 18:30 your local time, Halloween after dark in October, the night sea otherwise. The engine re-checks the clock every minute, so if you leave the tab open across dusk, the sea changes under you.
10The weather dial
A storm is weather, not a place, so the storm world takes one number: setWeather(w), from 0 (a calm, starry night) to 1 (the full storm). Every storm parameter is a function of it, written so that w = 1 reproduces the original storm exactly:
shared.uSkySteps.value = deck > 0 ? Math.round(SKY_STEPS * (0.45 + 0.55 * deck)) : 0;
shared.uMoonLit.value = 1 - smooth(0.3, 0.85, weather);
shared.uStarVis.value = 1 - smooth(0.15, 0.65, weather);
boltRate = smooth(0.5, 1, weather);
rainLevel = 0.3 * ramp(0.4, 1, weather);
The cloud deck is ramp(0.25, 1, w), and the raymarch's step count follows it down to zero, so a calm night doesn't pay for clouds it isn't drawing: the most expensive shader in the scene simply switches off, a performance win that came free with the feature. Wave amplitudes blend toward a calm table, the moon and its glitter road fade in, stars come out, and a little bioluminescence appears around the raft. Below 0.5, a click never calls lightning; a click on the sky sends a shooting star instead.
A small director moves the dial. Your first night visit in a session opens on the full storm, which clears after two or three minutes; after that, storms roll in for two to four minutes and calm spells last six to twelve. Changes are smoothstep eases scaled by distance: 40 seconds for a full natural change, 6 after you pick a theme, 10 when the chat builds one. The chat does drive the weather. While the castaway is thinking the dial is held at 0.75 or higher and rain falls on the ripple sim; when his answer streams in it goes to 1 and lightning strikes; afterwards the storm lingers for 15 seconds before the natural schedule takes over.
Storm and night never use the dissolve below. Moving between them is just turning the dial, which is why the weather can change in the middle of a sentence without anything snapping.
11Daylight: ink on paper
The storm's whole aesthetic is bright glyphs on black. Daylight can't work that way: a sunny sky rendered as bright characters is a page of noise. So the day theme inverts the problem and draws ink on paper. The page goes cream, and the glyph matcher, which matches on luminance, is fed darkness instead of light, weighted per material:
float ink = smoothstep(0.1, 0.9, dark);
if (m < 0.175) w = 0.4; // sky: mostly paper
...
else if (m < 0.42) w = 1.25; // palm: solid silhouette
else if (m < 0.85) w = 0.45; // foam: white water reads as paper
else w = 0.0; // sun / glitter: drawn by the screen pass
Brightness alone made the day look like a photocopy, so a second pass draws pen lines from materials instead: cloud contours one cell wide however the light falls on them, the island's edge, and the palm as strokes with ring scars on the trunk, fronds and coconuts. The sea is engraving-style hatching in perspective, lines a row apart at the horizon spreading to several rows in the foreground, with troughs inked and lit faces lifted, so it moves without ever going solid. The world itself is lighter than the storm: a long calm swell, an island mesh with one palm, flat-bottomed cumulus as 2D noise on a plane (no raymarch at all), and gulls.
The sun was the hard part, because the sun can't be white on white paper. It's drawn by the screen pass, not the scene: a ring of tangent strokes () \ _ / ( -) chosen by the angle of the limb at each cell, a warm fill, and twelve dashed rays turning slowly.
One small piece of plumbing worth stealing: on paper, sea hatching showing through a sprite makes it unreadable. So the day theme keeps a one-byte-per-cell knock-out mask. Each sprite row's span of glyphs, dilated by one cell, becomes blank paper. It's rebuilt only when a sprite changes, not every frame.
12Halloween
The October world is a haunted shore: a huge orange harvest moon low over the horizon with a glitter road under it, the wreck of a ghost ship on the rocks (black silhouette, green lanterns and portholes, a snapped foremast, tattered sails) against a pale mist, fog as three noise sheets at fixed heights, and will-o'-wisps. The castaway is a ghost under a sheet with a jack-o'-lantern on his mast. He says "boo!", lifts the pumpkin, plays an accordion shanty, and sometimes fades out until only his orange eyes are left.
The wreck needed an outline, and it reuses the trick from the lightning, at a smaller scale. Within a cell, the pass looks at which of its six sub-samples are solid ship and picks a stroke from their spread and covariance:
float cv = sQ.z / nSolid - mu.x * mu.y;
if (nSolid < 1.5) code = mu.y > 1.5 ? 39 : (mu.y < 0.5 ? 46 : 45); // ' . -
else if (vx < 0.01 && vy > 0.1) code = 124; // |
else if (vy < 0.01) code = mu.y < 0.5 ? 95 : 45; // _ -
...
else code = cv > 0.0 ? 47 : 92; // / or \
Solid cells stay black and the outline cells get a thin green stroke, so the ship reads as a silhouette with an edge instead of a blob. The moon's limb gets the same treatment from neighbour coverage: a clean ring of ( ) / \ _ -.
Every so often a flock of three or four bats crosses the moon, flapping at 7 Hz, and the ghost startles a beat and a half later. Bats are sprites (see section 14), and they needed to be black against the moon but violet everywhere else, which the sprite layer can't express. The fix is a bit of a hack I'm fond of: a bat is written with one exact intensity, 0.62, that nothing else uses, and the screen pass treats that value as "silhouette here, glow elsewhere". The wisps are drawn in the pass too, as @ and O orbs with halos of motes, rather than in the scene, where they'd be smaller than a cell.
13The dissolve
Switching worlds with a crossfade looked like a slideshow. What I wanted was for the new world to be redrawn over the old one, the way the scene is drawn in the first place: one character at a time.
During a transition both worlds render their finished frames into textures, and a final pass decides, per cell, which one to show. A front sweeps out from the horizon straight ahead and reaches the far corners after 2.2 seconds. Distance is measured in cell heights, and two octaves of low-frequency value noise make the front ragged, deliberately with no per-cell jitter, so it reads as one shoreline rather than static:
float n = vnoise(d * 0.06) * 0.65 + vnoise(d * 0.16 + 7.3) * 0.35;
float thr = r + n * RAG; // RAG = 0.28
float band = FRONT / span; // FRONT = 1.5 cells
float e = uP * (1.0 + RAG + band) - thr; // < 0 old, > band new
if (e <= 0.0) { gl_FragColor = a; return; }
if (e >= band) { gl_FragColor = b; return; }
Cells on the front itself, a band about a cell and a half wide, show neither world. They draw one of twelve shape glyphs (. : ' - ~ / \ | , ` ^ _, never letters), re-picked every 70 ms, faded in and out with a bell curve across the band, in navy ink if the cell is light and amber if it's dark. It looks like the pen moving across the page. The page's own palette (dark or light) flips at the halfway point, and the adaptive-quality monitor ignores the transition, since rendering two worlds at once for two seconds says nothing about your machine. With reduced motion turned on, all of this becomes a plain 0.6-second crossfade.
14One texture for every sprite
The castaway, the bottles, the bats, the words that splash into the sea while the bot answers, and the sentence written beneath the raft afterwards are all the same thing: characters written from JavaScript into one small texture, one texel per cell. Red is the character code, green an intensity, blue an ink (accent, sand or white). The screen pass checks it first and, where green is above zero, draws that character instead of whatever the glyph matcher chose:
vec4 m = texture2D(tMsg, (cell + 0.5) / uGrid);
if (m.g > 0.01) {
code = int(m.r * 255.0 + 0.5);
vec3 ink = m.b < 0.25 ? uAccent : (m.b < 0.75 ? vec3(1.0, 0.86, 0.62) : vec3(1.0));
That keeps the hand-drawn art out of the 3D scene entirely. The sprites are placed by projecting a point in the world (where the raft is, where each bottle is) into the grid, and the texture is only re-uploaded when something actually changes: each sprite has a key folding its frame and position together, and an unchanged key is never re-sent.
The storm castaway is 13×5 characters of hand-drawn frames: his lantern flickers between *, + and # at 7 Hz, his mouth opens and closes while he talks, and every 8 to 20 seconds he bails water, watches a bird, shifts his weight or waves. A close lightning flash makes him startle. The day castaway lies under a parasol in sunglasses that glint; the Halloween one is the ghost. Day and Halloween share one small state machine for priorities (thinking beats talking beats your cursor beats idle fidgets).
The bottles are 8×2 sprites, a body with a rolled note inside, a shoulder, a neck and a cork, in three poses: level, neck up and neck down. There's no rotation in a character grid, so the pose comes from the swell. Each frame the engine asks the world where the bottle was a quarter of a second ago and tilts it by its vertical velocity:
const y0 = world.bottlePoint(i, time - 0.25, bobTmp)!.y;
const v = (b.pos.y - y0) / 0.25;
f = v > 2.2 ? 1 : v < -2.2 ? 2 : 0; // rising: neck up; falling: neck down
Rising water pitches the neck up and falling water pitches it down, which reads as a bottle riding the wave. In the storm they glint when the lightning catches them, and in every world the one under your cursor glints too.
The castaway also talks to you, in a speech bubble that is plain DOM rather than texture, because it needs to wrap, be clickable and be read by a screen reader. It types each line out while the sprite mouths it, names the post in whichever bottle you point at, and says something new now and then if you stay. What he says when you click him is a whole other system, with retrieval, tools and evals; that's the next bottle over.
15Making it smooth
The storm is a background, so it should use a small slice of the 16.7 ms frame and degrade gracefully when it can't. Here's where the larger wins came from, measured on my machine:
| change | before | after |
|---|---|---|
| sky raymarch, calm | 0.32 ms | 0.14 ms |
| sky cost during a flash | 2.0× | 1.3× |
| ocean mesh | 109k verts | 20k verts |
| cold shader link (blocking) | ~1.5 s | ~0.6 s, hidden |
| stroke glyphs (added) | n/a | +4 µs |
The ocean mesh. It started as a uniform plane of 109,000 vertices, most of them far away and packed into a few pixels each. Now the rows are spaced in proportion to their distance from the camera (every max(3, 0.035·d) units), and each row only spans the view's width at that distance, with 160 columns. That's about 20,000 vertices with roughly even density on screen.
Frame pacing. requestAnimationFrame fires at 120 or 144 Hz on some displays, and a background has no business rendering that often. The loop banks elapsed time and renders when the bank reaches the interval, minus a little slack:
const interval = tier >= 2 || scroll > 0.9 ? 1000 / 30 : 1000 / 60;
budget += now - prevTick;
prevTick = now;
if (budget < interval - SLACK) return; // SLACK = 1.2 ms
budget = Math.min(budget - interval, interval / 2); // carry a little, never a burst
The slack stops vsync jitter on a 60 Hz screen from making a tick land a hair early and skip a frame; the cap on carry-over means a hitch is never followed by a burst of catch-up frames. Scrolled down behind the text panels, the storm drops to 30 fps.
Adaptive quality. The loop keeps an exponential moving average (α = 0.1) of the interval between rendered frames. If it stays above 1.32× the target (22 ms at 60 fps) for 1.5 seconds, quality steps down one tier: first cells two pixels taller, which means fewer cells and makes every pass cheaper; then 30 fps; then a device pixel ratio of 1. Steps are 4 seconds apart at least, and the first 3 seconds after start and the first second after a tab becomes visible are ignored, since that's where one-off hitches come from. Tiers only ever go down within a session. That's a real tradeoff: a hiccup caused by something else on your machine costs you quality for the rest of the visit. I took it over a scene that oscillates between tiers, which looks worse than either.
Shader warm-up. On Windows, WebGL runs through ANGLE on top of Direct3D, and linking the sky and sea programs cold took about 1.5 seconds. Done lazily, that froze the main thread on the first render. Now every program is compiled up front with compileAsync (which uses KHR_parallel_shader_compile where available), each against the kind of render target its pass draws into, since three.js keys programs partly on output colour space. That's about 0.6 seconds, off the critical path and hidden behind the intro, where the scene "trains" out of glyph noise with a braille loss curve. The intro started as a joke and turned into the loading screen.
Bytes. About 590 KB of JavaScript, mostly three.js, goes over the wire as 126 KB: the build writes brotli-11 and gzip copies next to each file, and the server sends them as-is. The font atlas waits at most 1.5 seconds for IBM Plex Mono, then builds with the fallback font and rebuilds in place when the real one arrives.
16What I'd do next
- More samples per cell. Six samples can't separate a lot of glyphs: ( and [, or + and *, are nearly the same vector. Harri's post goes further on sampling than I did. The cost would land in the scene pass, currently the cheap side of the bargain.
- Perceptual hysteresis. The margin is one constant. Switching . to , is nearly invisible, while / to ( is loud. A margin weighted by how different two glyphs look would let quiet changes through sooner and hold loud ones longer.
- Strokes beyond lightning. The horizon and the wave crests are thin bright features too. The risk is that the whole picture starts to look like a line drawing instead of a storm, so it would need a light touch.
- Seasons that blend. Day, night and Halloween are separate worlds joined by a dissolve. Dusk is the obvious missing piece: the day world's ink thinning into the night world's light over half an hour, rather than a two-second redraw at 18:30.
- Clouds at half rate. The deck moves slowly. Raymarching half the cells each frame and reprojecting the rest would roughly halve the biggest remaining cost.
If you haven't yet, go back to the sea, hold the cursor still until the static builds, and click somewhere on the water. Use the picker in the header to watch the dissolve, or ask the castaway to change the sky for you; he can do that now.
Credits. Shape vectors and contrast enhancement: Alex Harri, "ASCII characters are not pixels". Edge-direction glyphs: Acerola's ASCII shader in AcerolaFX. The rotated-octave fbm and most of my noise habits come from Inigo Quilez's articles. Everything is drawn with IBM Plex Mono and rendered with three.js.