ultra‑text documentation

Documentation

ultra‑text‑threejs

Browser-laid-out HTML, retained as instanced text and rectangles in Three.js WebGPURenderer.

The default backend bakes a compact single-channel SDF. The optional experimental Windfoil backend keeps quadratic outlines and evaluates analytic coverage directly on the GPU with no coverage texture.

npm install ultra-text three@0.186
Install

The default text backend bakes an SDF atlas. Windfoil is a separate analytic vector backend that evaluates outlines on the GPU without an atlas.

01 — Start

Create a retained copy

Use ESM, Three.js r186 (>=0.186.0 <0.187.0), and WebGPURenderer. Await fonts before capture so the browser measures the final faces. DomText and DomSdf are aliases for the same retained DOM group.

import { PerspectiveCamera, Scene, WebGPURenderer } from 'three/webgpu'
import { DomText } from 'ultra-text'

const renderer = new WebGPURenderer({ antialias: true })
await renderer.init()

const scene = new Scene()
const camera = new PerspectiveCamera(40, innerWidth / innerHeight, 0.1, 5000)
camera.position.z = 900

await document.fonts.ready
const source = document.querySelector<HTMLElement>('#profile')
if (!source) throw new Error('Missing #profile')

const copy = await DomText.create(renderer, source, { quality: 'high' })
copy.position.set(-copy.width / 2, -copy.height / 2, 0)
scene.add(copy)

renderer.setAnimationLoop(() => renderer.render(scene, camera))

// Later: scene.remove(copy); copy.dispose()
Renderer, capture, ownership

Local (0, 0) is the capture's bottom-left corner. The source must be connected and have a non-zero layout box during capture. dispose() is idempotent and leaves the renderer, scene, source, cameras, and lights caller-owned.

String text with CSS

When you do not already have a DOM tree to mirror, use Text to build a Three.js group directly from a string, inline css, and stylesheet classes (cssClass).

import { Text } from 'ultra-text'

const label = new Text('Hello, WebGPU!', {
  renderer,
  css: { fontFamily: 'Arial, sans-serif', fontSize: 48, color: '#ffcc00', width: 320 },
  cssClass: 'headline',
})

await label.ready
scene.add(label)

label.text = 'Updated text'
label.setCss({ fontSize: 36, color: 'tomato' })
label.setClass(['headline', 'compact'])
await label.rebuild()
Direct string text with CSS & classes

Text manages a visually clipped, measurable DOM element attached to document.body (or a custom parent), waits for fonts on initial build, and accepts every DomText rendering option. Pass tone: { backdrop } when light text sits over a dark 3D scene.

Using Vue or React? Start with the TresJS guide or React Three Fiber guide.

02 — Markup

Mark only what crosses over

<article id="profile" data-sdf-rect data-sdf-receive-shadow>
  <h1 data-sdf-block>Browser layout, GPU ink.</h1>
  <p data-sdf-block data-sdf-depth="2">Retained text.</p>
  <button data-sdf-ignore>HTML-only action</button>
</article>
Ordinary, accessible source HTML
Attribute Effect
data-sdf-block Capture this text block and its inline descendants.
data-sdf-rect Capture its solid background, opacity, box, and uniform radius.
data-sdf-ignore Exclude the subtree.
data-sdf-depth="n" Set inherited Z depth.
data-sdf-receive-shadow Put the rectangle in the shadow-receiving batch.

Keep this semantic source in the accessibility tree and mark the visual canvas aria-hidden="true". Capture it with a real layout box; when observation is disabled, it can become visually transparent afterward without using aria-hidden.

03 — Configure

Choose layout and text coverage

Option Default Use
layoutEngine 'range' Browser Range fidelity, or lazy 'pretext' Canvas layout.
textBackend 'sdf' Retained SDF atlas or experimental, WebGPU-only 'windfoil' / 'slug'.
atlasMode 'auto' SDF only: 'gpu', 'cpu', or GPU-first auto.
bakeSize / padding 96 / 12 SDF raster resolution and distance range.
quality 'standard' SDF only: one tap, or 'high' four-tap sampling and mips.
tone on Text ink at the weight the browser gives it, for both backends. Set { backdrop } where the scene shows the text over another colour than the page; false keeps exact coverage.
colorGlyphs 'emoji' SDF browser colours; 'all' for colour fonts, 'none' for silhouettes.
extraGlyphs — Reserve characters or whole emoji sequences for changing copy; other new text glyphs are drawn into the atlas in place when they fit.
outline / dropShadow — Local-unit SDF outline (width, color, opacity) and hard shadow (offsetX, offsetY, color, opacity), on every backend.
fill / vertex — Custom TSL callbacks for linear RGBA glyph fills (TextFill) and local vec3 corner positions (TextVertex).
motion slide up Enable/mode/strength/speed/stagger; update later with setMotion().
observe / reflowable true / false Automatic rebuilds; optionally retain data for width-only reflow.

