Turning noise into signal

Behind the headline on my portfolio there is a cloud of letters drifting about like dust in a sunbeam. It looks like noise, and mostly it is. But hidden in it is a sentence, and if you move the pointer near the right spot the letters there turn round, line up and spell it out. Click and the whole cloud gathers into the message, holds it for a few seconds, and lets go.

Move across the top right to find the message. Click to send a shockwave and assemble all of it.

The sentence is fifteen years of turning noise into signal, which is about as close as I get to a job description. The same line is hidden in my GitHub profile README. The box above is narrower than my portfolio, so it gets a shorter version of the sentence when the long one won't fit.

Four versions

It took four goes to get here, and each one is still in the code as a fallback for the next.

  1. July: ASCII static. A 2D canvas cut into 26px cells, each showing a random glyph from #%&/\|=+*<>!?, a few lower-case letters and digits, very faint and flickering. The message letters sat in fixed cells near the top right and brightened within 120px of the pointer. A click held them lit for 2.2 seconds.
  2. August: three.js points. Between 1,500 and 4,500 round dots, each drifting on two slow sine waves around a home position. I drew the message into an offscreen canvas, sampled every third pixel, and gave each dot a target inside the letters. Near the pointer they slid to their targets. The draft I was working on then said THIRTEEN YEARS OF. It got corrected.
  3. 17 October: a letter cloud. Still three.js, but the dots became letters with depth of field: sharp ones in the middle distance, blurred ones near and far. The pointer got real physics, computed on the CPU and uploaded every frame.
  4. 18 October: WebGL2. The whole simulation moved onto the GPU. That is the one above.

Here is the July original, so you can see how far it moved. It is still the last fallback on the portfolio.

The July version. Move near the top right. Click to hold the message for 2.2 seconds.

On the GPU

The trouble with the letter cloud was the CPU: thousands of letters updated in JavaScript and sent to the graphics card sixty times a second.

In the WebGL2 version the letters' state lives in textures. Each letter is one pixel in two floating-point textures: one holds position and velocity, the other holds rotation, spin, how assembled it is, and how brightly it glows. A fragment shader reads last frame's textures and writes this frame's, both at once through multiple render targets, then the two pairs swap. It needs WebGL2 and the EXT_color_buffer_float extension, and if either is missing the code returns null and the page falls back to the older versions.

Every letter is pushed around by a few forces, all in the shader:

  • Curl noise. A slowly changing simplex noise field, turned into currents by taking its curl. Curl noise has no sources or sinks, so letters swirl without bunching up. Each depth layer reads a different slice, so near and far letters drift differently.
  • A bow wave. The pointer is treated as a body with its own smoothed velocity. Letters within about 92px are shoved outwards (harder the faster you move), dragged along in your wake and set spinning by the torque.
  • A shockwave. A click sends a ring out at 900px a second, 130px thick, fading over 1.5 seconds. It shoves letters outwards and spins them.
  • The message. Every letter has a target inside the message. Near the pointer, or for 4.2 seconds after a click, it is pulled towards it, with a little sideways swirl so it spirals in rather than flying straight, and it rotates upright as it arrives.
// curl of a noise field: smooth currents that never pile up
vec2 q = p*0.0017 + vec2(d*13.0, uTime*0.022*s); float ep = 0.012;
float n1 = snoise(q+vec2(0,ep)), n2 = snoise(q-vec2(0,ep));
float n3 = snoise(q+vec2(ep,0)), n4 = snoise(q-vec2(ep,0));
vec2 curl = vec2(n1-n2, -(n3-n4)) / (2.0*ep);
vec2 flow = (curl*9.0 + vec2(2.0,-6.5)) * s * (1.0-e);
v += (flow - v) * (1.0 - exp(-dt*1.1));

The vec2(2.0,-6.5) is a steady breeze up and to the right. Letters wrap round when they leave the box.

Depth, cheaply

There is no 3D here. Depth is a random number per letter that sets its size, speed, opacity, how much it shifts when you move the pointer, and how blurred it is. About 2.5% of letters are foreground letters: big, faint and soft, sliding past like specks on a lens.

The blur is a small trick. The glyphs are painted into a texture atlas twice, once sharp and once soft. For the soft copy each letter is drawn 600px to the left, off the canvas, with a 9px shadow offset 600px to the right. Only the blurry shadow lands on the canvas. Each letter then mixes between the two copies by its depth.

Letters near the pointer also decode: while they glow, they cycle through the alphabet about nine times a second, until they either settle back or join the message.

Fallbacks

On the portfolio the cloud only runs on screens at least 900px wide, and not at all if you've asked for reduced motion. If WebGL2 isn't there, it tries the three.js version, and if that fails too, the ASCII static. On this page, if WebGL2 isn't available, the first box shows the ASCII static instead. With reduced motion on, both boxes show a still frame of the static with the message lit.

The library only listens for mouse events, so for this page I pass touch drags through to it as if they were a mouse. Tap anywhere for a shockwave, or drag sideways to push letters around.

Built
July to October 2026
Stack
WebGL2, float textures, no libraries
Lives at
thomasalex.in/portfolio
Source
letters-gl.js

Built with Claude Code. The letters are set in Hanken Grotesk and have no idea what they're spelling.