DEMO // ORDERED DITHERING

27.01.25

Below is a (simple) implementation of ordered dithering.


Also check out my demo about error diffusion dithering.
Click the image on the right (or drag and drop) to upload your own image.
A wise man (me) once said: "Javascript is not a fucking graphics library"; keep your images relatively small or your CPU will cry.


Pro tip: You can use mouse wheel on the grid to quickly change values.

Some rough intuitive details Gamma - Gamma for a gamma transform. M Size - Power of 2 width/height of the threshold map M.
Threshold Map Grid - Set of inputs to build a custom threshold map.

ABOUT

What is this? How does it work?


I'm glad you asked! Or scrolled.

If you hate reading, check out this video instead!

Historically, and in some rather small use cases today, dithering is a method of creating the illusion of colour/intensity depth with a limited pallette. This is done with patterns of varying density, where the average among each pattern patch suggests some degree of intensity/colour between the composing values. There are two main categories of dithering algorithms today, ordered and error diffusion.

When reducing a the pallette of an image, you need to threshold or "clamp" it's colour/intensity to an available value. In the case of this demonstration, our only options are 0 (pure black) or white (255) - meaning that anything in between (1-254) needs to be mapped to one of these colours.


threshold demo

We want clusters of a pattern to imply a certain shade of gray. This shade of gray is implied by the average of the pixels within a cluster. Therefore, when using our 1-bit pallette (black/white only), grays can be visualized with white and black dots at varying frequencies.
Ordered dithering enacts these patterns by tiling a threshold map, M, over the entirety of the image. The grid you see in the demo is a visualization of this threshold map.
The way the threshold map works is pretty simple. After you tile the threshold map over the image, for each pixel in the original image you...

Grayscale/Pallette-based dithering is a bit more complex, where it's instead mapping the pixel to it's closest possible value in a pallette (i.e "bins" of valid colour/intensity).

Visualized, ordered dithering is doing something like this...

ordered dithering process breakdown

Due to ordered dithering's computational simplicity and pixel-independent nature, it's more suitable for realtime graphics (compared to error diffusion). This is why most games that utilize a dithering aesthetic (e.g Obra Din) use ordered dithering.

As an interesting side note, I was originally also going to mention Who's Lila, but that game appears to use both! For the 3D graphics part they use ordered dithering, but the face and cutscenes they use error diffusion. How weird!

The most common threshold map to use (and the default matrices in the demo) is the bayer matrix. Without diving into the maths of that matrix, it can be best described as recursive criss-cross matrix definition that results in incrementing values forming an X pattern with each-other.
This youtube video @7:54 does a great job visualizing this recursive definition.


The bayer matrix is defined in sizes that are powers of 2, meaning that we usually end up with M2, M4 and M8 as our choices. Larger matrices have diminishing returns, especially since anything larger than 16 would have more thresholds than typically available graylevels


This matrix definition arranges the thresholds such that the next pixel (in the pattern) to "light up" will be diagonal from the last, making a checkerboard pattern.

4x4 Bayer Matrix
4x4 Bayer Matrix For Different Grayscales. Source: wikimedia

You'll notice that our threshold map has values of 0-<n*n-1> instead of 0-255. This choice was originally influenced by how the bayer matrix works, assembling each cell as a "rank" of which pixel to "light up" next, this rank is then scaled to whatever intensity range value is needed. For example, with a 4x4 map, we have 16 cells ranked 0-15. We then scale these ranks to be 0-255 (rank*17). This layout allows the bayer matrix to easily function with many different intensity representations (e.g 0%-100% for HSL). While it's perfectly possible to define our threshold map with cells purely being 0-255, I found that degree of control to be more frustrating than liberating - the clear step in threshold values was much easier to work with. Instead, I added the gamma controls to pre-emptively change the intensity of the original image for a more general & reliable way to fine tune the resulting apparent intensity.


At some point I will try and make a demo for pallette-based (coloured/grayscale) dithering with arbitrary (and autogenerated) pallettes. But that is significantly more complex. Stay tuned.

The Code

tldr; Get image data from canvas, for each pixel intensity do;
threshold = scaleThisBitchTo0-255Range([row % n][col % n]);
if intensity <= threshold then make black, otherwise make white
Now do that whenever shit changes (fuckton of event listeners)

import ODW, { ALL } from "./ODW.js";

const defaultImage = "./static/ath.png"; // Path should be relative to index.

