# Dither engine

How dither-core, the engine behind every dithercn dither, turns a tone into dots: threshold matrices, levels, error diffusion, and the three ways to draw.

> For the complete documentation index, see [llms.txt](/llms.txt). Markdown variants are available by appending `.md` to any URL or sending an `Accept: text/markdown` header. The dithercn agent skill, for building any dither on the engine, is at [/.well-known/agent-skills/dithercn/SKILL.md](/.well-known/agent-skills/dithercn/SKILL.md).

`dither-core` is the dither engine. It installs into `lib/dither` as plain source, with the [agent skill](/docs/installation) or the first time you add an example, and every dither on this site is built on it. Import it to build your own:

```tsx
import { DitherShader, ditherMaskStyle, ditherImage } from "@/lib/dither";
```

It draws dithers three ways. Pick by what you're making:

| You want                           | Use                                | Drawn with        |
| ---------------------------------- | ---------------------------------- | ----------------- |
| An animated or full-bleed surface  | `DitherShader`                     | WebGL             |
| A static fill for a shape or block | `DitherPattern`, `ditherMaskStyle` | SVG or a CSS mask |
| A dithered photo or logo           | `ditherImage`                      | The CPU, once     |

## From tone to dots [#from-tone-to-dots]

A dither starts from a tone between 0 and 1 for each cell: 0 is paper, 1 is ink. A threshold matrix gives every cell in a small repeating tile its own threshold. Where the tone is above the cell's threshold, the cell is inked. A tone of 0.25 inks a quarter of the cells, spread evenly across the tile.

The matrix decides how those cells are spread. Each one below draws the same left-to-right ramp:

```tsx title="examples/concept-matrices.tsx"
import { DitherShader } from "@/lib/dither";
import type { DitherMatrix } from "@/lib/dither";

// A left-to-right ramp: tone equals the horizontal position.
const RAMP = `float field(vec2 uv, vec2 res, float t) { return uv.x; }`;

const MATRICES: { matrix: DitherMatrix; note: string }[] = [
  { matrix: "bayer2", note: "5 tones, coarse crosshatch" },
  { matrix: "bayer4", note: "17 tones, the default" },
  { matrix: "bayer8", note: "65 tones, finer steps" },
  { matrix: "blue-noise", note: "257 tones, no visible grid" },
];

export default function ConceptMatrices() {
  return (
    <div className="flex w-full flex-col gap-4">
      {MATRICES.map(({ matrix, note }) => (
        <figure key={matrix} className="flex flex-col gap-1.5">
          <DitherShader
            field={RAMP}
            matrix={matrix}
            animate={false}
            className="h-12 rounded-lg sm:h-14"
          />
          <figcaption className="flex justify-between gap-4 text-xs">
            <span className="font-mono">{matrix}</span>
            <span className="text-muted-foreground">{note}</span>
          </figcaption>
        </figure>
      ))}
    </div>
  );
}
```

* `bayer2`, `bayer4` and `bayer8` are ordered Bayer matrices of 2, 4 and 8 cells square. Bigger tiles draw more distinct tones, with a regular crosshatch that reads as deliberate and printed.
* `blue-noise` is a 64×64 tile with no visible grid. It looks closest to film grain, and suits large soft surfaces where a crosshatch would distract.

`bayer4` is the default everywhere. Every matrix is ordered, so the same tone always gives the same dots. That keeps animated dithers calm: a cell only changes when its tone crosses its threshold.

## Levels [#levels]

Before dithering, the tone snaps to a fixed number of `levels`. By default that's every tone the matrix can draw, 17 for `bayer4`. Fewer levels give visible bands, a posterised look that works well for bold backgrounds:

```tsx title="examples/concept-levels.tsx"
"use client";

import { useId, useState } from "react";

import { DitherShader } from "@/lib/dither";

const RAMP = `float field(vec2 uv, vec2 res, float t) { return uv.x; }`;

export default function ConceptLevels() {
  const [levels, setLevels] = useState(5);
  const id = useId();

  return (
    <div className="flex w-full flex-col gap-4">
      <DitherShader
        field={RAMP}
        levels={levels}
        animate={false}
        className="h-24 rounded-lg sm:h-32"
      />
      <div className="flex items-center gap-4">
        <label htmlFor={id} className="text-muted-foreground shrink-0 text-sm">
          levels
        </label>
        <input
          id={id}
          type="range"
          min={2}
          max={17}
          value={levels}
          onChange={(event) => setLevels(Number(event.target.value))}
          className="accent-primary h-1 flex-1 cursor-pointer"
        />
        <output
          htmlFor={id}
          className="w-6 text-right font-mono text-sm tabular-nums"
        >
          {levels}
        </output>
      </div>
    </div>
  );
}
```

## Error diffusion for images [#error-diffusion-for-images]

Photos and logos look best with error diffusion. Each pixel is rounded to the nearest colour, and the rounding error is passed on to the pixels not yet visited. `ditherImage` supports `floyd-steinberg`, `atkinson` and `sierra-lite` alongside the ordered matrices:

