# Lettra: sharp text, baked flat

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

- Package: [`lettra`](https://www.npmjs.com/package/lettra) on npm, MIT licensed, by [JOYCO](https://joyco.studio)
- Source and full API: [https://github.com/joyco-studio/lettra](https://github.com/joyco-studio/lettra#readme)
- Renderer: `three/webgpu` `WebGPURenderer` + TSL, with a WebGL2 fallback
- Scope: Latin-script UI and display text, single or multiline, live string swap

## Getting started

```bash
pnpm add lettra three
```

`three >= 0.185` is an optional peer. The core entry (schema + layout) is
renderer-agnostic and runs anywhere, including Node.

```ts
import { createText, loadFont, loadFontTexture, wipe } from 'lettra/three'

const [font, map] = await Promise.all([
  loadFont('/fonts/display.json'),
  loadFontTexture('/fonts/display.png'),
])

const text = createText({
  font,
  map,
  text: '¡Hola! Sharp at any scale.',
  layout: { align: 'center', maxWidth: 900 },
  material: { fill: '#e8e4da', effect: wipe() },
})
scene.add(text.mesh)

// compile the pipeline + upload the atlas off the hot path
await text.warmup(renderer, camera, scene)

text.setText('live string swap')  // relayout, per keystroke is fine
text.uniforms.wipeIn.value = 0.5  // tween 0 -> 1 to reveal
```

### Task prompt

The homepage offers this as a one-click "copy agent prompt". It is the whole
integration brief, reproduced here so an agent reading Markdown gets the same
thing a human gets from the button:

```text
Add lettra (runtime MSDF text for Three.js WebGPURenderer + TSL) to this project.

Install: pnpm add lettra three   (three >= 0.185)

Quickstart:
import { createText, loadFont, loadFontTexture, wipe } from 'lettra/three'
const [font, map] = await Promise.all([loadFont('/fonts/display.json'), loadFontTexture('/fonts/display.png')])
const text = createText({ font, map, text: 'Hello', layout: { align: 'center' }, material: { fill: '#414141', effect: wipe() } })
scene.add(text.mesh)
await text.warmup(renderer, camera, scene) // pipeline compile + atlas upload off the hot path
text.uniforms.wipeIn.value = 1 // tween 0 -> 1 to reveal; wipeOut consumes

Fonts are baked once at build time with the bundled CLI (it instances variable fonts and recovers GPOS kerning):
npx lettra bake font.ttf --weights 400,700 --italic italic.ttf --charset latin-es --size 64 --pxrange 8 --out public/fonts/name
Weight/style variants compose into a family:
import { createRichText, 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' },
  ],
})

// nearest bake, sheared into an oblique only if no italic was baked
const heading = createText({ variant: await inter.load({ weight: 500, style: 'italic' }) })
heading.setVariant(await inter.load({ weight: 700 }))  // font + atlas + slant, one tick

// weight or italic spans inside a single paragraph-wide layout
await inter.loadAll()
const paragraph = createRichText({
  family: inter,
  text: 'one layout, regular to bold',
  spans: [{ start: 23, end: 27, weight: 700 }],
})
scene.add(paragraph.group)
- keep distance range 8 (erosion wipes need the SDF headroom), single atlas page, no rotated packing

Full API (layout engine, effects, composing TSL nodes, lifecycle contract): https://github.com/joyco-studio/lettra#readme
```

## How it works

**Bake once.** A font becomes a small PNG atlas and a metrics JSON: each glyph a
multi-channel distance field, each kerning pair carried over. It happens at
build time, by hand or script. The library starts where the bake ends.

```bash
npx lettra bake font.ttf --weights 400,700 --italic italic.ttf --charset latin-es --size 64 --pxrange 8 --out public/fonts/name
```

**Lay out on the CPU.** A typed port of the classic BMFont pen walk: pairwise
kerning, greedy word wrap, alignment, letter-spacing. Bounds come from the ink
itself rather than font metrics, so display faces center the way they look, not
the way their line boxes claim.

**Reconstruct on the GPU.** The material takes the median of three channels,
sharpens it over half a derivative's width, and exposes erosion wipes that
dissolve glyphs through the distance field, edges first and stroke skeletons
last. Every node is exported, typed, and replaceable.

### Bake rules the parser enforces

- Single atlas page. Multi-page bakes are rejected; raise the texture size.
- No rotated glyph packing.
- Distance range `-r 8`, for AA quality and erosion headroom.
- Include space and `?` in the charset; they back the runtime fallbacks.
- Check the kerning count. Variable fonts bake with 0 pairs when their kerning
  lives in variable GPOS. `npx lettra bake` instances them first, which is what
  keeps the pairs; baking by hand means running
  `python3 -m fontTools.varLib.instancer font.ttf wght=400 -o static.ttf` yourself.

## Families and italics

```ts
import { createRichText, 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' },
  ],
})

const text = createText({ variant: await inter.load({ weight: 500 }), text: 'Hello' })
text.setVariant(await inter.load({ weight: 700, style: 'italic' }))
```

Resolution is CSS-like: an exact hit serves its atlas, a weight in between
serves the closest bake unmodified, and an italic request with no italic bake
gets a sheared one. Weight itself is never synthesized: bake the weights you
want.

`createRichText({ family, text, spans })` puts several variants in one
paragraph, so an italic or bold run inside a sentence keeps the paragraph's
wrapping, alignment and baseline. Spans take the same `{ weight, style }` keys
and bucket by resolved variant, so repeated spans share a draw call.

## Effects

The base material is plain MSDF fill + opacity. An effect is a uniform bag plus
a set of per-wire transforms: pass one as `material.effect` and its uniforms
merge into `text.uniforms`, typed. Each ships from its own module, so an
effect you never import never reaches your bundle.

- `wipe({ band?, coord? })` is a threshold-erosion dissolve, not a clip. Glyph
  edges dissolve first and stroke skeletons last while the front sweeps across
  the ink width. `wipeIn` reveals, `wipeOut` consumes, both 0 -> 1.
- `scramble({ font, chars?, rate?, drive?, capacity? })` is the decoder effect.
  A glyph renders a random glyph from the same atlas, re-rolled `rate` times a
  second. Reads best when the pool shares an ink box, like monospace faces.

## Composition

Two effects on one text is `composeEffects(a, b)`: uniforms merge, `uv`
remaps chain, erosions add, and the typing carries through.

The deeper seam is `drive`, which takes any TSL node at all. That is where the
library stops and your scene starts, so an audio level, a cursor distance or a
whole fluid sim all plug in the same way. The homepage ships that last one end
to end: a cursor-following water trail whose rim scrambles a paragraph and whose
interior tints the ink wet.

## Layout

```ts
import { layout, parseFont } from 'lettra'

const result = layout(font, 'Hello\nworld', {
  align: 'center',      // against the widest line
  letterSpacing: 2,     // extra advance, baked px
  lineHeight: 72,       // overrides the baked value
  maxWidth: 900,        // enables greedy word wrap
  mode: 'greedy',       // 'pre' (only \n) | 'nowrap'
})
// -> { glyphs: [{ x, y, w, h, u0..v1, index, line }], width, height, inkOrigin, metrics }
```

`width` and `height` are the ink bounding box, not font metrics. Line-box
numbers live in `metrics`.

## Lifecycle contract

- `dispose()` releases geometry, material, and by default the atlas. Pass
  `{ map: false }` when atlases are shared.
- `warmup(renderer, camera, scene?)` forces atlas upload and one pipeline
  compile so the first visible frame does not hitch.
- `swapFont({ font, map, text? })` rebinds geometry and atlas in one
  synchronous block. Load the texture first.
- `onChange(cb)` fires when rendered output changes. Demand-driven render loops
  invalidate exactly one frame from it.

## Non-goals

No complex shaping (Arabic, Indic, contextual ligatures), no CJK-scale charsets,
no color emoji, no bidi paragraphs. Those need a real shaper at runtime; use
[@pmndrs/glyph](https://github.com/pmndrs/glyph) instead. Lettra is for
Latin-script UI and display text that wants to be tiny and fast.

Known limit: `lettra/three` imports `three/webgpu`, which ships ESM-only. The
CJS build of that subpath exists but is only usable through bundlers.

## What pairs with it

Lettra draws text. It does not own your canvas, your scroll, or your render
loop, and it never will. What we reach for alongside it:

- [@joycostudio/metri](https://hub.joyco.studio/toolbox/metri) measures DOM
  elements into cached document-space rects behind one shared ResizeObserver,
  which is how each figure here finds its viewport.
- [@joycostudio/susano](https://www.npmjs.com/package/@joycostudio/susano) for
  asset loading and preload dedupe, including the atlas PNG and metrics JSON.
- [@joycostudio/xyz](https://www.npmjs.com/package/@joycostudio/xyz) for
  scene-wide warmup that covers text meshes along with everything else.
- The [WebGL scroll sync](https://hub.joyco.studio/logs/08-webgl-scroll-sync)
  log for pinning one canvas to the document without drift.

## This site

Every figure on the homepage is ink on one shared WebGPU canvas in page space,
scroll-synced to the document. Append `?forceWebGL` to exercise the WebGL2
fallback. Specimen faces: Bebas Neue and Lora, OFL. Layout ported from Jam3's
layout-bmfont-text (MIT).

Machine-readable entry points:

- [https://lettra.joyco.studio/llms.txt](https://lettra.joyco.studio/llms.txt) covers when to reach for Lettra and how to call it
- [https://lettra.joyco.studio/index.md](https://lettra.joyco.studio/index.md) is this document at a stable URL
- [https://lettra.joyco.studio/sitemap.xml](https://lettra.joyco.studio/sitemap.xml)
- Any URL on this host answers to `Accept: text/markdown`

MIT © [joyco.studio](https://joyco.studio). Bugs and questions: [https://github.com/joyco-studio/lettra/issues](https://github.com/joyco-studio/lettra/issues).
