Dither and the studio field

The 1-bit material live: the CPU dither loop with its field factories, the Bayer screen's coverage tiers, and the ten studio field presets on WebGL.

Updated

General Translation draws its signature texture as 1-bit cells: every cell is either ink or ground, and an ordered Bayer screen decides which. Prototemplate has two engines for it. The CPU dither loop in src/lib/dither.ts thresholds any function of position and time on a 2D canvas. The studio field in src/lib/studio-field.ts runs ten tuned presets as WebGL shaders on one shared context. The gt-dither skill holds the rules for where each one goes.

The ordered screen

The plate below shows the threshold matrix, one tile repeated at the chosen coverage, and the ramp of every tier. Move the coverage to light more cells, and switch between the 4 by 4 and the 8 by 8 matrix.

A cell is lit when its threshold sits under the coverage. Every lit cell of one tier stays lit in every higher tier, so the tiers nest. That property lets a figure fill neighboring regions with different tiers on one shared grid without drawing any cell twice, which is how the edge globe's halo and the dithered shine are built.

Import

import { BAYER_8 } from '@/lib/dither'; // 8 by 8, thresholds 0 to 63
import { BAYER4 } from '@/app/d/toolchain/diagrams/DitheredMark'; // 4 by 4, thresholds 0 to 15

Where each screen runs

ScreenLevelsWhere it runs
4 by 417The studio field's bayer4 and most of its presets, the edge globe's halo, the dithered shine
8 by 865The CPU dither loop, the picture cutter, the deck's ramp, studio presets 02 and 10 and bayerSphere
2 by 25Studio preset 07, bayer-chunk, only

Cells are square screen pixels. A canvas engine writes one buffer pixel per cell and lets CSS upscale the buffer with image-rendering: pixelated, and an SVG dither sets shape-rendering: crispEdges.

The dither loop

The plate runs createDitherLoop on a canvas with five field factories: a radial burst, a lit globe, streak bands, a gradient ramp and a dithered letterform. The cell, bias, gamma, speed and invert controls change the running loop through setOptions. A field change hands a new field to the same loop through setField, so the canvas is never rebuilt.

The ink is the docs theme's ink, read from the stage's --dmo-ink token and read again whenever html[data-theme] changes. The paper is transparent, so the stage's own ground shows through the unlit cells.

Import and props

import {
  createDitherLoop,
  globe,
  gradientRamp,
  makeGlyphField,
  mixFields,
  multiplyFields,
  radialBurst,
  streakBands,
} from '@/lib/dither';

const loop = createDitherLoop(canvas, globe({ aspect: 2 }), { scale: 3, ink: 'rgb(7, 7, 7)', paper: 'transparent' });
loop.setOptions({ bias: 0.1 });
loop.destroy();

A field is a function (u, v, t) => number that returns 0 to 1 for a position in the box and a time in seconds. The factories take an options object whose fields are all optional, and multiplyFields, maxFields, mixFields and mapField combine fields.

OptionTypeDefaultWhat it does
scalenumber3Width of one cell in CSS pixels. It sets both the look and the cost.
inkCSS color'#fff'Color of a lit cell.
paperCSS color'#000'Color of an unlit cell. 'transparent' composites over the page.
biasnumber0Added to the field before thresholding: lighter or darker without a new field.
gammanumber1Exponent on the field. Above 1 darkens the midtones.
invertbooleanfalseSwaps ink and paper.
phase{ x, y }{ x: 0, y: 0 }Offsets the tile in cells, so two canvases can share one page grid.
speednumber1Multiplier on elapsed seconds.
fpsnumber30Frame budget. The loop skips ticks to hold it.
reducedMotionTimenumber0The time of the one frame drawn under reduced motion.
observeResizebooleantrueDraws again when the canvas resizes.
pauseOffscreenbooleantrueStops while the canvas is out of view.

The handle returns render(time), start(), stop(), running, setField(fn), setOptions(patch) and destroy().

When to use it

Use the dither loop for a texture whose field is a formula: a globe, a burst, a ramp, a letterform, or a picture cut into a tone grid. It runs on the main thread and its cost grows with the number of cells, so a larger cell or a smaller canvas costs less. Use the studio field instead for a full-bleed hero, where the shader runs on the GPU.

Reduced motion and themes

Under prefers-reduced-motion: reduce the loop draws one frame at reducedMotionTime and never schedules an animation frame. Otherwise it stops when the canvas leaves the viewport or the tab is hidden. The loop does not watch the theme by itself: the host reads its ink token and calls setOptions({ ink }) when html[data-theme] changes, as this plate does. redrawDithers() does the same for every canvas.dither the brand deck draws.

The studio field

The plate runs createStudioField with the ten presets of BAYER_PRESETS. Grain, strength, rotation and brightness go to the running engine through setParams. A preset or speed change remounts the engine, and Still pauses it and draws its static frame.

