EP
Writing
2 notes
2 min

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.