Skip to content

Latest commit

 

History

3 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

dither-flow

A 1-bit dithered flow field for the web. Cursor-stirred ink that swirls along curl noise, quantized through an 8×8 Bayer matrix — pure ink pixels on transparency, no grays, no dependencies, raw WebGL2 in one file.

dither-flow — ink stirred across the words DITHER FLOW, revealing a sunset through the dither

Live demo → · seen in the wild on caleblemos.com

What it does

  • Flow: a low-resolution density field advected along two octaves of curl noise (ping-pong FBOs, R16F with an R8 fallback). Splats inject density — from your pointer, a click, device tilt, or an autonomous walker — then it drifts, swirls, and decays.
  • Dither: a full-resolution pass snaps the field to a chunky pixel grid through ordered Bayer dithering. Every pixel is either ink or nothing.
  • Type interplay (optional): hand it a mask canvas — rasterized text, shapes, a logo — and the ink interacts with it: crossing smoke knocks letterforms out to paper (pass), erodes them into the current (erode), or rolls over everything (over).
  • Image reveal (optional): hide an image behind the surface and the smoke becomes a window into it — as a true 1-bit halftone (halftone) or in full color through the dithered opening (color).

Quickstart

npm i dither-flow

Or skip the install entirely — it's one ES module with no dependencies.

<canvas id="field" style="width:100%;height:100vh"></canvas>
<script type="module">
  import { DitherFlow } from "./dither-flow.js"; // from dist/

  const field = new DitherFlow(document.getElementById("field"), {
    reveal: "color",     // "ink" | "halftone" | "color"
    autoFlow: "idle",    // walker takes over when the cursor rests
  });
  field.loadImage("/sunset.jpg");
  field.bindPointer(document.body);
  field.start();
</script>

Or with a bundler / TypeScript: copy src/dither-flow.ts into your project — it has zero imports.

import { DitherFlow } from "./dither-flow";

Pair it with an IntersectionObserver to stop() offscreen instances; the field already pauses itself when the tab is hidden.

Options

option default what it does
ink / paper #121110 / #faf9f4 field color, and the color masked pixels knock out to
cell 2 dither cell size in device pixels — the whole look lives here
decay 0.962 density kept per frame; higher = longer trails
flowSpeed 1.0 advection speed
noiseScale 4.2 swirl frequency
drift 0.06 constant rise (+) or sink (−)
splatRadius / splatStrength 0.075 / 0.85 pointer ink size and amount
variant "pass" how the field meets the mask: pass / erode / over
reveal "ink" ink, halftone, or color (needs an image)
imageContrast 1.6 luminance contrast for the halftone reveal
autoFlow "off" on = walker from the first frame · idle = walker after 2.6 s of cursor rest
maxFps 0 frame cap (try 30 on mobile)
dpr auto (≤2) device pixel ratio
autoResize / autoPause true ResizeObserver sizing · pause on hidden tab

Every option can be changed live with field.set({ ... }).

Methods

field.start(); field.stop(); field.destroy();
field.pointer(x, y);          // CSS px, relative to the canvas box
field.pointerEnd();
field.bindPointer(element);   // convenience wiring; returns an unbind fn
field.splat(x, y, radius?, strength?);
field.setGravity(x, y);       // -1..1 — feed device tilt, ink slides downhill
field.setImage(imageOrCanvas); await field.loadImage(url);
field.setMask(canvas); field.clearMask();
field.idleMs();               // ms since real pointer input
DitherFlow.isSupported();     // WebGL2 check before constructing

The text-mask recipe

The signature move — type that the smoke passes through — is a mask you paint yourself, so it works with any font, layout, or language. Rasterize your real DOM text into a canvas (white on black), matching each element's computed style, and hand it over:

const mask = document.createElement("canvas");
mask.width = canvasBox.width * dpr;
mask.height = canvasBox.height * dpr;
const ctx = mask.getContext("2d");
ctx.scale(dpr, dpr);
ctx.fillStyle = "#000"; ctx.fillRect(0, 0, w, h);
ctx.fillStyle = "#fff"; ctx.textBaseline = "top";
for (const el of document.querySelectorAll("[data-mask-me]")) {
  const cs = getComputedStyle(el);
  ctx.font = `${cs.fontWeight} ${cs.fontSize} ${cs.fontFamily}`;
  for (const rect of el.getClientRects()) {
    ctx.fillText(el.textContent, rect.left - box.left,
      rect.top - box.top + (rect.height - parseFloat(cs.fontSize)) / 2);
  }
}
field.setMask(mask);

Rebuild it on resize (and after document.fonts.ready), keep the real DOM text in place underneath — it stays selectable, accessible, and indexable; the canvas only composites with it. Set debugMask: true while aligning.

How it works

Two draw calls per frame, one fullscreen triangle each.

  1. Sim pass at ~1/5 resolution. The density field samples its previous frame upstream along a velocity built from two octaves of curl noise (finite-difference rotated gradient of a value-noise field — divergence-free by construction, which is what makes it read as fluid without pressure solving). Gaussian splats add ink, an exponential decay and a small linear bleed take it away, so trails dissolve instead of lingering as fog.
  2. Render pass at full resolution. Pixels are grouped into cells; each cell samples density once and compares it against an 8×8 Bayer threshold computed procedurally from the cell coordinates (three levels of the recursive index matrix, bit-twiddled — no texture lookup). Density above threshold is ink; everything else is transparent. The mask and hidden image sample the same cell centers, which is why the halftone reveal reads as authentic screen-printing rather than a filtered photograph.

There is deliberately no gray anywhere in the pipeline's output: the field is continuous, but the page only ever sees ink or paper.

Performance

The sim runs at 1/5 canvas resolution, so cost scales with the dither cell budget, not the display. On phones, { maxFps: 30, dpr: 1 } is plenty — the dither aesthetic hides both caps completely. The whole engine is a single class, two shaders, two small FBOs.

License

MIT © Caleb Lemos

About

A 1-bit dithered flow field for the web — cursor-stirred ink, curl-noise advection, Bayer ordered dithering. Raw WebGL2, zero dependencies.

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages