Skip to content

GPU-accelerated, resolution-independent text rendering using the Slug algorithm.

The @three-flatland/slug package renders crisp, resolution-independent text on the GPU using the Slug algorithm . Font outlines are rasterized per-pixel from quadratic Bezier curves — no texture atlases, no SDF approximations, no blurry edges at any zoom level.

import { WebGPURenderer } from 'three/webgpu';
import { SlugFontLoader, SlugText } from '@three-flatland/slug';
const font = await SlugFontLoader.load('/fonts/Inter-Regular.ttf');
const text = new SlugText({
font,
text: 'Hello, Slug!',
fontSize: 48,
color: 0xffffff,
align: 'center',
});
scene.add(text);
// Per-frame update (required for dilation + dirty rebuild)
function animate() {
requestAnimationFrame(animate);
text.update(camera);
renderer.render(scene, camera);
}

SlugFontLoader is the single entry point for loading fonts. It automatically tries pre-baked data first (a single .slug.glb), and falls back to runtime parsing from the .ttf if baked data isn’t available.

SlugFontLoader.load(url) — baked fast path vs. runtime parsethe loader probes for a pre-baked sibling; a hit skips opentype.js entirelySlugFontLoader.load(url)probe for.slug.glb sibling?font.slug.glbHITload baked GLB directlyno opentype.jsno .ttf fetchno runtime parsesmaller bundle · faster startupMISSfetch the .ttf(no baked sibling)font.ttfparse at runtimeopentype.js(lazy-imported)import('opentype.js')build glyph datain-browserlarger bundle — opentype.js cost paid only on this pathready glyph atlas / datafor GPU text rendering
SlugFontLoader resolution — a baked .slug.glb loads directly and skips opentype.js; a miss falls back to fetching and parsing the .ttf at runtime.
import { SlugFontLoader } from '@three-flatland/slug';
const font = await SlugFontLoader.load('/fonts/MyFont.ttf');

After loading, the font provides:

  • font.glyphs — parsed glyph data (Map<number, SlugGlyphData>)
  • font.curveTexture — GPU texture with Bezier control points
  • font.bandTexture — GPU texture with spatial acceleration data
  • font.unitsPerEm, font.ascender, font.descender, font.capHeight — font metrics
  • font.measureText(text, fontSize), font.measureParagraph(text, fontSize, opts), font.wrapText(text, fontSize, maxWidth) — measurement helpers

SlugText extends InstancedMesh — each glyph is an instanced quad evaluated per-pixel by the Slug fragment shader.

PropertyTypeDefaultDescription
fontSlugFont | nullnullFont to render with
textstring''Text content
fontSizenumber16Font size in scene units
colorColor | number0xffffffText color
opacitynumber1.0Fill opacity
align'left' | 'center' | 'right''left'Horizontal alignment
lineHeightnumber1.2Line height multiplier
maxWidthnumber | undefinedundefinedMax width for word wrapping
stylesStyleSpan[][]Underline / strike / super-sub spans (see below)
outlineSlugOutlineOptions | nullnullOptional stroke outline (see below)

All properties can be set after construction. Changes are applied on the next update() call.

SlugText.update(camera) does two things:

  1. Rebuilds geometry if any property changed (dirty flag)
  2. Updates the MVP matrix for vertex dilation — this must happen every frame since the camera or object may move
function animate() {
requestAnimationFrame(animate);
text.update(camera);
renderer.render(scene, camera);
}

For correct edge dilation (half-pixel anti-aliasing at quad boundaries), call setViewportSize when the viewport changes:

text.setViewportSize(window.innerWidth, window.innerHeight);
window.addEventListener('resize', () => {
text.setViewportSize(window.innerWidth, window.innerHeight);
});

Change any property and the text rebuilds on the next update():

text.text = 'New content'
text.fontSize = 96
text.align = 'right'
text.color = 0xff0000
// Geometry rebuilds automatically on next update() call

Set maxWidth to enable automatic word wrapping. Line breaks (\n) are also supported:

const text = new SlugText({
font,
text: 'This is a long paragraph that will wrap automatically.',
fontSize: 24,
maxWidth: 400,
lineHeight: 1.4,
})

SlugFont exposes a measurement API mirroring CanvasRenderingContext2D.measureText for layouts that don’t need GPU geometry — overlays, hit-testing, fitting text inside a box.

const m = font.measureText('Hello, Slug!', 48)
// m.width
// m.actualBoundingBoxLeft / .actualBoundingBoxRight
// m.actualBoundingBoxAscent / .actualBoundingBoxDescent
// m.fontBoundingBoxAscent / .fontBoundingBoxDescent

For wrapped paragraphs:

const para = font.measureParagraph(longText, 24, {
maxWidth: 400,
lineHeight: 1.4, // matches SlugText's default — measurements agree with rendering
})
// para.width (widest line), para.height (block height)
// para.lines: [{ text, width }, ...]