Text effects, custom fills & vertex hooks

DomText.create(), Text, SdfTextMaterial, and createWindfoilShapes accept optional TSL fill and vertex callbacks alongside outline and dropShadow effects. Both callbacks build TSL graphs once per material—including across clone() and static-to-animated setMotion() upgrades—without running in the animation loop.

import { mix, sin, time, vec3, vec4 } from 'three/tsl'
import { DomText } from 'ultra-text'

const copy = await DomText.create(renderer, source, {
  padding: 24,
  outline: { width: 1.2, color: '#cafae2', opacity: 0.8 },
  dropShadow: { offsetX: 5, offsetY: -6, color: '#030c09', opacity: 1 },
  fill: ({ position }) => {
    const wave = sin(position.x.mul(0.02).add(time)).mul(0.5).add(0.5)
    return vec4(mix(vec3(0.1, 0.8, 0.5), vec3(1, 0.25, 0.08), wave), 1)
  },
  vertex: ({ position, center, sequence }) => {
    const bob = sin(time.mul(2).add(sequence.mul(0.35))).mul(2)
    return position.add(vec3(0, bob, sin(center.x.mul(0.015).add(time)).mul(4)))
  },
})
Effects, TSL fragment fill, and TSL vertex displacement

fill receives { uv, position, color, colorWeight, glyphIndex } and returns linear RGBA; glyph coverage, browser tone, DOM opacity, and scene shadow masking remain applied. vertex receives { position, center, uv, glyphIndex, sequence, rect, clip } after built-in motion and quad trimming/padding, and returns the displaced local vec3 corner for glyphs and text decorations. Block clipping is recorded before vertex runs so 3D waves and Z-extrusions are not clipped at static line bounds.

Analytic Windfoil outlines

Browsers expose the selected CSS font description, but not its outline bytes. Windfoil and Slug therefore take outlines from an app-owned resolver or, when it is omitted, trace the browser's own glyphs at 256 px per em (within 0.006 em of the font's outlines on Noto Sans in Chrome; colour glyphs use an SDF companion). They are WebGPU-only; a null outline can fail the capture, fall the whole capture back to SDF ('sdf'), or use an SDF companion for missing glyphs only ('sdf-glyphs', the default without a resolver). outline and dropShadow draw from an SDF underlay beneath the analytic fill, at the cost of one more atlas bake and one more draw.

import type { WindfoilOutlineProvider } from 'ultra-text'

const outlineProvider: WindfoilOutlineProvider = (request, signal) =>
  myFontRegistry.resolveQuadraticOutline(request, signal)

const copy = await DomSdf.create(renderer, source, {
  textBackend: 'windfoil',
  windfoil: {
    outlineProvider,
    fallback: 'sdf-glyphs',
    sdfBelowPx: 96, // optional GPU size switch: SDF when small, Windfoil when magnified
  },
})
App-owned font mapping; Windfoil loads lazily

Return OpenType-style units with positive Y up, normally-negative descent, and the glyph ink bounding box in the same coordinates. Emit every closed contour edge as a flat quadratic run [x0, y0, controlX, controlY, x1, y1]: promote lines, convert cubics, and preserve contour/hole winding for the nonzero fill rule. The resolver above is illustrative; the library does not ship a font parser. Cache outlines, honor aborts, and avoid per-glyph network fetches; automatic optical sizing creates size-specific requests.

Setting windfoil.sdfBelowPx bakes plain single-paint outline glyphs into the SDF companion as well: in each render pass the GPU measures each glyph's projected em in device pixels and draws from the SDF atlas below the threshold or analytic Windfoil at and above it, with zero per-frame CPU work.

Windfoil is new and experimental. The upstream project describes the technique as novel and advises adopters to assess patent risk. Adapted portions are covered by the included Apache-2.0 notice.

Secondary options and defaults
textSelector / rectSelector / ignoreSelector The three data-attribute selectors above.
includeRootBackground true
pixelsPerUnit 1; scales captured X/Y, not Z.
pixelSnap false; puts lines and glyph origins on the pixels the browser paints them on, for flat 1:1 overlays.
tone.backdrop / tone.model / tone.strength 'auto' / 'auto' / 1; the model is 'outline', 'macos' or 'windows'.
textDepth / rectDepth / panelDepth 1 / 0 / -1
maxAtlasSize 2048; overflow rejects.
threshold / stemDarkening 0.5 / 0, SDF only: the baked outline; weight comes from tone.
shadowCutoff / shadowFloor 0.5 / 0.24
castShadow true
side Three's FrontSide; use DoubleSide for flips.
signal / onError Abort builds; receive unawaited observer errors.
windfoil.fallback 'error'; use 'sdf' for whole-capture fallback or 'sdf-glyphs' for a per-glyph SDF companion.
windfoil.sdfBelowPx Off by default; positive device-pixel em threshold for switching plain outline glyphs between the SDF companion and Windfoil on the GPU.
windfoil.targetPiecesPerBand 10

