shadimage
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 · medium
animationgsap

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
}

More from morphiq

Browse all components in this library →