Skip to content

Sharp text, baked flat

[webgpu]

Lettra renders kerned text on the GPU from a font baked once into a multi-channel signed distance field (MSDF). No runtime shaper, no wasm: a few kilobytes of layout and a Three.js node material, sharp at any scale and angle.

Every figure below is a live WebGPU view, kept in sync with the page by PortalGL. Hit the </> button on a figure to see its code.

Getting started

pnpm add lettra three

or and paste it into your agent.

Try the Pond experiment

fig. 01 · live specimen on the pond · drag through the water

File:
specimen.ts
import { createText, loadFont, loadFontTexture } from 'lettra/three'
const [font, map] = await Promise.all([  loadFont('/fonts/display.json'),  loadFontTexture('/fonts/display.png'),])
const text = createText({  font,  map,  text: 'Make it\nrespond.',  layout: { align: 'center', letterSpacing: 0 },})scene.add(text.mesh)
await text.warmup(renderer, camera, scene)text.setText('live string swap')

Families & italics

[family]

Three weights and two italics are baked here. The slider steps through CSS weights, with the baked ones ticked darker, and the readout shows which atlas is in use. A weight in between uses the closest bake as is: weight is never synthesized. Only the oblique is, when a family has no italic.

Loading…

fig. 02 · weights and italics from one family · drag to turn the leaf

File:
family.ts
import { createText, defineFamily } from 'lettra/three'
const inter = defineFamily({  src: [    { json: '/fonts/inter-400.json', atlas: '/fonts/inter-400.png', weight: 400 },    { json: '/fonts/inter-700.json', atlas: '/fonts/inter-700.png', weight: 700 },    { json: '/fonts/inter-400i.json', atlas: '/fonts/inter-400i.png', weight: 400, style: 'italic' },  ],})
// load is the only async point; weight 500 serves the 400 bakeconst text = createText({ variant: await inter.load({ weight: 500 }), text: 'Hello' })text.setVariant(await inter.load({ weight: 700, style: 'italic' }))

Spans in one paragraph

[rich text]

A span is a { start, end, weight, style } range over the string, resolved through the family. One layout measures the whole paragraph, so wrap, alignment and baseline hold across spans. Spans are grouped by variant, so two italic spans cost one draw call. Kerning stops at span boundaries, since kerning tables are per font.

The figure is the page from the home, lying by the pond. Click to type, select words to make them bold or italic, and change a block’s spacing. The editor state is just { text, spans }. The page bakes Roboto Serif in two weights, each with its italic.

Press the page to write on it. Select words on it to set them bold or italic; space the blocks and align the page from the tools above.

3 spans · 4 draw calls

fig. 03 · weight and italic spans · createRichText

File:
rich-text.ts
import { createRichText } from 'lettra/three'
await inter.loadAll() // spans resolve synchronously
const rich = createRichText({  family: inter,  text: 'Mark a word bold, set another in italic, and the paragraph still wraps as one block.',  spans: [    { start: 12, end: 16, weight: 700 },    { start: 33, end: 39, style: 'italic' },  ],  layout: { align: 'center', maxWidth: 1150 },})scene.add(rich.group)

How it works

[pipeline]

Bake once. A font becomes a PNG atlas and a metrics JSON: each glyph a multi-channel distance field, kerning pairs included. It runs at build time, by hand or in a script.

Lay out on the CPU. A typed port of BMFont layout: pair kerning, word wrap, alignment, letter-spacing. Bounds come from the ink, not the font metrics, so display faces center the way they look.

Render on the GPU. The material takes the median of the three channels and antialiases the edge with screen-space derivatives. Every node is exported, typed and replaceable.

Opt-in effects

[effects]

The base material is MSDF fill and opacity. An effect is a set of uniforms plus shader transforms: pass one as material.effect and its uniforms merge into text.uniforms, typed. Each effect is its own module, so the ones you don’t import don’t reach your bundle. Lettra ships the two below.

Erosion wipes

The wipe doesn’t mask, it erodes. A front sweeps across the text and raises the distance threshold as it passes: thin edges go first, stroke centers last.

fig. 04 · erosion wipe · plays as it enters