The presets vary the matrix order (2 by 2, 4 by 4 or 8 by 8), the cell scale, the tone field under the screen, the motion and the balance of ink and blue. Preset 02, bayer-8x8, is the default and the material of the landing hero.

Import and props

import StudioField from '@/components/shared/StudioField';
import { BAYER_PRESETS, createStudioField } from '@/lib/studio-field';

<StudioField className='hero-field' preset='bayer8' speed={1} />;

const field = createStudioField(canvas, { preset: 'bayerHot', params: { grain: 40 } });
field?.setParams({ rotation: 30 });
field?.destroy();
PropTypeDefaultWhat it does
presetStudioPreset'bayer'One of the eleven shaders: the ten in BAYER_PRESETS and bayerSphere.
paramsPartial<StudioParams>the preset'sOverrides at mount: colorA, colorB, colorC, strength, detail, frequency, grain, amplitude, brightness, rotation.
speednumber1Multiplier on the shader clock.
dprnumbermin(devicePixelRatio, 2)The device pixel cap. The cells are per device pixel, so the cap stays at 2.
classNamestringnoneThe canvas's class. The caller positions it and keeps a dark plate behind it.

createStudioField returns setParams, pause, resume, renderStatic and destroy, or null when WebGL is missing. The wrapper takes its options once, so a change of preset is a remount with a new key.

When to use it

Use the studio field for the one dithered material of a product surface or a hero band, on an ink ground. Its palettes are the house blues over ink, so the plate stays dark in both themes. Use the CPU dither loop instead on paper, or where the field has to follow the page's ink.

Reduced motion and themes

Under reduced motion the engine draws one static frame and stops. It also pauses when the canvas leaves the viewport or the tab is hidden. Every mount shares one WebGL context for the session, and destroy() releases the mount but keeps the context, because creating a context per mount exhausts the browser's budget.

What a field costs

The CPU loop calls the field once per cell per frame, so its cost is linear in the number of cells. The cell count grows with the square of the inverse cell size: halving scale quadruples the work. These are the field evaluation times per frame at 144k cells, scale 3 on a 1440 by 900 box:

FieldMilliseconds a frame
gradientRamp0.8
makeGlyphField1.9
globe4.8, or 1.4 with graticule and landmass at 0
streakBands5.3
radialBurst15.1
  • The same box at scale 1 is 1.3 million field calls a frame, so a full-bleed field never animates at scale 1. The artifact pictures run at scale 1 because they are stills between short mixes.
  • radialBurst animates at scale 4 or more, which costs about 8 ms, or it renders once. Its dials are layers and warpOctaves; the globe's are graticule and landmass.
  • The loop writes every cell through one reused ImageData with a Uint32Array view and blits once with putImageData. That costs about 1 ms, where a fillRect per cell on 144k cells costs tens of milliseconds, so src/lib/dither.ts has no per-cell fillRect.

Dither on a moving sprite

A dither pattern on something that moves flickers unless the pattern moves by whole cells, and in practice it flickers even then. The glyph rain first moved its dithered sprites by whole CSS pixels to keep the Bayer pattern rigid, and slow rises stepped at about 15 Hz. Kevin reported them as looking "less than 60fps". The atlas then moved to device-pixel cells blitted on whole device pixels, so each step moved the pattern by whole cells, and the rain still flickered. On 2026-08-06 the dither came off the moving rain. Its depth now reads by glyph size and a quantized alpha (TIER_COVER in src/lib/glyph-field.ts), and the dither stays for still consumers. A later pass that redrew the rain as two-tone pixel type was reverted.

Put dither on a field or a still. A moving sprite carries solid ink.

Engine copies and canvas traps

  • The two copies of the CPU engine differ. gt-cloud's packages/ui/src/lib/dither.ts keeps field time across pauses, so a loop that was off screen resumes where it stopped, and its stop() holds the loop so the observers cannot restart it. Prototemplate's src/lib/dither.ts restarts field time at 0 on every start() and has no hold: its IntersectionObserver and its visibilitychange handler restart a stopped loop. Only Prototemplate's copy has the phase option.
  • A canvas that was never drawn keeps its default 300 by 150 backing store, and CSS stretches that store across the box for a frame. createDitherLoop paints one frame synchronously before it schedules any requestAnimationFrame for this reason.
  • A cloned canvas carries no bitmap. redrawDithers() therefore draws the deck's ramp canvases again from the computed --pt-paper and --pt-ink tokens, and again on every html[data-theme] change. It sizes each canvas from clientWidth and clientHeight, because a clone inside a scaled thumbnail is laid out at full size and only transformed down.
  • Two canvases share one page grid only when their phases differ by their offset. A canvas whose top-left cell sits n cells below another's takes phase: { x: 0, y: n % 8 }, or one tone lights different cells on each side of the seam.

Dither in practice gives the reasons behind ordered dither, cell sizes, transitions on one grid and the picture standard.

On this page