A high fidelity font renderer and text layout engine for Three.js
Caution
three-text is an alpha release and the API may break rapidly. This warning will last at least through the end of 2025. If API stability is important to you, consider pinning your version. Community feedback is encouraged; please open an issue if you have any suggestions or feedback, thank you
three-text renders and formats text from TTF, OTF, and WOFF font files in Three.js. It uses TeX-based parameters for breaking text into paragraphs across multiple lines, and turns font outlines into 3D shapes on the fly, caching their geometries for low CPU overhead in languages with lots of repeating glyphs. Variable fonts are supported as static instances at a given axis coordinate
Under the hood, three-text relies on HarfBuzz for text shaping, Knuth-Plass line breaking, Liang hyphenation, libtess2 (based on the OpenGL Utility Library (GLU) tessellator by Eric Veach) for removing overlaps and triangulation, bezier curve polygonization from Maxim Shemanarev's Anti-Grain Geometry, Visvalingam-Whyatt line simplification, and Three.js as a platform for 3D rendering on the web
- Overview
- Getting started
- Development and examples
- Why three-text?
- Library structure
- Key concepts and methods
- Configuration
- Querying text content
- API reference
- Memory management
- Debugging
- Browser compatibility
- Testing
- Build system
- Build outputs
- Acknowledgements
- License
To use three-text in your own project:
npm install three-text threeharfbuzzjs is a direct dependency and will be installed automatically
The library bundles harfbuzzjs but requires the WASM binary to be available at runtime. You have two options for providing it:
This is the simplest and recommended approach. The library's internal caching ensures the WASM file is fetched only once, even if you create multiple Text instances
Copy the WASM binary to a public directory:
cp node_modules/harfbuzzjs/hb.wasm public/hb/Then, before any Text.create() calls, tell three-text where to find it:
import { Text } from 'three-text';
Text.setHarfBuzzPath('/hb/hb.wasm');This method is essential for applications that use Web Workers, as it is the only way to share a single fetched resource across multiple threads. It gives you full control over loading and prevents each worker from re-downloading the WASM binary
import { Text } from 'three-text';
// On your main thread, fetch the assets once.
const wasmResponse = await fetch('/hb/hb.wasm');
const wasmBuffer = await wasmResponse.arrayBuffer();
// Then, in each of your Web Workers, receive the buffer and
// provide it to the library before use.
// worker.js:
self.onmessage = (e) => {
const { wasmBuffer } = e.data;
Text.setHarfBuzzBuffer(wasmBuffer);
// ... now you can call Text.create()
};The library will prioritize the buffer if both a path and a buffer have been set
For ES Modules (recommended): Import and register only the languages you need:
import enUs from 'three-text/patterns/en-us';
import { Text } from 'three-text';
Text.registerPattern('en-us', enUs);For UMD builds: Copy patterns to your public directory and load via script tags:
cp -r node_modules/three-text/dist/patterns public/patterns/For modern browsers that support ES Modules:
<script type="module">
import { Text } from 'three-text';
import fr from 'three-text/patterns/fr';
// Configure once
Text.setHarfBuzzPath('/hb/hb.wasm');
Text.registerPattern('fr', fr);
const text = await Text.create({
text: 'Bonjour',
font: '/fonts/YourFont.woff', // or .ttf, .otf
size: 72,
layout: { width: 400, align: 'center', language: 'fr' },
});
</script><!-- Load Three.js and three-text -->
<script src="three-text/dist/index.umd.js"></script>
<!-- Load hyphenation patterns as needed (auto-registers) -->
<script src="/patterns/fr.umd.js"></script>
<script>
const { Text } = window.ThreeText;
// Configure once
Text.setHarfBuzzPath('/hb/hb.wasm');
const text = await Text.create({
text: 'Bonjour',
font: '/fonts/YourFont.woff', // or .ttf, .otf
size: 72,
layout: { width: 400, align: 'center', language: 'fr' }
});
</script>Patterns loaded via script tags automatically register themselves this way
For react-three-fiber projects, use the <ThreeText> component which manages font loading, geometry creation, and React's lifecycle:
import { ThreeText } from 'three-text/react';
function Scene() {
return (
<ThreeText
font="/fonts/Font-Regular.woff"
size={72}
depth={100}
layout={{
hyphenate: true,
language: 'fr',
}}
onLoad={(geometry, info) => console.log('Text loaded!', info)}
>
Hello world
</ThreeText>
);
}All TextOptions flow through as props, alongside standard Three.js mesh properties:
<ThreeText
font="/fonts/MyFont.woff"
size={100}
depth={100}
lineHeight={1.2}
letterSpacing={0.02}
fontVariations={{ wght: 500, wdth: 100 }}
layout={{
width: 800,
align: 'justify',
language: 'en-us',
}}
position={[0, 0, 0]}
rotation={[0, Math.PI / 4, 0]}
material={customMaterial}
vertexColors={true} // Default: true
onLoad={(geometry, info) => {}}
onError={(error) => {}}
>
Your text content here
</ThreeText>Vertex colors are included by default for maximum compatibility with custom materials. Set vertexColors={false} to disable them
three-text is built with TypeScript, and requires Node for compilation. If you don't already have Node installed on your system, visit nodejs.org to download and install it
To clone the repo and try the demo:
git clone --recurse-submodules git@github.com:countertype/three-text.git
cd three-text
npm install
npm run build
npm run serveThen navigate to http://127.0.0.1:8080/examples/
Although Three.js has deprecated UMD, for maximum device support there is also an example of the library without ESM at http://127.0.0.1:8080/examples/index-umd.html
For React developers, there's also a React Three Fiber example with Vite and Leva GUI controls:
cd examples/react-three-fiber
npm install
npm run devThen navigate to http://localhost:3000
three-text was designed to produce high-fidelity, 3D mesh geometry for
Three.js scenes
Existing Three.js text solutions take different approaches:
- Three.js native TextGeometry uses fonts converted by facetype.js to JSON format. It creates 3D text by extruding flat 2D character outlines. While this produces true 3D geometry with depth, there is no support for real fonts or OpenType features needed for many of the world's scripts
- three-bmfont-text is a 2D approach, using pre-rendered bitmap fonts with SDF support. Texture atlases are generated at specific sizes, and artifacts are apparent up close
- troika-three-text uses MSDF, which improves quality, and like three-text, it is built on HarfBuzz, which provides substantial language coverage, but is ultimately a 2D technique in image space. For flat text that does not need formatting or extrusion, and where artifacts are acceptable up close, troika works well
three-text generates true 3D geometry from font files via HarfBuzz. It is sharper at close distances than three-bmfont-text and troika-three-text when flat, and can fully participate in the scene as a THREE.BufferGeometry. The library caches tesselated glyphs, so a paragraph of 1000 words might only require 50 tessellations depending on the language and only one draw call is made. This makes it well-suited to longer texts. In addition to performance considerations, three-text provides control over typesetting and paragraph justification via TeX-based parameters
three-text/
├── src/
│ ├── core/ # Core text rendering engine
│ │ ├── Text.ts # Main Text class, user-facing API
│ │ ├── types.ts # Shared TypeScript interfaces and types
│ │ ├── cache/ # Glyph caching system
│ │ ├── font/ # Font loading and metrics
│ │ ├── shaping/ # HarfBuzz text shaping
│ │ ├── layout/ # Line breaking and text layout
│ │ └── geometry/ # Tessellation and geometry processing
│ ├── react/ # React Three Fiber components
│ ├── hyphenation/ # Language-specific hyphenation patterns
│ └── utils/ # Performance logging, data structures
├── scripts/ # Scripts for converting hyphenation patterns and more
├── examples/ # Demos and usage examples
└── dist/ # Built library (ESM, CJS, UMD) including patterns
Text shaping is the process of converting a string of Unicode text into positioned glyphs. This is handled entirely by HarfBuzz, which processes OTF and TTF font binaries, shaping them according to the OpenType specification by applying features like kerning, contextual alternates, mark positioning, and diacritic placement. A font and a text string go in; low-level drawing instructions come out
For text justification, the Knuth-Plass algorithm finds optimal line breaks by minimizing the total "badness" of a paragraph. Unlike greedy algorithms that make locally optimal (per-line) decisions, Knuth-Plass considers all possible break points across the entire paragraph
The algorithm models text using three fundamental elements:
- Boxes: Non-breakable content such as letters, words, or inline objects
- Glue: Stretchable and shrinkable spaces between boxes with natural width, maximum stretch, and maximum shrink values
- Penalties: Potential break points with associated costs, including hyphenation points and explicit breaks
Line badness is calculated based on how much glue must stretch or shrink from its natural width to achieve the target line length. The algorithm finds the sequence of breaks that minimizes total badness across the paragraph
This uses a three-pass approach: first without hyphenation (pretolerance), then with hyphenation (tolerance), and finally with emergency stretch for difficult paragraphs that cannot be broken acceptably
Hyphenation uses patterns derived from the Tex hyphenation project, converted into optimized trie structures for efficient lookup. The library supports over 70 languages with patterns that follow Liang's algorithm for finding valid hyphenation points while avoiding false positives
To optimize performance, three-text generates the geometry for each unique glyph or glyph cluster only once. The result is stored in a cache for reuse. This initial geometry creation is a multi-stage pipeline:
- Path collection: HarfBuzz callbacks provide low level drawing operations
- Curve polygonization: Uses Anti-Grain Geometry's recursive subdivision to convert bezier curves into polygons, concentrating points where curvature is high
- Geometry optimization:
- Visvalingam-Whyatt simplification: removes vertices that contribute the least to the overall shape, preserving sharp corners and subtle curves
- Colinear point removal: eliminates redundant points that lie on straight lines within angle tolerances
- Overlap removal: removes self-intersections and resolves overlapping paths between glyphs, preserving correct winding rules for triangulation.
- Triangulation: converts cleaned 2D shapes into triangles using libtess2 with non-zero winding rule
- Mesh construction: generates 2D or 3D geometry with front faces and optional depth/extrusion (back faces and side walls)
The multi-stage geometry approach (curve polygonization followed by cleanup, then triangulation) can reduce triangle counts while maintaining high visual fidelity and removing overlaps in variable fonts
The library uses a hybrid caching strategy to maximize performance while ensuring visual correctness
By default, it operates with glyph-level cache. The geometry for each unique character (a, b, c...) is generated only once and stored for reuse to avoiding redundant computation
For text with tight tracking, connected scripts, or complex kerning pairs, individual glyphs can overlap. When an overlap within a word is found, the entire word is treated as a single unit and escalated to a word-level cache. All of its glyphs are tessellated together to correctly resolve the overlaps, and the resulting geometry for the word is cached
When depth is 0, the library generates single-sided geometry and relies on THREE.DoubleSide materials for back face rendering, reducing triangles by approximately 50%. Custom shaders may need to handle normal flipping for consistent lighting on both sides
The library converts bezier curves into line segments by recursively subdividing curves until they meet specified quality thresholds. This is based on the AGG library, attempting to place vertices only where they are needed to maintain the integrity of the curve. You can control curve fidelity with distanceTolerance and angleTolerance
distanceTolerance: The maximum allowed deviation of the curve from a straight line segment, measured in font units. Lower values produce higher fidelity and more vertices. Default is0.5, which is nearly imperceptable without extrusionangleTolerance: The maximum angle in radians between segments at a join. This helps preserve sharp corners. Default is0.2
In general, this step helps more with time to first render than ongoing interactions in the scene.
// Using the default configuration
const text = await Text.create({
text: 'Sample text',
font: '/fonts/Font.ttf',
size: 72,
});
const text = await Text.create({
text: 'Sample text',
font: '/fonts/Font.ttf',
curveFidelity: {
distanceTolerance: 0.2, // Tighter tolerance for smoother curves
angleTolerance: 0.1, // Sharper angle preservation
},
});three-text uses a line simplification algorithm after creating lines to reduce the complexity of the shapes as well, which can be combined with curveFidelity for different types of control. It is enabled by default:
// Default optimization (automatic)
const text = await Text.create({
text: 'Sample text',
font: '/fonts/Font.ttf',
});
// Custom optimization settings
```javascript
const text = await Text.create({
text: 'Sample text',
font: '/fonts/Font.ttf',
geometryOptimization: {
areaThreshold: 1.0, // Default: 1.0 (remove triangles < 1 font unit²)
colinearThreshold: 0.0087, // Default: ~0.5° in radians
minSegmentLength: 10, // Default: 10 font units
},
});The Visvalingam-Whyatt simplification removes vertices whose removal creates triangles with area below the threshold
Colinear point removal eliminates redundant vertices that lie on straight lines within the specified angle tolerance
The default settings provide a significant reduction while maintaining high visual quality, but won't be perfect for every font. Adjust thresholds based on your quality requirements, performance constraints, and testing
The Knuth-Plass algorithm provides extensive control over line breaking quality:
- pretolerance (100): Maximum badness for the first pass without hyphenation
- tolerance (800): Maximum badness for the second pass with hyphenation
- emergencyStretch (0): Additional stretchability for difficult paragraphs
- autoEmergencyStretch (0.1): Emergency stretch as percentage of line width (e.g., 0.1 = 10%). Defaults to 10% for non-hyphenated text
- disableSingleWordDetection (false): Disable automatic prevention of short single-word lines
- linepenalty (10): Base penalty added to each line's badness before squaring
- looseness (0): Try to make the paragraph this many lines longer (positive) or shorter (negative)
- lefthyphenmin (2): Minimum characters before a hyphen
- righthyphenmin (4): Minimum characters after a hyphen
- hyphenpenalty (50): Penalty for breaking at automatic hyphenation points
- exhyphenpenalty (50): Penalty for breaking at explicit hyphens
- doublehyphendemerits (10000): Additional demerits for consecutive hyphenated lines
- adjdemerits (10000): Demerits when adjacent lines have incompatible fitness classes (very tight next to very loose)
Lower penalty/tolerance values produce tighter spacing but may fail to find acceptable breaks for challenging text
By default, the library detects and prevents short single-word lines (words occupying less than 50% of the line width on non-final lines) by iteratively applying emergency stretch. This can be disabled if needed:
const text = await Text.create({
text: 'Your text content',
font: '/fonts/Font.ttf',
layout: {
width: 1000,
disableSingleWordDetection: true,
},
});Import and register patterns statically for better tree-shaking:
import enUs from 'three-text/patterns/en-us';
import { Text } from 'three-text';
Text.setHarfBuzzPath('/hb/hb.wasm');
Text.registerPattern('en-us', enUs);
const text = await Text.create({
text: 'Long text content',
font: '/fonts/Font.ttf',
layout: {
width: 400,
language: 'en-us',
},
});Alternative: Patterns can also load dynamically where preferred (requires pattern files to be deployed):
const text = await Text.create({
text: 'Long text content',
font: '/fonts/Font.ttf',
layout: {
width: 400,
language: 'fr',
patternsPath: '/patterns/', // Optional, defaults to '/patterns/'
},
});For shader-based animations and interactive effects, the library can generate per-vertex attributes that identify which glyph each vertex belongs to:
const text = await Text.create({
text: 'Sample text',
font: '/fonts/Font.ttf',
separateGlyphsWithAttributes: true,
});
// Geometry includes these vertex attributes:
// - glyphCenter (vec3): center point of each glyph
// - glyphIndex (float): sequential glyph index
// - glyphLineIndex (float): line numberThis option bypasses overlap-based clustering and adds vertex attributes suitable for per-character manipulation in vertex shaders. Each unique glyph is still tessellated only once and cached for reuse. The tradeoff is potential visual artifacts where glyphs actually overlap (tight kerning, cursive scripts)
Variable fonts allow dynamic adjustment of typographic characteristics through variation axes:
const text = await Text.create({
text: 'Sample text',
font: '/fonts/VariableFont.ttf',
fontVariations: {
wght: 700, // Weight
wdth: 125, // Width
slnt: -15, // Slant
opsz: 14, // Optical size
},
});As long as the axis is valid, it will be available by its 4-character tag
The library automatically extracts axis information from variable fonts, including human-readable names from the Style Attributes (STAT) table when available:
const loadedFont = text.getLoadedFont();
if (loadedFont?.variationAxes) {
console.log(loadedFont.variationAxes);
// Output for fonts with STAT table:
// {
// wght: { min: 100, default: 400, max: 900, name: "Weight" },
// wdth: { min: 75, default: 100, max: 125, name: "Width" },
// opsz: { min: 8, default: 14, max: 144, name: "Optical Size" },
// XOPQ: { min: 27, default: 96, max: 175, name: "Parametric Thick Stroke" }
// }
}For fonts with a STAT table, human-readable axis names are automatically extracted. This enables user interfaces to display "Weight" instead of "wght", or "Optical Size" instead of "opsz". Custom parametric axes will also have their proper names extracted if defined
Axis values are applied through HarfBuzz, which handles the interpolation between master designs
The library automatically removes overlaps (self-intersections) in variable fonts. Static fonts skip this step by default, but a removeOverlaps parameter can be set to false
const text = await Text.create({
text: 'Sample text',
font: '/fonts/VariableFont.ttf',
fontVariations: { wght: 500 },
removeOverlaps: false,
});After creating text geometry, use the query() method to find text ranges:
const text = await Text.create({
text: 'Contact us at hello@example.com or visit our website',
font: '/fonts/Font.ttf',
layout: { width: 800, align: 'justify' },
});
const ranges = text.query({
byText: ['Contact', 'website'],
});Each range contains:
- start/end character indices
- bounds: array of bounding boxes (multiple if text spans lines)
- glyphs: relevant glyph geometry data
- lineIndices: which lines the range spans
The API supports two query strategies:
// Find exact text matches (case-sensitive)
const ranges = text.query({
byText: ['hello', 'world', 'Hello world'],
});// Direct character index ranges
const ranges = text.query({
byCharRange: [
{ start: 0, end: 5 }, // First 5 characters
{ start: 10, end: 20 }, // Characters 10-20
],
});Multiple query types can be used together:
const ranges = text.query({
byText: ['OpenType', 'TypeScript'],
byCharRange: [{ start: 0, end: 5 }],
});
// Returns all matches as a single TextRange[] arrayThe color option accepts either a single RGB array for uniform coloring or an object for selective coloring. Coloring is applied during geometry creation, after line breaking and hyphenation
// Uniform coloring
const text = await Text.create({
text: 'Hello world',
font: '/fonts/Font.ttf',
color: [1, 0.5, 0],
});
// Selective coloring
const text = await Text.create({
text: 'Warning: connection failed at line 42',
font: '/fonts/Font.ttf',
color: {
default: [1, 1, 1],
byText: {
Warning: [1, 0, 0],
connection: [1, 1, 0],
},
// byCharRange: [{ start: 35, end: 37, color: [0, 1, 1] }],
},
});Text matching occurs after layout processing, so patterns like "connection" will be found even if hyphenation splits them across lines. The coloredRanges property on the returned object contains the resolved color assignments for programmatic access to the colored parts of the geometry
The library's full TypeScript definitions are the most complete source of truth for the API. The core data structures and configuration options can be found in src/core/types.ts
Creates text geometry with automatic font loading and HarfBuzz initialization. This is the primary API for all text rendering. Returns a result object with:
geometry: BufferGeometry- Three.js geometry ready for renderingglyphs: GlyphGeometryInfo[]- Per-glyph informationplaneBounds- Overall text boundsstats- Performance and optimization statisticsquery(options)- Method to find text rangesgetLoadedFont()- Access to font metadata and variable font axesgetCacheStatistics()- Glyph cache performance dataclearCache()- Clear the glyph geometry cache for this instancemeasureTextWidth(text, letterSpacing?)- Measure text width in font units
Required. Sets the path for the HarfBuzz WASM binary. Must be called before Text.create()
Registers a hyphenation pattern for a language. Use with static imports for tree-shaking
Initializes HarfBuzz WebAssembly. Called automatically by create(), but can be called explicitly for early initialization
Preloads hyphenation patterns for specified languages. Useful for avoiding async pattern loading during text rendering
The following methods are available on instances created by Text.create():
Returns font metrics including ascender, descender, line gap, and units per em. Useful for text layout calculations
Below are the most important configuration interfaces. For a complete list of all properties and data structures, see src/core/types.ts
interface TextOptions {
text: string; // Text content to render
font?: string | ArrayBuffer; // Font file path or buffer (TTF, OTF, or WOFF)
size?: number; // Font size in scene units (default: 72)
depth?: number; // Extrusion depth (default: 0)
lineHeight?: number; // Line height multiplier (default: 1.0)
letterSpacing?: number; // Letter spacing as a fraction of em (e.g., 0.05)
fontVariations?: { [key: string]: number }; // Variable font axis settings
removeOverlaps?: boolean; // Override default overlap removal (auto-enabled for VF only)
separateGlyphsWithAttributes?: boolean; // Force individual glyph tessellation and add shader attributes
color?: [number, number, number] | ColorOptions; // Text coloring (simple or complex)
// Configuration for geometry generation and layout
curveFidelity?: CurveFidelityConfig;
geometryOptimization?: GeometryOptimizationOptions;
layout?: LayoutOptions;
}
interface ColorOptions {
default?: [number, number, number]; // Default color for all text
byText?: { [text: string]: [number, number, number] }; // Color specific text matches
byCharRange?: {
start: number;
end: number;
color: [number, number, number];
}[]; // Color character ranges
}interface LayoutOptions {
width?: number; // Line width in scene units
align?: 'left' | 'center' | 'right' | 'justify';
direction?: 'ltr' | 'rtl';
respectExistingBreaks?: boolean; // Preserve line breaks in input text (default: true)
hyphenate?: boolean; // Enable hyphenation
language?: string; // Language code for hyphenation (e.g., 'en-us')
patternsPath?: string; // Optional base path for dynamic pattern loading (default: '/patterns/')
hyphenationPatterns?: HyphenationPatternsMap; // Pre-loaded pattern data
// Knuth-Plass line breaking parameters:
tolerance?: number; // Maximum badness for second pass (default: 800)
pretolerance?: number; // Maximum badness for first pass (default: 100)
emergencyStretch?: number; // Additional stretchability for difficult paragraphs
autoEmergencyStretch?: number; // Emergency stretch as percentage of line width (defaults to 10% for non-hyphenated)
disableSingleWordDetection?: boolean; // Disable automatic single-word line prevention (default: false)
lefthyphenmin?: number; // Minimum character
// s before hyphen (default: 2)
righthyphenmin?: number; // Minimum characters after hyphen (default: 4)
linepenalty?: number; // Base penalty per line (default: 10)
adjdemerits?: number; // Penalty for incompatible fitness classes (default: 10000)
hyphenpenalty?: number; // Penalty for automatic hyphenation (default: 50)
exhyphenpenalty?: number; // Penalty for explicit hyphens (default: 50)
doublehyphendemerits?: number; // Penalty for consecutive hyphenated lines (default: 10000)
looseness?: number; // Try to make paragraph longer/shorter by this many lines
}interface CurveFidelityConfig {
distanceTolerance?: number; // Max deviation from curve in font units (default: 0.5)
angleTolerance?: number; // Max angle between segments in radians (default: 0.2)
}interface GeometryOptimizationOptions {
enabled?: boolean; // Enable geometry optimization (default: true)
areaThreshold?: number; // Min triangle area for Visvalingam-Whyatt (default: 1.0)
colinearThreshold?: number; // Max angle for colinear removal in radians (default: 0.0087)
minSegmentLength?: number; // Min segment length in font units (default: 10)
}interface TextGeometryInfo {
geometry: BufferGeometry; // The final Three.js geometry, ready for use in a Mesh
glyphs: GlyphGeometryInfo[]; // Detailed information and bounds for each individual glyph
planeBounds: {
min: { x: number; y: number; z: number };
max: { x: number; y: number; z: number };
};
stats: {
trianglesGenerated: number; // Total triangles in the final mesh
verticesGenerated: number; // Total vertices in the final mesh
pointsRemovedByVisvalingam: number; // Curve points removed by Visvalingam-Whyatt simplification
pointsRemovedByColinear: number; // Redundant points removed from straight lines
originalPointCount: number; // Total curve points before any optimization
};
query(options: TextQueryOptions): TextRange[]; // Method to find ranges of text within the geometry
coloredRanges?: ColoredRange[]; // If `color` option was used, an array of the resolved color ranges
}The coloredRanges property contains resolved color assignments when the color option was used. This data includes spatial bounds and glyph references, useful for hit detection or analysis without re-querying
interface TextQueryOptions {
byText?: string[]; // Exact text matches
byCharRange?: { start: number; end: number }[]; // Character index ranges
}interface TextRange {
start: number; // Starting character index
end: number; // Ending character index
originalText: string; // The matched text content
bounds: {
min: { x: number; y: number; z: number };
max: { x: number; y: number; z: number };
}[]; // Array of bounding boxes (splits across lines)
glyphs: GlyphGeometryInfo[]; // Glyphs within this range
lineIndices: number[]; // Line numbers this range spans
}three-text manages memory in two ways: a shared glyph cache for all text, and instance-specific resources for each piece of text you create
The shared cache is handled automatically through an LRU (Least Recently Used) policy. The default cache size is 250MB, but you can configure it per text instance. Tessellated glyphs are cached to avoid expensive recomputation when the same characters (or clusters of overlapping characters) appear multiple times
const text = await Text.create({
text: 'Hello world',
font: '/fonts/font.ttf',
size: 72,
maxCacheSizeMB: 1024, // Custom cache size in MB
});
// Check cache performance
const stats = text.getCacheStatistics();
console.log('Cache Statistics:', {
hitRate: stats.hitRate, // Cache hit percentage
memoryUsageMB: stats.memoryUsageMB, // Memory used in MB
uniqueGlyphs: stats.uniqueGlyphs, // Distinct cached glyphs
totalGlyphs: stats.totalGlyphs, // Total glyphs processed
saved: stats.saved // Tessellations avoided
});Fonts are cached internally and persist for the application lifetime. The glyph geometry cache uses an LRU eviction policy, so memory usage is bounded by maxCacheSizeMB. When a text mesh is no longer needed, dispose of its geometry as you would any Three.js BufferGeometry:
textMesh.geometry.dispose();Enable internal logging by setting a global flag before the library loads:
In a browser environment:
window.THREE_TEXT_LOG = true;In a Node.js environment:
THREE_TEXT_LOG=true node your-script.jsThe library will output timing information for font loading, geometry generation, line breaking, and text shaping operations. Errors and warnings are always visible regardless of the flag
The library requires WebAssembly support for HarfBuzz text shaping:
- Chrome 57+
- Firefox 52+
- Safari 11+
- Edge 16+
WOFF font support requires the DecompressionStream API:
- Chrome 80+
- Firefox 113+
- Safari 16.4+
- Edge 80+
WOFF fonts are automatically decompressed to TTF/OTF using the browser's native decompression with zero bundle cost. For older browsers, use TTF or OTF fonts directly
ES modules (recommended) are supported in:
- Chrome 61+
- Firefox 60+
- Safari 10.1+
- Edge 16+
UMD build is needed for older browsers:
- Chrome < 61
- Firefox < 60
- Safari < 10.1
- Internet Explorer (all versions)
While three-text runs on all modern browsers, performance varies significantly based on hardware and browser implementation. In testing on an M2 Max with a 120Hz ProMotion display as well as driving an external 5K display:
Chrome provides the best experience
Firefox also delivers great performance but may exhibit less responsive mouse interactions in WebGL contexts due to the way it handles events
Safari for macOS shows reduced performance, which is likely due to the platform's conservative resource management, particularly around battery life; 120FPS is not acheivable
The library was also tested on a Brightsign 223HD, which took a long time to generate the initial geometry but seemed fine after that
The library includes a test suite using Vitest that covers core functionality, error handling, layout features, and performance optimizations:
npm test # Run all tests
npm test -- --watch # Watch mode
npm test -- --coverage # Coverage reportTests use mocked HarfBuzz and tessellation libraries for fast execution without requiring WASM files
npm run dev # Watch mode with rollup
npm run serve # Start development server for demosnpm run build # Complete build including patternsnpm run build:patterns # Generate hyphenation patterns for all languages
npm run build:patterns:en-us # Generate only English US patterns (faster for development)The build:patterns script uses the tex-hyphen git submodule, which must be initialized. The git clone command in the quick start handles this for you
However, if you cloned the repository without the --recurse-submodules flag, you will need to initialize the submodule manually before this script will work:
git submodule update --init --recursiveThe script then processes the TeX hyphenation data into optimized trie structures. The process is slow for the complete set of languages (~1 minute on an M2 Max), so using --languages for development is recommended
The build generates multiple module formats:
- ESM:
dist/index.js- ES6 modules for modern bundlers - CommonJS:
dist/index.cjs- Node.js compatibility - UMD:
dist/index.umd.js- Universal module for browsers - Types:
dist/index.d.ts- TypeScript declarations
Hyphenation patterns are available in both ESM and UMD formats in dist/patterns/
three-text is built on top of HarfBuzz, TeX, and Three.js, and this library would not exist without the authors and communities who contibute to, support, and steward these projects. Thanks to Theo Honohan and Yasi Perera for the advice on graphics
three-text was written by Jeremy Tribby (@jpt) and is licensed under the GNU Affero General Public License v3.0 or later (AGPL-3.0-or-later). See the LICENSE file for details
This software includes code from third-party libraries under compatible permissive licenses. For full license details, see the LICENSE_THIRD_PARTY file
