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.
Every figure below is ink on one shared canvas, scroll-synced to the page. Hit the </> square on any figure to read its snippet.
pnpm add lettra threeimport { createText, loadFont, loadFontTexture } from 'lettra/three'
const [font, map] = await Promise.all([
loadFont('/fonts/respira.json'),
loadFontTexture('/fonts/respira.png'),
])
const text = createText({
font,
map,
text: "I am Sir Fabroos\nThe destroyer of bugs",
layout: { align: 'center' },
material: { fill: '#414141' },
})
scene.add(text.mesh)
// pipeline compile + atlas upload off the hot path
await text.warmup(renderer, camera, scene)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.
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.
The wipe never masks; it erodes. A front sweeps the ink and raises the distance threshold as it passes. Thin edges give way first, stroke skeletons hold out last, every glyph dissolving through its own field.
While driven, a glyph renders a random same-font glyph instead, re-rolled a few times a second: the decoder effect, straight from the atlas. Glyphs engage in stable random order, so sweeping the drive down decodes the line letter by letter.
The scramble's drive is just a scalar field, so anything can hold the pen. Here it's a small GPU fluid sim: ink splatted along the cursor stroke, advected by its own velocity, swirling while you move and soaking away when you stop. Glyphs on its rim re-roll through the atlas, the wet interior darkens the ink. None of it is library code; an audio level or a wipe front plugs into the same seam.
The page keeps a single WebGPU canvas in page space and slides it back over the viewport each frame, the “absolute” approach from the JOYCO WebGL Scroll Sync log. Content never drifts from the DOM during scroll; 25% padding top and bottom absorbs the one-frame-stale transform. Each figure is a placeholder div measured by Metri (cached document-space bounds, one shared ResizeObserver) and rendered into its rect with a scissored viewport. Frames are demand-driven: no scroll, no tween, no render.
import { PerspectiveCamera, Scene, WebGPURenderer } from 'three/webgpu'
import type { Texture } from 'three/webgpu'
import type { Bounds, Metri, Viewport } from '@joycostudio/metri'
import type { MSDFFont } from 'lettra'
import { loadFont, loadFontTexture } from 'lettra/three'
export type FontName = 'bebas' | 'lora' | 'respira' | 'roboto' | 'lettra'
export interface FontBundle {
font: MSDFFont
map: Texture
}
export interface StageView {
scene: Scene
camera: PerspectiveCamera
/** Placeholder size changed (CSS px) — reframe the camera. */
resize?(width: number, height: number): void
/** Called once per frame while the view is on screen. Return true to keep
* rendering next frame (time-driven effects like scramble). */
update?(time: number): boolean | void
}
export interface ViewHandle {
/** Request a render — call after mutating uniforms, geometry, rotation… */
invalidate(): void
dispose(): void
}
export interface Stage {
renderer: WebGPURenderer
fonts: Record<FontName, FontBundle>
addView(el: HTMLElement, view: StageView): ViewHandle
invalidate(): void
dispose(): void
}
async function loadFontBundle(name: FontName): Promise<FontBundle> {
const [font, map] = await Promise.all([loadFont(`/fonts/${name}.json`), loadFontTexture(`/fonts/${name}.png`)])
return { font, map }
}
interface RegisteredView {
el: HTMLElement
view: StageView
bounds: Bounds | undefined
dispose(): void
}
/** One canvas for every example on the page, scroll-synced with the
* "absolute" approach from the JOYCO WebGL Scroll Sync log: the canvas lives
* in page space (moves with the compositor, so tracked content never drifts
* from the DOM) and is slid back over the viewport each frame, oversized by
* 25% top and bottom so the one-frame-stale transform never shows an edge.
* Placeholder tracking comes from Metri — cached document-space bounds, one
* shared ResizeObserver, no per-frame getBoundingClientRect. */
export async function createStage(canvas: HTMLCanvasElement, metri: Metri): 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 renderer = new WebGPURenderer({
canvas,
antialias: true,
alpha: true,
forceWebGL: new URLSearchParams(location.search).has('forceWebGL'),
})
await renderer.init()
renderer.setClearColor(0x000000, 0)
renderer.setPixelRatio(Math.min(devicePixelRatio, 2))
// atlas uploads off the hot path, before any view compiles against them
for (const bundle of Object.values(fonts)) renderer.initTexture(bundle.map)
/* canvas geometry: viewport width × 150% viewport height */
let viewportW = 0
let viewportH = 0
let pad = 0
let canvasH = 0
let needsRender = true
let lastScrollY = Number.NaN
const invalidate = () => {
needsRender = true
}
const applySize = () => {
if (viewportW === 0 || viewportH === 0) return
pad = Math.round(viewportH * 0.25)
canvasH = viewportH + pad * 2
canvas.style.width = `${viewportW}px`
canvas.style.height = `${canvasH}px`
renderer.setSize(viewportW, canvasH, false)
lastScrollY = Number.NaN // force the transform + a render
}
const onViewportResize = (viewport: Viewport) => {
if (viewport.width === viewportW && viewport.height === viewportH) return
viewportW = viewport.width
viewportH = viewport.height
applySize()
}
metri.on('viewportResize', onViewportResize)
// the event doesn't replay for late subscribers — seed from the cache
if (metri.viewport) onViewportResize(metri.viewport)
/* view registry */
const views = new Set<RegisteredView>()
const addView: Stage['addView'] = (el, view) => {
const registered: RegisteredView = { el, view, bounds: undefined, dispose: () => {} }
const tracking = metri.track(el, (bounds) => {
const previous = registered.bounds
registered.bounds = bounds
if (!previous || previous.width !== bounds.width || previous.height !== bounds.height) {
view.resize?.(bounds.width, bounds.height)
}
invalidate()
})
registered.dispose = () => {
tracking.dispose()
views.delete(registered)
invalidate()
}
views.add(registered)
return { invalidate, dispose: registered.dispose }
}
/* frame loop */
renderer.setAnimationLoop((time: number) => {
if (canvasH === 0) return
const scrollY = metri.scroll.scrollY
if (scrollY !== lastScrollY) {
lastScrollY = scrollY
// slide the page-space canvas back over the viewport (stale by ≤1
// frame — the padding absorbs it)
canvas.style.transform = `translate3d(0, ${scrollY - pad}px, 0)`
needsRender = true
}
const visible: RegisteredView[] = []
for (const registered of views) {
const { bounds } = registered
if (!bounds || bounds.width === 0 || bounds.height === 0) continue
const viewportY = bounds.top - scrollY
if (viewportY > viewportH + pad || viewportY + bounds.height < -pad) continue
visible.push(registered)
if (registered.view.update?.(time)) needsRender = true
}
if (!needsRender) return
needsRender = false
renderer.setScissorTest(false)
renderer.clear()
renderer.setScissorTest(true)
for (const { view, bounds } of visible) {
const { left, top, width, height } = bounds!
const y = top - scrollY + pad // canvas-local; the Renderer API is top-origin
renderer.setViewport(left, y, width, height)
renderer.setScissor(left, y, width, height)
renderer.render(view.scene, view.camera)
}
})
return {
renderer,
fonts,
addView,
invalidate,
dispose() {
renderer.setAnimationLoop(null)
for (const registered of [...views]) registered.dispose()
metri.off('viewportResize', onViewportResize)
for (const bundle of Object.values(fonts)) bundle.map.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))
}
/** Token-cancelled rAF tween with cubic ease-out, invalidating per step. */
export function createTweener(invalidate: () => void) {
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))
invalidate()
if (t < 1) requestAnimationFrame(step)
}
requestAnimationFrame(step)
},
cancel() {
token++
},
}
}
import { Group, PerspectiveCamera, Scene } from 'three/webgpu'
import type { LayoutOptions } from 'lettra'
import { createText } from 'lettra/three'
import type { FontName, Stage } from '../stage'
import { frameText } from '../stage'
export type Align = 'left' | 'center' | 'right'
export interface SpecimenState {
text: string
font: FontName
align: Align
letterSpacing: number
/** 0 = no wrap */
maxWidth: number
}
export interface SpecimenView {
apply(state: SpecimenState): void
dispose(): void
}
function layoutOptions(state: SpecimenState): LayoutOptions {
return {
align: state.align,
letterSpacing: state.letterSpacing,
...(state.maxWidth > 0 ? { maxWidth: state.maxWidth } : {}),
mode: state.maxWidth > 0 ? 'greedy' : 'pre',
}
}
/** fig. 01 — the interactive specimen: live text, font swap, drag to tilt. */
export async function createSpecimenView(stage: Stage, el: HTMLElement, initial: SpecimenState): Promise<SpecimenView> {
const scene = new Scene()
const camera = new PerspectiveCamera(35, 1, 0.1, 100)
camera.position.z = 10
const rig = new Group()
scene.add(rig)
let current = initial
const text = createText({
font: stage.fonts[current.font].font,
map: stage.fonts[current.font].map,
text: current.text,
layout: layoutOptions(current),
material: { fill: '#414141' },
})
rig.add(text.mesh)
const frame = () => {
frameText(camera, {
width: text.layout.width,
height: text.layout.height,
fontSize: text.layout.metrics.fontSize,
})
handle.invalidate()
}
const handle = stage.addView(el, {
scene,
camera,
resize(width, height) {
camera.aspect = width / height
camera.updateProjectionMatrix()
frame()
},
})
text.onChange(() => handle.invalidate())
// pipeline compile off the hot path
await text.warmup(stage.renderer, camera, scene)
frame()
/* drag to tilt */
let dragging = false
let lastX = 0
let lastY = 0
const onPointerDown = (event: PointerEvent) => {
dragging = true
lastX = event.clientX
lastY = event.clientY
el.setPointerCapture(event.pointerId)
}
const onPointerMove = (event: PointerEvent) => {
if (!dragging) return
rig.rotation.y += (event.clientX - lastX) * 0.005
rig.rotation.x = Math.max(-1.2, Math.min(1.2, rig.rotation.x + (event.clientY - lastY) * 0.005))
lastX = event.clientX
lastY = event.clientY
handle.invalidate()
}
const onPointerUp = () => {
dragging = false
}
el.addEventListener('pointerdown', onPointerDown)
el.addEventListener('pointermove', onPointerMove)
el.addEventListener('pointerup', onPointerUp)
return {
apply(next) {
const fontChanged = next.font !== current.font
current = next
if (fontChanged) {
// atomic: geometry + atlas rebind happen in one tick inside swapFont
text.swapFont({
font: stage.fonts[next.font].font,
map: stage.fonts[next.font].map,
text: next.text,
layout: layoutOptions(next),
})
} else {
text.setText(next.text, layoutOptions(next))
}
frame()
},
dispose() {
el.removeEventListener('pointerdown', onPointerDown)
el.removeEventListener('pointermove', onPointerMove)
el.removeEventListener('pointerup', onPointerUp)
handle.dispose()
text.dispose({ map: false })
},
}
}
| package | lettra · npm |
| renderer | webgpu · webgl fallback |
| engine | three/webgpu + tsl |
| tracking | @joycostudio/metri |
| license | mit |
Latin scripts, single and multiline, live string swap. No complex shaping, no color emoji, no bidi; that work belongs to a real shaper. Layout ported from Jam3's layout-bmfont-text (MIT). Specimen faces: Bebas Neue & Lora, OFL. Append ?forceWebGL to exercise the fallback. MIT © joyco.studio