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.

| Property | Type | Default | Description | | ------------ | ------------------------------- | ----------- | ------------------------------------------------ | | font | SlugFont \| null | null | Font to render with | | text | string | '' | Text content | | fontSize | number | 16 | Font size in scene units | | color | Color \| number | 0xffffff | Text color | | opacity | number | 1.0 | Fill opacity | | align | 'left' \| 'center' \| 'right' | 'left' | Horizontal alignment | | lineHeight | number | 1.2 | Line height multiplier | | maxWidth | number \| undefined | undefined | Max width for word wrapping | | styles | StyleSpan[] | [] | Underline / strike / super-sub spans (see below) | | outline | SlugOutlineOptions \| null | null | Optional 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"
],
})

| Field | Type | Description | | -------------- | --------- | -------------------------------------------------------------------------------------------------- | | start, end | number | Half-open code-unit range. | | underline | boolean | Underline rect at the font's underlinePosition. | | strike | boolean | Strikethrough rect at the font's strikethroughPosition. | | scriptLevel | number | Slug 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):

| Option | Type | Default | Description | | ------------- | --------- | ------- | ---------------------------------------- | | evenOdd | boolean | false | Even-odd fill rule (compile-time). | | weightBoost | boolean | false | sqrt() coverage boost (compile-time). | | stemDarken | number | 0 | Stem darkening strength; ~0.4 is subtle. | | thicken | number | 0 | Coverage thickening at low ppem. | | pixelSnap | boolean | true | Snap glyph positions to pixel grid. | | supersample | boolean | false | 2×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.

| Range | Glyphs | Raw | Brotli | | ------- | ------ | -------- | ------ | | All | 2,849 | 12.78 MB | 724 KB | | latin | 523 | 2.15 MB | 208 KB | | ascii | 95 | 412 KB | 32 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.