// Type cast to remove possible null warnings.
const canvas = document.getElementById("canvas") as HTMLCanvasElement;
const ctx = canvas.getContext("2d");
const MBuilder = document.getElementById("MBuilder") as HTMLDivElement;
const MSize = document.getElementById("MSize") as HTMLInputElement;
const MSizeLabel = document.getElementById("MSizeVal") as HTMLElement;
const gammaSlider = document.getElementById("gamma") as HTMLInputElement;
const gammaValue = document.getElementById("gammaValue");
const fileInput = document.getElementById("fileInput") as HTMLInputElement;

let image: ODW;

// Note: (its a feature not a mistake I swear) - modifying currentM is modifying these by reference.
// So it "saves" the M you created at each size.
const ThresholdMaps: { [size: number]: number[][] } = {
    2: [
        [0, 2],
        [3, 1]
    ],
    4: [
        [0, 8, 2, 10],
        [12, 4, 14, 6],
        [3, 11, 1, 9],
        [15, 7, 13, 5]
    ],
    8: [
        [0, 32, 8, 40, 2, 34, 10, 42],
        [48, 16, 56, 24, 50, 18, 58, 26],
        [12, 44, 4, 36, 14, 46, 6, 38],
        [60, 28, 52, 20, 62, 30, 54, 22],
        [3, 35, 11, 43, 1, 33, 9, 41],
        [51, 19, 59, 27, 49, 17, 57, 25],
        [15, 47, 7, 39, 13, 45, 5, 37],
        [63, 31, 55, 23, 61, 29, 53, 21]
    ]
};

let currentM = ThresholdMaps[4]; // Use 4x4 as default.

function createGammaLUT(gamma: number): Uint8Array {
    const gammaCorrection = 1 / gamma;
    return Uint8Array.from({ length: 256 }, (_, i) => Math.round(Math.pow(i / 255, gammaCorrection) * 255));
}

function gammaTransform(og: ODW, gamma: number): void {
    const lut = createGammaLUT(gamma);
    const data = og.im.data;

    for (let i = 0; i < data.length; i += 4) {
        data[i] = lut[data[i]];       // Red
        data[i + 1] = lut[data[i + 1]]; // Green
        data[i + 2] = lut[data[i + 2]]; // Blue
        // Alpha remains unchanged.
    }
}

function dither(og: ODW, M: number[][]): void {
    const n = M.length;
    og.transform([ALL, ALL], (oldRGBA, row, col) => {
        const oldIntensity = oldRGBA[0];
        const thresh = M[row % n][col % n] * (255 / ((n * n) - 1));
        const newIntensity = oldIntensity <= thresh ? 0 : 255;
        return [newIntensity, newIntensity, newIntensity, 255];
    });
}

function processImage(): void {
    const processed = ODW.clone(image);
    canvas.width = image.width;
    canvas.height = image.height;
    gammaTransform(processed, Number(gammaValue?.textContent));
    dither(processed, currentM);
    ctx?.putImageData(processed.im, 0, 0);
}

async function loadImage(url: string = defaultImage): Promise {
    try {
        image = await ODW.fromImageUrl(url);
        processImage();
    } catch (error) {
        console.error("Error processing new image:", error);
    }
}

function handleTickerUpdate(i: number, j: number, val: string | number): void {
    currentM[i][j] = Number(val);
    processImage();
}

function createTicker(row: number, col: number, n: number): HTMLInputElement {
    const ticker = document.createElement("input");
    ticker.type = "number";
    ticker.max = String(Math.pow(n, 2) - 1);
    ticker.min = "0";
    ticker.value = String(currentM[row][col]);
    updateTickerStyle(ticker, currentM[row][col], n);

    ticker.addEventListener("change", (event) => {
        const input = event.currentTarget as HTMLInputElement;
        const value = input.value;
        updateTickerStyle(input, value, n);
        handleTickerUpdate(row, col, value);
    });

    ticker.addEventListener("wheel", (event) => {
        event.preventDefault();
        const input = event.currentTarget as HTMLInputElement;
        const value = adjustValueWithWheel(input, event.deltaY);
        input.value = String(value);
        updateTickerStyle(input, value, n);
        handleTickerUpdate(row, col, value);
    });

    return ticker;
}

function updateTickerStyle(ticker: HTMLInputElement, value: string | number, n: number): void {
    ticker.style.setProperty("--val", calculatePercentage(value, n));
    ticker.style.setProperty("color", Number(value) > (Number(ticker.max) / 2) ? "black" : "white");
}

function calculatePercentage(value: string | number, n: number): string {
    return `${(100 * Number(value)) / ((n * n) - 1)}%`;
}