Lower targetPiecesPerBand values create more bands: less curve work per fragment, but more duplicated storage and preprocessing. Higher values do the reverse.

Optional shapes and colour

SVG icons and vector emoji

svg: true captures inline and standalone SVGs with Range layout. Native Path2D preserves fills, strokes and fill rules in the SDF atlas. Each icon's paint layers share motion, clip and shadows. collectDomSnapshot becomes asynchronous when SVG capture is enabled.

const text = await DomSdf.create(renderer, root, {
  svg: { unsupported: 'error', maxPathData: 1_000_000 },
  motion: { enabled: false },
})
Inline SVG capture

svg: { render: 'bitmap', images: true } retains authored SVG colours and decoded, canvas-clean images. Images remain application-owned; no URLs are fetched. Nested opacity groups composite before baking, and root/DOM opacity applies once. Baked paint alpha follows the SDF silhouette: alpha below 50% is discarded and interior alpha is hardened. stats.shapes counts shape layers separately from text glyphs.

For scene-authored shapes, import svgToWindfoilOutlines and createWindfoilShapes from ultra-text. Conversion is synchronous; windfoilOutlineFromSvgPaths is its smaller DOM-free core. Shape handles retain outlines and support setInstances, setMotion and idempotent dispose.

const provider = createSvgEmojiOutlineProvider({
  text: fontOutlineProvider,
  resolve: (cluster, signal) => loadAppEmojiSvg(cluster, signal),
  em: { unitsPerEm: 1000, ascent: 800, descent: -200 },
})
const text = await DomSdf.create(renderer, root, {
  textBackend: 'windfoil',
  windfoil: { outlineProvider: provider, fallback: 'sdf-glyphs' },
})
Vector emoji outline provider

The emoji artwork, its loading and attribution belong to the application. The provider receives complete emoji clusters. It caches conversions and aligns them with the text baseline. Missing outlines use the optional SDF companion without demoting the other glyphs. Atlas statistics describe that companion; fallbackGlyphs counts its text glyphs.

Windfoil outlines can carry foreground, solid, linear, radial and sweep paints. compositing: 'layers' remains the default. Opt-in compositing: 'shader' combines layers before applying text opacity, giving correct whole-glyph group opacity; nested SVG opacity groups are not represented. In DomSdf, an outline clipBox uses analytic rectangle clipping and automatically selects the composite path for that glyph, including base-only outlines and captures in layers mode. Standalone shape handles accept compositing: 'shader' too, which enables outline clipBox; instance clip rectangles work in both modes. Shader compositing walks only the layers that can change each pixel, takes full coverage without integrating from a layer that covers the pixel's whole index cell, and stops once it is opaque, so it suits densely layered or magnified artwork; measure both modes on your target hardware.

SVG conversion reports warnings for unsupported effects, strokes, external references and ambiguous evenodd intersections. Inspect those warnings. Neither conversion nor capture is a complete SVG renderer; filters, masks and arbitrary clip paths are not reproduced. Windfoil remains experimental; see the README's licensing and patent note.

04 — Pipeline

Baking, updates & stats

Path Build Retained data
SDF · GPU Canvas mask → guarded 16×16 boundary/JFA compute → R8 resolve. One texture; transient seed buffers are released.
SDF · CPU Canvas mask → exact Euclidean distance transform. One R8 texture; works as the auto fallback.
Windfoil Outline resolver → monotone split → horizontal row bands. Read-only curve/row buffers; analytic per-fragment coverage.

Rebuilds are transactional: newer work aborts older work, and live content is swapped only after its replacement is ready. An unchanged glyph signature reuses retained text data; an identical capture stops after the DOM read.

copy.setMotion({ mode: 'offset', speed: 0.8 }) // uniforms only
await copy.rebuild()                     // re-read DOM; reuse text data if possible
await copy.rebuild({ atlas: true })    // force SDF/Windfoil data rebuild

const flexible = await DomSdf.create(renderer, source, { reflowable: true })
flexible.reflow(560)                 // width-only wrap; no DOM/font/backend work
Steady-state controls

reflow() keeps each block's captured text-align and text-align-last (left, center, right or justify; start and end by direction, <center> and align= included), wraps and aligns words by their own width in the content box, and justifies over word spaces only. It is a block-flow approximation: it does not recompute text-indent, mixed-size line pitch, bidi ordering, hyphenation, floats, media queries, or rectangles. Use rebuild() when CSS must be measured again. A rebuild draws new text glyphs into the live SDF atlas when they fit, keeping its texture and materials; for dynamic strings, list likely characters or emoji sequences in extraGlyphs so a new glyph needs no atlas work at all.

