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
- Browser-native layout and line breaking
- Static instanced geometry; no DOM reads in the render loop
- GPU SDF baking, exact CPU fallback, or analytic quadratic outlines
- TSL motion, clipping, colour, and shadow silhouettes
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()
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()
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>
| 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)))
},
})
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
},
})
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 },
})
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' },
})
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
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.
- Code-point capture keeps emoji sequences whole but is not full shaping. Complex scripts, ligatures, contextual forms, bidi edge cases and vertical writing may differ.
- Text strokes, CSS shadows, filters, masks and transforms are not reproduced. SDF emoji retain browser colours from the canvas default palette; custom font palettes and soft interior alpha below 50% are not retained.
- Rectangles support solid colour, effective opacity, one radius, depth, and optional shadow receiving—not borders, images, per-corner radii, or CSS effects.
- Text clips to its marked block's rectangular bounds.
-
Text weight follows the browser through
tone. It is measured against Chrome on macOS; other platforms follow Chromium's source. One backdrop colour serves a whole capture. - Windfoil currently uses nonzero fill and quadratic data; provider mapping must account for font fallback, variation settings, and synthetic styles. Rotated or sheared coverage uses an axis-aligned glyph-space box sized to each pixel's footprint, which approximates rather than equals an exact screen-pixel filter.
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.
ultra-text/vanilla. Copy the Mini3 starter
or consume packed typed-array buffers directly.
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.