hraza.dev

Shader teardown

Building the halftone brush shader, one idea at a time

Ten live stages. Each adds a single concept on top of the last, until the whole thing assembles itself. Every preview is the real shader running — drag the sliders. The finished version runs on the home page.

01 / 10

A shader is one function, run on every pixel

A fragment shader has no idea there is an image, a grid, or a brushstroke. It is a single function the GPU runs once for every pixel on screen, all in parallel. Each run knows only one thing: where it is. Everything else has to be reconstructed from that position.

The vertex shader draws one rectangle that covers the whole canvas and hands each pixel a coordinate called vUv, running from 0 to 1 across the screen (bottom-left is 0,0; top-right is 1,1). Here we just paint that coordinate straight into colour: red is the horizontal position, green is the vertical.

Drag zoom up. We multiply vUv and take fract() of it, so the 0..1 ramp repeats. That single trick — scale, then take the fractional part — is the seed of the entire grid system later.

varying vec2 vUv;            // 0..1 across the screen

void main() {
  vec2 uv = fract(vUv * zoom); // repeat the ramp
  gl_FragColor = vec4(uv, 0.0, 1.0);
}

02 / 10

From pixels to a grid of cells

The effect is a halftone: a regular grid of marks. To build a grid, multiply vUv by the resolution to get pixel coordinates, divide by a cell size, and floor() it. Every pixel inside the same square now computes the same whole-number cellIndex — that integer is the cell's identity.

The red lines are cell borders (fract near the edge); the two greys are a checkerboard from mod(cellIndex.x + cellIndex.y, 2.0). The real shader keeps only one colour of the checkerboard, so the marks sit in an offset dot pattern instead of a dense block.

Drag pixelSize: bigger cells, fewer marks. This is the whole coordinate system — there is no array of cells anywhere, just arithmetic that every pixel redoes independently.

vec2 pixelCoord = vUv * resolution;
vec2 cellIndex  = floor(pixelCoord / pixelSize);

float checker = mod(cellIndex.x + cellIndex.y, 2.0);

03 / 10

Stable randomness from position alone

Each mark needs its own character — a random angle, size, speed. But a shader has no memory between frames and no per-mark storage. The fix is a hash: a pure function that turns a cell's coordinate into a repeatable pseudo-random number. Same cell in, same number out, every frame. Think of it as Math.random() seeded by location instead of by hidden state.

Here each surviving cell is tinted by its hash, so you can see the values are random across space yet rock-steady in time. The white dots are jittered: we push each cell's centre by a hashed angle so the grid stops looking mechanical.

Drag dotJitter to push the centres around. Note the dots never flicker — jitter is deterministic, derived from the cell index.

float hash(vec2 p) {
  return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453);
}

float ang = hash(cellIndex + vec2(13.7, 5.3)) * TAU;
vec2  jitter = vec2(cos(ang), sin(ang)) * dotJitterAmount * pixelSize;
vec2  cellCenter = (cellIndex + 0.5) * pixelSize + jitter;

04 / 10

Reading the painting

Now we bring in the source image. Each cell samples the texture once, at its centre, and uses that colour for its mark. Sampling per cell instead of per pixel is what turns a photo into a mosaic — drop pixelSize toward 1 and it becomes the raw image again.

The uvScale term corrects for the difference between the image's aspect ratio and the canvas's, so the painting is never stretched — the same idea as CSS object-fit: cover.

We also compute luma, the perceived brightness of the sampled colour (a weighted mix favouring green, matching how the eye works). Drag lumaView to fade from colour to that brightness map. Luma is the signal that drives mark size in the next step: dark areas get bigger marks.

vec2 cellUv   = cellCenter / resolution;
vec2 sampleUv = (cellUv - 0.5) * uvScale + 0.5;   // aspect-correct
vec4 texColor = texture2D(paintingTexture, sampleUv);

float luma = dot(vec3(0.2126, 0.7152, 0.0722), texColor.rgb);

05 / 10

Classic halftone: size by darkness

A real halftone makes darker regions denser by drawing bigger dots there. coverage is just (1.0 - luma): bright pixels get small dots, dark pixels get large ones. We draw the dot with a distance check, softened by smoothstep for an anti-aliased edge.

sizeDistribution adds variety: each cell picks one of four size buckets from its hash, so the marks are not all identical. At 0 every dot is the same; at 1 you get the full spread.

This already reads as a stylised print. Everything from here is replacing the round dot with a brushstroke, then bringing it to life.

float coverage = clamp((1.0 - luma) + 0.25, 0.0, 1.0);
float radius   = coverage * pixelSize * 0.5 * sizeMul;

float d   = distance(pixelCoord, cellCenter);
float dot = 1.0 - smoothstep(radius - 1.0, radius + 1.0, d);
color = mix(vec3(1.0), texColor.rgb, dot);   // paint on white

06 / 10

Dot to brushstroke: a signed distance field

The dot becomes a stroke by changing the shape function. We work in the cell's local space (boxPoint), then build a capsule with a signed distance field: abs(p.y) - width gives a horizontal bar, and max() with abs(p.x) - halfLen caps its length. fwidth(d) measures how fast that distance changes across one pixel, which gives a resolution-independent anti-aliased edge.