stats reports layout/build/geometry timings, glyphs, shapes, lines, rectangles, draws, textBackend, nullable atlasMode, fallbackGlyphs, switchableGlyphs, and textStorageBytes. Atlas dimensions are zero and atlasTexture is null under pure Windfoil, or describe the SDF companion in mixed mode.

05 — API

Lower-level SDF stages

Import from the package root only. The lower-level exports remain SDF-specific; Windfoil is currently selected through DomSdf so its storage and material lifecycle stays coherent.

collectDomSnapshot Capture typed layout with Range or lazy Pretext.
rasterizeGlyphAtlas Create the transient Canvas glyph mask.
buildSdfAtlas Build a disposable GPU/CPU SDF atlas.
createGlyphGeometry / SdfTextMaterial Build standalone retained SDF text.
createRectGeometry / createRectMaterial Build plain or shadow-receiving rectangles.
reflowSnapshot Re-wrap retained block metrics.

The package root also exports every matching TypeScript contract, including DomTextBackend, TextFill, TextFillContext, TextVertex, TextVertexContext, WindfoilGlyphOutline, WindfoilOutlineProvider, and WindfoilOptions. See the public entry and public types.

06 — Limits

Limits & browser support

This is selective visual reconstruction, not a general DOM renderer.

Chromium WebGPU is covered by end-to-end tests. Recent WebGPU Safari and Firefox releases are expected but not automated here. The SDF CPU path can run through WebGPURenderer's WebGL2 backend; Windfoil cannot. The classic WebGLRenderer is unsupported.

Serve over HTTPS, await renderer.init(), guard browser-only capture during SSR, and prefer native DOM or a shaping-aware pipeline when exact typography or full CSS fidelity is required.

07 — Frameworks & Engines

One component for your framework or engine

Copy the starter components once, then pass your source element to UltraText. They handle WebGPU setup, fonts, automatic DOM updates and cleanup. For Mini3 or custom WebGPU/Canvas engines without Three.js, import from ultra-text/vanilla.

The Three.js guides use Three.js 0.186.1 and the latest stable framework releases checked on 28 October 2026; the Mini3 and Vanilla JS guide uses ultra-text/vanilla without Three.js. All examples are MIT licensed and include instructions for coding agents.

08 — Comparison

Compared with other 3D web text libraries

ultra-text renders the HTML you already have with the fonts the page already loaded: no font file to fetch or bake, browser layout and line breaking, native colour emoji, and one draw for a whole captured subtree. Its first frame comes before glyph's and after troika's; the libraries that skip the DOM read still edit text faster.

One browser benchmark, benchmarks/vs-ecosystem: each library on the three.js renderer it supports, in the same headless Chrome, with the same font, a 1,000-glyph paragraph or 200 labels, and a 3200×2400 target. A quick run on 2026-11-04, Apple M3 Max, Chrome 156, on a shared machine: read the medians as ranges.

Measure ultra-text (SDF) @pmndrs/glyph 0.1.0 troika-three-text 0.53.1
three.js renderer WebGPURenderer, or raw WebGPU via ultra-text/vanilla WebGPURenderer Classic WebGLRenderer only
Fonts Whatever the page loaded (WOFF2, system fonts, colour emoji); no bake step Baked .font.glb or a runtime TTF/OTF bake; no WOFF/WOFF2 .ttf/.otf/.woff URL; a CDN for missing characters
First frame, paragraph 41 ms 53 ms (MSDF) 23 ms
Edit, a never-drawn letter 7.4 ms (GPU atlas), 6.5 ms (CPU atlas), 5.1 ms with extraGlyphs 2.8 ms (baked ahead) 3.0 ms
Width change, paragraph 1.7 ms with reflow() 2.1 ms 2.9 ms
Frame, 200 labels 0.09 ms, 1 draw 0.22 ms, 2 draws 1.67 ms, 200 draws (0.19 ms, 1 draw with BatchedText)
GPU memory, paragraph 0.64 MiB 3.4 MiB (MSDF), 0.35 MiB (Slug) 2.0 MiB
Outline, shadow, emoji Outline and drop shadow on every backend; native colour emoji Outline and hard shadow on MSDF; colour emoji not yet Outline with blur and offset, inner stroke; monochrome emoji
JS, min+gzip 38.7 kB 134.2 kB + 511.2 kB Wasm 46.3 kB

Timings run from the change to GPU completion of the next frame. First frames count from the first authoring call, with each library's font already loaded; ultra-text spends about 11 ms of its first frame baking the SDF atlas and 14 ms building its material's shaders. Frame times are whole frames including each renderer's clear. The README holds the full table, its footnotes and the sources of the facts not measured here.