@vshaders/dither

Ordered (Bayer) dithering thresholds and quantizers as pure WGSL modules. Ordered dithering trades color depth for spatial pattern: each pixel compares against a fixed per-pixel threshold, so the average of a dithered region matches the input while individual pixels snap to a small set of levels. Every module is functions only, no bindings, no entry points, so vgpu's resolver prunes whatever you don't import.

npm install @vshaders/dither

@vshaders/dither/ordered

SignatureDescription
bayer2(coord: vec2u) -> f32Threshold from the 2x2 Bayer matrix.
bayer4(coord: vec2u) -> f32Threshold from the 4x4 Bayer matrix.
bayer8(coord: vec2u) -> f32Threshold from the 8x8 Bayer matrix.
ditherQuantize(value: f32, levels: f32, threshold: f32) -> f32Quantize value to levels evenly spaced levels over [0, 1], with threshold (from a bayer* function) deciding which neighbor a value between two levels snaps to. levels = 2.0 is the 1-bit look.
ditherQuantize3(color: vec3f, levels: f32, threshold: f32) -> vec3fComponent-wise ditherQuantize with a shared threshold; levels counts levels per channel.

Guarantees: each bayer* function returns the normalized threshold in [0, 1) for a pixel coordinate, and the matrix tiles the plane (only the low bits of coord are read, which is the wrap). Thresholds of the n by n matrix are the permutation of (index + 0.5) / n², so they average exactly 0.5 and never hit 0 or 1; larger matrices give more intermediate shades before banding. For the quantizers, levels <= 1 returns the value unquantized instead of dividing by zero, and values outside [0, 1] quantize onto the extended grid, unclamped.

dither.wgsl
import { bayer8, ditherQuantize3 } from "@vshaders/dither/ordered";
import { linearToSrgb3 } from "@vgpu/wgsl-std/color";

@fragment fn main(@builtin(position) position: vec4f) -> @location(0) vec4f {
  let gradient = vec3f(position.x / 512.0);      // linear-light ramp
  let display = linearToSrgb3(gradient);         // quantize display-referred
  let threshold = bayer8(vec2u(position.xy));
  return vec4f(ditherQuantize3(display, 2.0, threshold), 1.0); // classic 1-bit look
}

Linear vs display

Dithering happens in whatever space you quantize in: a dithered region reads as the average of its pixels in that space. Quantizing linear-light values makes mid-gradients come out visibly too bright once encoded, so for a correct look convert to display-referred first (linearToSrgb3 from @vgpu/wgsl-std/color) and dither that, as in the example above. Quantize linear light only when the quantized values feed further linear-light math instead of the screen.

Provenance

The threshold matrices are B. E. Bayer's ordered-dither index matrices (1973); this package computes indices with the standard recursive bit-interleaving construction rather than storing literal matrices. All WGSL here is an original implementation, with edge-case guards (levels <= 1) in the argument ranges the math leaves undefined. MIT licensed.