Ordered dithering
on = v > BAYER[(y & 3) × 4 + (x & 3)]
How it works
The field
Three sine waves are summed per pixel: one travelling on x, one on a diagonal, and one radial from the centre using hypot. Each contributes half its amplitude, and the total is divided by three and biased to land roughly in 0 to 1. Where they reinforce you get plateaus, where they cancel you get the interference bands.
The pointer adds a fourth term — sin(d × 0.32 − t × 5) windowed by a linear falloff — which reads as a ripple dropped into the surface.
Why a Bayer matrix
Quantising a smooth gradient to one bit with a fixed threshold gives hard bands. Ordered dithering varies the threshold per pixel instead, using a small tiled matrix indexed by x & 3 and y & 3 — cheap because the grid is a power of two.
The 4×4 Bayer matrix is built by recursive subdivision, so its 16 values are spread as evenly as possible in both space and value. Any local 4×4 region therefore contains one threshold at each level, and a mid-grey area alternates in a fine checker rather than clumping. The result is a stable, non-flickering stipple — unlike error-diffusion dithering, which is serial and crawls when the image moves.
The comparison also stays purely local: no neighbouring pixel is consulted, so the whole pass is one flat loop over an ImageData buffer.
Chunky by construction
The field is computed into an offscreen buffer at a fifth of the display size, then drawn up to full size with imageSmoothingEnabled = false. Nearest-neighbour scaling keeps the dither pattern crisp at 5×5 blocks, and the per-pixel loop stays 25× cheaper than working at device resolution.
Pixels below the threshold are written with zero alpha rather than a background colour, so the piece keeps the same theme independence as the flow field. Those above it interpolate cyan to pink by how far past the threshold they landed, which recovers a little tonal range from a one-bit decision.
Source
'use client';
import { useRef } from 'react';import { useCanvasCraft } from '../useCraft';
const PIXEL = 5;
// Ordered 4×4 Bayer matrix, normalised to 0..1.const BAYER = [ 0, 8, 2, 10, 12, 4, 14, 6, 3, 11, 1, 9, 15, 7, 13, 5,].map(v => (v + 0.5) / 16);
const LOW: [number, number, number] = [0, 212, 255]; // cyanconst HIGH: [number, number, number] = [255, 45, 120]; // pink
/** * Interfering sine waves quantised to 1-bit through an ordered Bayer matrix, * rendered at 1/5 scale and blown up with smoothing off. The cursor drops a * radial ripple into the field. */export default function DitherWaves() { const buf = useRef<{ canvas: HTMLCanvasElement; img: ImageData; lw: number; lh: number } | null>(null);
const seed = (w: number, h: number) => { const lw = Math.max(1, Math.ceil(w / PIXEL)); const lh = Math.max(1, Math.ceil(h / PIXEL)); const canvas = document.createElement('canvas'); canvas.width = lw; canvas.height = lh; const ctx = canvas.getContext('2d'); if (!ctx) return; buf.current = { canvas, img: ctx.createImageData(lw, lh), lw, lh }; };
const canvasRef = useCanvasCraft(({ ctx, w, h, t, pointer }) => { const b = buf.current; if (!b) return;
const { img, lw, lh } = b; const data = img.data;
const px = pointer.inside ? pointer.x / PIXEL : -999; const py = pointer.inside ? pointer.y / PIXEL : -999;
for (let y = 0; y < lh; y++) { for (let x = 0; x < lw; x++) { // Three drifting waves plus a cursor ripple. let v = Math.sin(x * 0.11 + t * 0.9) * 0.5 + Math.sin((x * 0.05 - y * 0.09) + t * 0.6) * 0.5 + Math.sin(Math.hypot(x - lw / 2, y - lh / 2) * 0.14 - t * 1.4) * 0.5;
if (pointer.inside) { const d = Math.hypot(x - px, y - py); v += Math.sin(d * 0.32 - t * 5) * Math.max(0, 1 - d / 26) * 1.6; }
v = v / 3 + 0.5; // → roughly 0..1
const i = (y * lw + x) * 4; const threshold = BAYER[(y & 3) * 4 + (x & 3)];
if (v > threshold) { // Duotone: cooler where the field is weak, hotter where it peaks. const k = Math.min(Math.max((v - threshold) * 1.6, 0), 1); data[i] = LOW[0] + (HIGH[0] - LOW[0]) * k; data[i + 1] = LOW[1] + (HIGH[1] - LOW[1]) * k; data[i + 2] = LOW[2] + (HIGH[2] - LOW[2]) * k; data[i + 3] = 235; } else { data[i + 3] = 0; } } }
const lowCtx = b.canvas.getContext('2d'); if (!lowCtx) return; lowCtx.putImageData(img, 0, 0);
ctx.clearRect(0, 0, w, h); ctx.imageSmoothingEnabled = false; ctx.drawImage(b.canvas, 0, 0, w, h); }, seed);
return <canvas ref={canvasRef} style={{ width: '100%', height: '100%', display: 'block', cursor: 'crosshair' }} />;}The shared loop every craft runs on, plus the noise and dither helpers.
'use client';
import { useEffect, useLayoutEffect, useRef, useState, type RefObject } from 'react';
/** Decorative previews don't need every frame; the detail pages get the lot. */const PREVIEW_FPS = 30;
/** * Counts capped loops so each can be given a different phase. Without this they * all skip the same frames and compute on the same ones, so the index page does * nothing for one frame and every simulation at once on the next — which shows * up as a dropped frame rather than as evenly spread work. */let phaseCounter = 0;const PHASES = 3;
/** * rAF loop that only runs while the target is on screen and the tab is visible. * Each craft runs its own, so several visible at once is several animations' * worth of work — which is why the grid previews are capped: on the index a * handful of simulations compete for one main thread, and at a glance nobody can * tell 30 frames from 60. */export function useVisibleRaf( targetRef: RefObject<HTMLElement | null>, onFrame: (t: number, dt: number) => void,) { const cb = useRef(onFrame); cb.current = onFrame;
useEffect(() => { const el = targetRef.current; if (!el) return;
const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches; // The class that marks a stage as a thumbnail rather than the main event. const isPreview = el.closest('.craft-stage-preview') !== null; // A frame of slack, so a 30fps cap doesn't skip to 20 by missing a vsync. const minGap = isPreview ? 1000 / PREVIEW_FPS - 4 : 0; const phase = minGap ? ((phaseCounter++ % PHASES) * minGap) / PHASES : 0;
let raf = 0; let running = false; let visible = false; const start = performance.now(); let last = start - phase;
const loop = (now: number) => { raf = requestAnimationFrame(loop); if (now - last < minGap) return; const dt = Math.min((now - last) / 1000, 0.05); last = now; cb.current((now - start) / 1000, dt); };
const play = () => { if (running || !visible || document.hidden) return; running = true; // Keep this loop's phase on resume, or scrolling would resync them all. last = performance.now() - phase; raf = requestAnimationFrame(loop); };
const pause = () => { running = false; cancelAnimationFrame(raf); };
const io = new IntersectionObserver( ([entry]) => { visible = entry.isIntersecting; if (visible) play(); else pause(); }, { threshold: 0.05 }, ); io.observe(el);
const onVis = () => (document.hidden ? pause() : play()); document.addEventListener('visibilitychange', onVis);
// Reduced motion: paint one frame, then stop. if (reduced) { cb.current(0, 0); io.disconnect(); document.removeEventListener('visibilitychange', onVis); return; }
return () => { pause(); io.disconnect(); document.removeEventListener('visibilitychange', onVis); }; }, [targetRef]);}
export interface CraftFrame { ctx: CanvasRenderingContext2D; w: number; h: number; t: number; dt: number; pointer: { x: number; y: number; inside: boolean };}
/** * Canvas sized to its CSS box with DPR scaling, driven by useVisibleRaf. * `onInit` fires once per resize — use it to seed grids and particles. */export function useCanvasCraft( onFrame: (f: CraftFrame) => void, onInit?: (w: number, h: number) => void,) { const canvasRef = useRef<HTMLCanvasElement>(null); const box = useRef({ w: 0, h: 0 }); const pointer = useRef({ x: -9999, y: -9999, inside: false }); const frame = useRef(onFrame); const init = useRef(onInit); frame.current = onFrame; init.current = onInit;
useEffect(() => { const canvas = canvasRef.current; if (!canvas) return; const ctx = canvas.getContext('2d'); if (!ctx) return;
const resize = () => { const rect = canvas.getBoundingClientRect(); if (!rect.width || !rect.height) return; const dpr = Math.min(window.devicePixelRatio || 1, 2); box.current = { w: rect.width, h: rect.height }; canvas.width = Math.round(rect.width * dpr); canvas.height = Math.round(rect.height * dpr); ctx.setTransform(dpr, 0, 0, dpr, 0, 0); init.current?.(rect.width, rect.height); };
resize(); const ro = new ResizeObserver(resize); ro.observe(canvas);
/* * Touch has no hover: pointermove only arrives while a finger is down, and * the browser stops sending it the moment it decides the gesture is a * scroll. So the position is also taken on pointerdown — otherwise a tap, * or a grab that hasn't moved yet, reads as no pointer at all. */ const at = (e: PointerEvent) => { const r = canvas.getBoundingClientRect(); pointer.current = { x: e.clientX - r.left, y: e.clientY - r.top, inside: true }; };
const down = (e: PointerEvent) => { at(e); // Keeps a drag that wanders off the canvas mid-gesture still tracking. try { canvas.setPointerCapture(e.pointerId); } catch { // Pointer already gone; nothing to capture. } };
const up = (e: PointerEvent) => { // A lifted finger stops existing, where a clicked mouse is still hovering. if (e.pointerType !== 'mouse') pointer.current.inside = false; };
const leave = () => { pointer.current.inside = false; };
canvas.addEventListener('pointerdown', down); canvas.addEventListener('pointermove', at); canvas.addEventListener('pointerup', up); canvas.addEventListener('pointercancel', leave); canvas.addEventListener('pointerleave', leave);
return () => { ro.disconnect(); canvas.removeEventListener('pointerdown', down); canvas.removeEventListener('pointermove', at); canvas.removeEventListener('pointerup', up); canvas.removeEventListener('pointercancel', leave); canvas.removeEventListener('pointerleave', leave); }; }, []);
useVisibleRaf(canvasRef, (t, dt) => { const canvas = canvasRef.current; const ctx = canvas?.getContext('2d'); if (!ctx) return; frame.current({ ctx, w: box.current.w, h: box.current.h, t, dt, pointer: pointer.current }); });
return canvasRef;}
/** Current theme, kept in sync with the dock's toggle. Starts 'light' so SSR matches. */export function useCraftTheme(): 'light' | 'dark' { const [theme, setTheme] = useState<'light' | 'dark'>('light');
useLayoutEffect(() => { const el = document.documentElement; const read = () => setTheme((el.dataset.theme as 'light' | 'dark') ?? 'light'); read(); const mo = new MutationObserver(read); mo.observe(el, { attributes: true, attributeFilter: ['data-theme'] }); return () => mo.disconnect(); }, []);
return theme;}
/** Seeded PRNG, so anything generated looks the same on every visit. */export function mulberry32(seed: number) { return () => { seed = (seed + 0x6d2b79f5) | 0; let t = Math.imul(seed ^ (seed >>> 15), 1 | seed); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; };}
/** Ordered 4×4 Bayer matrix, normalised to 0..1. Shared by the dithered pieces. */export const BAYER4 = [ 0, 8, 2, 10, 12, 4, 14, 6, 3, 11, 1, 9, 15, 7, 13, 5,].map(v => (v + 0.5) / 16);
/** Cheap hash-based value noise — enough character for a flow field, no lookup tables. */export function noise2(x: number, y: number): number { const xi = Math.floor(x); const yi = Math.floor(y); const xf = x - xi; const yf = y - yi; const u = xf * xf * (3 - 2 * xf); const v = yf * yf * (3 - 2 * yf);
const h = (a: number, b: number) => { let n = Math.imul(a, 374761393) + Math.imul(b, 668265263); n = Math.imul(n ^ (n >>> 13), 1274126177); return ((n ^ (n >>> 16)) >>> 0) / 4294967295; };
return ( h(xi, yi) * (1 - u) * (1 - v) + h(xi + 1, yi) * u * (1 - v) + h(xi, yi + 1) * (1 - u) * v + h(xi + 1, yi + 1) * u * v );}