How We Render 2 Million Halftone Dots at 120fps
A deep dive into the WGSL shader behind PopCraft's live halftones — and how we turn them into real, editable vector dots.
Halftones are the soul of pop art: a photograph dissolved into dots whose size carries the tone. They're also a performance nightmare. A full-bleed poster at print resolution can contain millions of dots, and designers expect to drag a slider and see every one of them update instantly.
Here's how we made that happen.
Step 1: never draw a dot
The live halftone effect doesn't create geometry at all. It's a single fragment shader pass. For each pixel we:
- Rotate the pixel's position into the screen angle (the classic 15°, 45°, 75° CMYK angles are presets).
- Find which cell of the grid it falls in and sample the source image's tone at the cell center.
- Compute the dot radius from the tone, then return ink if the pixel is inside the dot.
let cell = floor(rotated / spacing);
let center = (cell + 0.5) * spacing;
let tone = luminance(textureSample(src, samp, unrotate(center)));
let radius = sqrt(1.0 - tone) * spacing * 0.7071;
let ink = 1.0 - smoothstep(radius - aa, radius + aa, distance(rotated, center));Because every pixel is independent, the GPU chews through a 4K canvas in well under a millisecond.
Step 2: make it real when you need it
Live effects are great until you want to edit a dot, or send the poster to a print shop as SVG. So we wrote the same math a second time in TypeScript. Convert to vector walks the same cell layout, runs the same tone and band calculations, and emits polygons — real, selectable, exportable vector dots.
Step 3: keep the two in sync forever
The scariest bug in a system like this is drift: the shader and the vector version slowly disagreeing. Our test suite renders both, rasterizes the vector output and diffs it against the GPU image. If they differ by more than a hair, the build fails.
If the preview lies, the tool lies. We don't ship lies. We ship dots.