Skip to content
06 / 10Interaction7 min read

Halftone

The work index crossfades one project into another three pixels at a time. Quantise the screen coordinate, hash the square, threshold once per cell. A dissolve made of grain rather than of edge.

Used on this site

The work index. Hover a project row and the panel that parks beside it crossfades into that project. That crossfade is this shader, running at a three-pixel cell.

Paused
Drag Cell down to 1. That's the same shader with the quantiser switched off, and it's per-pixel noise. Put it back to 3, then press Ordered.

The hover preview on the work index crossfades one project into another three pixels at a time, and the entire mechanism is one floor().

A crossfade between two full-bleed images has the same problem a page transition has: mix(a, b, t) is correct, and it reads as nothing happening slightly slowly. Entry 01 answers that with a continuous field, five octaves of fbm warping a front that has structure at every scale. This is the opposite answer to the same question, and it's four instructions long.

Quantise the fragment coordinate into squares, hash the square, and every pixel inside it gets the same random number. The comparison against progress then happens once per cell rather than once per pixel, so the picture changes in discrete tiles that pop in a scattered order. Nothing about it is organic, and nothing is meant to be.

The floor is the whole technique

floor(f / uCell) does all the work. It maps a range of coordinates onto one integer pair, that pair goes into the hash, and the hash returns one threshold for the whole square. Take the floor away and everything below it still runs, still produces a mask, still animates. It just looks like television static, because a per-pixel threshold gives the eye no structure to lock onto. The demo has a Cell slider so you can watch exactly that happen.

halftone.frag.glsl
// the whole technique: floor() gives every pixel in a cell the same
// coordinate, so the cell gets one threshold and flips as one square
vec2 cell = floor(f / uCell);

float rnd = hash(cell);
float ord = bayer4(cell);
float threshold = mix(rnd, ord, uOrdered);

// feathered rather than step(), so a cell spends a few frames part-way.
// that window is the only softness in the effect
float m = smoothstep(uProgress - uFeather, uProgress + uFeather, threshold);

gl_FragColor = vec4(mix(b, a, m), 1.0);
Two lines of mechanism and one of taste. Everything above this is drawing the patterns.

The cell is measured in device pixels, because gl_FragCoord is. And that distinction earns its keep: the shipped version multiplies the stripe widths by the device pixel ratio on the way into the uniforms and leaves the cell alone, so on a 2× display a cell is 1.5 CSS pixels across and the grid reads as grain, while on a 1× display it's three and you can see the squares. Both are fine. A cell that changed apparent size between the two displays would not be.

ConstantDefault
uCell3 pxthe quantiser, in device pixels. At 1 there is no cell and this is a per-pixel dissolve; past about 12 the squares stop being texture and start being tiles
uFeather±0.13half the width of the progress window a cell spends part-way. At 0 it is step() and every cell flips inside a single frame
uOrdered0blends the per-cell hash toward the 4×4 rank. Same mask, same cost. One reads as a dissolve, the other as newsprint
dt / 330330 msthe shipped sweep, taken off the master loop's timestamp rather than a frame count, so it lasts the same at 60 Hz and at 120

What ships on the work index

The shipped program is fourteen lines of GLSL, one of which is an interpolated hash function. stripe() is one more: two statements packed onto a single line, which dot the fragment onto a unit direction and step() the fractional part. That's the entire drawing side of the panel. Each project carries an angle, a width and two colours, and those four values become the pattern the crossfade runs between.

src/lib/shaders.ts — DISSOLVE_VERT / DISSOLVE_FRAG
export const DISSOLVE_VERT = /* glsl */ `
attribute vec2 p;
void main(){ gl_Position = vec4(p, 0.0, 1.0); }
`;

export const DISSOLVE_FRAG = /* glsl */ `
precision highp float;
uniform vec2 uRes; uniform float uProg;
uniform float uAngA, uWA; uniform vec3 uC1A, uC2A;
uniform float uAngB, uWB; uniform vec3 uC1B, uC2B;
float stripe(vec2 f, float a, float w){ vec2 d = vec2(cos(a), sin(a)); return step(0.5, fract(dot(f,d)/(2.0*w))); }
${HASH}
void main(){
  vec2 f = gl_FragCoord.xy;
  vec3 a = mix(uC1A, uC2A, stripe(f, uAngA, uWA));
  vec3 b = mix(uC1B, uC2B, stripe(f, uAngB, uWB));
  float n = hash(floor(f/3.0));
  float m = smoothstep(uProg-0.13, uProg+0.13, n);
  gl_FragColor = vec4(mix(b, a, m), 1.0);
}
`;
The shipped shader in full. The demo above is this, with the 3.0 and the 0.13 pulled out as sliders.