```tsx title="examples/concept-diffusion.tsx"
"use client";

import { useEffect, useRef } from "react";

import { ditherImage, useDitherColors } from "@/lib/dither";
import type { DitherMethod, Rgba } from "@/lib/dither";

const SIZE = 96;
const METHODS: DitherMethod[] = [
  "bayer4",
  "blue-noise",
  "floyd-steinberg",
  "atkinson",
];

// A test picture: a lit sphere on a soft vertical gradient.
const testImage = () => {
  const data = new Uint8ClampedArray(SIZE * SIZE * 4);
  for (let y = 0; y < SIZE; y += 1) {
    for (let x = 0; x < SIZE; x += 1) {
      const dx = (x - SIZE * 0.55) / (SIZE * 0.3);
      const dy = (y - SIZE * 0.5) / (SIZE * 0.3);
      const inside = dx * dx + dy * dy;
      const sky = 0.15 + 0.35 * (y / SIZE);
      const light = Math.max(0, 1 - Math.hypot(dx + 0.45, dy + 0.45) / 1.6);
      const tone = inside < 1 ? 0.1 + 0.9 * light : sky;
      const offset = (y * SIZE + x) * 4;
      data.fill(Math.round(tone * 255), offset, offset + 3);
      data[offset + 3] = 255;
    }
  }
  return { data, height: SIZE, width: SIZE };
};

const DitheredCanvas = ({
  method,
  paper,
  ink,
}: {
  method: DitherMethod;
  paper: Rgba;
  ink: Rgba;
}) => {
  const ref = useRef<HTMLCanvasElement>(null);

  useEffect(() => {
    const context = ref.current?.getContext("2d");
    if (!context) {
      return;
    }
    // Dark pixels become paper and light ones ink, so the picture reads
    // the same on a black or a white page.
    const result = ditherImage(testImage(), {
      method,
      palette: [
        [0, 0, 0],
        [1, 1, 1],
      ],
    });
    const { data } = result;
    for (let index = 0; index < data.length; index += 4) {
      const color = (data[index] ?? 0) > 127 ? ink : paper;
      data[index] = color[0] * 255;
      data[index + 1] = color[1] * 255;
      data[index + 2] = color[2] * 255;
    }
    context.putImageData(result, 0, 0);
  }, [method, paper, ink]);

  return (
    <canvas
      ref={ref}
      width={SIZE}
      height={SIZE}
      className="aspect-square w-full rounded-lg [image-rendering:pixelated]"
    />
  );
};

export default function ConceptDiffusion() {
  const root = useRef<HTMLDivElement>(null);
  const colors = useDitherColors(root);

  return (
    <div ref={root} className="grid w-full grid-cols-2 gap-4 sm:grid-cols-4">
      {METHODS.map((method) => (
        <figure key={method} className="flex flex-col gap-1.5">
          {colors ? (
            <DitheredCanvas method={method} ink={colors[0]} paper={colors[1]} />
          ) : (
            <div className="aspect-square w-full" />
          )}
          <figcaption className="font-mono text-xs">{method}</figcaption>
        </figure>
      ))}
    </div>
  );
}
```

`ditherImage` never changes its input. It returns a new `ImageData` you can draw with `putImageData`, and it has no DOM dependency beyond `ImageData`, so it runs in a Web Worker too. Error diffusion depends on every earlier pixel, so a small change to the input can move dots everywhere. Keep it for still images and use a matrix for anything that moves.

```ts
const dithered = ditherImage(context.getImageData(0, 0, width, height), {
  method: "atkinson",
  palette: [
    [0.05, 0.05, 0.1],
    [0.55, 0.6, 1],
  ],
});
context.putImageData(dithered, 0, 0);
```

## Writing a field [#writing-a-field]

`DitherShader` runs a GLSL function you write, called the field. [Write your own dither](/docs/engine/write-your-own) builds one step by step; this is the short version. It receives the cell's position and returns its tone; the core does the rest: resizing, quantising, the palette, motion and cleanup.

```tsx
const FIELD = `
float field(vec2 uv, vec2 res, float t) {
  // uv: cell centre in [0, 1], y up. res: size in cells. t: seconds.
  float glow = 1.0 - distance(uv, glowCentre);
  return clamp(glow + 0.2 * (fbm(uv * 3.0 + t * 0.05) - 0.5), 0.0, 1.0);
}
`;

<DitherShader
  field={FIELD}
  uniforms={{ glowCentre: [0.5, 0.2] }}
  className="absolute inset-0"
/>;
```

Values in `uniforms` are declared for you: a number becomes a `float`, and a tuple of 2, 3 or 4 numbers a `vec2`, `vec3` or `vec4`. Changing a value only updates the uniform; changing the field's source recompiles it.

The core gives every field three helpers: `hash21(vec2)` for stable per-cell randomness in \[0, 1), `snoise(vec2)` for simplex noise in \[-1, 1], and `fbm(vec2)` for four octaves of it in \[0, 1]. Names starting with `u_` belong to the core's own uniforms, so your uniforms can't use them.

A second, optional function, `colorField`, sets the colour of the dots independently of their density, for rainbows and a colour per shape. [Palettes](/docs/engine/palettes#any-number-of-hues) shows it, with the `spectrum` and `paletteColor` helpers it can use.

## Static fills [#static-fills]

Charts and small surfaces don't need a canvas. `DitherPattern` is an SVG `<pattern>` you reference from any shape, and `ditherMaskStyle` masks any element to one tone:

```tsx
<svg>
  <defs>
    <DitherPattern id="half" level={0.5} />
  </defs>
  <rect width={80} height={40} fill="url(#half)" />
</svg>

<div className="bg-dither-ink h-8" style={ditherMaskStyle(0.25)} />
```

Both render on the server, follow the theme through CSS variables, and cost nothing after the first paint.