morphiq
componentbitmap-imagesource: ok · refreshed
Bitmap Image
Converts an image into a dithered bitmap — ordered Bayer or true error diffusion (Floyd-Steinberg, Atkinson, Jarvis), which no shader can do. Canvas 2D, no WebGL.
npx shadcn add "https://morphiq.prathampatel.design/r/bitmap-image.json"free
No selection tags yet — empty means no evidence, not lack of support.
Dependencies
Bundle weight · mediumanimationgsap
Files8 · source cached 10/8/2026, 10:12:09 AM
src/components/BitmapImage/index.js
export { default } from './BitmapImage.jsx'
src/components/BitmapImage/BitmapImage.jsx
'use client'
import { useRef, useEffect } from 'react'
import { useBitmapField, useLiveParams } from '../bitmap-core/engine.js'
import { createRasterSource } from '../bitmap-core/sources.js'
import '../bitmap-core/bitmap.css'
const SUPERSAMPLE = 2
/* =========================================================
BitmapImage
Converts an image into a dithered bitmap.
The image is drawn once into an offscreen canvas at 2× the
cell grid and box-filtered down, so every cell gets a properly
antialiased grey before it is thresholded. Skipping that step
is the usual reason naive dither looks like broken JPEG rather
than newsprint — a hard threshold of a hard-edged source has
no midtones left to diffuse.
Because the source is cached as a luminance buffer, the frame
loop only re-dithers. With no warp and a still pointer it does
not even do that: the engine skips identical frames outright.
========================================================= */
/** True when `src` will need CORS to be read back out of a canvas. */
function isCrossOrigin(src) {
if (!/^https?:/i.test(src)) return false
try {
return new URL(src, window.location.href).origin !== window.location.origin
} catch {
return false
}
}
/**
* An image, rendered as dither.
*
* <BitmapImage src={url} cell={4} dither="floyd" fit="cover" />
*
* `src` accepts a URL, a data/blob URL, or anything canvas can draw directly
* (an HTMLImageElement, ImageBitmap, HTMLCanvasElement or HTMLVideoElement).
*/
export default function BitmapImage({
// --- source ---
src,
alt = '', // as on <img>: describe it, or leave empty if decorative
fit = 'cover', // cover · contain · fill
focusX = 0.5, // crop anchor, 0..1
focusY = 0.5,
// --- raster ---
cell = 4,
levels = 2,
dither = 'floyd', // error diffusion suits photographs best
ditherAmount = 1,
serpentine = true,
// --- tone ---
contrast = 1.1,
brightness = 0,
invert = false,
// --- interaction ---
warp = 0,
cursorRadius = 160,
cursorStrength = 0,
cursorLift = 0,
// --- paint ---
color = '#e8e4dc',
background = '#0a0b0e',
onStatus, // 'ready' | 'empty' | 'tainted'
paused = false,
respectReducedMotion = true,
fullscreen = false,
className = '',
style,
...rest
}) {
const rootRef = useRef(null)
const canvasRef = useRef(null)
const imageRef = useRef(null)
const params = useLiveParams({
fit, focusX, focusY,
cell, levels, dither, ditherAmount, serpentine,
contrast, brightness, invert,
warp, cursorRadius, cursorStrength, cursorLift,
color, background, paused, respectReducedMotion, onStatus,
})
const { rebuild, refresh, repaint } = useBitmapField({
rootRef,
canvasRef,
params,
createSource: () =>
createRasterSource(
(ctx, w, h) => {
const img = imageRef.current
if (!img) return false
const iw = img.naturalWidth || img.videoWidth || img.width
const ih = img.naturalHeight || img.videoHeight || img.height
if (!iw || !ih) return false
const p = params.current
if (p.fit === 'fill') {
ctx.drawImage(img, 0, 0, w, h)
return true
}
// cover takes the larger scale (crop), contain the smaller
// (letterbox); the offset formula is the same either way, since
// the leftover space just changes sign.
const s =
p.fit === 'contain'
? Math.min(w / iw, h / ih)
: Math.max(w / iw, h / ih)
const dw = iw * s
const dh = ih * s
ctx.drawImage(img, (w - dw) * p.focusX, (h - dh) * p.focusY, dw, dh)
return true
},
{ supersample: SUPERSAMPLE }
),
})
// Load the image, then hand it to the source. A cross-origin URL is tried
// with CORS first because that is the only way the pixels can be read back;
// if the host refuses, retry without so the image at least displays for any
// future non-readback use, and report the failure.
useEffect(() => {
if (!src) {
imageRef.current = null
refresh.current()
return
}
if (typeof src !== 'string') {
imageRef.current = src
refresh.current()
return
}
let cancelled = false
const load = (withCors) => {
const img = new Image()
if (withCors) img.crossOrigin = 'anonymous'
img.onload = () => {
if (cancelled) return
imageRef.current = img
refresh.current()
}
img.onerror = () => {
if (cancelled) return
if (withCors) return load(false)
imageRef.current = null
params.current.onStatus?.('empty')
refresh.current()
}
img.src = src
}
load(isCrossOrigin(src))
return () => {
cancelled = true
}
}, [params, refresh, src])
// Re-rasterise only when the framing changes; re-lay-out on cell size;
// repaint on colour.
useEffect(() => { refresh.current() }, [refresh, fit, focusX, focusY])
useEffect(() => { rebuild.current() }, [rebuild, cell])
// Every prop below is read live inside the render passes rather than baked
// into the source, so a static field — which the engine otherwise lets idle
// — needs an explicit nudge to show the change.
useEffect(() => {
repaint.current()
}, [repaint, color, background, levels,
dither, ditherAmount, serpentine, contrast, brightness, invert,
warp, cursorRadius, cursorStrength, cursorLift, paused])
return (
<div
ref={rootRef}
className={`bitmap-field${fullscreen ? ' bitmap-field--fullscreen' : ''} ${className}`.trim()}
style={{ background: background ?? 'transparent', ...style }}
/* Same contract as <img>: a description names the picture, an empty alt
marks it decorative and hides it. Without either, the canvas was
exposed as an unlabelled element that told the user nothing. */
role={alt ? 'img' : undefined}
aria-label={alt || undefined}
aria-hidden={alt ? undefined : 'true'}
{...rest}
>
<canvas className="bitmap-field__canvas" ref={canvasRef} aria-hidden="true" />
</div>
)
}
src/components/bitmap-core/engine.js
import { useEffect, useLayoutEffect, useRef } from 'react'
import gsap from 'gsap'
import { useGSAP } from '@gsap/react'
import { bayerMatrix, DIFFUSION_KERNELS, ORDERED_SIZES } from './dither.js'
gsap.registerPlugin(useGSAP)
/* =========================================================
Bitmap engine
The shared render pipeline behind BitmapNoise, BitmapImage and
BitmapText. Everything except "how bright is this cell?" lives
here; that one question is answered by a source (see sources.js).
Per frame, in four passes over a cols×rows grid:
1 warp — per-cell source coordinates, displaced by the
pointer and by an optional flow field
2 source — luminance 0..1 at those coordinates
3 tone — contrast, brightness, invert, cursor lift
4 dither — quantise to `levels`, then colour into an
ImageData at *grid* resolution
The last step is the one that makes this cheap enough to run on
the CPU at full-bleed: we never draw cells. We write one pixel
per cell into a cols×rows ImageData, then let the GPU blow it up
with `imageSmoothingEnabled = false`. A 4px cell on a 1440×900
viewport is a 360×225 buffer — 81,000 pixels, not 81,000 draw
calls — and the nearest-neighbour upscale is free.
========================================================= */
const MAX_DPR = 2
/**
* Resolve any CSS colour — hex, rgb(), hsl(), a named colour, `transparent` —
* to [r, g, b, a]. Painting onto a cleared 1×1 canvas means the browser does
* the parsing, so we inherit every colour syntax it supports for free.
*/
function resolveColor(css) {
const c = document.createElement('canvas')
c.width = 1
c.height = 1
const ctx = c.getContext('2d', { willReadFrequently: true })
ctx.clearRect(0, 0, 1, 1)
ctx.fillStyle = '#000'
ctx.fillStyle = css == null ? 'transparent' : css
ctx.fillRect(0, 0, 1, 1)
const d = ctx.getImageData(0, 0, 1, 1).data
return [d[0], d[1], d[2], d[3]]
}
/**
* A `levels`-entry ramp from background to foreground, packed as native-endian
* RGBA words. Writing one Uint32 per cell instead of four bytes roughly
* quarters the colour pass; building the words through a byte view rather than
* shifting keeps it correct on big-endian machines.
*/
function buildPalette(levels, color, background) {
const [fr, fg, fb, fa] = resolveColor(color)
const [br, bg_, bb, ba] = resolveColor(background)
const pal = new Uint32Array(levels)
const bytes = new Uint8Array(4)
const word = new Uint32Array(bytes.buffer)
for (let i = 0; i < levels; i++) {
const t = levels === 1 ? 1 : i / (levels - 1)
bytes[0] = br + (fr - br) * t
bytes[1] = bg_ + (fg - bg_) * t
bytes[2] = bb + (fb - bb) * t
bytes[3] = ba + (fa - ba) * t
pal[i] = word[0]
}
return pal
}
/* useGSAP runs as a layout effect, so the live parameters are synced in one
too — declared by the caller ahead of useBitmapField, it runs first, and a
build or repaint in the same commit already sees the new values. A write
during render would be simpler and is unsound under concurrent rendering:
React can render and then discard, leaving the loop reading props that were
never committed. Plain useEffect on the server, where layout effects warn. */
const useIsomorphicLayoutEffect = typeof window === 'undefined' ? useEffect : useLayoutEffect
/** A ref holding the latest committed props, for loops that must not restart. */
export function useLiveParams(values) {
const params = useRef(values)
useIsomorphicLayoutEffect(() => {
params.current = values
})
return params
}
/**
* Wires a source to a canvas: sizing, the pointer, the gsap ticker, and the
* four render passes. Returns imperative handles the calling component fires
* from its own effects — the same live-params pattern the rest of the library
* uses, so changing a prop never tears down the loop.
*
* rebuild() grid geometry changed (cell size, element resized)
* refresh() the source's own input changed (new image, new text)
* repaint() palette changed (colour, background, levels)
*/
export function useBitmapField({ rootRef, canvasRef, params, createSource }) {
const rebuild = useRef(() => {})
const refresh = useRef(() => {})
const repaint = useRef(() => {})
useGSAP(
() => {
const canvas = canvasRef.current
const root = rootRef.current
const ctx = canvas.getContext('2d')
const source = createSource()
// The grid-resolution buffer everything is composed into.
const grid = document.createElement('canvas')
const gctx = grid.getContext('2d')
let cols = 0
let rows = 0
let count = 0
let w = 0
let h = 0
let dpr = 1
let cellPx = 4
let lum = new Float32Array(0)
let wx = new Float32Array(0)
let wy = new Float32Array(0)
let lift = new Float32Array(0)
let err = new Float32Array(0)
let idx = new Uint8Array(0)
let imageData = null
let out32 = null
let palette = new Uint32Array(2)
let ready = false
// Own clock: integrating our own delta means changing `speed` eases into
// the new rate instead of jumping the field to a different phase.
let clock = 0
const cursor = { x: -1e5, y: -1e5 }
const xTo = gsap.quickTo(cursor, 'x', { duration: 0.28, ease: 'power3' })
const yTo = gsap.quickTo(cursor, 'y', { duration: 0.28, ease: 'power3' })
const reduced = window.matchMedia?.('(prefers-reduced-motion: reduce)')
const makePalette = () => {
const p = params.current
palette = buildPalette(Math.max(2, p.levels | 0), p.color, p.background)
}
repaint.current = () => {
makePalette()
draw()
}
const refreshSource = () => {
const result = source.prepare(cols, rows)
ready = result === true
// 'ready' | 'empty' | 'tainted' — only the image component listens, to
// tell "still loading" apart from "this file can never be read".
params.current.onStatus?.(result === true ? 'ready' : result || 'empty')
draw()
}
refresh.current = refreshSource
const build = () => {
const p = params.current
dpr = Math.min(window.devicePixelRatio || 1, MAX_DPR)
w = root.clientWidth
h = root.clientHeight
if (w < 1 || h < 1) return
cellPx = Math.max(1, p.cell)
cols = Math.max(1, Math.ceil(w / cellPx))
rows = Math.max(1, Math.ceil(h / cellPx))
count = cols * rows
canvas.width = Math.round(w * dpr)
canvas.height = Math.round(h * dpr)
canvas.style.width = w + 'px'
canvas.style.height = h + 'px'
ctx.imageSmoothingEnabled = false
grid.width = cols
grid.height = rows
imageData = gctx.createImageData(cols, rows)
out32 = new Uint32Array(imageData.data.buffer)
lum = new Float32Array(count)
wx = new Float32Array(count)
wy = new Float32Array(count)
lift = new Float32Array(count)
err = new Float32Array(count)
idx = new Uint8Array(count)
makePalette()
refreshSource()
}
rebuild.current = build
const animating = () => {
const p = params.current
if (p.paused) return false
if (p.respectReducedMotion && reduced?.matches) return false
return true
}
/* ---- pass 1: warped source coordinates, in cell space ---- */
const warpPass = () => {
const p = params.current
const R = p.cursorRadius / cellPx
const push = p.cursorStrength / cellPx
const cx = cursor.x / cellPx
const cy = cursor.y / cellPx
const active = R > 0 && (push !== 0 || p.cursorLift !== 0) && cursor.x > -1e4
const r2 = R * R
const flow = p.warp
const t = clock
for (let y = 0, i = 0; y < rows; y++) {
for (let x = 0; x < cols; x++, i++) {
let sx = x
let sy = y
let f = 0
if (active) {
const dx = x - cx
const dy = y - cy
const d2 = dx * dx + dy * dy
if (d2 < r2) {
const d = Math.sqrt(d2) || 1e-4
f = 1 - d / R
f *= f
const s = (f * push) / d
sx += dx * s
sy += dy * s
}
}
// Cheap analytic flow: two crossed waves read as a slow liquid
// shear without needing a second noise texture per frame.
if (flow !== 0) {
sx += Math.sin(sy * 0.09 + t * 0.9) * flow
sy += Math.cos(sx * 0.075 - t * 0.7) * flow
}
wx[i] = sx
wy[i] = sy
lift[i] = f
}
}
}
/* ---- pass 3: tone ---- */
const tonePass = () => {
const p = params.current
const contrast = p.contrast
const bright = p.brightness
const cl = p.cursorLift
const inv = p.invert
for (let i = 0; i < count; i++) {
let v = (lum[i] - 0.5) * contrast + 0.5 + bright + lift[i] * cl
if (inv) v = 1 - v
lum[i] = v < 0 ? 0 : v > 1 ? 1 : v
}
}
/* ---- pass 4: dither + colour ---- */
let bayer = bayerMatrix(8)
let bayerN = 8
let bayerKey = 'bayer8'
const ditherPass = () => {
const p = params.current
const levels = Math.max(2, p.levels | 0)
const q = levels - 1
const amount = p.ditherAmount
const mode = p.dither
if (mode === 'none' || amount === 0) {
for (let i = 0; i < count; i++) {
const v = Math.round(lum[i] * q)
out32[i] = palette[v]
}
return
}
const ordered = ORDERED_SIZES[mode]
if (ordered) {
if (bayerKey !== mode) {
bayer = bayerMatrix(ordered)
bayerN = ordered
bayerKey = mode
}
// The threshold offset is scaled by the quantisation step, so a
// 2-level field gets the full ±0.5 kick and a 16-level one only
// dithers across the gap between its own neighbouring levels.
const k = amount / q
for (let y = 0, i = 0; y < rows; y++) {
const brow = (y % bayerN) * bayerN
for (let x = 0; x < cols; x++, i++) {
let v = Math.round((lum[i] + bayer[brow + (x % bayerN)] * k) * q)
if (v < 0) v = 0
else if (v > q) v = q
out32[i] = palette[v]
}
}
return
}
// Error diffusion. Sequential by definition — each cell's rounding
// error is pushed into cells that haven't been visited yet — which is
// precisely why this look can't be a shader.
const kernel = DIFFUSION_KERNELS[mode] || DIFFUSION_KERNELS.floyd
const klen = kernel.length
err.set(lum)
const serp = p.serpentine
for (let y = 0; y < rows; y++) {
const l2r = !serp || (y & 1) === 0
const row = y * cols
const start = l2r ? 0 : cols - 1
const stop = l2r ? cols : -1
const step = l2r ? 1 : -1
for (let x = start; x !== stop; x += step) {
const i = row + x
const old = err[i]
let v = Math.round(old * q)
if (v < 0) v = 0
else if (v > q) v = q
idx[i] = v
const e = (old - v / q) * amount
if (e === 0) continue
for (let k = 0; k < klen; k++) {
const kern = kernel[k]
const nx = x + (l2r ? kern[0] : -kern[0])
if (nx < 0 || nx >= cols) continue
const ny = y + kern[1]
if (ny >= rows) continue
err[ny * cols + nx] += e * kern[2]
}
}
}
for (let i = 0; i < count; i++) out32[i] = palette[idx[i]]
}
const draw = () => {
if (!imageData || count === 0) return
warpPass()
if (ready) source.fill(lum, wx, wy, count, cols, rows, clock)
else lum.fill(0)
tonePass()
ditherPass()
gctx.putImageData(imageData, 0, 0)
ctx.clearRect(0, 0, canvas.width, canvas.height)
ctx.drawImage(grid, 0, 0, cols * cellPx * dpr, rows * cellPx * dpr)
}
// A static source with no warp and a still pointer produces a byte-for-byte
// identical frame every tick. Detecting that and returning early means a
// dithered image or headline costs nothing once it has settled, instead of
// re-running four passes 60 times a second forever.
let lastCx = NaN
let lastCy = NaN
const tick = (time, deltaMs) => {
if (!animating()) return
const p = params.current
if (!source.animated && p.warp === 0) {
const live = p.cursorRadius > 0 && (p.cursorStrength !== 0 || p.cursorLift !== 0)
if (!live) return
if (cursor.x === lastCx && cursor.y === lastCy) return
lastCx = cursor.x
lastCy = cursor.y
draw()
return
}
clock += Math.min(deltaMs, 50) / 1000
draw()
}
build()
const onMove = (e) => {
const rect = root.getBoundingClientRect()
xTo(e.clientX - rect.left)
yTo(e.clientY - rect.top)
}
const onLeave = () => {
xTo(-1e5)
yTo(-1e5)
}
// ResizeObserver rather than window.resize: these can live inside a
// sized container, not just full-bleed.
const ro = new ResizeObserver(() => build())
ro.observe(root)
window.addEventListener('pointermove', onMove, { passive: true })
root.addEventListener('pointerleave', onLeave)
reduced?.addEventListener?.('change', draw)
gsap.ticker.add(tick)
return () => {
ro.disconnect()
window.removeEventListener('pointermove', onMove)
root.removeEventListener('pointerleave', onLeave)
reduced?.removeEventListener?.('change', draw)
gsap.ticker.remove(tick)
rebuild.current = () => {}
refresh.current = () => {}
repaint.current = () => {}
}
},
{ scope: rootRef }
)
return { rebuild, refresh, repaint }
}
/** Props every bitmap component shares, and their defaults. */
export const BASE_DEFAULTS = {
cell: 4,
levels: 2,
dither: 'bayer8',
ditherAmount: 1,
serpentine: true,
contrast: 1,
brightness: 0,
invert: false,
warp: 0,
cursorRadius: 160,
cursorStrength: 0,
cursorLift: 0,
color: '#e8e4dc',
background: '#0a0b0e',
paused: false,
respectReducedMotion: true,
}
src/components/bitmap-core/sources.js
/* =========================================================
Sources
A "source" is anything that can answer "how bright is this
cell?" for every cell in the grid. Three of them exist:
- noise — procedural, generated fresh every frame from a
pair of pre-baked tileable fBm tiles that pan past
each other. No input, infinite duration.
- raster — a static luminance buffer painted once by a draw
callback (an image, or text) and then sampled with
bilinear filtering so it can be warped and scrolled.
Both expose the same two methods so the engine never has to
know which one it is holding:
prepare(cols, rows) -> boolean rebuild for a new grid size
fill(lum, wx, wy, n, cols, rows, t) write luminance 0..1
`wx`/`wy` are per-cell source coordinates the engine has already
warped (cursor push, flow). Sources read them and never compute
their own warp — that keeps warping identical across all three.
========================================================= */
/* ---------- deterministic RNG so a given seed always tiles the same ---------- */
function mulberry32(seed) {
let a = seed >>> 0
return () => {
a = (a + 0x6d2b79f5) >>> 0
let t = Math.imul(a ^ (a >>> 15), 1 | a)
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
}
}
const smooth = (t) => t * t * (3 - 2 * t)
/**
* One octave of tileable value noise, rendered into a size×size Float32Array.
* `period` lattice points span the tile exactly, and lattice lookups wrap, so
* the result tiles seamlessly in both axes — which is what lets us pan it
* forever without ever hitting an edge.
*/
function valueNoiseTile(size, period, rng) {
const lattice = new Float32Array(period * period)
for (let i = 0; i < lattice.length; i++) lattice[i] = rng()
const out = new Float32Array(size * size)
const step = size / period
for (let y = 0; y < size; y++) {
const fy = y / step
const y0 = Math.floor(fy)
const ty = smooth(fy - y0)
const r0 = (y0 % period) * period
const r1 = ((y0 + 1) % period) * period
for (let x = 0; x < size; x++) {
const fx = x / step
const x0 = Math.floor(fx)
const tx = smooth(fx - x0)
const c0 = x0 % period
const c1 = (x0 + 1) % period
const top = lattice[r0 + c0] + (lattice[r0 + c1] - lattice[r0 + c0]) * tx
const bot = lattice[r1 + c0] + (lattice[r1 + c1] - lattice[r1 + c0]) * tx
out[y * size + x] = top + (bot - top) * ty
}
}
return out
}
/**
* Fractal Brownian motion baked into a single tileable tile. Summing the
* octaves once up front means the render loop pays for exactly one bilinear
* lookup per layer per cell instead of re-summing octaves 80,000 times a frame
* — this is the whole reason the noise mode stays affordable on the CPU.
*/
export function makeNoiseTile(size = 256, octaves = 4, basePeriod = 4, seed = 1) {
const rng = mulberry32(seed)
const out = new Float32Array(size * size)
let amp = 1
let norm = 0
for (let o = 0; o < octaves; o++) {
const period = basePeriod * 2 ** o
if (period > size) break
const layer = valueNoiseTile(size, period, rng)
for (let i = 0; i < out.length; i++) out[i] += layer[i] * amp
norm += amp
amp *= 0.5
}
// Summed octaves cluster hard around 0.5 — four averaged random layers land
// within roughly 0.14..0.83, and blending two of those tiles narrows it
// further. Left alone the field never leaves the midtones and dithers to flat
// 50% static instead of patches. Stretch each tile onto the full 0..1 range
// so `patch` has real signal to work with.
let lo = Infinity
let hi = -Infinity
for (let i = 0; i < out.length; i++) {
const v = (out[i] /= norm)
if (v < lo) lo = v
if (v > hi) hi = v
}
const span = hi - lo || 1
for (let i = 0; i < out.length; i++) out[i] = (out[i] - lo) / span
return out
}
/** Bilinear sample of a tile, wrapping on both axes. `x`/`y` are in tile px. */
function sampleWrap(tile, size, x, y) {
let x0 = Math.floor(x)
let y0 = Math.floor(y)
const tx = x - x0
const ty = y - y0
x0 = ((x0 % size) + size) % size
y0 = ((y0 % size) + size) % size
const x1 = x0 + 1 === size ? 0 : x0 + 1
const y1 = y0 + 1 === size ? 0 : y0 + 1
const r0 = y0 * size
const r1 = y1 * size
const top = tile[r0 + x0] + (tile[r0 + x1] - tile[r0 + x0]) * tx
const bot = tile[r1 + x0] + (tile[r1 + x1] - tile[r1 + x0]) * tx
return top + (bot - top) * ty
}
/** Bilinear sample of a buffer, clamping at the edges. `x`/`y` are in buffer px. */
export function sampleClamp(buf, w, h, x, y) {
if (x < 0) x = 0
else if (x > w - 1) x = w - 1
if (y < 0) y = 0
else if (y > h - 1) y = h - 1
const x0 = x | 0
const y0 = y | 0
const x1 = x0 + 1 < w ? x0 + 1 : x0
const y1 = y0 + 1 < h ? y0 + 1 : y0
const tx = x - x0
const ty = y - y0
const r0 = y0 * w
const r1 = y1 * w
const top = buf[r0 + x0] + (buf[r0 + x1] - buf[r0 + x0]) * tx
const bot = buf[r1 + x0] + (buf[r1 + x1] - buf[r1 + x0]) * tx
return top + (bot - top) * ty
}
const TILE = 256
/* Blend weights for the two drifting layers, and the factor that restores the
spread averaging them costs: 1 / sqrt(WA^2 + WB^2). */
const WA = 0.62
const WB = 0.38
const BLEND_GAIN = 1 / Math.sqrt(WA * WA + WB * WB)
/**
* Procedural source: two fBm tiles drifting past each other on different
* headings, then squeezed by `patch` into distinct blobs rather than a smooth
* gradient. The counter-drift is what stops the field from reading as one
* sheet sliding by — the interference between the two layers is what makes
* patches appear, dissolve and reform in place.
*
* `params` is a live ref-like object read fresh every frame.
*/
export function createNoiseSource(params) {
const tileA = makeNoiseTile(TILE, 4, 4, 1)
const tileB = makeNoiseTile(TILE, 3, 6, 9)
return {
animated: true,
prepare: () => true,
fill(lum, wx, wy, n, cols, rows, t) {
const { scale, speed, patch, drift, density } = params.current
// A tile feature spans ~TILE/4 px; `k` maps that onto `scale` cells.
const k = 3.2 / Math.max(scale, 0.05)
const kb = k * 0.62
const ax = t * speed * 9
const ay = t * speed * 3.5
const bx = -t * speed * 5 + drift * 40
const by = t * speed * 7
const lo = 0.5 - Math.max(1 - patch, 0.02) * 0.5
const hi = 0.5 + Math.max(1 - patch, 0.02) * 0.5
const span = hi - lo
// How much of the field ends up lit. This belongs to the field, not to
// the tone stage: a 50/50 dither on a light-on-dark palette already
// reads as light-dominant, so the neutral-looking default sits below
// half and `brightness` stays free as a user-facing control.
const bias = density - 0.5
for (let i = 0; i < n; i++) {
const sx = wx[i]
const sy = wy[i]
const a = sampleWrap(tileA, TILE, sx * k + ax, sy * k + ay)
const b = sampleWrap(tileB, TILE, sx * kb + bx, sy * kb + by)
// Averaging two independent layers shrinks the spread by
// sqrt(wa^2 + wb^2); BLEND_GAIN puts it back, so the blend costs
// contrast-free interference rather than a washed-out field.
let v = 0.5 + ((a - 0.5) * WA + (b - 0.5) * WB) * BLEND_GAIN
// Squeeze the midtones outward so the field breaks into patches.
v = (v - lo) / span
v = v <= 0 ? 0 : v >= 1 ? 1 : smooth(v)
v += bias
lum[i] = v <= 0 ? 0 : v >= 1 ? 1 : v
}
},
}
}
/**
* Static source: a draw callback paints into an offscreen canvas at SS× the
* grid resolution, which is then box-filtered down to grid resolution. That
* supersample is what makes the difference between a jagged, unreadable
* threshold and clean antialiased greys that dither into legible shapes.
*
* `draw(ctx, w, h)` returns false to signal "nothing to draw yet" (an image
* that hasn't loaded, empty text). `prepare` returns true on success, false
* when there is nothing to draw, or the string 'tainted' when the canvas
* could not be read back because of cross-origin restrictions.
*/
export function createRasterSource(draw, { supersample = 2 } = {}) {
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d', { willReadFrequently: true })
let buf = new Float32Array(0)
let bw = 0
let bh = 0
return {
// Static: the engine can skip whole frames when nothing else is moving.
animated: false,
prepare(cols, rows) {
if (cols < 1 || rows < 1) return false
const w = Math.max(1, Math.round(cols * supersample))
const h = Math.max(1, Math.round(rows * supersample))
canvas.width = w
canvas.height = h
ctx.setTransform(1, 0, 0, 1, 0, 0)
ctx.clearRect(0, 0, w, h)
if (draw(ctx, w, h) === false) {
bw = 0
bh = 0
return false
}
// A cross-origin image drawn without CORS headers taints the canvas and
// makes this throw. Fail soft: the field renders empty and the component
// surfaces the error rather than taking the page down.
let px
try {
px = ctx.getImageData(0, 0, w, h).data
} catch {
bw = 0
bh = 0
return 'tainted'
}
if (buf.length !== cols * rows) buf = new Float32Array(cols * rows)
bw = cols
bh = rows
// Box-filter SS×SS blocks down to one cell. Alpha is folded in so
// transparent regions read as black instead of stray white fringes.
const inv = 1 / (supersample * supersample * 255)
for (let y = 0; y < rows; y++) {
const y0 = Math.round(y * supersample)
const y1 = Math.min(h, y0 + supersample)
for (let x = 0; x < cols; x++) {
const x0 = Math.round(x * supersample)
const x1 = Math.min(w, x0 + supersample)
let sum = 0
for (let sy = y0; sy < y1; sy++) {
let o = (sy * w + x0) * 4
for (let sx = x0; sx < x1; sx++, o += 4) {
const a = px[o + 3] / 255
// Rec. 709 luma, premultiplied by coverage.
sum += (0.2126 * px[o] + 0.7152 * px[o + 1] + 0.0722 * px[o + 2]) * a
}
}
buf[y * cols + x] = sum * inv
}
}
return true
},
fill(lum, wx, wy, n) {
if (!bw || !bh) {
lum.fill(0, 0, n)
return
}
for (let i = 0; i < n; i++) lum[i] = sampleClamp(buf, bw, bh, wx[i], wy[i])
},
}
}
src/components/bitmap-core/dither.js
/* =========================================================
Dither kernels
Two families, and they are not interchangeable:
- Ordered (Bayer): a fixed threshold map tiled over the grid.
Every cell is independent, so it parallelises trivially and
produces the stable crosshatch you know from early Mac and
Amiga art. Because cells don't talk to each other, an animated
source stays temporally stable — no crawling.
- Error diffusion (Floyd–Steinberg, Atkinson, Jarvis): quantise a
cell, then push the rounding error into cells not yet visited.
Inherently sequential — each cell depends on its neighbours —
which is exactly why this look cannot be done as a per-pixel
shader, and why the whole component stays on the CPU.
========================================================= */
/**
* Recursive Bayer threshold matrix. `n` must be a power of two (2, 4, 8, 16).
* Returns a Float32Array of n*n thresholds centred on zero, i.e. in
* (-0.5, 0.5), ready to be added to a luminance before quantising.
*/
export function bayerMatrix(n) {
let m = [0]
let s = 1
while (s < n) {
const w = s * 2
const next = new Array(w * w)
for (let y = 0; y < s; y++) {
for (let x = 0; x < s; x++) {
const v = m[y * s + x] * 4
next[y * w + x] = v
next[y * w + (x + s)] = v + 2
next[(y + s) * w + x] = v + 3
next[(y + s) * w + (x + s)] = v + 1
}
}
m = next
s = w
}
const total = n * n
const out = new Float32Array(total)
for (let i = 0; i < total; i++) out[i] = (m[i] + 0.5) / total - 0.5
return out
}
/* Error-diffusion kernels as flat [dx, dy, weight] triples. dx is mirrored
automatically on right-to-left rows when serpentine scanning is on. */
export const DIFFUSION_KERNELS = {
floyd: [
[1, 0, 7 / 16],
[-1, 1, 3 / 16],
[0, 1, 5 / 16],
[1, 1, 1 / 16],
],
atkinson: [
[1, 0, 1 / 8],
[2, 0, 1 / 8],
[-1, 1, 1 / 8],
[0, 1, 1 / 8],
[1, 1, 1 / 8],
[0, 2, 1 / 8],
],
jarvis: [
[1, 0, 7 / 48],
[2, 0, 5 / 48],
[-2, 1, 3 / 48],
[-1, 1, 5 / 48],
[0, 1, 7 / 48],
[1, 1, 5 / 48],
[2, 1, 3 / 48],
[-2, 2, 1 / 48],
[-1, 2, 3 / 48],
[0, 2, 5 / 48],
[1, 2, 3 / 48],
[2, 2, 1 / 48],
],
}
export const ORDERED_SIZES = { bayer2: 2, bayer4: 4, bayer8: 8, bayer16: 16 }
src/components/bitmap-core/bitmap.css
.bitmap-field {
position: relative;
width: 100%;
height: 100%;
overflow: hidden;
}
/* Opt-in full-bleed, for when one of these is the page background rather
than a sized element. */
.bitmap-field--fullscreen {
position: fixed;
inset: 0;
}
.bitmap-field__canvas {
display: block;
width: 100%;
height: 100%;
}
src/components/BitmapImage/BitmapImage.d.ts
import type { BitmapFieldProps } from '../bitmap-core/bitmap'
export interface BitmapImageProps extends BitmapFieldProps {
src?: string
/** As on `<img>`: describe the picture, or leave empty to mark it decorative. */
alt?: string
fit?: 'cover' | 'contain' | 'fill'
/** Crop anchor, 0..1. */
focusX?: number
focusY?: number
onStatus?: (status: 'ready' | 'empty' | 'tainted') => void
}
declare const BitmapImage: (props: BitmapImageProps) => JSX.Element
export default BitmapImage
src/components/bitmap-core/bitmap.d.ts
import type { ComponentPropsWithoutRef } from 'react'
/**
* The raster settings the three bitmap components share. They all run the same
* engine — only the source differs — so the knobs below mean the same thing in
* each, and are declared once here rather than three times.
*/
export type DitherKernel =
| 'bayer2'
| 'bayer4'
| 'bayer8'
| 'bayer16'
| 'floyd'
| 'atkinson'
| 'jarvis'
| 'none'
export interface BitmapFieldProps extends ComponentPropsWithoutRef<'div'> {
/** Pixels per cell. Smaller is finer and costs more per frame. */
cell?: number
/** Tone steps. 2 is one-bit; higher posterises. */
levels?: number
dither?: DitherKernel
/** 0 passes the tone through untouched, 1 applies the kernel in full. */
ditherAmount?: number
/** Alternate scan direction. Error-diffusion kernels only. */
serpentine?: boolean
contrast?: number
brightness?: number
invert?: boolean
/** Flow-field shear, in cells. */
warp?: number
/** Reach of the pointer's influence, in px. */
cursorRadius?: number
/** Outward push at the centre of that reach, in px. */
cursorStrength?: number
/** Luminance added at the centre of that reach. */
cursorLift?: number
color?: string
/** `null` renders transparent. */
background?: string | null
/** Stops the frame loop without unmounting. */
paused?: boolean
/** Honour `prefers-reduced-motion`, and keep honouring it if it changes. */
respectReducedMotion?: boolean
/** Position the field to the viewport rather than its container. */
fullscreen?: boolean
}