Shaders & Backgroundsv0.3.0
Dither Wildlife
Galloping horses, a wheeling flock or swaying trees in shaded, dithered ASCII.
Installation
CLI
npx shadcn@latest add https://hantui-theta.vercel.app/r/dither-wildlife.jsonManual
Install the dependencies
npm install clsx tailwind-mergeAdd the shared helpers
Components use
cn()fromlib/utils.tsand the motion tokens below. See Installation if you have not set them up yet.lib/color.tsexport type Vec3 = [r: number, g: number, b: number]; /** * Parses #rgb, #rrggbb or #rrggbbaa (alpha is ignored) into 0–255 channels. * Anything else falls back to black, so a typo never crashes a shader. */ export function hexToRgb(hex: string): Vec3 { let value = hex.trim().replace(/^#/, ""); if (value.length === 3 || value.length === 4) value = [...value.slice(0, 3)].map((c) => c + c).join(""); if (!/^[\da-f]{6}([\da-f]{2})?$/i.test(value)) return [0, 0, 0]; const n = Number.parseInt(value.slice(0, 6), 16); return [(n >> 16) & 255, (n >> 8) & 255, n & 255]; } /** Hex color as a normalized vec3 (each channel 0–1) for shader uniforms. */ export function hexToVec3(hex: string): Vec3 { const [r, g, b] = hexToRgb(hex); return [r / 255, g / 255, b / 255]; } /** * Any CSS color the browser understands (hex, rgb(), oklch(), color-mix() or a variable such as * `var(--primary)`) as 0–255 channels, computed in the context of `element` so theme variables * resolve. Hex takes a fast path; everything else is computed once per call, so cache the result * instead of calling this every frame. Browser only; unreadable values fall back to black. */ export function cssColorToRgb(color: string, element: Element): Vec3 { const value = color.trim(); if (/^#[\da-f]{3,8}$/i.test(value)) return hexToRgb(value); // Children of a canvas still get computed styles, but a regular parent is the safer host. const host = element instanceof HTMLCanvasElement ? (element.parentElement ?? document.body) : element; const probe = document.createElement("span"); probe.style.cssText = "position:absolute;width:0;height:0;overflow:hidden;visibility:hidden"; probe.style.color = value; host.appendChild(probe); const computed = getComputedStyle(probe).color; probe.remove(); // A 1×1 canvas turns any computed color space (oklch, color(srgb …)) into sRGB bytes. const ctx = document.createElement("canvas").getContext("2d", { willReadFrequently: true }); if (!ctx) return [0, 0, 0]; ctx.fillStyle = "#000"; ctx.fillStyle = computed; ctx.fillRect(0, 0, 1, 1); const [r, g, b] = ctx.getImageData(0, 0, 1, 1).data; return [r, g, b]; } /** * Calls `onChange` when the page theme may have changed: a class or data-theme change on <html> or * <body> (shadcn's `.dark`, next-themes, a data-theme switch) or a change of the OS color scheme. * Resolved colors are cached, so re-resolve them here. Browser only; returns the unsubscribe. */ export function observeTheme(onChange: () => void): () => void { const observer = new MutationObserver(onChange); const options = { attributes: true, attributeFilter: ["class", "data-theme"] }; observer.observe(document.documentElement, options); if (document.body) observer.observe(document.body, options); const scheme = window.matchMedia("(prefers-color-scheme: dark)"); scheme.addEventListener("change", onChange); return () => { observer.disconnect(); scheme.removeEventListener("change", onChange); }; } /** `cssColorToRgb` as a normalized vec3 (each channel 0–1) for shader uniforms. */ export function cssColorToVec3(color: string, element: Element): Vec3 { const [r, g, b] = cssColorToRgb(color, element); return [r / 255, g / 255, b / 255]; } const toLinear = (c: number) => (c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4); const toGamma = (c: number) => (c <= 0.0031308 ? 12.92 * c : 1.055 * c ** (1 / 2.4) - 0.055); /** 0–255 sRGB to OKLCH: lightness 0–1, chroma, hue in radians. */ export function rgbToOklch([r, g, b]: Vec3): Vec3 { const [lr, lg, lb] = [r, g, b].map((v) => toLinear(v / 255)); const l = Math.cbrt(0.4122214708 * lr + 0.5363325363 * lg + 0.0514459929 * lb); const m = Math.cbrt(0.2119034982 * lr + 0.6806995451 * lg + 0.1073969566 * lb); const s = Math.cbrt(0.0883024619 * lr + 0.2817188376 * lg + 0.6299787005 * lb); const L = 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s; const A = 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s; const B = 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s; return [L, Math.hypot(A, B), Math.atan2(B, A)]; } /** OKLCH back to 0–255 sRGB, clamped into gamut. */ export function oklchToRgb([L, C, h]: Vec3): Vec3 { const A = C * Math.cos(h); const B = C * Math.sin(h); const l = (L + 0.3963377774 * A + 0.2158037573 * B) ** 3; const m = (L - 0.1055613458 * A - 0.0638541728 * B) ** 3; const s = (L - 0.0894841775 * A - 1.291485548 * B) ** 3; const rgb = [4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s, -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s, -0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s]; return rgb.map((v) => Math.round(Math.min(1, Math.max(0, toGamma(Math.min(1, Math.max(0, v))))) * 255)) as Vec3; } /** * Mixes two 0–255 colors in OKLCH, taking the short way around the hue circle, so midpoints keep * their chroma instead of turning muddy the way an RGB mix does. */ export function mixOklch(a: Vec3, b: Vec3, t: number): Vec3 { const [L1, C1, h1] = rgbToOklch(a); const [L2, C2, h2] = rgbToOklch(b); // A near-gray end has no meaningful hue: borrow the other end's. const ha = C1 < 0.02 ? h2 : h1; const hb = C2 < 0.02 ? h1 : h2; let dh = hb - ha; if (dh > Math.PI) dh -= Math.PI * 2; if (dh < -Math.PI) dh += Math.PI * 2; return oklchToRgb([L1 + (L2 - L1) * t, C1 + (C2 - C1) * t, ha + dh * t]); }lib/glyph/ascii-life.ts/** * Building blocks for ASCII creatures on a canvas: a grid of glyph cells (taller than wide, like * monospace glyphs), a diffusing wake field, follow-the-leader spines with a bend limit and a * travelling body wave, nearest-point queries in body coordinates, and a cell buffer that is drawn * in two batched passes (shaded blocks grouped by color, then glyphs blitted from a sprite atlas). * * The approach (shaded blocks under density-matched glyphs, supersampled silhouettes, a wake field * that lights the water, glyphs keyed to body coordinates) is learned from ascii-sharks by drewdi, * https://github.com/stackedlol/ascii-sharks (MIT). This file is an independent implementation. */ import type { GlyphAtlas } from "@/lib/glyph/glyph-atlas"; /* ------------------------------------------------------------------ grid */ export interface CellGrid { /** Cell width and height in CSS pixels. */ cw: number; ch: number; cols: number; rows: number; } /** A grid of cells `cell` wide and `cell × aspect` tall covering width × height. */ export function cellGrid(width: number, height: number, cell: number, aspect = 1.45): CellGrid { const cw = Math.max(4, cell); const ch = Math.max(4, Math.round(cw * aspect)); return { cw, ch, cols: Math.max(1, Math.ceil(width / cw)), rows: Math.max(1, Math.ceil(height / ch)) }; } export const cellAt = (g: CellGrid, x: number, y: number) => { const c = Math.floor(x / g.cw); const r = Math.floor(y / g.ch); return c < 0 || r < 0 || c >= g.cols || r >= g.rows ? -1 : r * g.cols + c; }; /* ------------------------------------------------------------------ hashing */ /** Integer hash → [0, 1). Stable, so textures keyed to it don't flicker. */ export function hash3(x: number, y: number, z: number) { let h = Math.imul(x | 0, 0x2c1b3c6d) ^ Math.imul(y | 0, 0x297a2d39) ^ Math.imul(z | 0, 0x7feb352d); h = Math.imul(h ^ (h >>> 15), 0x846ca68b); h ^= h >>> 16; return (h >>> 0) / 4294967296; } /* ------------------------------------------------------------------ wake field */ /** * Energy per cell that things deposit as they swim. Each step it blurs into its neighbors and * decays, so wakes spread like ripples and fade. */ export class WakeField { energy: Float32Array; private tmp: Float32Array; constructor( readonly cols: number, readonly rows: number, ) { this.energy = new Float32Array(cols * rows); this.tmp = new Float32Array(cols * rows); } add(index: number, amount: number, max = 1.6) { if (index >= 0) this.energy[index] = Math.min(max, this.energy[index] + amount); } /** A soft disc of energy around (x, y), `radius` cells wide (rows count wider, cells are tall). */ stamp(g: CellGrid, x: number, y: number, radius: number, amount: number) { const c0 = Math.floor(x / g.cw); const r0 = Math.floor(y / g.ch); const rr = Math.ceil(radius / 1.4); for (let r = r0 - rr; r <= r0 + rr; r++) { for (let c = c0 - radius; c <= c0 + radius; c++) { if (c < 0 || r < 0 || c >= this.cols || r >= this.rows) continue; const d = Math.hypot(c - c0, (r - r0) * 1.4); if (d <= radius + 0.5) this.add(r * this.cols + c, amount * (1 - d / (radius + 1))); } } } /** A thin ring of energy, for splashes and dropped food. */ ring(g: CellGrid, x: number, y: number, radiusPx: number, amount: number) { const steps = Math.max(8, Math.ceil((radiusPx * Math.PI * 2) / g.cw)); for (let i = 0; i < steps; i++) { const a = (i / steps) * Math.PI * 2; this.add(cellAt(g, x + Math.cos(a) * radiusPx, y + Math.sin(a) * radiusPx * 0.9), amount); } } step(dt: number, decayPerSecond = 1.5) { const { cols, rows, energy: e, tmp } = this; const decay = Math.exp(-dt * decayPerSecond); for (let r = 0; r < rows; r++) { for (let c = 0; c < cols; c++) { const i = r * cols + c; const l = c > 0 ? e[i - 1] : e[i]; const rt = c < cols - 1 ? e[i + 1] : e[i]; const u = r > 0 ? e[i - cols] : e[i]; const d = r < rows - 1 ? e[i + cols] : e[i]; tmp[i] = (e[i] * 4 + l + rt + u + d) * 0.125 * decay; } } this.tmp = e; this.energy = tmp; } } /* ------------------------------------------------------------------ spines */ const angleDiff = (a: number, b: number) => { let d = (b - a) % (Math.PI * 2); if (d > Math.PI) d -= Math.PI * 2; if (d < -Math.PI) d += Math.PI * 2; return d; }; /** * Pulls every node after the head to `segment` px behind the one before it, bending at most * `maxBend` radians per joint, so a body curls into a C but never folds back on itself. */ export function followSpine(nodes: Float32Array, heading: number, segment: number, maxBend: number) { let prev = heading + Math.PI; for (let k = 1; k < nodes.length / 2; k++) { const px = nodes[(k - 1) * 2]; const py = nodes[(k - 1) * 2 + 1]; let angle = Math.atan2(nodes[k * 2 + 1] - py, nodes[k * 2] - px); angle = prev + Math.max(-maxBend, Math.min(maxBend, angleDiff(prev, angle))); nodes[k * 2] = px + Math.cos(angle) * segment; nodes[k * 2 + 1] = py + Math.sin(angle) * segment; prev = angle; } } /** A straight spine from (x, y) trailing back from `heading`. */ export function straightSpine(count: number, x: number, y: number, heading: number, length: number) { const nodes = new Float32Array(count * 2); const seg = length / (count - 1); for (let k = 0; k < count; k++) { nodes[k * 2] = x - Math.cos(heading) * seg * k; nodes[k * 2 + 1] = y - Math.sin(heading) * seg * k; } return nodes; } /** * Copies `src` into `out` with a travelling sideways wave whose amplitude grows toward the tail, * fills `arc` with cumulative lengths and returns the total length. */ export function waveSpine(src: Float32Array, out: Float32Array, arc: Float32Array, phase: number, amplitude: number, waves = 5) { const n = src.length / 2; for (let k = 0; k < n; k++) { const s = k / (n - 1); const a = Math.max(0, k - 1); const b = Math.min(n - 1, k + 1); let tx = src[a * 2] - src[b * 2]; let ty = src[a * 2 + 1] - src[b * 2 + 1]; const tl = Math.hypot(tx, ty) || 1; tx /= tl; ty /= tl; const off = amplitude * (0.1 + 0.9 * s ** 1.8) * Math.sin(phase - s * waves); out[k * 2] = src[k * 2] - ty * off; out[k * 2 + 1] = src[k * 2 + 1] + tx * off; } arc[0] = 0; for (let k = 1; k < n; k++) arc[k] = arc[k - 1] + Math.hypot(out[k * 2] - out[(k - 1) * 2], out[k * 2 + 1] - out[(k - 1) * 2 + 1]); return arc[n - 1] || 1; } /** Result of `nearestOnSpine`: position along (s, 0 head → 1 tail), signed offset across (v), and the side's normal. */ export interface SpineHit { s: number; v: number; dist: number; nx: number; ny: number; } /** Nearest point on a spine polyline to (px, py), in body coordinates. */ export function nearestOnSpine(spine: Float32Array, arc: Float32Array, total: number, px: number, py: number, hit: SpineHit) { const n = spine.length / 2; let best = Infinity; for (let k = 0; k < n - 1; k++) { const ax = spine[k * 2]; const ay = spine[k * 2 + 1]; const sx = spine[(k + 1) * 2] - ax; const sy = spine[(k + 1) * 2 + 1] - ay; const len2 = sx * sx + sy * sy || 1; const u = Math.max(0, Math.min(1, ((px - ax) * sx + (py - ay) * sy) / len2)); const qx = ax + sx * u; const qy = ay + sy * u; const d2 = (px - qx) ** 2 + (py - qy) ** 2; if (d2 < best) { best = d2; const len = Math.sqrt(len2); const side = sx * (py - ay) - sy * (px - ax) >= 0 ? 1 : -1; hit.s = (arc[k] + u * len) / total; hit.nx = (-sy / len) * side; hit.ny = (sx / len) * side; hit.v = side; } } hit.dist = Math.sqrt(best); hit.v *= hit.dist; return hit; } /* ------------------------------------------------------------------ cell buffer */ /** Sub-cell sample points (x, y pairs in cell units) for anti-aliased silhouettes. */ export const SUBSAMPLES = [0.25, 0.28, 0.75, 0.3, 0.27, 0.74, 0.74, 0.72]; /** * What to draw in each cell this frame: an optional solid block (an index into a list of fill * styles, 0 = none) and an optional glyph (atlas glyph and color, 255 = none) at an opacity level. */ export class CellBuffer { block: Uint16Array; glyph: Uint8Array; color: Uint8Array; alpha: Uint8Array; /** Which layer last wrote the cell; nearer things overwrite farther ones. */ depth: Float32Array; private order: Int32Array; constructor(readonly size: number) { this.block = new Uint16Array(size); this.glyph = new Uint8Array(size); this.color = new Uint8Array(size); this.alpha = new Uint8Array(size); this.depth = new Float32Array(size); this.order = new Int32Array(size); this.clear(); } clear() { this.block.fill(0); this.glyph.fill(255); this.alpha.fill(255); this.depth.fill(-1); } /** Writes a cell if `depth` is at least the cell's current depth. Alpha is 0–255. */ set(i: number, depth: number, block: number, glyph: number, color: number, alpha = 255) { if (i < 0 || depth < this.depth[i]) return; this.depth[i] = depth; this.block[i] = block; this.glyph[i] = glyph; this.color[i] = color; this.alpha[i] = alpha; } /** * Draws blocks grouped by fill style (one fillStyle change per color), then glyphs from the * atlas (globalAlpha changes only when the level does). The context must be in CSS pixels. */ draw(ctx: CanvasRenderingContext2D, g: CellGrid, styles: readonly string[], atlas: GlyphAtlas | null) { const { block, glyph, color, alpha, order, size } = this; const counts = new Int32Array(styles.length + 1); for (let i = 0; i < size; i++) if (block[i]) counts[block[i]]++; const starts = new Int32Array(styles.length + 1); for (let b = 1; b <= styles.length; b++) starts[b] = starts[b - 1] + counts[b - 1]; const cursor = starts.slice(); for (let i = 0; i < size; i++) if (block[i]) order[cursor[block[i]]++] = i; for (let b = 1; b < styles.length; b++) { if (!counts[b]) continue; ctx.fillStyle = styles[b]; for (let k = starts[b]; k < starts[b] + counts[b]; k++) { const i = order[k]; ctx.fillRect((i % g.cols) * g.cw + 0.5, ((i / g.cols) | 0) * g.ch + 0.5, g.cw - 1, g.ch - 1); } } if (!atlas) return; // Atlas sprites are square (as tall as a cell), so center them in the narrower cell. const ox = (g.cw - atlas.cell) / 2; const oy = (g.ch - atlas.cell) / 2; let current = 255; for (let i = 0; i < size; i++) { if (glyph[i] === 255) continue; if (alpha[i] !== current) { current = alpha[i]; ctx.globalAlpha = current / 255; } atlas.draw(ctx, glyph[i], color[i], (i % g.cols) * g.cw + ox, ((i / g.cols) | 0) * g.ch + oy); } ctx.globalAlpha = 1; } } /* ------------------------------------------------------------------ colors */ /** Parses any CSS color a canvas understands into 0–255 RGB (browser only). */ export function parseColor(color: string): [number, number, number] { const c = document.createElement("canvas").getContext("2d", { willReadFrequently: true }); if (!c) return [0, 0, 0]; c.fillStyle = "#000"; c.fillStyle = color; c.fillRect(0, 0, 1, 1); const [r, g, b] = c.getImageData(0, 0, 1, 1).data; return [r, g, b]; } export const mix3 = (a: readonly number[], b: readonly number[], t: number) => `rgb(${Math.round(a[0] + (b[0] - a[0]) * t)} ${Math.round(a[1] + (b[1] - a[1]) * t)} ${Math.round(a[2] + (b[2] - a[2]) * t)})`;lib/glyph/canvas-loop.ts/** * The render loop every glyph and dither canvas shares: sizes the canvas to its host at a capped * pixel ratio, runs only while the host is on screen, the tab is visible, nothing is paused and * the user hasn't asked for reduced motion, and otherwise keeps one up-to-date still frame. * Framework-free: start it in an effect and dispose it in the cleanup. Browser only. */ export interface CanvasLoopFrame { /** Seconds since the previous frame (capped at 0.05), or 0 for a redraw without advancing. */ dt: number; /** Seconds the loop has been running. */ time: number; /** True under reduced motion: draw a calm, finished-looking frame. */ still: boolean; /** Host size in CSS pixels. */ width: number; height: number; /** Device pixel ratio of the canvas (capped). */ dpr: number; } export interface CanvasLoopOptions { /** Element observed for size and visibility. */ host: HTMLElement; /** Canvas sized to the host times the pixel ratio. */ canvas: HTMLCanvasElement; /** Advance by `frame.dt` and draw. Called every animation frame while running, and with dt 0 after resizes and redraws. */ render: (frame: CanvasLoopFrame) => void; /** Called after the canvas is resized, before it is redrawn. */ resize?: (frame: CanvasLoopFrame) => void; /** Read before every frame; while it returns true the loop stops and the last frame stays. */ isPaused?: () => boolean; /** Highest device pixel ratio used. @default 2 */ maxDpr?: number; /** Margin around the viewport that still counts as on screen. @default "120px" */ rootMargin?: string; } export interface CanvasLoop { /** Re-checks paused, visibility and reduced motion, and starts or stops the loop. Call it when props change. */ wake(): void; /** Draws one frame without advancing (no-op while the loop is running, since the next frame will). */ redraw(): void; /** Stops the loop and removes every observer and listener. */ dispose(): void; } export function runCanvasLoop(options: CanvasLoopOptions): CanvasLoop { const { host, canvas, rootMargin = "120px" } = options; const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)"); let width = 0; let height = 0; let dpr = 1; let raf = 0; let last = 0; let time = 0; let inView = false; let disposed = false; const frame = (dt: number): CanvasLoopFrame => ({ dt, time, still: reducedMotion.matches, width, height, dpr }); const shouldRun = () => !disposed && inView && !document.hidden && !reducedMotion.matches && !options.isPaused?.(); const tick = (now: number) => { raf = 0; if (!shouldRun()) return; const dt = last ? Math.min(0.05, (now - last) / 1000) : 0; last = now; time += dt; options.render(frame(dt)); raf = requestAnimationFrame(tick); }; const wake = () => { if (disposed) return; if (shouldRun()) { if (!raf) { last = 0; raf = requestAnimationFrame(tick); } return; } if (raf) cancelAnimationFrame(raf); raf = 0; }; const redraw = () => { if (!disposed && !raf && width > 0) options.render(frame(0)); }; const resize = () => { width = Math.max(1, host.clientWidth); height = Math.max(1, host.clientHeight); dpr = Math.min(window.devicePixelRatio || 1, options.maxDpr ?? 2); const w = Math.max(1, Math.round(width * dpr)); const h = Math.max(1, Math.round(height * dpr)); if (canvas.width !== w || canvas.height !== h) { canvas.width = w; canvas.height = h; } options.resize?.(frame(0)); // Resizing clears the canvas, so repaint right away even while running. options.render(frame(0)); }; const resizeObserver = new ResizeObserver(resize); const intersection = new IntersectionObserver( ([entry]) => { inView = entry?.isIntersecting ?? true; wake(); }, { rootMargin }, ); const onVisibility = () => wake(); const onMotionChange = () => { wake(); redraw(); }; resize(); resizeObserver.observe(host); intersection.observe(host); document.addEventListener("visibilitychange", onVisibility); reducedMotion.addEventListener("change", onMotionChange); return { wake, redraw, dispose() { disposed = true; if (raf) cancelAnimationFrame(raf); raf = 0; resizeObserver.disconnect(); intersection.disconnect(); document.removeEventListener("visibilitychange", onVisibility); reducedMotion.removeEventListener("change", onMotionChange); }, }; }lib/glyph/glyph-atlas.ts/** * Glyph sprite atlases: every glyph of a ramp is rendered once per color into one canvas, and * cells are blitted from it with `drawImage`. Drawing text per cell per frame (`fillText`) is far * slower, so glyph canvases never do it. Browser only. */ export interface GlyphAtlasOptions { /** Glyphs to pre-render, e.g. `rampGlyphs("classic")`. Spaces are kept as empty sprites. */ glyphs: readonly string[]; /** One tint per color, as any color a canvas accepts. Resolve CSS variables first with `resolveCanvasColors`. */ colors: readonly string[]; /** Size of one square cell in CSS pixels. */ cell: number; /** Device pixel ratio to render for, capped at 2. @default 1 */ dpr?: number; /** Font stack. @default Geist Mono (`--font-geist-mono`), then system monospace */ fontFamily?: string; /** Font weight. @default 500 */ fontWeight?: number | string; /** Glyph size as a share of the cell. @default 0.8 */ fontScale?: number; /** Largest scale a sprite will be drawn at, so enlarged glyphs stay sharp. @default 1 */ maxScale?: number; } export interface GlyphAtlas { readonly canvas: HTMLCanvasElement; readonly glyphs: readonly string[]; readonly colors: readonly string[]; /** Cell size in CSS pixels. */ readonly cell: number; /** Index of a glyph in the atlas, or -1. */ index(glyph: string): number; /** * Blits a glyph in a color with its cell's top-left corner at (x, y), in the context's current * units (CSS pixels when the context is scaled by the device pixel ratio). `scale` grows it * around the cell center. Set `globalAlpha` on the context for faded glyphs. */ draw(ctx: CanvasRenderingContext2D, glyph: number, color: number, x: number, y: number, scale?: number): void; } const FALLBACK_MONO = 'ui-monospace, SFMono-Regular, Menlo, Monaco, "Cascadia Mono", "Roboto Mono", monospace'; const SHADES: Record<string, number> = { "░": 0.25, "▒": 0.5, "▓": 0.75, "█": 1 }; const GUTTER = 1; const MAX_CACHED = 16; const cache = new Map<string, GlyphAtlas>(); /** Geist Mono as set up by the site (`--font-geist-mono`), followed by system monospace fonts. */ export function monoFontFamily(element: Element = document.documentElement): string { const geist = getComputedStyle(element).getPropertyValue("--font-geist-mono").trim(); return geist ? `${geist}, ${FALLBACK_MONO}` : FALLBACK_MONO; } /** * Turns CSS colors a canvas can't parse (`var(--primary)`, `color-mix(…)`, `currentColor`) into * computed `rgb()` strings by styling a hidden probe inside `element`. Plain colors pass through. */ export function resolveCanvasColors(colors: readonly string[], element: Element): string[] { let probe: HTMLSpanElement | null = null; const out = colors.map((color) => { if (!/var\(|color-mix\(|currentcolor/i.test(color)) return color; if (!probe) { probe = document.createElement("span"); probe.style.display = "none"; element.appendChild(probe); } probe.style.color = ""; probe.style.color = color; return getComputedStyle(probe).color || color; }); (probe as HTMLSpanElement | null)?.remove(); return out; } const fontSpec = (options: GlyphAtlasOptions, size: number) => `${options.fontWeight ?? 500} ${size}px ${options.fontFamily ?? FALLBACK_MONO}`; /** Resolves once the atlas font is loaded (or failed to load), so atlases built after it use the real face. */ export async function loadGlyphFont(options: Pick<GlyphAtlasOptions, "glyphs" | "fontFamily" | "fontWeight">): Promise<void> { try { await document.fonts?.load(fontSpec({ ...options, colors: [], cell: 16 }, 16), options.glyphs.join("").replace(/\s/g, "") || "0"); } catch { // A missing font falls back to the system monospace face. } } function createAtlas(options: GlyphAtlasOptions): GlyphAtlas { const { glyphs, colors, cell } = options; const dpr = Math.min(2, Math.max(1, options.dpr ?? 1)); const sprite = Math.max(2, Math.ceil(cell * dpr * Math.max(1, options.maxScale ?? 1))); const stride = sprite + GUTTER * 2; const canvas = document.createElement("canvas"); canvas.width = Math.max(1, glyphs.length * stride); canvas.height = Math.max(1, colors.length * stride); const ctx = canvas.getContext("2d"); if (ctx) { ctx.font = fontSpec(options, Math.round(sprite * (options.fontScale ?? 0.8))); ctx.textAlign = "center"; ctx.textBaseline = "middle"; colors.forEach((color, row) => { ctx.fillStyle = color; glyphs.forEach((glyph, col) => { const x = col * stride + GUTTER; const y = row * stride + GUTTER; const shade = SHADES[glyph]; if (shade === 1) ctx.fillRect(x, y, sprite, sprite); else if (shade) { // Shade blocks as crisp dot screens rather than whatever the font draws. const dots = 4; const size = sprite / dots; for (let dy = 0; dy < dots; dy++) { for (let dx = 0; dx < dots; dx++) { const on = shade === 0.5 ? (dx + dy) % 2 === 0 : shade === 0.25 ? dx % 2 === 0 && dy % 2 === 0 : !(dx % 2 === 1 && dy % 2 === 1); if (on) ctx.fillRect(x + dx * size, y + dy * size, Math.ceil(size), Math.ceil(size)); } } } else if (glyph.trim()) ctx.fillText(glyph, x + sprite / 2, y + sprite / 2 + sprite * 0.04); }); }); } const lookup = new Map(glyphs.map((g, i) => [g, i])); return { canvas, glyphs, colors, cell, index: (glyph) => lookup.get(glyph) ?? -1, draw(target, glyph, color, x, y, scale = 1) { if (glyph < 0 || color < 0 || glyph >= glyphs.length || color >= colors.length) return; const size = cell * scale; const offset = (size - cell) / 2; target.drawImage(canvas, glyph * stride + GUTTER, color * stride + GUTTER, sprite, sprite, x - offset, y - offset, size, size); }, }; } /** * Returns a glyph atlas for these options, building it on first use. Atlases are shared between * components with identical options. Once the font has loaded, the next call builds a fresh atlas * with the real face instead of reusing one drawn with the fallback. */ export function getGlyphAtlas(options: GlyphAtlasOptions): GlyphAtlas { const dpr = Math.min(2, Math.max(1, options.dpr ?? 1)); const fontReady = typeof document === "undefined" || !document.fonts ? true : document.fonts.check(fontSpec(options, 16)); const key = [options.glyphs.join(""), options.colors.join("|"), options.cell, dpr, options.fontFamily, options.fontWeight, options.fontScale, options.maxScale, fontReady].join("§"); const hit = cache.get(key); if (hit) { cache.delete(key); cache.set(key, hit); return hit; } const atlas = createAtlas({ ...options, dpr }); cache.set(key, atlas); while (cache.size > MAX_CACHED) cache.delete(cache.keys().next().value as string); return atlas; }Copy the source
components/ui/dither-wildlife/dither-wildlife.tsx"use client"; import { useEffect, useRef, useState, type ComponentProps, type ReactNode } from "react"; import { observeTheme } from "@/lib/color"; import { cn } from "@/lib/utils"; import { runCanvasLoop, type CanvasLoop } from "@/lib/glyph/canvas-loop"; import { getGlyphAtlas, loadGlyphFont, monoFontFamily, resolveCanvasColors, type GlyphAtlas } from "@/lib/glyph/glyph-atlas"; import { CellBuffer, cellAt, cellGrid, WakeField, type CellGrid } from "@/lib/glyph/ascii-life"; export type DitherWildlifeScene = "horses" | "birds" | "grove"; export type DitherWildlifeLook = "ascii" | "dither"; export interface DitherWildlifeProps extends Omit<ComponentProps<"div">, "children"> { /** Content above the scene, e.g. a hero. */ children?: ReactNode; /** What runs, flies or sways across the canvas. @default "horses" */ scene?: DitherWildlifeScene; /** `ascii` draws shaded glyph cells (tone blocks under glyphs that thicken in shadow); `dither` draws crisp dithered pixels. @default "ascii" */ look?: DitherWildlifeLook; /** Cell size in px: a glyph cell's width in `ascii` (cells are 1.45 times as tall), one pixel in `dither`. @default 8 in ascii, 5 in dither */ cell?: number; /** Ink tones from light to dark; shading is dithered between them. @default four grays */ tones?: string[]; /** Paper color behind the scene (any CSS color, or "transparent"). @default "#ffffff" */ background?: string; /** How many horses, birds or trees. @default 3, 26 or 8 by scene */ count?: number; /** Speed multiplier. @default 1 */ speed?: number; /** Birds scatter from the pointer, trees bend away from it, and in `ascii` it stirs up dust. @default true */ interactive?: boolean; /** Freezes the scene. @default false */ paused?: boolean; } const DEFAULT_TONES = ["#c6c6c6", "#9c9c9c", "#6f6f6f", "#474747"]; const TAU = Math.PI * 2; 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 hash = (n: number) => { let h = Math.imul(n | 0, 2654435761) ^ 0x9e3779b9; h = Math.imul(h ^ (h >>> 15), 2246822507); return ((h ^ (h >>> 13)) >>> 0) / 4294967296; }; /** Darkness 0 (paper) to 1 (darkest ink), lightened with distance. */ const ink = (d: number, fade = 0) => { const g = Math.round(255 * (1 - Math.max(0, Math.min(1, d * (1 - fade))))); return `rgb(${g},${g},${g})`; }; /** Like `ink`, but marked (blue channel at full) as ground or shadow, which ascii draws as texture, not blocks. */ const groundInk = (d: number, fade = 0) => { const g = Math.round(255 * (1 - Math.max(0, Math.min(1, d * (1 - fade))))); return `rgb(${g},${g},255)`; }; const parse = (color: string) => { const c = document.createElement("canvas").getContext("2d"); if (!c) return [0, 0, 0]; c.fillStyle = color; c.fillRect(0, 0, 1, 1); const [r, g, b] = c.getImageData(0, 0, 1, 1).data; return [r, g, b]; }; /* ------------------------------------------------------------------ horses */ interface Horse { x: number; z: number; phase: number; speed: number; seed: number; } /** A galloping horse facing left, drawn in body units (about 4 wide, 3 tall) around its barrel. */ function drawHorse(ctx: CanvasRenderingContext2D, phase: number, fade: number) { const bob = -0.1 * Math.sin(phase * 2); const pitch = 0.05 * Math.sin(phase * 2 + 1); ctx.save(); ctx.translate(0, bob); ctx.rotate(pitch); ctx.lineCap = "round"; ctx.lineJoin = "round"; // Legs: rotary gallop, far legs paler and drawn first. const legs = [ { hip: [0.72, 0.18], front: false, p: 0, far: true }, { hip: [-0.62, 0.22], front: true, p: 1.7, far: true }, { hip: [0.7, 0.2], front: false, p: 0.7, far: false }, { hip: [-0.66, 0.24], front: true, p: 2.4, far: false }, ]; const leg = (l: (typeof legs)[number]) => { const s = Math.sin(phase + l.p); const swing = Math.max(0, Math.sin(phase + l.p + 1.5)) ** 1.4; const a = l.front ? 0.2 + 0.75 * s : -0.15 + 0.7 * s; const b = l.front ? a - 1.5 * swing : a - 0.45 - 0.9 * swing; const [hx, hy] = l.hip; const kx = hx - 0.6 * Math.sin(a); const ky = hy + 0.6 * Math.cos(a); const fx = kx - 0.62 * Math.sin(b); const fy = ky + 0.62 * Math.cos(b); const d = l.far ? 0.42 : 0.62; ctx.strokeStyle = ink(d, fade); ctx.lineWidth = 0.24; ctx.beginPath(); ctx.moveTo(hx, hy); ctx.lineTo(kx, ky); ctx.stroke(); ctx.lineWidth = 0.13; ctx.beginPath(); ctx.moveTo(kx, ky); ctx.lineTo(fx, fy); ctx.stroke(); ctx.fillStyle = ink(d + 0.25, fade); ctx.beginPath(); ctx.arc(fx, fy, 0.075, 0, TAU); ctx.fill(); }; legs.filter((l) => l.far).forEach(leg); // Tail, streaming back. const wave = Math.sin(phase * 2 + 0.6); ctx.strokeStyle = ink(0.72, fade); ctx.lineWidth = 0.22; ctx.beginPath(); ctx.moveTo(1.0, -0.28); ctx.quadraticCurveTo(1.45, -0.35 + wave * 0.1, 1.8, 0.05 + wave * 0.18); ctx.stroke(); ctx.lineWidth = 0.12; ctx.beginPath(); ctx.moveTo(1.05, -0.2); ctx.quadraticCurveTo(1.5, -0.12 + wave * 0.12, 1.72, 0.28 + wave * 0.15); ctx.stroke(); // Body: barrel, chest and quarters, lit from above. const body = ctx.createLinearGradient(0, -0.5, 0, 0.5); body.addColorStop(0, ink(0.3, fade)); body.addColorStop(0.5, ink(0.5, fade)); body.addColorStop(1, ink(0.82, fade)); ctx.fillStyle = body; ctx.beginPath(); ctx.ellipse(0, 0, 0.95, 0.42, 0, 0, TAU); ctx.moveTo(-0.32, 0.02); ctx.arc(-0.72, 0.02, 0.4, 0, TAU); ctx.moveTo(1.15, -0.05); ctx.arc(0.7, -0.05, 0.45, 0, TAU); ctx.fill(); // Neck and head. const neck = ctx.createLinearGradient(-1.4, -1, -0.6, 0.2); neck.addColorStop(0, ink(0.36, fade)); neck.addColorStop(1, ink(0.68, fade)); ctx.fillStyle = neck; ctx.beginPath(); ctx.moveTo(-0.45, -0.4); ctx.quadraticCurveTo(-0.95, -0.75, -1.18, -1.02); ctx.lineTo(-1.36, -1.08); ctx.quadraticCurveTo(-1.62, -0.9, -1.9, -0.56); ctx.quadraticCurveTo(-1.98, -0.4, -1.86, -0.33); ctx.quadraticCurveTo(-1.66, -0.38, -1.48, -0.55); ctx.quadraticCurveTo(-1.3, -0.25, -1.05, 0.2); ctx.lineTo(-0.6, 0.2); ctx.closePath(); ctx.fill(); // Muzzle, eye and ear in deeper ink. ctx.fillStyle = ink(0.8, fade); ctx.beginPath(); ctx.ellipse(-1.84, -0.43, 0.08, 0.07, 0.5, 0, TAU); ctx.fill(); ctx.beginPath(); ctx.arc(-1.4, -0.88, 0.06, 0, TAU); ctx.fill(); ctx.fillStyle = ink(0.6, fade); ctx.beginPath(); ctx.moveTo(-1.2, -1.02); ctx.lineTo(-1.14, -1.28); ctx.lineTo(-1.32, -1.08); ctx.closePath(); ctx.fill(); // Mane, flicking with the stride. ctx.strokeStyle = ink(0.74, fade); ctx.lineWidth = 0.16; ctx.beginPath(); ctx.moveTo(-1.22, -1.0); for (let i = 1; i <= 6; i++) { const t = i / 6; const mx = -1.22 + t * 0.85; const my = -1.0 + t * 0.62 + Math.sin(phase * 2 + i) * 0.05; ctx.lineTo(mx + 0.06, my - 0.1); ctx.lineTo(mx, my); } ctx.stroke(); // A soft highlight across the back and neck, like sun on the coat. const sheen = ctx.createRadialGradient(-0.4, -0.42, 0, -0.4, -0.42, 0.9); sheen.addColorStop(0, "rgba(255,255,255,0.9)"); sheen.addColorStop(1, "rgba(255,255,255,0)"); ctx.fillStyle = sheen; ctx.beginPath(); ctx.ellipse(-0.3, -0.25, 1.0, 0.45, -0.2, 0, TAU); ctx.fill(); legs.filter((l) => !l.far).forEach(leg); ctx.restore(); } /* ------------------------------------------------------------------- birds */ interface Bird { /** Place in the flock, relative to its wandering center. */ ox: number; oy: number; x: number; y: number; vx: number; vy: number; z: number; flap: number; rate: number; glide: number; } /** A gull seen from the side and below: an "M" of wings around a small body, about 2 units wide. */ function drawBird(ctx: CanvasRenderingContext2D, flap: number, fade: number) { const up = Math.cos(flap); ctx.fillStyle = ink(0.78, fade); for (const side of [-1, 1]) { const ex = side * 0.42; const ey = -0.18 - 0.32 * up; const tx = side * 1.0; const ty = 0.02 - 0.62 * up; ctx.beginPath(); ctx.moveTo(0, -0.04); ctx.quadraticCurveTo(side * 0.2, ey - 0.06, ex, ey); ctx.quadraticCurveTo(side * 0.72, ey - 0.02, tx, ty); ctx.quadraticCurveTo(side * 0.7, ey + 0.16, ex, ey + 0.14); ctx.quadraticCurveTo(side * 0.2, ey + 0.12, 0, 0.12); ctx.closePath(); ctx.fill(); } ctx.beginPath(); ctx.ellipse(0, 0.06, 0.14, 0.1, 0, 0, TAU); ctx.fill(); } /* ------------------------------------------------------------------- trees */ interface Tree { kind: "round" | "pine"; x: number; z: number; h: number; lean: number; seed: number; blobs: { x: number; y: number; r: number }[]; } function makeTree(x: number, z: number, seed: number): Tree { const blobs: Tree["blobs"] = []; const n = 7 + Math.floor(hash(seed) * 5); for (let i = 0; i < n; i++) { const a = hash(seed * 7 + i) * TAU; const d = Math.sqrt(hash(seed * 13 + i)) * 0.42; blobs.push({ x: Math.cos(a) * d * 1.3, y: -0.78 + Math.sin(a) * d * 0.8, r: 0.2 + hash(seed * 3 + i) * 0.17 }); } return { kind: hash(seed * 17) > 0.6 ? "pine" : "round", x, z, h: 0.7 + hash(seed * 5) * 0.5, lean: (hash(seed * 11) - 0.5) * 0.12, seed, blobs }; } /** A tree 1 unit tall standing at the origin; `sway` bends the crown sideways. */ function drawTree(ctx: CanvasRenderingContext2D, tree: Tree, sway: number, fade: number) { ctx.lineCap = "round"; if (tree.kind === "pine") { // Stacked tiers, each bending a little more toward the top. ctx.strokeStyle = ink(0.78, fade); ctx.lineWidth = 0.06; ctx.beginPath(); ctx.moveTo(0, 0); ctx.lineTo(sway * 0.4, -0.3); ctx.stroke(); for (let i = 0; i < 4; i++) { const y = -0.22 - i * 0.26; const w = 0.42 - i * 0.08; const bend = sway * (0.5 + i * 0.35); const g = ctx.createLinearGradient(-w + bend, 0, w + bend, 0); g.addColorStop(0, ink(0.3, fade)); g.addColorStop(1, ink(0.72, fade)); ctx.fillStyle = g; ctx.beginPath(); ctx.moveTo(-w + bend * 0.8, y); ctx.lineTo(bend * 1.2, y - 0.42); ctx.lineTo(w + bend * 0.8, y); ctx.closePath(); ctx.fill(); } return; } const top = { x: tree.lean + sway * 0.6, y: -0.62 }; ctx.strokeStyle = ink(0.78, fade); ctx.lineWidth = 0.075; ctx.beginPath(); ctx.moveTo(0, 0); ctx.quadraticCurveTo(tree.lean * 0.5, -0.3, top.x, top.y); ctx.stroke(); ctx.lineWidth = 0.035; for (const b of tree.blobs.slice(0, 4)) { ctx.beginPath(); ctx.moveTo(top.x * 0.6, -0.42); ctx.lineTo(b.x * 0.7 + sway, b.y * 0.9); ctx.stroke(); } for (const b of tree.blobs) { const bend = sway * (0.6 + -b.y * 0.6); const cx = b.x + bend; const g = ctx.createRadialGradient(cx - b.r * 0.4, b.y - b.r * 0.45, b.r * 0.1, cx, b.y, b.r); g.addColorStop(0, ink(0.2, fade)); g.addColorStop(0.7, ink(0.48, fade)); g.addColorStop(1, ink(0.66, fade)); ctx.fillStyle = g; ctx.beginPath(); ctx.arc(cx, b.y, b.r, 0, TAU); ctx.fill(); } } /* --------------------------------------------------------------- component */ /** Glyphs from sparse to dense: shading picks one, dithered between neighbors. */ const RAMP = [..." .:;=+t%S#8@"]; const SLOPES = ["-", "\\", "|", "/"]; const DOTS = [".", ",", "'", "`", ":"]; const BIRD = { up: ["\\", "v", "/"], mid: ["~", "v", "~"], down: ["/", "v", "\\"] }; const GLYPHS = [...new Set([...RAMP, ...SLOPES, ...DOTS, "^", "~", "v", "-"])]; const G = Object.fromEntries(GLYPHS.map((g, i) => [g, i])) as Record<string, number>; /** A line glyph (- \ | /) for a direction in radians, y down. */ const slope = (angle: number) => G[SLOPES[((Math.round(angle / (Math.PI / 4)) % 4) + 4) % 4]]; /** Sub-cell sample points: two across, three down (cells are taller than wide). */ const SX = [0.25, 0.75]; const SY = [1 / 6, 0.5, 5 / 6]; /** Block shades blended along the tones, so bodies shade smoothly. */ const SHADES = 12; /** Body paint lighter than this still gets a block, at the lightest shade. */ const BLOCK_MIN = 0.06; /** * Wildlife in a handful of gray inks: horses galloping past, a flock of birds wheeling (and * scattering from the pointer), or a grove of trees swaying in the wind. Shapes are shaded softly * into a small buffer, then drawn as ASCII: each glyph cell is sub-sampled, covered cells become a * tone block under a glyph that thickens in shadow (both ordered-dithered between levels), partly * covered cells become edge glyphs that follow the silhouette, birds and leaves are glyph sprites, * and hooves kick up dust that drifts and fades. `look="dither"` draws dithered pixels instead. */ export function DitherWildlife({ children, scene = "horses", look = "ascii", cell, tones = DEFAULT_TONES, background = "#ffffff", count, speed = 1, interactive = true, paused = false, className, style, ...props }: DitherWildlifeProps) { const rootRef = useRef<HTMLDivElement>(null); const canvasRef = useRef<HTMLCanvasElement>(null); const live = useRef({ speed, interactive, paused, loop: null as CanvasLoop | null }); useEffect(() => { live.current.speed = speed; live.current.interactive = interactive; live.current.paused = paused; live.current.loop?.wake(); }); const toneKey = tones.join(","); // A palette made of theme variables is re-resolved after a theme switch. const themed = /var\(|color-mix\(|currentcolor/i.test(toneKey); const [themeKey, setThemeKey] = useState(0); useEffect(() => (themed ? observeTheme(() => setThemeKey((k) => k + 1)) : undefined), [themed]); useEffect(() => { const root = rootRef.current; const canvas = canvasRef.current; const ctx = canvas?.getContext("2d"); const low = document.createElement("canvas"); const lctx = low.getContext("2d", { willReadFrequently: true }); const out = document.createElement("canvas"); const octx = out.getContext("2d"); if (!root || !canvas || !ctx || !lctx || !octx) return; const state = live.current; const ascii = look === "ascii"; const cellPx = Math.max(ascii ? 5 : 2, Math.round(cell ?? (ascii ? 8 : 5))); const toneList = toneKey.split(","); const inks = toneList.map(parse); const levels = inks.length; const fontFamily = monoFontFamily(root); // Glyphs sit two tones darker than their block; the darkest blocks get a deeper ink of their own. const deepest = inks[levels - 1].map((v) => Math.round(v * 0.55)); const atlasColors = resolveCanvasColors([...toneList, `rgb(${deepest.join(",")})`], root); const blockStyles = [""]; for (let k = 0; k < SHADES; k++) { const t = (k / (SHADES - 1)) * (levels - 1); const a = inks[Math.floor(t)]; const b = inks[Math.min(levels - 1, Math.floor(t) + 1)]; const f = t - Math.floor(t); blockStyles.push(`rgb(${a.map((v, j) => Math.round(v + (b[j] - v) * f)).join(",")})`); } const dustStyles = [0.3, 0.45, 0.6, 0.75].map((a) => `rgba(${inks[Math.min(1, levels - 1)].join(",")},${a})`); /** CSS px per buffer texel; the scenes are painted in texels. */ let unit = cellPx; let cols = 0; let rows = 0; let width = 1; let height = 1; let dpr = 1; let time = 0; let disposed = false; let grid: CellGrid = cellGrid(1, 1, cellPx); let cells = new CellBuffer(1); let dust = new WakeField(1, 1); let cover = new Float32Array(1); let dark = new Float32Array(1); let ground = new Uint8Array(1); let sampleX = new Int32Array(0); let sampleY = new Int32Array(0); let atlas: GlyphAtlas | null = null; const pointer = { x: -1e4, y: -1e4, on: false, moved: 0 }; const n = count ?? (scene === "horses" ? 3 : scene === "birds" ? 26 : 8); let horses: Horse[] = []; let birds: Bird[] = []; let trees: Tree[] = []; let leaves: { x: number; y: number; vy: number; s: number }[] = []; /** Horses run on an endless loop this long, so they stay evenly spaced. */ const loopLength = () => cols + rows * 0.24 * 5; const seedScene = () => { if (scene === "horses") { horses = Array.from({ length: n }, (_, i) => ({ x: cols * 0.3 + (i * loopLength()) / n, z: 1 - ((i * 0.618) % 1) * 0.6, phase: hash(i + 3) * TAU, speed: 0.9 + hash(i + 9) * 0.25, seed: i, })); birds = Array.from({ length: 5 }, (_, i) => ({ ox: 0, oy: 0, x: cols * hash(i + 40), y: rows * (0.1 + hash(i + 50) * 0.18), vx: -0.9 - hash(i) * 0.3, vy: 0, z: 0.25, flap: hash(i + 7) * TAU, rate: 9, glide: 0 })); } else if (scene === "birds") { birds = Array.from({ length: n }, (_, i) => { const z = 0.25 + hash(i + 11) * 0.75; return { ox: (hash(i + 31) - 0.5) * 0.7, oy: (hash(i + 32) - 0.5) * 0.45, x: cols * hash(i + 1), y: rows * (0.15 + hash(i + 2) * 0.6), vx: 1.6 + hash(i + 3), vy: (hash(i + 4) - 0.5) * 0.6, z, flap: hash(i + 5) * TAU, rate: 7 + hash(i + 6) * 4, glide: 0 }; }); } else { trees = Array.from({ length: n }, (_, i) => makeTree(cols * ((i + 0.3 + (hash(i + 21) - 0.5) * 0.6) / n), 0.3 + ((i * 0.382) % 1) * 0.7, i + 1)); trees.sort((a, b) => a.z - b.z); birds = Array.from({ length: 4 }, (_, i) => ({ ox: 0, oy: 0, x: cols * hash(i + 60), y: rows * (0.08 + hash(i + 70) * 0.15), vx: 1.1 + hash(i) * 0.4, vy: 0, z: 0.25, flap: hash(i + 8) * TAU, rate: 9, glide: 0 })); leaves = Array.from({ length: 14 }, (_, i) => ({ x: cols * hash(i + 80), y: rows * hash(i + 90), vy: 0.2 + hash(i + 95) * 0.3, s: hash(i + 99) })); } }; /** Draw the shaded grayscale scene into the low-res buffer. In ascii, birds and leaves become glyphs later. */ const paint = () => { lctx.setTransform(1, 0, 0, 1, 0, 0); lctx.fillStyle = "#fff"; lctx.fillRect(0, 0, cols, rows); const ground = rows * 0.92; if (scene === "horses") { const order = [...horses].sort((a, b) => a.z - b.z); for (const h of order) { const s = rows * 0.2 * (0.45 + 0.75 * h.z); const gy = ground - (1 - h.z) * rows * 0.28; const fade = (1 - h.z) * 0.55; // A soft shadow on the ground. lctx.setTransform(1, 0, 0, 1, 0, 0); lctx.fillStyle = groundInk(0.16, fade); lctx.beginPath(); lctx.ellipse(h.x, gy, s * 1.3, s * 0.12, 0, 0, TAU); lctx.fill(); lctx.setTransform(s, 0, 0, s, h.x, gy - s * 1.25); drawHorse(lctx, h.phase, fade); } } if (scene === "grove") { lctx.setTransform(1, 0, 0, 1, 0, 0); const field = lctx.createLinearGradient(0, rows * 0.7, 0, rows); field.addColorStop(0, groundInk(0.08)); field.addColorStop(1, groundInk(0.3)); lctx.fillStyle = field; lctx.fillRect(0, rows * 0.74, cols, rows); for (const t of trees) { const s = rows * (0.16 + t.z * 0.24) * t.h; const base = rows * (0.74 + t.z * 0.2); const near = pointer.on ? Math.exp(-(((pointer.x - t.x) / (cols * 0.18)) ** 2)) * Math.sign(t.x - pointer.x || 1) : 0; const sway = Math.sin(time * 0.9 + t.seed * 1.7) * 0.05 + Math.sin(time * 2.3 + t.seed) * 0.015 + near * 0.12; lctx.setTransform(s, 0, 0, s, t.x, base); drawTree(lctx, t, sway, (1 - t.z) * 0.6); } if (!ascii) { lctx.setTransform(1, 0, 0, 1, 0, 0); lctx.fillStyle = ink(0.7); for (const l of leaves) lctx.fillRect(Math.round(l.x), Math.round(l.y), 1, 1); } } if (ascii) return; for (const b of birds) { const s = rows * (scene === "birds" ? 0.02 + b.z * 0.04 : 0.022); const tilt = Math.max(-0.5, Math.min(0.5, (b.vy / (Math.abs(b.vx) + 1)) * 0.5)) * Math.sign(b.vx || 1); lctx.setTransform(s, 0, 0, s, b.x, b.y); lctx.rotate(tilt); drawBird(lctx, b.glide > 0 ? 1.2 : b.flap, (1 - b.z) * 0.55); } }; /** Ordered-dither the buffer into the inks and blit it, pixel-crisp, to the screen. */ const presentDither = () => { const src = lctx.getImageData(0, 0, cols, rows); const dst = octx.createImageData(cols, rows); for (let y = 0; y < rows; y++) { for (let x = 0; x < cols; x++) { const i = (y * cols + x) * 4; const v = (1 - src.data[i] / 255) * levels; let level = Math.floor(v); if (v - level > BAYER[(y & 3) * 4 + (x & 3)]) level++; if (level <= 0) continue; const [r, g, b] = inks[Math.min(levels, level) - 1]; dst.data[i] = r; dst.data[i + 1] = g; dst.data[i + 2] = b; dst.data[i + 3] = 255; } } octx.putImageData(dst, 0, 0); ctx.setTransform(1, 0, 0, 1, 0, 0); ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.imageSmoothingEnabled = false; ctx.drawImage(out, 0, 0, cols * cellPx * dpr, rows * cellPx * dpr); }; /** Sub-sample every glyph cell, then draw tone blocks, glyphs, edges, sprites and dust. */ const presentAscii = () => { const img = lctx.getImageData(0, 0, cols, rows).data; const { cols: gc, rows: gr } = grid; for (let r = 0; r < gr; r++) { for (let c = 0; c < gc; c++) { let hits = 0; let sum = 0; let groundHits = 0; for (let a = 0; a < SY.length; a++) { const y = sampleY[r * SY.length + a]; for (let b = 0; b < SX.length; b++) { const x = sampleX[c * SX.length + b]; if (x < 0 || y < 0) continue; const k = (y * cols + x) * 4; const d = 1 - img[k] / 255; if (d > 0.05) { hits++; sum += d; if (img[k + 2] - img[k] > 12) groundHits++; } } } const i = r * gc + c; cover[i] = hits / (SX.length * SY.length); dark[i] = hits ? sum / hits : 0; ground[i] = groundHits * 2 > hits ? 1 : 0; } } cells.clear(); for (let r = 0; r < gr; r++) { for (let c = 0; c < gc; c++) { const i = r * gc + c; const cov = cover[i]; if (!cov) continue; const d = dark[i]; const bayer = BAYER[(r & 3) * 4 + (c & 3)]; if (cov < 0.6 && !ground[i]) { // A partly covered cell: a light glyph along the silhouette, no block. const gx = (c + 1 < gc ? cover[i + 1] : 0) - (c > 0 ? cover[i - 1] : 0); const gy = (r + 1 < gr ? cover[i + gc] : 0) - (r > 0 ? cover[i - gc] : 0); const strong = Math.hypot(gx, gy) > 0.35; const glyph = strong ? slope(Math.atan2(gy / grid.ch, gx / grid.cw) + Math.PI / 2) : G[DOTS[(c * 7 + r * 3) % DOTS.length]]; cells.set(i, 1, 0, glyph, Math.min(levels, d > 0.45 ? levels - 1 : 1), Math.round(110 + cov * 200)); continue; } // The glyph is ordered-dithered between its two nearest densities. const gf = d * (RAMP.length - 1); const gi = Math.min(RAMP.length - 1, Math.floor(gf) + (gf - Math.floor(gf) > bayer ? 1 : 0)); if (ground[i]) { // Ground and shadows: a sparse glyph texture with no block. if (gi > 0) cells.set(i, 1, 0, G[RAMP[Math.min(gi, 4)]], Math.min(levels - 1, 2), 230); continue; } const shade = Math.min(1, Math.max(0, (d - BLOCK_MIN) / (1 - BLOCK_MIN)) ** 0.7); cells.set(i, 1, 1 + Math.round(shade * (SHADES - 1)), G[RAMP[Math.max(1, gi)]], Math.min(levels, Math.round(shade * (levels - 1)) + 2)); } } // Birds as glyph sprites: one cell when far or small, three when near. for (const b of birds) { const span = (2 * rows * (scene === "birds" ? 0.02 + b.z * 0.04 : 0.022) * unit) / grid.cw; const pose = b.glide > 0 ? "mid" : Math.cos(b.flap) > 0.35 ? "up" : Math.cos(b.flap) < -0.35 ? "down" : "mid"; const tone = Math.max(1, Math.min(levels, Math.round(1 + b.z * (levels - 1)))); const x = b.x * unit; const y = b.y * unit; if (span < 2.4) cells.set(cellAt(grid, x, y), 2, 0, G[pose === "up" ? "v" : pose === "down" ? "^" : "-"], tone); else BIRD[pose].forEach((g, k) => cells.set(cellAt(grid, x + (k - 1) * grid.cw, y), 2, 0, G[g], tone)); } for (const l of leaves) cells.set(cellAt(grid, l.x * unit, l.y * unit), 2, 0, G[DOTS[Math.floor(time * 3 + l.s * 10) % 3]], Math.min(levels, 2)); ctx.setTransform(1, 0, 0, 1, 0, 0); ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.setTransform(dpr, 0, 0, dpr, 0, 0); // Dust lights small dots in empty cells and fades. const e = dust.energy; const size = Math.max(2, Math.round(grid.cw * 0.28)); for (let level = 0; level < dustStyles.length; level++) { ctx.fillStyle = dustStyles[level]; for (let i = 0; i < e.length; i++) { if (e[i] < 0.14 || cells.depth[i] >= 0) continue; if (Math.min(dustStyles.length - 1, Math.floor((e[i] - 0.14) * 5)) !== level) continue; ctx.fillRect(Math.round((i % grid.cols) * grid.cw + (grid.cw - size) / 2), Math.round(((i / grid.cols) | 0) * grid.ch + grid.ch * 0.6), size, size); } } cells.draw(ctx, grid, blockStyles, atlas); }; const step = (dt: number) => { const k = state.speed; time += dt * k; if (scene === "horses") { for (const h of horses) { const s = rows * 0.2 * (0.45 + 0.75 * h.z); h.phase += dt * k * 6.2 * h.speed; h.x -= dt * k * s * 1.5 * h.speed; if (h.x < -rows * 0.24 * 2.5) { h.x += loopLength(); h.z = 0.4 + hash(h.seed * 3 + Math.floor(time)) * 0.6; } if (ascii) { // Hooves kick up dust behind each stride. const gy = rows * 0.92 - (1 - h.z) * rows * 0.28; const kick = Math.max(0, Math.sin(h.phase * 2)) * h.z; dust.stamp(grid, (h.x + s * (0.7 + hash(Math.floor(time * 9) + h.seed) * 0.6)) * unit, (gy - s * 0.05) * unit, 1, 0.09 * kick * k); } } } if (scene === "grove") { for (const l of leaves) { l.y += dt * k * l.vy * 6; l.x += dt * k * Math.sin(time * 2 + l.s * 9) * 2; if (l.y > rows) { l.y = rows * 0.2; l.x = cols * hash(l.s * 100 + time); } } } const flock = scene === "birds"; const cx = cols * (0.5 + 0.3 * Math.sin(time * 0.23)); const cy = rows * (0.4 + 0.18 * Math.sin(time * 0.37 + 1)); for (const b of birds) { if (flock) { // Drift toward a wandering point, keep some room, and scatter from the pointer. // Each bird keeps its own place in a loose, slowly turning formation. const turn = time * 0.15; const tx = cx + (b.ox * Math.cos(turn) - b.oy * Math.sin(turn) * 0.5) * cols; const ty = cy + (b.oy * Math.cos(turn) + b.ox * Math.sin(turn) * 0.3) * rows; let ax = (tx - b.x) * 0.02; let ay = (ty - b.y) * 0.03; const room = (rows * 0.06) ** 2; for (const o of birds) { if (o === b) continue; const dx = b.x - o.x; const dy = b.y - o.y; const d2 = dx * dx + dy * dy; if (d2 < room && d2 > 0) { ax += (dx / d2) * room * 0.08; ay += (dy / d2) * room * 0.08; } } if (state.interactive && pointer.on) { const dx = b.x - pointer.x; const dy = b.y - pointer.y; const d2 = dx * dx + dy * dy; if (d2 < (rows * 0.3) ** 2) { ax += (dx / Math.sqrt(d2 + 1)) * 6; ay += (dy / Math.sqrt(d2 + 1)) * 6; } } b.vx += ax * dt * 4; b.vy += ay * dt * 4; const v = Math.hypot(b.vx, b.vy); const max = 7 + b.z * 6; if (v > max) { b.vx *= max / v; b.vy *= max / v; } b.x += b.vx * dt * k * 2; b.y += b.vy * dt * k * 2; } else { b.x += b.vx * dt * k * 6; b.y += Math.sin(time * 1.3 + b.flap) * dt * 0.8; if (b.x < -10) b.x = cols + 10; if (b.x > cols + 10) b.x = -10; } // In the flock, each wingbeat leaves a faint trail in the air. if (ascii && flock) dust.add(cellAt(grid, b.x * unit, b.y * unit), 0.05 + b.z * 0.05); // Flap in bursts, then glide a moment. if (b.glide > 0) b.glide -= dt; else { b.flap += dt * k * b.rate; if (hash(Math.floor(time * 3) + b.rate * 100) > 0.985) b.glide = 0.6; } } if (ascii) { // The pointer stirs a little dust as it moves. if (state.interactive && pointer.on && time - pointer.moved < 0.15) dust.stamp(grid, pointer.x * unit, pointer.y * unit, 1, 0.12); dust.step(dt, 1.6); } }; const buildAtlas = () => { if (ascii) atlas = getGlyphAtlas({ glyphs: GLYPHS, colors: atlasColors, cell: grid.ch, dpr, fontFamily, fontWeight: 600, fontScale: 0.74 }); }; const loop = runCanvasLoop({ host: root, canvas, isPaused: () => state.paused, resize: (frame) => { width = frame.width; height = frame.height; dpr = frame.dpr; if (ascii) { const next = cellGrid(width, height, cellPx); if (next.cols !== grid.cols || next.rows !== grid.rows) { cells = new CellBuffer(next.cols * next.rows); dust = new WakeField(next.cols, next.rows); cover = new Float32Array(next.cols * next.rows); dark = new Float32Array(next.cols * next.rows); ground = new Uint8Array(next.cols * next.rows); } grid = next; unit = grid.cw / 2; } const first = cols === 0; cols = low.width = out.width = Math.ceil(width / unit); rows = low.height = out.height = Math.ceil(height / unit); if (ascii) { // Which buffer texel each sub-sample of each cell reads. sampleX = Int32Array.from({ length: grid.cols * SX.length }, (_, k) => { const x = Math.floor(((Math.floor(k / SX.length) + SX[k % SX.length]) * grid.cw) / unit); return x < cols ? x : -1; }); sampleY = Int32Array.from({ length: grid.rows * SY.length }, (_, k) => { const y = Math.floor(((Math.floor(k / SY.length) + SY[k % SY.length]) * grid.ch) / unit); return y < rows ? y : -1; }); buildAtlas(); } if (first) { seedScene(); // A still frame shows the flock already gathered. if (frame.still) for (let i = 0; i < 240; i++) step(1 / 60); } }, render: ({ dt }) => { if (dt > 0) step(dt); paint(); if (ascii) presentAscii(); else presentDither(); }, }); state.loop = loop; const onMove = (e: PointerEvent) => { const r = root.getBoundingClientRect(); pointer.x = (e.clientX - r.left) / unit; pointer.y = (e.clientY - r.top) / unit; pointer.on = true; pointer.moved = time; }; const onLeave = () => { pointer.on = false; }; root.addEventListener("pointermove", onMove); root.addEventListener("pointerleave", onLeave); if (ascii) { loadGlyphFont({ glyphs: GLYPHS, fontFamily, fontWeight: 600 }).then(() => { if (disposed) return; buildAtlas(); loop.redraw(); }); } return () => { disposed = true; state.loop = null; loop.dispose(); root.removeEventListener("pointermove", onMove); root.removeEventListener("pointerleave", onLeave); }; }, [scene, look, cell, count, toneKey, themeKey]); return ( <div ref={rootRef} className={cn("relative isolate overflow-hidden", className)} style={{ background, ...style }} {...props}> <canvas ref={canvasRef} aria-hidden="true" className="pointer-events-none absolute inset-0 -z-10 size-full" /> {children} </div> ); }
Usage
import { DitherWildlife } from "@/components/ui/dither-wildlife/dither-wildlife";
<DitherWildlife scene="horses" className="h-[480px]">
<Hero />
</DitherWildlife>API reference
| Prop | Type | Default | Description |
|---|---|---|---|
children | ReactNode | – | Content above the scene, e.g. a hero. |
scene | DitherWildlifeScene | "horses" | What runs, flies or sways across the canvas. |
look | DitherWildlifeLook | "ascii" | `ascii` draws shaded glyph cells (tone blocks under glyphs that thicken in shadow); `dither` draws crisp dithered pixels. |
cell | number | 8 in ascii, 5 in dither | Cell size in px: a glyph cell's width in `ascii` (cells are 1.45 times as tall), one pixel in `dither`. |
tones | string[] | four grays | Ink tones from light to dark; shading is dithered between them. |
background | string | "#ffffff" | Paper color behind the scene (any CSS color, or "transparent"). |
count | number | 3, 26 or 8 by scene | How many horses, birds or trees. |
speed | number | 1 | Speed multiplier. |
interactive | boolean | true | Birds scatter from the pointer, trees bend away from it, and in `ascii` it stirs up dust. |
paused | boolean | false | Freezes the scene. |
Also accepts every native <div> attribute, including ref and className.
- Each frame is painted as soft grayscale shapes into a small buffer. In the default `ascii` look, every glyph cell (1.45 times as tall as wide, like a terminal) reads six sub-samples: covered cells get a block shaded smoothly along the `tones` under a glyph whose density follows the shade, ordered-dithered (4×4 Bayer) between its two nearest densities, and partly covered cells get an edge glyph (- \\ | /) that follows the silhouette.
- Ground and shadows are painted as texture, so they come out as sparse glyphs rather than blocks. Birds and falling leaves are glyph sprites (v, \\v/, ~v~). Galloping hooves kick up dust and the flock leaves faint trails in a small energy field that spreads and fades; the pointer stirs it too. Glyphs come from a shared sprite atlas.
- `look="dither"` keeps the original pixel look: the buffer (one texel per `cell`) is ordered-dithered into the `tones` and scaled up without smoothing, so the pixels stay crisp.
- The ASCII look learns from ascii-sharks by drewdi (MIT), like ASCII Shark Sea and ASCII Koi Pond; the code is our own.
- Scenes: `horses` gallop past at different depths with a few distant birds; `birds` is a flock that wheels around a drifting point and scatters from the pointer; `grove` is a row of trees swaying in the wind (bending away from the pointer) with falling leaves.
- Pass your own `tones` (light to dark) and `background` to recolor it, for example sepia inks on cream paper.
Accessibility
- The scene is a decorative canvas (aria-hidden); content passed as children keeps its own semantics.
- Under reduced motion it draws one still frame (the flock already gathered). It pauses offscreen, in hidden tabs and while `paused`.
- Birds react to a mouse or pen hovering over the scene; nothing depends on it.