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);}import { Canvas, extend, useFrame, useLoader, useThree } from '@react-three/fiber/webgpu';import { SlugText, SlugFontLoader } from '@three-flatland/slug/react';
extend({ SlugText });
function TextScene() { const font = useLoader(SlugFontLoader, '/fonts/Inter-Regular.ttf'); const ref = useRef<SlugText>(null); const { camera, size } = useThree();
useEffect(() => { ref.current?.setViewportSize(size.width, size.height); }, [size]);
useFrame(() => { ref.current?.update(camera); });
return ( <slugText ref={ref} font={font} text="Hello, Slug!" fontSize={48} color={0xffffff} align="center" /> );}Loading Fonts
Section titled “Loading Fonts”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.
import { SlugFontLoader } from '@three-flatland/slug';
const font = await SlugFontLoader.load('/fonts/MyFont.ttf');import { useLoader } from '@react-three/fiber/webgpu';import { SlugFontLoader } from '@three-flatland/slug/react';
// Inside a component within <Canvas>, with <Suspense> boundaryconst font = useLoader(SlugFontLoader, '/fonts/MyFont.ttf');After loading, the font provides:
font.glyphs— parsed glyph data (Map<number, SlugGlyphData>)font.curveTexture— GPU texture with Bezier control pointsfont.bandTexture— GPU texture with spatial acceleration datafont.unitsPerEm,font.ascender,font.descender,font.capHeight— font metricsfont.measureText(text, fontSize),font.measureParagraph(text, fontSize, opts),font.wrapText(text, fontSize, maxWidth)— measurement helpers
SlugText Properties
Section titled “SlugText Properties”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.
Per-Frame Updates
Section titled “Per-Frame Updates”SlugText.update(camera) does two things:
- Rebuilds geometry if any property changed (dirty flag)
- 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);}useFrame(() => { ref.current?.update(camera);});Viewport Size
Section titled “Viewport Size”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);});const { size } = useThree();
useEffect(() => { ref.current?.setViewportSize(size.width, size.height);}, [size]);Dynamic Text
Section titled “Dynamic Text”Change any property and the text rebuilds on the next update():
text.text = 'New content'text.fontSize = 96text.align = 'right'text.color = 0xff0000// Geometry rebuilds automatically on next update() callMulti-line & Word Wrap
Section titled “Multi-line & Word Wrap”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,})Measurement
Section titled “Measurement”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 / .fontBoundingBoxDescentFor 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);import { extend } from '@react-three/fiber/webgpu';import { SlugStackText, SlugFontStack } from '@three-flatland/slug/react';
extend({ SlugStackText });
function StackedText({ fonts }: { fonts: SlugFont[] }) { const stack = useMemo(() => new SlugFontStack(fonts), [fonts]); return <slugStackText fontStack={stack} text="Lorem search" fontSize={48} />;}SlugStackText renders one InstancedMesh per backing font — one extra draw call per fallback font. It has full SlugText parity (styles, outline, setOpacity, dispose).
Outline
Section titled “Outline”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 visibleThe 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.
Material Options
Section titled “Material Options”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). |
Pre-baking Fonts
Section titled “Pre-baking Fonts”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:
# Install (or use npx)npx slug-bake Inter-Regular.ttf
# Subset to ASCII only — 32 KB with Brotlinpx slug-bake Inter-Regular.ttf --range ascii
# Latin extendednpx slug-bake Inter-Regular.ttf --range latin
# Multiple rangesnpx slug-bake Inter-Regular.ttf -r latin -r 0x2000-0x206FPlace 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).
How It Works
Section titled “How It Works”The Slug algorithm renders each pixel by:
- Casting dual rays (horizontal + vertical) from the pixel center
- Solving quadratic equations to find Bezier curve intersections
- Computing winding number from intersection results
- 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.