Image-to-Pixel

Library

How it works

The shader pipeline, the diffusion wavefront and the CPU twin.

The pipeline#

Every image goes through the same steps. On the GPU each step is a fragment-shader pass run by ShaderMate, in plain WebGL 2 GLSL; on the CPU each is a function in lib/core/cpu/stages.js written to match its shader line for line.

Pass Does Target
prep Box or nearest resample, adjust, alpha cut 32-bit float
quant Ordered threshold + nearest palette colour 8-bit
diffuse Error diffusion, one wave per iteration 32-bit float, ping-pong
resolve Palette index → colour (or pass quant through) 8-bit
orphan Stray-pixel removal 8-bit
smooth 3 × 3 majority 8-bit
out Outline, drawn to the canvas canvas

All passes run at the output size (96 × 64 is 6,144 fragments), so they are cheap. The source is uploaded once and only re-uploaded when it changes. Changing a setting only updates uniforms; nothing recompiles. The palette (up to 256 colours) and the threshold map are small textures; the result is read back once with readPixels.

Passes address pixels from the top-left with integer coordinates and texelFetch, never filtered sampling, so the GPU and CPU agree to the bit. npm run screenshots renders 15 configurations both ways in a headless browser and fails if they differ.

Choosing colours#

Each pixel takes the palette colour at the smallest distance. In Perceptual mode the distance is measured in OKLab, where equal distances look equally different. Ordered dithering adds (threshold − ½) × spread × strength first, where spread is the average distance between neighbouring palette colours, so strength behaves the same for any palette.

Error diffusion on a GPU#

Floyd–Steinberg is sequential: each pixel needs the error of the pixel to its left and of three pixels in the row above. A fragment shader computes every pixel at once, so it can't simply run it.

But the dependency has a shape. If every kernel offset (dx, dy) pushes error to a pixel on a later wave x + s·y, then all pixels on one wave are independent of each other. For Floyd–Steinberg, Atkinson and Sierra Lite s = 2; for the wider Jarvis, Stucki, Burkes and Sierra kernels s = 3.

The diffuse pass uses ShaderMate's iterations to draw itself once per wave, reading its previous output (u_recursion is the wave number):

A 96 × 64 image takes 96 + 2 × 63 = 222 draws. The output equals the sequential algorithm exactly; floating-point summation order can flip a handful of near-tie pixels in very large images.

On software WebGL (SwiftShader, llvmpipe) hundreds of full-screen draws are slower than the plain CPU loop, so diffusion: 'auto' uses the CPU there.

Notes on ShaderMate#

The shaders use ShaderMate's plain-GLSL style: void main() writing fragColor, built-ins like u_resolution, and passes wired by name. ShaderMate also runs ShaderToy-style mainImage shaders, which is handy if you want to prototype a new effect elsewhere and bring it in as a pass.