render
How the field on the index page is computed. A spiral galaxy in a WebGL2 fragment shader, rasterised into Braille sub-cells. The arms turn as a density wave. Nothing here is a loop or a pasted sprite.
The arms are a wave, not an object
A spiral galaxy’s disk rotates differentially: the inner parts complete an orbit far faster than the outer ones. If the arms were material, actual lanes of stars, differential rotation would wind them into a tight coil within a couple of galactic rotations and every spiral we can see would have stopped being one long ago. That is the winding problem.
Lin and Shu resolved it in 1964: the arms are density waves. They are regions of compression that disk material passes through, the way a traffic jam persists on a motorway while individual cars enter and leave it. The pattern rotates rigidly at a single constant pattern speed, independent of the orbital speed at any radius.
That is both the physics and the only thing that survives a loop running for minutes. Rotating the material would visibly wind the arms up while you watched. Here the arm phase carries a rigid −t·Ω term so the arms stay permanently open, while a separate noise field is advected at Ω(r) ∝ 1/r so the clumping shears past the pattern. Both are true at once, which is the point.
Logarithmic spirals and pitch angle
Real arms are close to logarithmic, which means a constant pitch angle: the angle between the arm and a circle drawn through it is the same at every radius.
θ = ln(r/a) / tan(p)
Pitch angle is what separates the Hubble types. Sa through Sc average at or under 15.5 degrees, opening up toward later types. This one runs at 19 degrees, in Sc territory. That is a choice the medium forced rather than a physical one: tighter than about 15 degrees and the arms drop below the resolution of the glyph grid and read as concentric rings instead of a spiral.
Disk, bulge, and why the core is amber
Surface brightness is the sum of two components. The disk is exponential, Σ(R) = Σ₀·exp(−R/Rs), which is why the galaxy fades smoothly instead of ending at an edge. The bulge is a Sérsic profile at n = 4, the de Vaucouleurs case, far more centrally concentrated: it climbs steeply enough that the nucleus saturates the ramp, which is correct.
The two colours are not decoration. Bulges really are red-yellow, being old stellar populations long since done forming stars, and arms are blue-white because that is where star formation is happening now and the brightest, shortest-lived stars sit. Tinting the nucleus and the HII regions amber while the arms stay in ink is the astronomically correct way to spend a two-colour palette.
Dust lanes sit just inside the arms, on the leading edge of the wave where gas piles up before it forms stars. That is a phase offset, not a separate texture.
Braille buys 8x the resolution
U+2800–28FF is a 2×4 dot matrix per character, so rendering into Braille rather than a luminance ramp carries eight times the effective resolution on exactly the same character grid: 80,360 addressable dots at 205×49 instead of 10,045 cells. It is the only mode the field ships in; the coarser ramp survives underneath purely as a fallback for a font without the Braille block, and nothing user-facing selects it.
It comes with a hard constraint. Departure Mono contains 0 of the 256 Braille patterns, measured from its cmap; Commit Mono contains all 256. A missing glyph does not fail loudly, it gets substituted by the fallback face at the fallback’s advance width, silently shearing the 7px lattice. So the Braille atlas is built from Commit Mono and the builder refuses to run against a face lacking the block.
The threshold is interleaved gradient noise, not Bayer. An ordered 8×8 Bayer matrix aligns with the 2×4 cell structure and shows up as a hard four-column repeat across the field. IGN has no periodic structure, and because it is still a pure function of position it never shimmers between frames the way white noise would.
The ramp is measured, not chosen
@ covers about 0.42 of its cell in one monospace face and 0.58 in another. Hand-ordering a ramp is why naive ASCII looks muddy in the midtones. At boot every candidate glyph is rasterised into a 7×14 cell, its alpha integrated, and the pool sorted by actual ink coverage; near-duplicates within 2.8% are dropped because they waste a ramp step and cause banding.
Index 0 is a literal space. Empty sky has to be a hole in the text, not a dim glyph. That one choice does more for legibility than anything else in the pipeline.
The cell is 7×14px, exactly
Departure Mono has unitsPerEm = 550 and an advance width of 350. At 11px that is an advance of exactly 7.000px and a line box of exactly 14.000px: a cell aspect of exactly 0.5, on an integer pixel grid. The face is drawn on a 50-unit grid for precisely this. Every dimension on this site is a multiple of those two numbers, and the atlas is only ever rasterised at integer multiples of 11px, because a pixel font at a fractional size is just mush.
The aspect ratio is also why the field has to be aspect-corrected before tracing. Skip it and a face-on galaxy renders as an ellipse. It is why the Sobel gradient is rescaled into cell space before atan, too: cells are 2:1 tall, so a / depicts a ~60° line on screen, not 45°.
Three passes, no readback
- Field. Closed-form evaluation of the disk, bulge and density wave at 2× the cell grid horizontally and 4× vertically — one texel per Braille dot. Outputs surface brightness and the accent mask in two channels.
- Quantise. In Braille, threshold each of the eight dots against interleaved gradient noise. In the ramp fallback, box-downsample to the cell grid, Sobel in cell space for directional glyphs, 4×4 Bayer dither on the ramp index, and temporal hysteresis against the previous frame.
- Composite. One quad sampling a NEAREST glyph atlas.
There is no gl.readPixels anywhere. Reading back the frame you just drew forces a GPU→CPU sync stall that is very easy to spend a day blaming on the shader. Similarly, texture(tex, floor(uv*grid)/grid) samples one texel — the cell's top-left corner — not the cell average, which aliases and crawls on animated content. The downsample is an explicit box filter.
Temporal hysteresis is the least glamorous part and the most important: a cell sitting on a quantisation boundary flips glyph every single frame otherwise, which is the worst artifact animated ASCII has. Cells only leave their previous bin when the field moves more than 0.575 of a ramp step.
Degrading
The frame you see first is not WebGL at all. It is a pre-baked 148×46 frame produced at build time by a CPU twin of the same field, shipped inline in the server HTML as a single <pre> with one text node. Both sides import their structural constants from one module, because two hand-kept copies had already drifted. It is the reduced-motion state, the no-JS state, the no-WebGL state, and the first paint before the renderer chunk downloads.
It is one DOM node, not 3,600. Rendering the art as one <span> per cell fails Lighthouse's DOM-size audit on its own and makes every interaction pay a style recalculation.
| tier | max cols | ramp ss | trigger |
|---|---|---|---|
| 1 | 260 | 2× | default |
| 2 | 260 | 1× | frame time > 12ms |
| 3 | 110 | 1× | still > 12ms after demoting |
| — | 148 | — | reduced motion, no JS, no WebGL |
The loop stops whenever it cannot be seen: an IntersectionObserver on exit and visibilitychange when the tab is backgrounded, both calling cancelAnimationFrame. The context is requested with powerPreference: 'low-power', DPR is capped at 2, and webglcontextlost is handled because iOS Safari drops contexts on backgrounding.
There is a keyboard-reachable pause control. WCAG SC 2.2.2 is a Level A requirement and it applies here: the animation starts automatically, runs for more than five seconds, and sits alongside content. SC 2.2.2 requires a mechanism, not a permanently visible button, so it uses the skip-link pattern — out of the layout until it takes focus, at which point it is a real, reachable, labelled control.
Why any of this is on a portfolio
Because a portfolio that claims systems ability should be a system, not a picture of one. Everything above is checkable: open devtools and read the shader, count the columns against the grid you are actually looking at, turn on reduced motion and get a single pre-baked frame, turn off JavaScript and still read every word on the site.
The field does not respond to the cursor, deliberately. An earlier version mapped pointer position to the viewing angle, and it was the wrong instinct: a background that swings around when you move the mouse asks to be played with, and this one sits directly beside the only two sentences on the page that need reading. It holds still at incl = 1.0 rad, looking down onto the disk from a little above its plane.