Two touches make it look painted. Pressure tapers the width along the stroke with two smoothsteps, so it is thin–thick–thin like a brush pressing and lifting. Bend curves the spine. And fbm noise punches dry, gappy holes into the paint, strongest where pressure is low.

Drag the sliders to feel the shape. One thing is deliberately wrong here: strokes are clipped into hard rectangles at the cell edges, because each pixel still only considers its own cell. That bug is the whole point of the next step.

float d  = abs(p.y) - width;        // a horizontal bar...
d = max(d, abs(p.x) - halfLen);     // ...capped in length
float aa = fwidth(d);               // 1-pixel soft edge
float mask = 1.0 - smoothstep(-aa, aa, d);

// pressure taper + dry-brush noise
float dry = 1.0 - smoothstep(0.35, 0.95, fbm(p * 4.0 + cellIndex));
mask *= mix(1.0, dry, dryAmt);

07 / 10

Why each pixel scans its neighbours

A stroke is longer than the cell that owns it, so it spills into neighbouring cells. But a pixel cannot ask a cell to "draw outward" — there is no canvas to draw onto, only this pixel deciding its own colour. So we invert the question: every pixel loops over the surrounding cells and asks each one, "does your stroke reach me?" Whichever stroke covers the pixel most strongly (maxStroke) wins.

searchRadius is that loop. Start it at 0 — you see the same clipped fragments as the last step. Drag it to 1, 2, 3 and watch the strokes complete as pixels start picking up strokes owned by cells further and further away.

This is the central trick of cell-based shaders: an object bigger than its cell is rendered by having every nearby pixel look inward at all the cells that could possibly reach it.

for (int dx = -3; dx <= 3; dx++)
for (int dy = -3; dy <= 3; dy++) {
  if (abs(float(dx)) > searchRadius) continue;   // <- the slider
  vec2 cell = baseCell + vec2(float(dx), float(dy));
  float stroke = brushStrokeMask(/* this pixel in cell's space */);
  if (stroke > maxStroke) { strokeColor = texColor.rgb; }
  maxStroke = max(maxStroke, stroke);
}

08 / 10

Bringing it to life: draw, hold, fade

Now time. Each cell gets a cyclePos that loops 0..1 via fract(time * cycleSpeed + offset). We split that loop into three acts: draw (the stroke paints on), hold (it stays), and fade (it disappears) — controlled by drawFrac and holdFrac.

stagger is what stops every stroke firing at once: each cell offsets its phase by its own hash, so strokes appear in a scattered sequence, like a hand moving across the canvas. There is also a left-to-right reveal inside the draw phase — the stroke's visible edge sweeps along its length, so it looks drawn rather than faded in.

Drag cycleSpeed to speed up the loop, and stagger to spread the timing. drawFrac and holdFrac reshape the rhythm of paint-on versus linger.

float cyclePos = fract(time * cycleSpeed + phaseSeed * staggerAmount);

float drawProg = smoothstep(0.0, 1.0, clamp(cyclePos / drawFrac, 0.0, 1.0));
float fadeOut  = 1.0 - smoothstep(drawFrac + holdFrac, 1.0, cyclePos);

// stroke's edge sweeps left-to-right as it draws on
float revealX = mix(-halfLen, halfLen, drawProg);
stroke *= fadeOut * ltrReveal * opacity;

09 / 10

Colour, finally

The marks have been using the raw sampled colour. The painterly look comes from working in HSV (hue, saturation, value) so we can nudge the hue without touching brightness — awkward in raw RGB, natural in HSV. Each stroke shifts its hue slightly, plus a slow time-based wobble (hueDrift), giving the surface life.

The finish has three steps. mix(white, strokeColor, maxStroke) lays the paint onto white paper. saturateColor pushes vividness toward or away from grey. And pow(color, 1.0 / lightness) is a gamma curve — with lightness above 1 it lifts the midtones, giving the print its soft, slightly bloomed finish.

Drag saturation and lightness to see the grade, and hueDrift to let the colours breathe over time.

vec3 hsv = rgbToHsv(texColor.rgb);
hsv.x -= baseHueShift + sin(phaseSeed * TAU + time * hueDriftSpeed) * hueDriftAmount;
vec3 shifted = hsvToRgb(hsv);

vec3 color = mix(vec3(1.0), strokeColor, maxStroke);  // paint on paper
color = saturateColor(color, saturation);
color = pow(color, vec3(1.0 / lightness));            // gamma

10 / 10

The whole thing

Every layer at once, exactly as the original runs: seeded randomness, jitter, image sampling, luma-driven size with bucketed variation, the SDF brushstroke with pressure and dry-brush, the neighbour search, the draw–hold–fade cycle with per-cell speed and stagger, and the HSV colour grade composited onto white.

All controls are live. This is the same shader that runs on the home page — only here you have arrived at it one idea at a time.

// = stages 01–09 combined, per pixel:
// floor into a cell -> hash for character -> sample the painting
// -> size by darkness -> stamp an SDF brushstroke
// -> scan neighbours, keep the strongest -> animate the cycle
// -> shift hue, grade, composite on white

That is the entire shader. The same code, the same neighbour search, the same cycle — just unrolled. See it running on the home page.