Skip to content
06 / 103D7 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 is the same shader with the quantiser switched off, and it is 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 and an edge at none of them. This is the opposite answer to the same question, and it is four instructions long.

Quantise the fragment coordinate into squares, hash the square, and every pixel that lands 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 is organic about it and nothing is meant to be.

The floor is the whole technique

floor(f / uCell) is doing all the work. It maps a range of coordinates onto one integer pair, that pair goes into the hash, and the hash therefore returns one threshold for the whole square. Remove the floor and every one of the arithmetic operations below it still runs, still produces a mask, and still animates — it just looks like television static, because a per-pixel threshold has no structure for the eye to lock onto.

halftone.frag.glsl
  // ---- the cell grid ----
  // QUANTISING THE COORDINATE IS THE ENTIRE TECHNIQUE. floor(f / cell) gives
  // every pixel inside one cell the same coordinate, so they all get the same
  // threshold and the cell flips as one square. Take the floor away and this
  // is a per-pixel dissolve, which reads as noise rather than as a mechanism.
  vec2 cell = floor(f / uCell);

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

  // A hard step() would flip each cell in one frame — correct, and it looks
  // cheap. Feathering the comparison lets a cell spend a few frames part-way,
  // which is what turns a switch into a transition.
  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. That distinction is not academic: 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 is three and you can see the squares. Both are fine. A cell that changed apparent size between the two 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 Bayer 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 is 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
/** floating-preview dissolve shader (Work index hover preview) */
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 is copied from B into A, the newly hovered row’s pattern becomes B, progress is reset to zero, and a flag goes up that the tick reads to advance it. The first hover is the exception and takes the early return: both slots get the same pattern, progress is parked at 1, and the panel appears already resolved rather than dissolving in from whatever happened to be in the buffer.

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 would look cheap: each cell would flip in the frame its threshold was 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 — about 86 ms of the 330, or five frames at 60 Hz. That is what turns a switch into a transition, and it is the only softness in the effect.

The feather is not free at the ends, though, and the shipped version does not 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 does not 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 crosshatch 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.

halftone.frag.glsl
/**
 * The ordered 4x4 Bayer matrix, as arithmetic rather than a lookup.
 *
 * A texture would need a texture; this is the standard bit-interleave that
 * produces the same sixteen values, and it costs four instructions. The result
 * is the crosshatch a newspaper uses — a REGULAR threshold per cell, which
 * dissolves in a visibly mechanical order rather than a random one.
 */
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;
  // the classic Bayer ordering is a bit-reversed interleave; this ranking is
  // close enough at 4x4 that the eye reads the same even spread
  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 is no order to anticipate — cells resolve in a scatter and the eye gives up trying to predict the next one. The ordered threshold reads as printing: it is the crosshatch of a newspaper halftone, and because the pattern repeats every four cells 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.

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

AlexanderSmith

Locating the studio25.2048°N · 55.2708°E