Driving it is four assignments. The pattern currently on screen gets copied from B into A, the newly hovered row's pattern becomes B, progress resets to zero, and a flag goes up for the tick to advance it. The first hover is the exception and takes the early return: both slots get the same pattern, progress parks at 1, and the panel appears already resolved instead of dissolving in from whatever happened to be in the buffer. (That early return exists because the first version didn't have it, and the panel's first appearance was a dissolve from garbage.)

DissolvePreview.tsx
// first appearance: park at the target instead of lerping in from 0,0
if (first) {
  const pos = posRef.current;
  pos.tx = pos.x = columnX();
  pos.ty = pos.y = columnY(window.scrollY);
}

if (!glReadyRef.current) {
  if (canvasRef.current) canvasRef.current.style.background = stripeCss(projects[i].stripe);
  return;
}
if (first) {
  setPat(aRef.current, i);
  setPat(bRef.current, i);
  progRef.current = 1;
  dissolvingRef.current = false;
  renderGL();
  return;
}
copyPat(bRef.current, aRef.current);
setPat(bRef.current, i);
progRef.current = 0;
dissolvingRef.current = true;
show(), from the first-appearance branch to the swap. The WebGL-less path falls back to a CSS stripe gradient.

A hard edge and a soft one

A bare step(uProg, n) would be correct, and it would look cheap. Each cell would flip in the frame its threshold got crossed, and the whole transition would be a field of instantaneous binary switches. Feathering the comparison to smoothstep(uProg - 0.13, uProg + 0.13, n) gives every cell a window 0.26 of the sweep wide to cross in. That's about 86 ms of the 330, five frames at 60 Hz, and it's what turns a switch into a transition. It's also the only softness in the whole effect.

The feather isn't free at the ends, though, and the shipped version doesn't pay for it. Progress sweeps 0 → 1, so at the start every cell that hashed below 0.13 is already part-way across before anything has moved, and at the finish every cell above 0.87 (roughly one in eight) stops before it has arrived.

Random threshold, ordered threshold

The threshold doesn't have to be random. Swap the hash for the rank of the cell within a repeating 4×4 block and the same mask, at the same cost, produces something entirely different: a regular pattern that fills in a fixed order. The demo blends between the two on a uniform, which is the honest way to compare them. One toggle, nothing else changed. (Full disclosure: the ranking in the demo is a cheap approximation of the classic Bayer order, four step() calls instead of the true bit-reversed interleave. Close enough to show the character difference, and the comment in the shader admits it.)

halftone.frag.glsl
// the 4x4 ordered rank as arithmetic, so no texture upload. this ranking is a
// cheap stand-in for the true bit-reversed Bayer interleave: close enough that
// the eye reads the same even spread at 4x4
float bayer4(vec2 c) {
  vec2 i = floor(mod(c, 4.0));
  float x = i.x,
    y = i.y;
  float v = 0.0;
  v += step(2.0, x) * 8.0;
  v += step(2.0, y) * 4.0;
  v += step(1.0, mod(x, 2.0)) * 2.0;
  v += step(1.0, mod(y, 2.0)) * 1.0;
  return v / 16.0;
}
Sixteen values from four step() calls. A lookup table would need a texture, and a texture would need uploading.

What each one reads as is the point. The random threshold reads as a dissolve, because there's no order to anticipate. Cells resolve in a scatter and the eye gives up predicting the next one. The ordered threshold reads as printing: the pattern repeats every four cells, so the viewer sees a texture arriving rather than a picture changing. The work index takes the hash. A four-cell repeat at a 330 ms sweep would put a visible screen door over the one moment the page is asking to be looked at.

P.S. This one is running on the site right now.

WebGL · Shader ·
← Back to the lab
Creative Web Designer & Developer25.2048°N · 55.2708°E

AlexanderSmith

Locating the studio25.2048°N · 55.2708°E