atelier

Catalog / Technique

Distance field

Use when drawing anything from the distance to the page's own type or a picture's shape, in a fragment shader: contour rings around a heading, an outline that grows, ink that sharpens, a mark made from a word. Covers the mask, the transform, the texture, the line, and where such an effect may and may not sit on a page.

Loads as
technique-distance-field
Written for
An agent, to follow

Distance field of the page’s own type

A heading rasterised where the page draws it, every pixel’s distance from the nearest inked edge computed exactly on the main thread, and a fragment pass that turns the distance into lines, a ring or a fill. The topology look applied to the page’s type: the map is of the word.

When to use

  • A hero, a page’s arrival, a section’s heading: rings around the heading that reveal outward and stay as a still.
  • An emblem or a divider made from a word, at build time, as SVG (the generator; see research/topic/distance-fields-of-type.md).

Do not use it for:

  • Type under 32 px: rings crowd it and diacritic dots merge into the first ring.
  • Beside a real map or chart, where drawn contours read as data.
  • Body text, a reading column, a listing, a card grid.
  • A language switch blending two words: the midpoint is a non-word with both languages legible at once. page-morph is the honest tool.

Rules

  1. Start from Lab 9’s mask and keep the alpha. The text as the page renders it (whitespace collapsed, the transform in the page’s language), measured through a Range, drawn at the half-leading baseline after the fonts settle; never textBaseline = 'middle'. Coverage is worth a three- to four-fold drop in mean error over a binary mask and costs one array: see word-contour.

  2. Felzenszwalb and Huttenlocher, recording the nearest feature, and correct by its coverage. sqrt(f) + 0.5 - a(nearest): mean error 0.08 to 0.12 px against 0.21 to 0.35 for a binary mask (measured). Do not seed the squared distance with plain (0.5 - a)²; it measured worse than binary.

  3. Measure the transform cold. It runs once, so warm numbers flatter it: 50 to 56 ms cold at 1,200 by 474 against 36 to 48 warm. Yield between the column half and the row half, so neither is one long task.

  4. The field’s resolution is judged against the mask’s size, not the gap alone. The research saw a half-resolution field indistinguishable from the second ring out on a 1,200 px mask; on the catalog’s 550 px band the same 0.5 wobbled along straight edges on every ring, and 0.25 badly. word-contour defaults to 1. Half when the mask is wide and nothing is drawn near the glyph; full when the effect ends at the edge.

  5. Upload R16F, linear, clamped, UNPACK_ALIGNMENT 1. Never R32F with linear filtering (incomplete, reads zero, silently). R8 only over a range of 64 px or less. R32F with texelFetch drew beaded rings; R8 over 255 px stepped ones.

  6. A distance field has slope 1, and its rings are polylines. An exact field is a union of cones from every edge pixel: the rings crease wherever the nearest pixel changes, and the coverage correction leaves a half-pixel notch at each change, which 4x crops showed at every pixel ratio and the owner read as jagged. Blur the field by a few pixels (word-contour defaults to 3) so the rings are curves. On the exact field use the constant 1 / (spacing * pixelRatio) for the line width; on a blurred one, or once noise is added, the derivative, since the slope is under 1 at a rounded crease. A quantised plateau has zero derivative and the first-order width blows up into blobs, which neither case has.

  7. Never cut the canvas word out of the live word. Registration is within about a pixel, not exact: an uncovered sliver shows as a seam down every stem. Cover it in its own colour, or keep a gap.

  8. Count index rings from the first ring after the gap. Counted from zero, the heavy ring sits at the gap and draws a stroke round every letter.

  9. Fade lines before the element’s box. Rings cut hard at a band’s edge were ugly at 360 px.

  10. Set a value computed in prepare as a uniform, never in module state. Two mounts on one page would share the variable; the research’s own sketch had this bug.

  11. A motion that emits rings must keep emitting. Sliding the levels outward and cutting everything inside the slide (step(0, v)) left the word bare after the first ring left; let the levels slide and fade the whole set instead.

  12. Wait for the clock in any check. The one-shot harness starts its clock when prepare resolves, which for a field is after three tasks; a check that reads the clock as soon as the canvas appears finds none.

  13. Check Turkish words for fusions (ı with n, r with n) in any effect that swells letters; “Işığın” read as “Işığm” at a third of ink-sharpen’s run.

  14. Shader comments cost bytes; TypeScript comments do not. The budget measures minified code.

  15. Draw the mask in the padding box. The harness’s canvas sits at inset 0 of the host, which is the padding box; a mask drawn in the border box is off by the border, and the band fixture’s 1 px border was the research’s unexplained 1 px offset. A page host (body) would squeeze a document-tall mask onto the viewport: refuse it.

  16. The computed font-stretch is a percentage and the canvas takes a keyword. Assigning the percentage is a silent no-op; map the nine named percentages and refuse the rest, or a stretched heading registers at normal width.

  17. The line function’s zero is at integers. A single level at q = 0 is ln(q), not ln(q + .5), which draws the two neighbouring half-lines instead; and ln(v / index) is zero at v = 0, the gap, so count index rings as ln((v + 1) / index).

  18. An effect that ends at distance zero covers the glyph in its own colour, alpha included, on the exact field. The fill is read from the heading’s computed colour through a colour-mix probe of its own, like the one the harness uses for tokens, with a parser that takes signs and exponents and clamps: display lettering set as a tint of the ink is ink at a low alpha, and a colour outside sRGB comes back from the mix with negative channels; a parser of digits alone fills in black either way. The field is not blurred, since a blurred field is positive inside the glyph’s edge and would pull the fill inside it. See ink-sharpen.

  19. A transform yields inside its halves too. On a hero-sized mask each half of the transform is over 100 ms; a yield every 120,000 pixels of work keeps every task to a few tens of milliseconds.

