dither@0.1.0

Dither

A dither filter over any texture, ported from Paper Shaders' image dithering: the source pixelates to the dither grid, luminance is gamma-shaped, and four patterns (random noise or 2x2/4x4/8x8 Bayer) quantize it into up to seven tones - printed in a back/front/highlight palette or the source's own colors. The gallery prints an orbiting sculpture through it; the registry ships the filter alone.

Controls

Back
#000000
Front
#EDEDED
Highlight
#EDEDED
Pattern4
Cell size6
Color steps6
Gamma0.80
Original colors
Inverted

Install

One command copies the composition into your project (with the @vshaders namespace registered in components.json; the plain URL https://vshaders.com/r/dither.json works with no setup). npm dependencies install alongside.

npx shadcn@latest add @vshaders/dither

Files that land in your tree: shaders/dither.wgsl, wgsl-env.d.ts.

The dither filter is a post-process pass: render your scene into an offscreen vgpu target, then bind that target's color as srcTexture (any linear sampler as srcSampler) and draw this shader to your canvas. Uniforms: resolution, cell, colorSteps, gamma, ditherType (1 random, 2/3/4 Bayer 2x2/4x4/8x8), colorBack/colorFront/colorHighlight, originalColors, inverted. Ported from Paper Shaders (github.com/paper-design/shaders, Apache-2.0). Live demo: https://vshaders.com/effects/dither. Colors (colorBack/colorFront/colorHighlight) are display-referred sRGB (hex/255, no linearization).

Passes

Rendered as 2 shader passes into offscreen textures each frame, in this order; only the display pass draws to the canvas.

scene.wgsl · The demo stage: the sculpture lit in grayscale on black, into an offscreen target.

display.wgsl · The filter itself - the registry item, ported from Paper Shaders: pixelates and dithers any source texture.

Imports

What the passes compose, deduplicated across all of them; every module resolves from npm.

import { bayer2, bayer4, bayer8 } from "@vshaders/dither/ordered";

import { luminance } from "@vgpu/wgsl-std/color";

import { hash2 } from "@vgpu/wgsl-std/hash";

import { clamp01 } from "@vgpu/wgsl-std/math";

import { lambert } from "@vgpu/wgsl-std/light";

Source

The display pass, dither/display.wgsl; the other passes follow below. Copy them and they are yours.

// halftone · display — the dither filter, and the only file the registry
// ships. A post-process over any texture: the source is sampled once per
// dither cell (pixelizing it), its luminance is gamma-shaped, offset by the
// cell's dither value, and quantized into colorSteps tones — printed either
// in the image's own colors or as a back/front/highlight palette.
//
// Ported to WGSL from Paper Shaders' image-dithering fragment shader
// (github.com/paper-design/shaders, packages/shaders/src/shaders/
// image-dithering.ts, Apache-2.0, Copyright paper-design). Changes: rewritten
// in WGSL on vshaders' scene-texture architecture (any render target as the
// source, so it sits behind or over a live scene, not just an image); the
// image fit/rotate/offset box and alpha handling dropped (the source here is
// an opaque fullscreen texture); Bayer values come from @vshaders/dither and
// the random pattern from wgsl-std's hash; a gamma control is added (Paper
// has none) to shape mid-tone density before quantization.
//
// Display-referred on purpose: the palette colors are authored in sRGB, like
// spot inks picked from a swatch book.

import { bayer2, bayer4, bayer8 } from "@vshaders/dither/ordered";
import { luminance } from "@vgpu/wgsl-std/color";
import { hash2 } from "@vgpu/wgsl-std/hash";
import { clamp01 } from "@vgpu/wgsl-std/math";

struct Uniforms {
  // Resolution of the output, physical pixels.
  resolution: vec2f,
  // Dither cell size in physical pixels: the source pixelates to this grid.
  cell: f32,
  // Number of tones between back and front, 1 to 7.
  colorSteps: f32,
  // The front ink: what full-luminance regions print as.
  colorFront: vec3f,
  // Mid-tone shaping before quantization; 1 is neutral, higher lifts mids.
  gamma: f32,
  // The back ink: what zero-luminance regions print as.
  colorBack: vec3f,
  // Pattern: 1 = random noise, 2 = 2x2 Bayer, 3 = 4x4 Bayer, 4 = 8x8 Bayer.
  ditherType: f32,
  // Replaces the front ink at the very top of the ramp; set it equal to
  // colorFront for classic two-color dithering.
  colorHighlight: vec3f,
  // 1 = keep the source's own colors instead of the palette.
  originalColors: f32,
  // 1 = invert the source luminance; the palette is unaffected.
  inverted: f32,
}

@group(0) @binding(0) var<uniform> uniforms: Uniforms;
@group(0) @binding(1) var srcTexture: texture_2d<f32>;
@group(0) @binding(2) var srcSampler: sampler;

@fragment
fn fs_main(@builtin(position) position: vec4f, @location(0) uv: vec2f) -> @location(0) vec4f {
  let res = max(uniforms.resolution, vec2f(1.0));
  let cell = max(uniforms.cell, 1.0);

  // Pixelize: one source sample per dither cell, centered on the cell.
  let cellCoord = floor(position.xy / cell);
  let sampleUv = (cellCoord + 0.5) * cell / res;
  let src = textureSampleLevel(srcTexture, srcSampler, sampleUv, 0.0).rgb;

  var lum = luminance(src);
  if (uniforms.inverted > 0.5) {
    lum = 1.0 - lum;
  }
  // Gamma shapes mid-tone density before the dither offset (our addition).
  lum = pow(clamp01(lum), 1.0 / max(uniforms.gamma, 0.05));

  // The cell's dither value, by pattern.
  let kind = i32(clamp(round(uniforms.ditherType), 1.0, 4.0));
  var dither = 0.0;
  if (kind == 1) {
    dither = hash2(cellCoord * cell + vec2f(0.13, 7.7)).x;
  } else if (kind == 2) {
    dither = bayer2(vec2u(cellCoord));
  } else if (kind == 3) {
    dither = bayer4(vec2u(cellCoord));
  } else {
    dither = bayer8(vec2u(cellCoord));
  }

  // Paper's quantization: the centered dither value perturbs the luminance
  // by one step, then the result snaps to the nearest of colorSteps tones.
  let steps = max(floor(uniforms.colorSteps), 1.0);
  let brightness = clamp01(lum + (dither - 0.5) / steps);
  let quantized = floor(brightness * steps + 0.5) / steps;

  if (uniforms.originalColors > 0.5) {
    // The source's own hue, carried by the quantized tone.
    let normColor = src / max(luminance(src), 0.001);
    return vec4f(clamp(normColor * quantized, vec3f(0.0), vec3f(1.0)), 1.0);
  }

  // Palette: back to front, the highlight taking over the top of the ramp.
  let front = select(
    uniforms.colorFront,
    uniforms.colorHighlight,
    brightness >= 1.02 - 0.02 * steps,
  );
  let color = mix(uniforms.colorBack, front, quantized);
  return vec4f(color, 1.0);
}
dither/scene.wgsl
// halftone · scene — the demo's stage, not the product. A sculpture (or the
// fallback torus) lit in grayscale on black: a key light from the upper
// left, a dim fill from the right, and a rim kick, tuned so the luminance
// ramp gives the halftone filter something worth printing. The registry item
// ships only the filter; this pass exists so the gallery has a scene.

import { lambert } from "@vgpu/wgsl-std/light";

struct Uniforms {
  // Camera clip transform and the model's orbit pose, column-major.
  viewProjection: mat4x4f,
  model: mat4x4f,
}

@group(0) @binding(0) var<uniform> uniforms: Uniforms;

struct VertexOut {
  @builtin(position) position: vec4f,
  @location(0) normal: vec3f,
}

@vertex
fn vs_main(@location(0) position: vec3f, @location(1) normal: vec3f) -> VertexOut {
  var out: VertexOut;
  out.position = uniforms.viewProjection * uniforms.model * vec4f(position, 1.0);
  // The model matrix is a rigid rotation, so it transforms normals directly.
  out.normal = (uniforms.model * vec4f(normal, 0.0)).xyz;
  return out;
}

@fragment
fn fs_main(@location(0) normal: vec3f) -> @location(0) vec4f {
  let n = normalize(normal);
  var light = lambert(n, vec3f(-0.55, -0.65, -0.52), vec3f(1.0), 0.95);
  light += lambert(n, vec3f(0.8, 0.1, -0.58), vec3f(1.0), 0.22);
  // Rim: faces turning away from the camera catch a thin back light.
  let rim = pow(1.0 - max(dot(n, vec3f(0.0, 0.0, -1.0)), 0.0), 3.0);
  light += vec3f(rim * 0.35);
  return vec4f(light, 1.0);
}