function adjustValueWithWheel(input: HTMLInputElement, deltaY: number): number {
    const step = Number(input.step) || 1;
    const min = input.min !== "" ? Number(input.min) : -Infinity;
    const max = input.max !== "" ? Number(input.max) : Infinity;
    let value = Number(input.value) || 0;
    value += deltaY < 0 ? step : -step;
    return Math.max(min, Math.min(max, value));
}

function buildMatrixUI(): void {
    const n = currentM.length;
    MBuilder.innerHTML = "";
    MBuilder.style.setProperty("--size", String(n));

    for (let row = 0; row < n; row++) {
        for (let col = 0; col < n; col++) {
            const ticker = createTicker(row, col, n);
            MBuilder.appendChild(ticker);
        }
    }
}

MSize.addEventListener("change", () => {
    MSizeLabel.textContent = MSize.value;
    currentM = ThresholdMaps[1 << Number(MSize.value)];
    buildMatrixUI();
    processImage();
});

gammaSlider.addEventListener("input", () => {
    gammaValue!.textContent = gammaSlider.value;
    processImage();
});

fileInput.addEventListener("change", async (event) => {
    const file = (event.target as HTMLInputElement).files![0];
    await loadImage(URL.createObjectURL(file));
});

canvas.addEventListener("dragover", (event) => {
    event.preventDefault();
});

canvas.addEventListener("drop", async (event) => {
    event.preventDefault();
    const file = event.dataTransfer!.files[0];
    await loadImage(URL.createObjectURL(file));
});

canvas.addEventListener("click", () => {
    fileInput.click();
});

// Initialize with default settings.
loadImage();
buildMatrixUI();
Where ODW is a custom wrapper class I made to simplify canvas-based image processing. It literally stands for "Omni's dumb wrapper."
Considering I over-engineered it (but still coded it like shit), I am now obligated to make more demos like this so I can utilize all the features I needlessly coded into this class.

import { RGBA } from "./types";

export const ALL = '*';

export async function loadImage(src: string): Promise {
	return new Promise((resolve, reject) => {
		const img = new Image();
		img.onload = () => resolve(img);
		img.onerror = reject;
		img.src = src;
	});
}

type rq = [number, number] | ['*', number] | [number, '*'];
type ForEachCB = (rgba: RGBA, row: number, col: number) => void;

/**
	* ODW: OMNI'S DUMB WRAPPER.
	* 
	* A wrapper for automating the canvas IP pipeline. Comes with abstraction functions for working in a more intuitive RGBA[][] configuration.
	* Of course, this abstraction causes some overhead so for anything complex/slow you should work with the buffer directly.
	*/
export default class ODW {
	readonly im: ImageData;
	readonly width: number;
	readonly height: number;

	constructor(im: ImageData) {
		this.im = im;
		this.width = im.width;
		this.height = im.height;
	}

	static clone(existing: ODW): ODW {
		// Directly copy the data array for performance
		const cloneData = new ImageData(
			new Uint8ClampedArray(existing.im.data),
			existing.width,
			existing.height
		);
		return new ODW(cloneData);
	}

	static fromImage(image: HTMLImageElement): ODW {
		const osCanvas = new OffscreenCanvas(image.width, image.height);
		const osCTX = osCanvas.getContext('2d')!;
		osCTX.drawImage(image, 0, 0, image.width, image.height);
		return new ODW(osCTX.getImageData(0, 0, image.width, image.height));
	}

	static async fromImageUrl(src: string): Promise {
		const image = await loadImage(src);
		return ODW.fromImage(image);
	}

	// ================================
	// Getters
	// ================================
	getPixel(row: number, col: number): RGBA {
		// Inline index calculation
		const idx = (row * this.width + col) << 2; // (row * width + col) * 4
		const data = this.im.data;
		// Return a typed subarray as RGBA
		return [
			data[idx],
			data[idx + 1],
			data[idx + 2],
			data[idx + 3]
		] as RGBA;
	}

	getRow(row: number): RGBA[] {
		const { data } = this.im;
		const w = this.width;
		const rowStart = row * w * 4;
		const out: RGBA[] = new Array(w);

		for (let col = 0; col < w; col++) {
			const idx = rowStart + (col << 2); // rowStart + col * 4
			out[col] = [
				data[idx],
				data[idx + 1],
				data[idx + 2],
				data[idx + 3]
			];
		}
		return out;
	}

