# Lettra > Runtime MSDF text for Three.js WebGPURenderer + TSL. Baked atlas in, kerned layout and a composable node material out. No wasm, no shaper, sharp at any scale. Lettra is an MIT-licensed npm package from [JOYCO](https://joyco.studio). It ships two entry points: `lettra` (BMFont schema parsing and layout, renderer-agnostic, runs in Node) and `lettra/three` (geometry, a TSL node material, and effects for `three/webgpu`). Install with `pnpm add lettra three`, where `three >= 0.185` is an optional peer. Every URL on this host answers to `Accept: text/markdown`, so you can fetch the Markdown representation of a page instead of parsing its HTML. The homepage is also at /index.md. ## When to use this Reach for Lettra when all of these hold: - The text has to live **inside a Three.js scene**, as geometry a camera can fly through, not as DOM or SVG laid over the canvas. - You are rendering through `WebGPURenderer` and want text that composes with your own **TSL node graph** rather than a closed shader. - The text must stay **crisp under arbitrary zoom, scale, and camera angle**, so a rasterized texture atlas or canvas texture will not do. - The script is **Latin** (the playground charset is ASCII printable plus `áéíóúüñÁÉÍÓÚÜÑ¿¡—–""''`). Headings, specimens, labels, UI copy, paragraphs. - You want **animated type**: reveals and dissolves through the distance field, decoder/scramble transitions, or glyph behavior driven by an arbitrary scalar field such as a cursor trail, an audio level, or a fluid surface SDF. - **Bundle size matters.** About 5 KB gzipped, no wasm, no runtime shaping engine, zero runtime dependencies. Do not reach for Lettra when: - You need **complex script shaping**: Arabic, Indic, contextual ligatures, bidirectional paragraphs, or CJK-scale charsets. Those need a real runtime shaper. Use [@pmndrs/glyph](https://github.com/pmndrs/glyph). - You need **color emoji**. - The project is **WebGL-only and cannot import `three/webgpu`**. Lettra runs a WebGL2 fallback through that same entry point, but the import is mandatory. - The text can just be **DOM or SVG**. Then it should be. How to call it, shortest path that works: ```bash pnpm add lettra three ``` ```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 ``` Three things that bite agents wiring this up for the first time: 1. **Fonts are baked ahead of time, with the CLI the package ships.** `createText` starts from an atlas PNG plus a metrics JSON. Produce them with `npx lettra bake font.ttf --weights 400,700 --italic italic.ttf --charset latin-es --size 64 --pxrange 8 --out public/fonts/name`. It preflights the font, instances variable fonts with fontTools (install once: `pip3 install fonttools`), recovers the class-based GPOS pairs the generator's parser misses, and prints a ready `defineFamily` block. Keep distance range 8: erosion wipes need the headroom. Single atlas page, no rotated packing. 2. **Check the kerning count the bake reports.** Variable fonts whose kerning lives in variable GPOS bake to 0 pairs silently; the CLI's instancing step is what keeps them. Baking by hand means running `python3 -m fontTools.varLib.instancer font.ttf wght=400 -o static.ttf` first. `parseFont` warns at runtime when a font arrives with an empty kerning table. 3. **Weights and styles compose into a family, not separate texts.** `defineFamily({ src: [...] })` declares the bakes and resolves requests 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. `createRichText({ family, text, spans })` puts several variants in one paragraph, so an italic run keeps the paragraph's wrapping and baseline. ## Docs - [Homepage as Markdown](https://lettra.dev/index.md): the specimen page, its figures, and the copy-paste task prompt - [README](https://github.com/joyco-studio/lettra#readme): full API, effects, composing your own node material, lifecycle contract - [Quickstart](https://github.com/joyco-studio/lettra#quickstart): install and first mesh - [Effects](https://github.com/joyco-studio/lettra#effects): wipe, scramble, composeEffects, writing your own - [The bake CLI](https://github.com/joyco-studio/lettra#the-bake-cli): every `lettra bake` flag, charset presets, what the output reports - [Baking fonts](https://github.com/joyco-studio/lettra#baking-fonts): the format's rules, manual routes, kerning pitfalls - [Layout](https://github.com/joyco-studio/lettra#layout): the renderer-agnostic layout pass and its options - [npm package](https://www.npmjs.com/package/lettra): versions and install size - [Issues](https://github.com/joyco-studio/lettra/issues): bug reports and questions ## Optional - [JOYCO](https://joyco.studio): the studio that maintains Lettra - [MSDF reconstruction notes](https://hub.joyco.studio/toolbox/msdfgen): why the median-of-three trick works - [PortalGL](https://hub.joyco.studio/toolbox/portalgl): keeps this site's WebGPU views aligned with the DOM - [@joycostudio/susano](https://www.npmjs.com/package/@joycostudio/susano): asset loading and preload dedupe used alongside Lettra - [@joycostudio/xyz](https://www.npmjs.com/package/@joycostudio/xyz): scene-wide warmup that covers text meshes - [Repository](https://github.com/joyco-studio/lettra): source, layout engine, and tests