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 threeor and paste it into your agent.
Try the Pond experiment
fig. 01 · live specimen on the pond · drag through the water
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
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 callsfig. 03 · weight and italic spans · createRichText
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.
npx lettra bake font.ttf --weights 400,700 \ --italic italic.ttf --charset latin-es \ --size 64 --pxrange 8 --out public/fonts/nameLay 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
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 consumeGlyph 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
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 decodeStacking 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.
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
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().
import { WebGPURenderer } from 'three/webgpu'import type { PerspectiveCamera, Texture } from 'three/webgpu'import { ThreeDOM } from 'portalgl/three'import type { MSDFFont } from 'lettra'import { defineFamily, loadFont, loadFontTexture } from 'lettra/three'import type { FontFamily } from 'lettra/three'
export type FontName = 'bebas' | 'lora' | 'respira' | 'roboto' | 'lettra'
export interface FontBundle { font: MSDFFont map: Texture}
export interface Stage { renderer: WebGPURenderer /** Every figure is a `dom.addView(el, { scene, camera, onFrame })`. */ dom: ThreeDOM fonts: Record<FontName, FontBundle> /** Declared here and shared by every figure that wants a variant, so the * five Inter bakes are one set of atlases however many figures use them. * Lazy: nothing loads until a view awaits `load`/`loadAll`, and concurrent * awaits of one bake share its request. Stage-owned — views never dispose * it. */ inter: FontFamily dispose(): void}
/** Baked by `npx lettra bake Inter.ttf --weights 200,400,700 --italic …`. */const INTER_SRC = [ { json: '/fonts/inter-200.json', atlas: '/fonts/inter-200.png', weight: 200 }, { 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' as const }, { json: '/fonts/inter-700i.json', atlas: '/fonts/inter-700i.png', weight: 700, style: 'italic' as const },]
async function loadFontBundle(name: FontName): Promise<FontBundle> { const [font, map] = await Promise.all([loadFont(`/fonts/${name}.json`), loadFontTexture(`/fonts/${name}.png`)]) return { font, map }}
/** One renderer for every example on the page. PortalGL does the DOM sync: * each placeholder is a view that tracks its element, sizes its camera and * skips drawing while offscreen. Native WebGPU gets a canvas per view, the * WebGL2 fallback one shared canvas pinned to `container`, which must start * at the document origin. */export async function createStage(container: HTMLElement): Promise<Stage> { const [bebas, lora, respira, roboto, lettra] = await Promise.all([ loadFontBundle('bebas'), loadFontBundle('lora'), loadFontBundle('respira'), loadFontBundle('roboto'), loadFontBundle('lettra'), ]) const fonts = { bebas, lora, respira, roboto, lettra } const inter = defineFamily({ src: INTER_SRC })
const renderer = new WebGPURenderer({ antialias: true, alpha: true, forceWebGL: new URLSearchParams(location.search).has('forceWebGL'), }) await renderer.init() renderer.setClearColor(0x000000, 0)
// atlas uploads off the hot path, before any view compiles against them for (const bundle of Object.values(fonts)) renderer.initTexture(bundle.map)
const dom = new ThreeDOM({ renderer, container }) renderer.setAnimationLoop((time: number) => dom.update(time))
return { renderer, dom, fonts, inter, dispose() { renderer.setAnimationLoop(null) dom.destroy() for (const bundle of Object.values(fonts)) bundle.map.dispose() inter.dispose() renderer.dispose() }, }}
/** Camera distance that frames a text handle's ink box with a little margin * — shared by every example view. */export function frameText(camera: PerspectiveCamera, layout: { width: number; height: number; fontSize: number }) { const scale = 1 / layout.fontSize const halfW = (layout.width * scale) / 2 const halfH = (layout.height * scale) / 2 const tanH = Math.tan((camera.fov * Math.PI) / 360) const distance = Math.max(halfH / tanH, halfW / (tanH * camera.aspect)) camera.position.z = Math.min(60, Math.max(3, distance * 1.25))}
/** ThreeDOM owns the camera aspect; returns an `onFrame` that reframes * whenever the host's aspect changed since the last frame. */export function reframeOnResize(camera: PerspectiveCamera, frame: () => void) { let aspect = Number.NaN return () => { if (camera.aspect === aspect) return aspect = camera.aspect frame() }}
/** Token-cancelled rAF tween with cubic ease-out. */export function createTweener() { let token = 0 return { tween(duration: number, apply: (t: number) => void) { const current = ++token const start = performance.now() const step = (now: number) => { if (current !== token) return const t = Math.min(1, (now - start) / duration) apply(1 - Math.pow(1 - t, 3)) if (t < 1) requestAnimationFrame(step) } requestAnimationFrame(step) }, cancel() { token++ }, }}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