	getColumn(col: number): RGBA[] {
		const { data } = this.im;
		const { width: w, height: h } = this;
		const out: RGBA[] = new Array(h);

		for (let row = 0; row < h; row++) {
			const idx = (row * w + col) << 2;
			out[row] = [
				data[idx],
				data[idx + 1],
				data[idx + 2],
				data[idx + 3]
			];
		}
		return out;
	}

	g([row, col]: rq) {
		if (typeof row === 'number' && typeof col === 'number') {
			return this.getPixel(row, col);
		} else if (typeof row === 'number' && col === '*') {
			return this.getRow(row);
		} else if (typeof col === 'number' && row === '*') {
			return this.getColumn(col);
		} else {
			throw new Error("Malformed get statement.");
		}
	}

	// ================================
	// Setters
	// ================================
	setPixel(row: number, col: number, rgba: RGBA): void {
		// Inline checks + inlining clamp
		if (row < 0 || row >= this.height) {
			throw new RangeError(`Row index ${row} is out of bounds (height: ${this.height}).`);
		}
		if (col < 0 || col >= this.width) {
			throw new RangeError(`Column index ${col} is out of bounds (width: ${this.width}).`);
		}
		const idx = (row * this.width + col) << 2;
		const data = this.im.data;
		data[idx]     = clamp(rgba[0]);
		data[idx + 1] = clamp(rgba[1]);
		data[idx + 2] = clamp(rgba[2]);
		data[idx + 3] = clamp(rgba[3] ?? 255);
	}

	setRow(row: number, fill: RGBA[]): void {
		if (row < 0 || row >= this.height) {
			throw new RangeError(`Row index ${row} is out of bounds (height: ${this.height}).`);
		}
		const { data } = this.im;
		const w = this.width;
		// Flatten to avoid calling setPixel repeatedly
		let offset = row * w * 4;
		for (let i = 0; i < Math.min(fill.length, w); i++) {
			const rgba = fill[i];
			data[offset++] = clamp(rgba[0]);
			data[offset++] = clamp(rgba[1]);
			data[offset++] = clamp(rgba[2]);
			data[offset++] = clamp(rgba[3] ?? 255);
		}
	}

	setCol(col: number, fill: RGBA[]): void {
		if (col < 0 || col >= this.width) {
			throw new RangeError(`Column index ${col} is out of bounds (width: ${this.width}).`);
		}
		const { data } = this.im;
		const { width: w, height: h } = this;
		// Flatten to avoid calling setPixel repeatedly
		for (let i = 0; i < Math.min(fill.length, h); i++) {
			const idx = (i * w + col) << 2;
			const rgba = fill[i];
			data[idx]     = clamp(rgba[0]);
			data[idx + 1] = clamp(rgba[1]);
			data[idx + 2] = clamp(rgba[2]);
			data[idx + 3] = clamp(rgba[3] ?? 255);
		}
	}

	s([row, col]: rq, fill: RGBA | RGBA[]): void {
		// Check if single RGBA or array
		if (!Array.isArray(fill[0])) {
			// Single RGBA
			if (typeof row === 'number' && typeof col === 'number') {
				this.setPixel(row, col, fill as RGBA);
			} else {
				throw new Error("Cannot set single pixel to a sequence of RGBA values.");
			}
		} else {
			// RGBA[]
			if (typeof row === 'number' && col === '*') {
				this.setRow(row, fill as RGBA[]);
			} else if (typeof col === 'number' && row === '*') {
				this.setCol(col, fill as RGBA[]);
			}
		}
	}

	// ================================
	// Iteration
	// ================================
	/* NOTE: IM ABANDONING MY D.R.Y MORALS HERE TO EXTRACT AS MUCH EFFICIENCY OUT OF THIS AS POSSIBLE
	TRYING TO REDUCE OVERHEAD OF A BUNCH OF GET/SET/PIXEL CALLS.  */
	forEach([rowReq, colReq]: rq | ['*', '*'], callback: ForEachCB): void {
		const { width: w, height: h, im: { data } } = this;

		if (rowReq === '*' && colReq === '*') {
			// All pixels
			let idx = 0;
			for (let r = 0; r < h; r++) {
				for (let c = 0; c < w; c++) {
					// RGBA from data
					const rgba: RGBA = [
						data[idx], 
						data[idx + 1],
						data[idx + 2],
						data[idx + 3]
					];
					callback(rgba, r, c);
					idx += 4;
				}
			}
		} else if (rowReq === '*' && typeof colReq === 'number') {
			// Single column
			const col = colReq;
			if (col < 0 || col >= w) {
				throw new RangeError(`Column index ${col} is out of bounds (width: ${w}).`);
			}
			for (let r = 0; r < h; r++) {
				const idx = (r * w + col) << 2;
				const rgba: RGBA = [
					data[idx],
					data[idx + 1],
					data[idx + 2],
					data[idx + 3]
				];
				callback(rgba, r, col);
			}
		} else if (typeof rowReq === 'number' && colReq === '*') {
			// Single row
			const row = rowReq;
			if (row < 0 || row >= h) {
				throw new RangeError(`Row index ${row} is out of bounds (height: ${h}).`);
			}
			let idx = row * w * 4;
			for (let c = 0; c < w; c++) {
				const rgba: RGBA = [
					data[idx],
					data[idx + 1],
					data[idx + 2],
					data[idx + 3]
				];
				callback(rgba, row, c);
				idx += 4;
			}
		} else {
			throw new Error(
				`Unsupported rq pattern for forEach: [${rowReq}, ${colReq}].
					Valid patterns: [*, *], [*, number], [number, *].`
			);
		}
	}

