@vshaders/dither
v0.2.0
Published
Ordered (Bayer) dithering thresholds and quantizers as pure WGSL modules for vgpu shaders
Maintainers
Readme
@vshaders/dither
Ordered (Bayer) dithering thresholds and quantizers as pure WGSL modules for vgpu shaders:
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
}Every module is pure WGSL — functions only, no bindings, no entry points — so vgpu's resolver can prune whatever you don't import. 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.
@vshaders/dither/ordered
bayer2(coord: vec2u) -> f32,bayer4(coord: vec2u) -> f32,bayer8(coord: vec2u) -> f32— the normalized ordered-dither threshold in[0, 1)for a pixel coordinate, from the 2×2, 4×4, and 8×8 Bayer matrix respectively. The matrix tiles the plane (only the low bits ofcoordare read, which is the wrap). Thresholds of the n×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.ditherQuantize(value: f32, levels: f32, threshold: f32) -> f32— quantizevaluetolevelsevenly spaced levels over[0, 1], withthreshold(from abayer*function) deciding which neighbor a value between two levels snaps to.levels = 2.0is the 1-bit look.levels <= 1returns the value unquantized instead of dividing by zero; values outside[0, 1]quantize onto the extended grid, unclamped.ditherQuantize3(color: vec3f, levels: f32, threshold: f32) -> vec3f— component-wiseditherQuantizewith a shared threshold;levelscounts levels per channel.
Linear vs display
Dithering happens in whatever space you quantize in. A dithered region reads as the average of its pixels — in the space you quantized. 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 ("An optimum method for two-level rendition of continuous-tone pictures", 1973); the recursive construction that generates them is textbook. This package computes indices with that 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.
Verifying
npx vgpu check path/to/your-entry-shader.wgslresolves the import graph, validates the composed shader, and prints its reflection.
License
MIT