Decisions

Question Choose Because
Which transform? Felzenszwalb and Huttenlocher on the CPU Exact, 40 lines, 40 ms at 1,200 by 600; jump flooding needs eleven passes over float targets for a shape that never changes
Signed or outside only? Outside only Nothing is drawn on or inside a glyph; inside doubles the cost
Which texture? R16F, linear Smooth and even; core in WebGL2; half precision is a quarter of a pixel at 512 px
Field resolution? 1 on a narrow band, 0.5 on a wide mask with a gap The wobble is in mask pixels, not CSS pixels
Exact or blurred? Blurred by about 3 px for rings; exact for a fill that ends at the glyph Exact rings are polylines with notches; a blur rounds them. A blurred field is positive inside the glyph’s edge and would pull a fill inside it
Gap in px or em? Em (0.1), spacing and reach in px scaled with the heading down to 0.6 Counters stay empty at every size; a phone heading gets rings at about 8 px
Reduced motion? Nothing Showing the finished still would change every one-shot recipe

Tuning

Starting values are the lab’s, judged on a 128 px heading in a 550 px band and a 44 px heading at 360 wide.

Dial Start Range One end The other
Spacing (CSS px, at 128 px type) 14 6 to 40 Moire and a grey mass below 6 A few loose rings, a sticker more than a map
Gap (em) 0.1 0.02 to 0.4 Rings touch the letters, tiny rings in counters A halo of empty ground
Reach (CSS px, at 128 px type) 220 40 to 600 A tight outline set Fills the band
Line width (CSS px) 1.25 0.75 to 2 Hairline Diagram-like
Index every 5 0, 4, 5
Terrain (CSS px) 0; 40 for the version 0 to 80 Pure offset rings Near rings wobble too above about 60
Feature (CSS px) 180 90 to 400 Busy wiggles Broad sweeps
Edge fade (CSS px) 24 0 to 64 Hard cut at the band’s edge
Smooth (CSS px) 3 0 to 8 Polylines with notches Creases rounded over a spacing; the first ring drifts off the glyph’s shape
Duration (ms) 1,400 900 to 2,400 A snap A loading screen

Rest state

Tide keeps its last frame as a plain 2D canvas, the rings as a still over the heading, when rest is 1; Ripple and Outline end empty and the canvas leaves. Under reduced motion, without WebGL2, on a software renderer, on type under 32 px or a two-line heading there are no rings at all: the heading, plain.

Cost

  • To build: medium; the parts are sourced and the work is tuning and the checks.
  • To run: the mask 15 to 43 ms, then the transform in two halves, the three as separate tasks each under 60 ms on a 550 by 490 band at full resolution (the research’s 50 to 56 ms was one task at 1,200 by 474 and half resolution); one texture read and a few dozen ALU ops per pixel, two octaves of noise more for terrain; a 1.4 s run, then a still or nothing. Memory: the mask and the float field, transient; the R16F texture, 2 bytes a texel, until the context goes.
  • To the reader: the heading is real text throughout, readable at full ink strength because no line touches it.

Checklist

  • The budget check passes.
  • npm run check:browser: not one lit pixel on a glyph or within half the gap of one.
  • npm run check:browser: the rings repeat at the spacing.
  • npm run check:browser: type under 32 px and a two-line mark mount nothing.
  • npm run check:browser: no task over 100 ms on the main thread.
  • Looked at once at 360 wide with Turkish text, pixel ratio 2.
  • Run once in Safari and once in Firefox before it is marked passed.

Recipes

  • word-contour: rings around the heading; Tide, Ripple, Outline; Terrain.
  • ink-sharpen: the heading swollen and inky, sharpening to itself; Bleed the reverse.

Sources

  • felzenszwalb-edt-2012: the lower envelope of parabolas, the exact transform in two 1D passes
  • mapbox-tiny-sdf-repo: the same canvas-rasterised glyph input through that transform in the browser
  • webgl2-spec: R16F with FLOAT upload and half-float linear filtering as core
  • mdn-oes-texture-float-linear: why R32F may not be filtered without an extension
  • rong-tan-jfa-2006: jump flooding, the GPU alternative this topic does not need
  • bcd-canvasrenderingcontext2d: letterSpacing and fontStretch support for the mask, Safari lacking the latter
  • wai-decorative-images: the canvas and any generated mark are decorative, hidden from readers
  • wcag-three-flashes: one short monotonic run, no flicker