shadimage
morphiq
componentbitmap-noisesource: ok · hit

Bitmap Noise

A self-generating dither field. Two drifting noise layers interfere to form patches of particles that appear, dissolve and reform in place. Canvas 2D, no WebGL.

npx shadcn add "https://morphiq.prathampatel.design/r/bitmap-noise.json"
free

Where it goes

blog-list

Visual style

particle

Interaction

scroll-reveal

Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.

Dependencies

Bundle weight · medium
animationgsap

Files8 · source cached 10/8/2026, 5:59:45 PM

src/components/BitmapNoise/index.js
export { default } from './BitmapNoise.jsx'
src/components/BitmapNoise/BitmapNoise.jsx
'use client'

import { useRef, useEffect } from 'react'
import { useBitmapField, useLiveParams } from '../bitmap-core/engine.js'
import { createNoiseSource } from '../bitmap-core/sources.js'
import '../bitmap-core/bitmap.css'

/* =========================================================
   BitmapNoise
   A dithered field with no input at all — patches of particles
   that form, drift, dissolve and reform on their own.

   Two pre-baked fBm noise tiles pan past each other on different
   headings; where they agree the field is bright, where they
   disagree it falls dark. The interference between the two is
   what makes patches appear to *evolve in place* rather than
   slide across the screen as one sheet. `patch` then squeezes
   the midtones outward so the result breaks into islands instead
   of reading as a smooth gradient.

   Everything is quantised through the shared bitmap engine, so
   the output is real 1-bit dither, not a blurred texture.
   ========================================================= */

/**
 * A self-generating dither field.
 *
 *   <BitmapNoise scale={2} speed={0.4} patch={0.55} dither="bayer8" />
 */
export default function BitmapNoise({
  // --- field ---
  scale = 2,               // patch size, in cells — bigger is blobbier
  speed = 0.4,             // drift rate; 0 freezes the field
  patch = 0.42,            // 0 = smooth gradient, 1 = hard islands
  density = 0.36,          // fraction of the field that ends up lit
  drift = 0,               // constant lateral push on the second layer

  // --- raster ---
  cell = 4,                // px per cell
  levels = 2,              // 2 = 1-bit; higher posterises
  dither = 'bayer8',       // bayer2|4|8|16 · floyd · atkinson · jarvis · none
  ditherAmount = 1,
  serpentine = true,       // alternate scan direction (error diffusion only)

  // --- tone ---
  contrast = 1,
  brightness = 0,
  invert = false,

  // --- interaction ---
  warp = 0,                // flow-field shear, in cells
  cursorRadius = 160,      // px
  cursorStrength = 0,      // px of outward push at the centre
  cursorLift = 0,          // luminance added at the centre

  // --- paint ---
  color = '#e8e4dc',
  background = '#0a0b0e',  // null renders transparent

  paused = false,
  respectReducedMotion = true,
  fullscreen = false,
  className = '',
  style,
  ...rest
}) {
  const rootRef = useRef(null)
  const canvasRef = useRef(null)

  // Live parameters: the loop reads these every frame, so changing a prop
  // retunes the field in place instead of restarting it.
  const params = useLiveParams({
    scale, speed, patch, density, drift,
    cell, levels, dither, ditherAmount, serpentine,
    contrast, brightness, invert,
    warp, cursorRadius, cursorStrength, cursorLift,
    color, background, paused, respectReducedMotion,
  })

  const { rebuild, repaint } = useBitmapField({
    rootRef,
    canvasRef,
    params,
    createSource: () => createNoiseSource(params),
  })

  // Cell size changes the grid geometry; everything else is read live by the
  // render passes and only needs a nudge for the frames where the field is
  // paused and therefore not redrawing on its own.
  useEffect(() => { rebuild.current() }, [rebuild, cell])
  useEffect(() => {
    repaint.current()
  }, [repaint, color, background, levels, dither, ditherAmount, serpentine,
      contrast, brightness, invert, scale, patch, density, drift, paused])

  return (
    <div
      ref={rootRef}
      className={`bitmap-field${fullscreen ? ' bitmap-field--fullscreen' : ''} ${className}`.trim()}
      style={{ background: background ?? 'transparent', ...style }}
      {...rest}
    >
      {/* A drifting noise field carries nothing to announce. */}
      <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/BitmapNoise/BitmapNoise.d.ts
import type { BitmapFieldProps } from '../bitmap-core/bitmap'

export interface BitmapNoiseProps extends BitmapFieldProps {
  /** Patch size, in cells. Bigger is blobbier. */
  scale?: number
  /** Drift rate. 0 freezes the field. */
  speed?: number
  /** 0 is a smooth gradient, 1 is hard islands. */
  patch?: number
  /** Fraction of the field that ends up lit. */
  density?: number
  /** Constant lateral push on the second layer. */
  drift?: number
}

declare const BitmapNoise: (props: BitmapNoiseProps) => JSX.Element
export default BitmapNoise
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 →