font.wrapText(text, fontSize, maxWidth) exposes the same wrap policy SlugText uses internally — useful when you need to compare line breaks against another renderer (Canvas2D, DOM).

Decorations (underline, strikethrough, super/sub)

Section titled “Decorations (underline, strikethrough, super/sub)”

Apply StyleSpan[] over half-open [start, end) UTF-16 ranges:

const text = new SlugText({
font,
text: 'Bold and underlined and ²',
fontSize: 48,
styles: [
{ start: 9, end: 21, underline: true }, // "underlined"
{ start: 24, end: 25, scriptLevel: 2 }, // superscript "2"
],
})
FieldTypeDescription
start, endnumberHalf-open code-unit range.
underlinebooleanUnderline rect at the font’s underlinePosition.
strikebooleanStrikethrough rect at the font’s strikethroughPosition.
scriptLevelnumberSlug script(n) level. Positive = superscript, negative = sub. Magnitude ∈ [1, 3] is the depth.

Decoration rects render through the same instance pipeline as glyphs (no second draw call) — they short-circuit the fragment shader to solid coverage via a sentinel bit on the instance attributes.

Multi-font fallback (SlugFontStack / SlugStackText)

Section titled “Multi-font fallback (SlugFontStack / SlugStackText)”

Render strings whose codepoints span multiple loaded fonts. The first font in the stack with a non-zero glyphId for a given codepoint wins (per-character, automatic — authors don’t tag runs).

import { SlugFontLoader, SlugFontStack, SlugStackText } from '@three-flatland/slug';
const [inter, fa] = await Promise.all([
SlugFontLoader.load('/fonts/Inter-Regular.ttf'),
SlugFontLoader.load('/fonts/FontAwesome-Regular.ttf'),
]);
const stack = new SlugFontStack([inter, fa]);
const text = new SlugStackText({
fontStack: stack,
text: 'Lorem search', // last char is an FA icon
fontSize: 48,
});
scene.add(text);

SlugStackText renders one InstancedMesh per backing font — one extra draw call per fallback font. It has full SlugText parity (styles, outline, setOpacity, dispose).

Add a stroked outline behind the fill:

const text = new SlugText({
font,
text: 'Hello, Slug!',
fontSize: 96,
color: 0xffffff,
outline: {
width: 0.025, // em-space half-width (~5% em total stroke width)
color: 0x000000,
},
})

Width and color update at runtime via uniforms — no rebuild:

text.setOutlineWidth(0.05)
text.setOutlineColor(0xff0000)

Outline-only text:

text.setOpacity(0) // hide fill, keep outline visible

The current dynamic shader uses min(distance) over the glyph’s curves, producing a clean bevel at exterior corners. Interior corners stay crisp by construction. Miter / round joins, caps, dashing, and a 1× fill cost baked path are landing in Phase 5 (#37); the dynamic shader will stay as outline.mode = 'dynamic' for live width scrubbing.

SlugText creates a SlugMaterial internally. Several shader-level options are available at construction time (some compile-time constants, some runtime uniforms):

OptionTypeDefaultDescription
evenOddbooleanfalseEven-odd fill rule (compile-time).
weightBoostbooleanfalsesqrt() coverage boost (compile-time).
stemDarkennumber0Stem darkening strength; ~0.4 is subtle.
thickennumber0Coverage thickening at low ppem.
pixelSnapbooleantrueSnap glyph positions to pixel grid.
supersamplebooleanfalse2×2 supersampling (expensive).

For production, use slug-bake to pre-process fonts offline. This eliminates opentype.js at runtime and lets you subset to only the glyphs you need:

Terminal window
# Install (or use npx)
npx slug-bake Inter-Regular.ttf
# Subset to ASCII only — 32 KB with Brotli
npx slug-bake Inter-Regular.ttf --range ascii
# Latin extended
npx slug-bake Inter-Regular.ttf --range latin
# Multiple ranges
npx slug-bake Inter-Regular.ttf -r latin -r 0x2000-0x206F

Place the .slug.glb file alongside the font. SlugFontLoader.load() detects it automatically — no code changes needed. The .ttf is never fetched when baked data is present, and opentype.js never enters the bundle.

RangeGlyphsRawBrotli
All2,84912.78 MB724 KB
latin5232.15 MB208 KB
ascii95412 KB32 KB

Missing glyphs render as a fallback rectangle. Named ranges: ascii, latin, latin+. Custom ranges use hex (0x20-0x7E) or decimal (32-126).

The Slug algorithm renders each pixel by:

  1. Casting dual rays (horizontal + vertical) from the pixel center
  2. Solving quadratic equations to find Bezier curve intersections
  3. Computing winding number from intersection results
  4. Producing fractional coverage for anti-aliased edges

Spatial band structures accelerate the per-pixel curve lookup, and vertex dilation expands each glyph quad by half a pixel to ensure boundary fragments are evaluated.