	/**
		* Run callback for every pixel in the image, update pixel to RGBA value from CB.
		* @param rq Selector for row, col. Use a number to select a specific row/col or '*' to select all of them.
		* @param transform transform callback, passed the following: (oldRGBA: RGBA, row: number, col: number) - The RGBA nple @ row/col.
		* @param profile boolean for debugging, enable use of simple performance.now() check for execution time.
		*/
	public transform(
		rq: rq | ['*', '*'],
		transform: (oldRGBA: RGBA, row: number, col: number) => RGBA,
		profile = false
	): void {
		const { width: w, height: h, im: { data } } = this;
		const [rowReq, colReq] = rq;

		const t0 = profile ? performance.now() : undefined;

		if (rowReq === '*' && colReq === '*') {
			// Entire image
			let idx = 0;
			for (let r = 0; r < h; r++) {
				for (let c = 0; c < w; c++) {
					const oldRGBA: RGBA = [
						data[idx],
						data[idx + 1],
						data[idx + 2],
						data[idx + 3]
					];
					const newRGBA = transform(oldRGBA, r, c);
					data[idx]     = clamp(newRGBA[0]);
					data[idx + 1] = clamp(newRGBA[1]);
					data[idx + 2] = clamp(newRGBA[2]);
					data[idx + 3] = clamp(newRGBA[3] ?? 255);
					idx += 4;
				}
			}
		} else if (rowReq === '*' && typeof colReq === 'number') {
			// Single column
			const col = colReq;
			if (col < 0 || col >= w) {
				throw new RangeError(`Column index ${col} out of bounds (width: ${w}).`);
			}
			for (let r = 0; r < h; r++) {
				const idx = (r * w + col) << 2;
				const oldRGBA: RGBA = [
					data[idx],
					data[idx + 1],
					data[idx + 2],
					data[idx + 3]
				];
				const newRGBA = transform(oldRGBA, r, col);
				data[idx]     = clamp(newRGBA[0]);
				data[idx + 1] = clamp(newRGBA[1]);
				data[idx + 2] = clamp(newRGBA[2]);
				data[idx + 3] = clamp(newRGBA[3] ?? 255);
			}
		} else if (typeof rowReq === 'number' && colReq === '*') {
			// Single row
			const row = rowReq;
			if (row < 0 || row >= h) {
				throw new RangeError(`Row index ${row} out of bounds (height: ${h}).`);
			}
			let idx = row * w * 4;
			for (let c = 0; c < w; c++) {
				const oldRGBA: RGBA = [
					data[idx],
					data[idx + 1],
					data[idx + 2],
					data[idx + 3]
				];
				const newRGBA = transform(oldRGBA, row, c);
				data[idx]     = clamp(newRGBA[0]);
				data[idx + 1] = clamp(newRGBA[1]);
				data[idx + 2] = clamp(newRGBA[2]);
				data[idx + 3] = clamp(newRGBA[3] ?? 255);
				idx += 4;
			}
		} else {
			throw new Error(
				`Unsupported rq pattern: [${rowReq}, ${colReq}].
					Valid patterns: [*, *], [*, number], [number, *].`
			);
		}

		if(profile) {
			const t1 = performance.now();
			console.log(`transform done in ${t1 - t0!} milliseconds`);
		}

	}
}

// Inlined clamp function for performance
function clamp(value: number): number {
	const v = (value + 0.5) | 0; // round
	return v < 0 ? 0 : v > 255 ? 255 : v;
}