Sharp text, baked flat

[webgpu]

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.

Getting started

pnpm add lettra three
orpaste it into your agent, it does the rest.
font
align
tracking0px
measureoff
fig. 01 · live specimen · drag to tilt
usage.ts
import { 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)

How it works

[pipeline]

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.

Erosion wipes

[effect]

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.

wipe
fig. 02 · threshold erosion · plays as it enters

Glyph scramble

[effect]

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.

drive0%
fig. 03 · atlas scramble · decodes as it enters

Water writes

[composition]

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.

fig. 04 · fluid-sim ink driving the scramble

One canvas, tracked

[stage]

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.

+fig. 05 · the stage gl/stage.ts
gl/stage.ts
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++
    },
  }
}
+fig. 06 · a view gl/views/specimen.ts
gl/views/specimen.ts
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 })
    },
  }
}
packagelettra · npm
rendererwebgpu · webgl fallback
enginethree/webgpu + tsl
tracking@joycostudio/metri
licensemit

From readme.md

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