01The rule it replaced
The front page is a 3D scene drawn entirely in characters. The first post covers the pipeline; the part that matters here is one decision made about 17,000 times a frame: given a small patch of the scene, which character should stand in for it?
The answer was a rule, after Alex Harri's shape vectors. Sample the patch at six points (a 2×3 grid), boost the contrast, and pick whichever of 29 symbols has its ink distributed most like those six numbers. Flat patches skip all that and get a dithered brightness ramp instead. Only about one cell in seven or eight is an "edge" cell that uses shape matching, but those cells carry the picture: wave crests, cloud bands, the horizon.
The rule is clever, and it's also a guess. It compares six brightness samples against six ink-coverage numbers with plain Euclidean distance, every sample weighted the same. Nobody checked whether that is how a glyph should be chosen. Six numbers are all it ever sees, so it can't use any knowledge of what the patch usually looks like when those numbers occur. That's a learning problem.
02A teacher that sees everything
To learn a better rule you need examples of the right answer, and nobody has labelled which character each patch of a procedural storm should be. So I built a teacher: a slow, exhaustive judge that sees each cell at full resolution instead of six samples, and picks the best glyph by brute force. Then a small student learns to imitate the teacher from the six samples the shader can actually afford. That's knowledge distillation.
The data came from the site itself. A dev-only hook renders the real scene pass at 10×16 texels per cell (the site uses 2×3), with the material id in the alpha channel, and a headless browser on the Pi collected 138 frames: full storm, a squall, a calm night, and the Halloween shore, plus two 24-frame clips at 30 fps for measuring flicker. That's a few minutes of capture.
The first teacher was wrong in an instructive way. I scored each glyph by pixel error against the cell, letting the glyph's brightness float freely, and it chose a blank for 80% of edge cells. A flat grey is the lowest-error picture of a soft edge; pixel error rewards blur. And brightness isn't the matcher's job anyway: the screen pass colours every glyph with boosted, normalised ink, so the ASCII image is stylised and can never match the scene pixel for pixel.
What the matcher controls is where the ink goes. So the second teacher scores each glyph by how well its ink pattern correlates with the cell's light pattern, both lightly blurred to stand in for viewing distance. That's the structure term of SSIM. It promptly developed a taste for @, # and %: the cloud deck has a fine grain from the raymarcher's jitter, and dense glyphs correlate with grain. A small penalty on ink density fixed it, so when two glyphs fit about equally, the simpler one wins.
The finished teacher draws cloud bands and wave crests with _ ~ = ^ `. The old rule, on the same cells, reached for { } | ;. Where they disagreed, the teacher was usually following the structure and the rule was cutting across it.
The teacher has one honest blind spot. Glyph ink is always bright, so a dark line on a bright background (a mast against a moonlit sky) correlates badly with |, and the teacher won't draw it. Your eye reads a silhouette edge as a line either way. On the site the Halloween wreck has its own outline pass, so this never shows, but it's a real limit of "correlation" as a definition of good.
{ } | ;; the student and the teacher follow them with _ ~ = ^.03A student that sees six numbers
The student is deliberately tiny, because it has to run inside a fragment shader for thousands of cells per frame. It's a multilayer perceptron: the six samples divided by their maximum (shape), plus the maximum itself (brightness), into two hidden layers of 32 ReLU units, out to 29 logits, one per glyph. About 2,200 weights, written in numpy, trained with Adam in about 12 seconds on the Pi's CPU. It doesn't learn hard labels. Glyphs near the teacher's best share the target probability ("soft labels"), so a near-tie doesn't get treated as a mistake.
The score I used is the share of the gap between the old rule and the teacher that the student closes, measured on frames it never trained on: two held-out moments of the storm world, and the whole Halloween world, which it never sees at all. The first student, with the same six samples the rule gets, closed 75% of the gap on held-out storm frames and 70% on Halloween. Everything it gained, it gained from how it reads the six numbers.
04Hill-climbing the student
Then I let a script search. Coordinate ascent: change one setting at a time (what the student sees, how wide it is, how soft its labels are, how long it trains), keep anything that raises the held-out score by more than 0.005, and repeat until nothing does. The score averages the storm and Halloween numbers, so a setting can't win by memorising the storm.
Three inputs were on the table:
- 6 samples, what the shader already has.
- 12 samples on a 3×4 grid, which means rendering the scene at twice the texels.
- Context: the six samples of this cell and its eight neighbours, 54 numbers. Most real-time methods pick each character in isolation, and a stroke that continues from the next cell seems like obvious evidence.
Context lost. On the first dataset it scored well on the storm (79%) and badly on Halloween (68%), the signature of overfitting: 54 inputs and ten thousand training cells is memorisation territory. So I captured 50 more frames and doubled everything again by mirroring. A mirrored sea is still a sea, and recomputing the teacher on the mirrored frame keeps even asymmetric glyphs like , and ` honest. With 52,000 training cells:
| student input | held-out storm | Halloween (unseen) | weights |
|---|---|---|---|
| 6 samples | 76% | 71% | 2,176 |
| context (6 × 9 cells) | 78% | 71% | 3,712 |
| 12 samples (shipped) | 84% | 81% | 2,368 |
More detail beats more context, by a lot. Whatever the neighbours know, the cell's own interior knows better, once you look at it closely enough. The climb also found that width barely matters: 32, 48 and 64 hidden units all scored within 0.005, so the shipped network uses 32 and costs no more than the six-sample version.
Flicker is a separate test, because a better still frame can still shimmer in motion. The old rule has hysteresis: a cell keeps last frame's glyph unless a new one fits clearly better. The student gets the same thing in logit space, keeping last frame's glyph unless the new favourite's logit is ahead by a margin. I chose the smallest margin at which it changes glyphs no more often than the old rule on the 30 fps clips, in both the storm and the calm. The storm only needed 0.75 (6.2 changes per hundred edge cells per frame, against the rule's 6.7); the calm night needed 1.5, so 1.5 shipped.
05A neural net in a fragment shader
Shipping it meant running the network in GLSL, once per edge cell, every frame. The weights go into a tiny RGBA float texture, one row per unit, with each unit's bias appended to its weights so the inputs can just carry a constant 1. Four weights per texel turns about 2,400 reads into about 680, all from a 9×93 texture that stays in cache:
vec4 H1[LHX]; // hidden layer 1, 4 units per vec4
for (int j = 0; j < LH / 4; j++) {
vec4 v;
for (int k = 0; k < 4; k++) {
float a = 0.0;
for (int c = 0; c < LX; c++) // inputs packed 4 per vec4, bias last
a += dot(texelFetch(tLearned, ivec2(c, j * 4 + k), 0), X[c]);
v[k] = max(a, 0.0); // ReLU
}
H1[j] = v;
}
The 12-sample version needed the scene rendered at 3×4 texels per cell instead of 2×3, and "2×3" was baked into more places than the matcher: the colour each glyph is tinted with, the material id, the lightning strokes, the Halloween wreck's outline. Rather than retune all of that, everything except the matcher reads the 3×4 render through a small helper that presents the old 2×3 view. Only the network sees all twelve samples. The scene pass now renders twice the texels, but it's the cheapest pass in the pipeline, and the network adds about the same work as the six-sample version.
The first build looked busier than the experiment predicted, and the cause is a classic one: training/serving skew. In training, a cell counted as an edge by the original six-sample test. In the shader, I'd let the new path decide from all twelve samples, which span a wider brightness range, so cells the network had never seen in training were suddenly being sent to it. Matching the shader's edge test to the training one fixed it.
The old rule is one URL away: ?matcher=classic. ?matcher=split shows both at once, old on the left and new on the right.
06Grading it honestly
Here's the problem with "84% of the gap closed": the student was trained to imitate the teacher and then scored by the teacher. It proves the student learned what the teacher wanted. It doesn't prove the teacher has good taste. So the last step was grading the shipped version with judges the model never trained against.
A dev-only compare mode runs three glyph matchers on the same scene render, each with its own hysteresis state: the learned matcher, the old rule, and a control that uses the brightness ramp everywhere (no shape matching at all). A snapshot then gives three renderings of one identical frame, plus the original image. I took 16, across all four worlds.
{ [ ( @ %; the learned matcher draws calmer runs that follow the water. The lightning is drawn by a separate stroke pass and is the same in both.| 16 frames | learned | old rule | ramp only |
|---|---|---|---|
| edge-direction agreement (higher is better) | 0.46 | 0.22 | 0.40 |
| SSIM, blurred (higher is better) | 0.630 | 0.630 | 0.616 |
| LPIPS (lower is better) | 0.542 | 0.546 | 0.545 |
Edge direction asks the question glyph choice exists to answer: do the strokes run the way the real edges do? It compares the orientation of structure in the original with the orientation in each rendering (from structure tensors), weighted toward the original's strongest edges. The learned matcher wins on all 16 frames. The surprise is the old rule: in the storm and the squall its score is negative. Its { | } cut across horizontal clouds and waves, which is worse than drawing no shapes at all; the ramp-only control beats it.
SSIM and LPIPS, the standard image-quality metrics, put all three within about 1% of each other. They measure overall similarity, and which of two thin characters sits in a cell is invisible to them. Worth knowing before reaching for them.
A blind AI judge. Claude Opus 5.5 got the original and two unlabelled renderings, A and B, and was asked which better captures the original's shapes and structure, with "tie" allowed. Every pair was judged in both orders. The learned matcher beat the old rule 61% of the time (95% bootstrap interval 51 to 71%): 29 wins, 20 ties, 15 losses. As a check on the judge itself, identical pairs came back as 64 ties out of 64, so there was no position bias.
The judge failed one check, and I'd rather say so than bury it. I expected it to rank the ramp-only control clearly last. It didn't: classic against ramp came out 52%, and 45% with zoomed-in crops. So the judge isn't independently validated as a judge of glyph choice. But its preferences line up with the edge metric, which says the old rule's shape matching barely beats no shape matching in this scene. The two judges agree with each other, and both prefer the network.
The verdict: the network stays. The evidence favours it most clearly in the storm, where the old rule was actively drawing the wrong strokes.
07What I'd do next
- Teach the day theme. Daylight feeds the matcher "darkness" on paper rather than light on black, a different input distribution the network has never seen. So day still runs the old rule. It needs its own frames and its own student.
- A teacher with better eyes. The teacher is the ceiling. Xu, Zhang and Wong's "Structure-based ASCII art" (SIGGRAPH 2010) uses a shape similarity that tolerates small misalignments, and a polarity-blind version would let the teacher draw dark masts as |.
- Cheaper students. A 2025 comparison found classical classifiers do about as well as CNNs at picking glyphs, and PixLab renders ASCII in real time with a decision tree. A small tree would cost a handful of comparisons instead of 680 texture reads.
All of it (the capture hook, the teacher, the students, the hill-climb, the export to the shader, and the grading) lives in the repo under experiments/glyph-matcher. Back to the sea, and if you have a big screen, try the split view during a storm.
Credits. Shape vectors: Alex Harri, "ASCII characters are not pixels". LPIPS: Zhang et al., "The Unreasonable Effectiveness of Deep Features as a Perceptual Metric". Built pairing with Claude Code. The judge was Claude Opus 5.5, and the whole grading run cost about two dollars.