Halftone waves in a fragment shader
The hero on this site is one triangle. Everything you see on it — the wave motion, the dot grid, the portrait, the way the field opens up under the text — happens in a fragment shader, once per pixel.
The field
Start with a scalar field. Three sine waves, each one distorted by the others, summed and normalised:
float field(vec2 p, float t){
float v = sin(p.x * 1.8 + sin(p.y * 1.1 + t * 0.45) * 2.4 + t * 0.55);
v += sin(p.y * 2.2 - sin(p.x * 1.5 - t * 0.35) * 2.1 - t * 0.25);
v += 0.85 * sin((p.x + p.y) * 1.25 + t * 0.2);
return v / 2.85;
}
Feeding one wave's output into another's phase is what stops it looking like corrugated iron. The result is a smooth value between roughly -1 and 1 that drifts forever without repeating in any obvious way.
The dots
A halftone screen is a grid of cells where each dot's radius encodes the tone underneath it. Rotate the grid — traditional print screens sit at 15 or 30 degrees, never square to the page:
float a = 0.5236; // 30 degrees
mat2 R = mat2(cos(a), -sin(a), sin(a), cos(a));
vec2 cell = fract(R * frag / S) - 0.5;
float ink = smoothstep(r + 0.055, r - 0.055, length(cell));
fract gives the position inside the cell, length the distance from its centre, and smoothstep an antialiased edge. The radius r comes from the field value. That is the whole technique.
The cursor
My first attempt displaced pixels radially away from the pointer. It looked like a magnifying glass sliding over the page — a visible disc with an edge, exactly the artefact I did not want.
What works instead is leaving the geometry alone and perturbing the field's inputs:
float infl = uWarp * exp(-md * md * 1.6);
float scale = baseScale - 0.45 * infl;
float v = field(uv * scale, t + 1.9 * infl);
A gaussian falloff means there is no edge anywhere. Near the cursor the waves stretch and run faster; far away nothing changes. Nobody can point at where the effect stops.
Legibility
Small type over a moving high-contrast field is unreadable, and dropping a panel behind it defeats the point of having the field. The compromise: elements that hold copy are measured from the DOM, normalised, and passed to the shader as rectangles. Inside them the dots swell until they merge into near-solid ink, and the text sits on top in paper white.
Measure on load, on resize, and after fonts settle — never per frame. Reading layout every frame to feed a shader is how you turn a 60fps page into a 20fps one.