01The idea
If you came here from the front page, 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. 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 whole thing is three render passes in one WebGL context with three.js. It targets 60 frames per second and steps its own quality down on machines that can't keep up. 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 _.
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.
09Making 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.
10What 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.
- 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. The castaway on the raft will talk to you if you click him too.
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.