File:
wipe.ts
import { createText, wipe } from 'lettra/three'
const text = createText({ font, map, text: 'SOFT EDGES GO', material: { effect: wipe({ band: 0.3 }) } })
text.uniforms.wipeIn.value = 1  // tween 0 -> 1 to revealtext.uniforms.wipeOut.value = 1 // tween 0 -> 1 to consume

Glyph scramble

While driven, each glyph shows a random glyph from the same atlas, re-rolled a few times a second. Glyphs switch in a fixed random order, so lowering the drive decodes the line letter by letter.

fig. 05 · atlas scramble · decodes as it enters

File:
scramble.ts
import { createText, scramble } from 'lettra/three'
const text = createText({  font,  map,  text: 'READING THE GLYPHS',  material: { fill: '#9ece6a', effect: scramble({ font, chars: 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789#$%&' }) },})text.uniforms.scramble.value = 1 // tween back to 0 to decode

Stacking and driving

[composition]

composeEffects(a, b) stacks two effects on one text: uniforms merge, uv remaps chain, erosions add, and types carry through. drive takes any TSL node, so an audio level, a cursor distance or a fluid sim all plug in the same way.

File:
drive.ts
import { attribute, float, max } from 'three/tsl'import { composeEffects, scramble, wipe, wipeErosion } from 'lettra/three'
const wipeFx = wipe({ band: 0.3 })const erosion = wipeErosion({  wipeIn: wipeFx.uniforms.wipeIn,  wipeOut: wipeFx.uniforms.wipeOut,  coord: attribute('layoutX', 'float'),  band: float(0.3),})const touched = erosion.mul(erosion.oneMinus()).mul(4) // bell: peaks mid-dissolveconst effect = composeEffects(wipeFx, scramble({ font, drive: (knob) => max(knob, touched) }))

Break the wall

The distance field isn’t only for drawing letters. Here the words are the home’s finale heading, torn out of the TV room’s wallpaper: each glyph’s distance against its threshold, pushed in and out by noise, is the tear line, and the curled backing, its shadow and the planked wood behind are all shaded from that one value. Rub the wall and the cursor paints a second field into a small render target; broken up by the same noise, it tears with the same edge. Reset lays the paper back over your tears; the words stay torn.

fig. 06 · the words’ distance field tearing the wallpaper · rub to tear

File:
tear.ts
import { createText } from 'lettra/three'import { fwidth, max, mix, mx_noise_float } from 'three/tsl'import { positionLocal, smoothstep, texture } from 'three/tsl'
const text = createText({ font, map, text: 'BREAK THE WALL' })const { distance, threshold } = text.nodes
// the glyph's signed distance past its edge, in emsconst glyph = threshold  .sub(distance)  .mul(font.distanceRange / font.size)// a ragged tear line: the edge pushed about by noiseconst noise = mx_noise_float(positionLocal.xy.mul(2.2))const tear = glyph.add(noise.mul(0.06))
// the cursor paints a second field into a targetconst rubbed = texture(brushTarget.texture).r
// paper gone wherever either tears, and a strip// of curled backing round the holeconst aa = fwidth(tear)const hole = max(  smoothstep(aa, aa.negate(), tear),  smoothstep(0.45, 0.55, rubbed))const edge = smoothstep(aa, aa.negate(), tear.sub(0.05))const backing = edge.sub(hole)const paper = mix(wallpaper, paperBacking, backing)material.colorNode = mix(paper, wood, hole)

What pairs with it

[ecosystem]

Lettra draws text. It doesn’t own your canvas, scroll or render loop. Here’s what we use for those, and how this page is built.

Each figure is a placeholder div registered as a PortalGL view. PortalGL tracks its bounds on scroll and resize, keeps the camera aspect in sync, and skips drawing it offscreen. On WebGPU each view gets its own canvas; the WebGL2 fallback shares one canvas across the page. The stage is one renderer, the atlases, and a loop that calls dom.update().

From readme.md

[colophon]
package
lettra · npm
renderer
webgpu · webgl fallback
engine
three/webgpu + tsl
tracking
portalgl
license
mit

Latin scripts, single and multiline, live text swaps. No complex shaping, color emoji or bidi; use a real shaper for those. Layout ported from Jam3’s layout-bmfont-text (MIT). Specimen faces: Respira and Roboto Serif (OFL). Add ?forceWebGL to test the fallback. MIT © joyco.studio