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-morphis the honest tool.
Rules
-
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: seeword-contour. -
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. -
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.
-
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-contourdefaults to 1. Half when the mask is wide and nothing is drawn near the glyph; full when the effect ends at the edge. -
Upload
R16F, linear, clamped,UNPACK_ALIGNMENT1. NeverR32Fwith linear filtering (incomplete, reads zero, silently).R8only over a range of 64 px or less.R32FwithtexelFetchdrew beaded rings;R8over 255 px stepped ones. -
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-contourdefaults to 3) so the rings are curves. On the exact field use the constant1 / (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. -
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.
-
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.
-
Fade lines before the element’s box. Rings cut hard at a band’s edge were ugly at 360 px.
-
Set a value computed in
prepareas a uniform, never in module state. Two mounts on one page would share the variable; the research’s own sketch had this bug. -
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. -
Wait for the clock in any check. The one-shot harness starts its clock when
prepareresolves, which for a field is after three tasks; a check that reads the clock as soon as the canvas appears finds none. -
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. -
Shader comments cost bytes; TypeScript comments do not. The budget measures minified code.
-
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. -
The computed
font-stretchis 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. -
The line function’s zero is at integers. A single level at
q = 0isln(q), notln(q + .5), which draws the two neighbouring half-lines instead; andln(v / index)is zero atv = 0, the gap, so count index rings asln((v + 1) / index). -
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. -
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
R16Ftexture, 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 passesmapbox-tiny-sdf-repo: the same canvas-rasterised glyph input through that transform in the browserwebgl2-spec:R16FwithFLOATupload and half-float linear filtering as coremdn-oes-texture-float-linear: whyR32Fmay not be filtered without an extensionrong-tan-jfa-2006: jump flooding, the GPU alternative this topic does not needbcd-canvasrenderingcontext2d:letterSpacingandfontStretchsupport for the mask, Safari lacking the latterwai-decorative-images: the canvas and any generated mark are decorative, hidden from readerswcag-three-flashes: one short monotonic run, no flicker