three-text 0.2.6 → 0.2.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -15,7 +15,7 @@ A high fidelity 3D font renderer and text layout engine for the web
15
15
  > [!CAUTION]
16
16
  > 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
17
17
 
18
- **three-text** renders and formats text from TTF, OTF, and WOFF font files as 3D geometry. It uses [TeX](https://en.wikipedia.org/wiki/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
18
+ **three-text** renders and formats text from TTF, OTF, and WOFF font files as 3D geometry. It uses [TeX](https://en.wikipedia.org/wiki/TeX)-based parameters for breaking text into paragraphs across multiple lines, and turns font outlines into 3D shapes on the fly. Glyph geometry is cached for low CPU overhead, especially in languages with lots of repeating glyphs. Variable fonts are supported as static instances at a given axis coordinate
19
19
 
20
20
  The library has a framework-agnostic core that returns raw vertex data, with lightweight adapters for [Three.js](https://threejs.org), [React Three Fiber](https://docs.pmnd.rs/react-three-fiber), [p5.js](https://p5js.org), [WebGL](https://developer.mozilla.org/en-US/docs/Web/API/WebGL_API), and [WebGPU](https://developer.mozilla.org/en-US/docs/Web/API/WebGPU_API)
21
21
 
@@ -243,7 +243,7 @@ Existing solutions take different approaches:
243
243
  - **three-bmfont-text** is a 2D approach for Three.js, using pre-rendered bitmap fonts with SDF support. Texture atlases are generated at specific sizes, and artifacts are apparent up close
244
244
  - **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
245
245
 
246
- three-text generates true 3D geometry from font files via HarfBuzz. It is sharper at close distances than bitmap approaches when flat, and produces real mesh data that can be used with any rendering system. The library caches tessellated glyphs, so a paragraph of 1000 words might only require 50 tessellations depending on the language. 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
246
+ three-text generates true 3D geometry from font files via HarfBuzz. It is sharper at close distances than bitmap approaches when flat, and produces real mesh data that can be used with any rendering system. The library caches glyph geometry, so a paragraph of 1000 words might only require 50 unique glyphs to be processed. 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
247
247
 
248
248
  ## Library structure
249
249
 
@@ -298,7 +298,7 @@ Hyphenation uses patterns derived from the Tex hyphenation project, converted in
298
298
 
299
299
  ### Geometry generation and optimization
300
300
 
301
- 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:
301
+ The geometry pipeline runs once per unique glyph (or glyph cluster), with intermediate results cached to avoid redundant work:
302
302
 
303
303
  1. **Path collection**: HarfBuzz callbacks provide low level drawing operations
304
304
  2. **Curve polygonization**: Uses Anti-Grain Geometry's recursive subdivision to convert bezier curves into polygons, concentrating points where curvature is high
@@ -315,7 +315,7 @@ The multi-stage geometry approach (curve polygonization followed by cleanup, the
315
315
 
316
316
  The library uses a hybrid caching strategy to maximize performance while ensuring visual correctness
317
317
 
318
- 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
318
+ 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, avoiding redundant computation
319
319
 
320
320
  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
321
321
 
@@ -958,6 +958,23 @@ npm test -- --coverage # Coverage report
958
958
 
959
959
  Tests use mocked HarfBuzz and tessellation libraries for fast execution without requiring WASM files
960
960
 
961
+ ### Benchmarking
962
+
963
+ For performance of the real pipeline using HarfBuzz, including shaping, layout, tessellation, extrusion, there is a dedicated benchmark:
964
+
965
+ ```bash
966
+ npm run benchmark
967
+ ```
968
+
969
+ This runs a Node/Vitest scenario that:
970
+
971
+ - initializes HarfBuzz from `hb.wasm` via `Text.setHarfBuzzBuffer`
972
+ - loads Nimbus Sans and tests the example paragraph from the demos
973
+ - performs a small number of cold runs followed by warm runs of `Text.create()` with justification and hyphenation enabled
974
+ - prints a per-stage timing table (font load, line breaking, polygonization, tessellation, extrusion, and overall geometry creation)
975
+
976
+ Use this to compare changes locally; it is meant as a sanity check on real work rather than a reliable micro-benchmark
977
+
961
978
  ## Build system
962
979
 
963
980
  ### Development
package/dist/index.cjs CHANGED
@@ -1,5 +1,5 @@
1
1
  /*!
2
- * three-text v0.2.6
2
+ * three-text v0.2.7
3
3
  * Copyright (C) 2025 Countertype LLC
4
4
  *
5
5
  * This program is free software: you can redistribute it and/or modify
@@ -125,6 +125,9 @@ class PerformanceLogger {
125
125
  this.metrics.length = 0;
126
126
  this.activeTimers.clear();
127
127
  }
128
+ reset() {
129
+ this.clear();
130
+ }
128
131
  time(name, fn, metadata) {
129
132
  if (!isLogEnabled)
130
133
  return fn();
@@ -324,6 +327,8 @@ class LineBreak {
324
327
  const filteredPoints = hyphenPoints.filter((pos) => pos >= lefthyphenmin && word.length - pos >= righthyphenmin);
325
328
  return filteredPoints;
326
329
  }
330
+ // Converts text into items (boxes, glues, penalties) for line breaking.
331
+ // The measureText function should return widths that include any letter spacing.
327
332
  static itemizeText(text, measureText, // function to measure text width
328
333
  hyphenate = false, language = 'en-us', availablePatterns, lefthyphenmin = DEFAULT_LEFT_HYPHEN_MIN, righthyphenmin = DEFAULT_RIGHT_HYPHEN_MIN, context) {
329
334
  const items = [];
@@ -1208,6 +1213,9 @@ function convertFontFeaturesToString(features) {
1208
1213
  }
1209
1214
 
1210
1215
  class TextMeasurer {
1216
+ // Measures text width including letter spacing
1217
+ // Letter spacing is added uniformly after each glyph during measurement,
1218
+ // so the widths given to the line-breaking algorithm already account for tracking
1211
1219
  static measureTextWidth(loadedFont, text, letterSpacing = 0) {
1212
1220
  const buffer = loadedFont.hb.createBuffer();
1213
1221
  buffer.addText(text);
@@ -1237,6 +1245,8 @@ class TextLayout {
1237
1245
  const { text, width, align, direction, hyphenate, language, respectExistingBreaks, tolerance, pretolerance, emergencyStretch, autoEmergencyStretch, hyphenationPatterns, lefthyphenmin, righthyphenmin, linepenalty, adjdemerits, hyphenpenalty, exhyphenpenalty, doublehyphendemerits, looseness, disableSingleWordDetection, letterSpacing } = options;
1238
1246
  let lines;
1239
1247
  if (width) {
1248
+ // Line breaking uses a measureText function that already includes letterSpacing,
1249
+ // so widths passed into LineBreak.breakText account for tracking
1240
1250
  lines = LineBreak.breakText({
1241
1251
  text,
1242
1252
  width,
@@ -1260,7 +1270,8 @@ class TextLayout {
1260
1270
  looseness,
1261
1271
  disableSingleWordDetection,
1262
1272
  unitsPerEm: this.loadedFont.upem,
1263
- measureText: (textToMeasure) => TextMeasurer.measureTextWidth(this.loadedFont, textToMeasure, letterSpacing)
1273
+ measureText: (textToMeasure) => TextMeasurer.measureTextWidth(this.loadedFont, textToMeasure, letterSpacing // Letter spacing included in width measurements
1274
+ )
1264
1275
  });
1265
1276
  }
1266
1277
  else {
@@ -2225,7 +2236,11 @@ class Tessellator {
2225
2236
  if (removeOverlaps) {
2226
2237
  logger.log('Two-pass: boundary extraction then triangulation');
2227
2238
  // Extract boundaries to remove overlaps
2239
+ perfLogger.start('Tessellator.boundaryPass', {
2240
+ contourCount: contours.length
2241
+ });
2228
2242
  const boundaryResult = this.performTessellation(contours, 'boundary');
2243
+ perfLogger.end('Tessellator.boundaryPass');
2229
2244
  if (!boundaryResult) {
2230
2245
  logger.warn('libtess returned empty result from boundary pass');
2231
2246
  return { triangles: { vertices: [], indices: [] }, contours: [] };
@@ -2238,7 +2253,11 @@ class Tessellator {
2238
2253
  logger.log(`Single-pass triangulation for ${isCFF ? 'CFF' : 'TTF'}`);
2239
2254
  }
2240
2255
  // Triangulate the contours
2256
+ perfLogger.start('Tessellator.triangulationPass', {
2257
+ contourCount: contours.length
2258
+ });
2241
2259
  const triangleResult = this.performTessellation(contours, 'triangles');
2260
+ perfLogger.end('Tessellator.triangulationPass');
2242
2261
  if (!triangleResult) {
2243
2262
  const warning = removeOverlaps
2244
2263
  ? 'libtess returned empty result from triangulation pass'
@@ -2425,10 +2444,16 @@ const OVERLAP_EPSILON = 1e-3;
2425
2444
  class BoundaryClusterer {
2426
2445
  constructor() { }
2427
2446
  cluster(glyphContoursList, positions) {
2447
+ perfLogger.start('BoundaryClusterer.cluster', {
2448
+ glyphCount: glyphContoursList.length
2449
+ });
2428
2450
  if (glyphContoursList.length === 0) {
2451
+ perfLogger.end('BoundaryClusterer.cluster');
2429
2452
  return [];
2430
2453
  }
2431
- return this.clusterSweepLine(glyphContoursList, positions);
2454
+ const result = this.clusterSweepLine(glyphContoursList, positions);
2455
+ perfLogger.end('BoundaryClusterer.cluster');
2456
+ return result;
2432
2457
  }
2433
2458
  clusterSweepLine(glyphContoursList, positions) {
2434
2459
  const n = glyphContoursList.length;
@@ -3069,6 +3094,11 @@ class GlyphContourCollector {
3069
3094
  this.currentGlyphBounds.max.set(-Infinity, -Infinity);
3070
3095
  // Record position for this glyph
3071
3096
  this.glyphPositions.push(this.currentPosition.clone());
3097
+ // Time polygonization + path optimization per glyph
3098
+ perfLogger.start('Glyph.polygonizeAndOptimize', {
3099
+ glyphId,
3100
+ textIndex
3101
+ });
3072
3102
  }
3073
3103
  finishGlyph() {
3074
3104
  if (this.currentPath) {
@@ -3092,6 +3122,8 @@ class GlyphContourCollector {
3092
3122
  // Track textIndex separately
3093
3123
  this.glyphTextIndices.push(this.currentTextIndex);
3094
3124
  }
3125
+ // Stop timing for this glyph (even if it ended up empty)
3126
+ perfLogger.end('Glyph.polygonizeAndOptimize');
3095
3127
  this.currentGlyphPaths = [];
3096
3128
  }
3097
3129
  onMoveTo(x, y) {
@@ -3326,6 +3358,184 @@ class DrawCallbackHandler {
3326
3358
  }
3327
3359
  }
3328
3360
 
3361
+ // Generic LRU (Least Recently Used) cache with optional memory-based eviction
3362
+ class LRUCache {
3363
+ constructor(options = {}) {
3364
+ this.cache = new Map();
3365
+ this.head = null;
3366
+ this.tail = null;
3367
+ this.stats = {
3368
+ hits: 0,
3369
+ misses: 0,
3370
+ evictions: 0,
3371
+ size: 0,
3372
+ memoryUsage: 0
3373
+ };
3374
+ this.options = {
3375
+ maxEntries: options.maxEntries ?? Infinity,
3376
+ maxMemoryBytes: options.maxMemoryBytes ?? Infinity,
3377
+ calculateSize: options.calculateSize ?? (() => 0),
3378
+ onEvict: options.onEvict
3379
+ };
3380
+ }
3381
+ get(key) {
3382
+ const node = this.cache.get(key);
3383
+ if (node) {
3384
+ this.stats.hits++;
3385
+ this.moveToHead(node);
3386
+ return node.value;
3387
+ }
3388
+ else {
3389
+ this.stats.misses++;
3390
+ return undefined;
3391
+ }
3392
+ }
3393
+ has(key) {
3394
+ return this.cache.has(key);
3395
+ }
3396
+ set(key, value) {
3397
+ // If key already exists, update it
3398
+ const existingNode = this.cache.get(key);
3399
+ if (existingNode) {
3400
+ const oldSize = this.options.calculateSize(existingNode.value);
3401
+ const newSize = this.options.calculateSize(value);
3402
+ this.stats.memoryUsage = this.stats.memoryUsage - oldSize + newSize;
3403
+ existingNode.value = value;
3404
+ this.moveToHead(existingNode);
3405
+ return;
3406
+ }
3407
+ const size = this.options.calculateSize(value);
3408
+ // Evict entries if we exceed limits
3409
+ this.evictIfNeeded(size);
3410
+ // Create new node
3411
+ const node = {
3412
+ key,
3413
+ value,
3414
+ prev: null,
3415
+ next: null
3416
+ };
3417
+ this.cache.set(key, node);
3418
+ this.addToHead(node);
3419
+ this.stats.size = this.cache.size;
3420
+ this.stats.memoryUsage += size;
3421
+ }
3422
+ delete(key) {
3423
+ const node = this.cache.get(key);
3424
+ if (!node)
3425
+ return false;
3426
+ const size = this.options.calculateSize(node.value);
3427
+ this.removeNode(node);
3428
+ this.cache.delete(key);
3429
+ this.stats.size = this.cache.size;
3430
+ this.stats.memoryUsage -= size;
3431
+ if (this.options.onEvict) {
3432
+ this.options.onEvict(key, node.value);
3433
+ }
3434
+ return true;
3435
+ }
3436
+ clear() {
3437
+ if (this.options.onEvict) {
3438
+ for (const [key, node] of this.cache) {
3439
+ this.options.onEvict(key, node.value);
3440
+ }
3441
+ }
3442
+ this.cache.clear();
3443
+ this.head = null;
3444
+ this.tail = null;
3445
+ this.stats = {
3446
+ hits: 0,
3447
+ misses: 0,
3448
+ evictions: 0,
3449
+ size: 0,
3450
+ memoryUsage: 0
3451
+ };
3452
+ }
3453
+ getStats() {
3454
+ const total = this.stats.hits + this.stats.misses;
3455
+ const hitRate = total > 0 ? (this.stats.hits / total) * 100 : 0;
3456
+ const memoryUsageMB = this.stats.memoryUsage / (1024 * 1024);
3457
+ return {
3458
+ ...this.stats,
3459
+ hitRate,
3460
+ memoryUsageMB
3461
+ };
3462
+ }
3463
+ keys() {
3464
+ const keys = [];
3465
+ let current = this.head;
3466
+ while (current) {
3467
+ keys.push(current.key);
3468
+ current = current.next;
3469
+ }
3470
+ return keys;
3471
+ }
3472
+ get size() {
3473
+ return this.cache.size;
3474
+ }
3475
+ evictIfNeeded(requiredSize) {
3476
+ // Evict by entry count
3477
+ while (this.cache.size >= this.options.maxEntries && this.tail) {
3478
+ this.evictTail();
3479
+ }
3480
+ // Evict by memory usage
3481
+ if (this.options.maxMemoryBytes < Infinity) {
3482
+ while (this.tail &&
3483
+ this.stats.memoryUsage + requiredSize > this.options.maxMemoryBytes) {
3484
+ this.evictTail();
3485
+ }
3486
+ }
3487
+ }
3488
+ evictTail() {
3489
+ if (!this.tail)
3490
+ return;
3491
+ const nodeToRemove = this.tail;
3492
+ const size = this.options.calculateSize(nodeToRemove.value);
3493
+ this.removeTail();
3494
+ this.cache.delete(nodeToRemove.key);
3495
+ this.stats.size = this.cache.size;
3496
+ this.stats.memoryUsage -= size;
3497
+ this.stats.evictions++;
3498
+ if (this.options.onEvict) {
3499
+ this.options.onEvict(nodeToRemove.key, nodeToRemove.value);
3500
+ }
3501
+ }
3502
+ addToHead(node) {
3503
+ if (!this.head) {
3504
+ this.head = this.tail = node;
3505
+ }
3506
+ else {
3507
+ node.next = this.head;
3508
+ this.head.prev = node;
3509
+ this.head = node;
3510
+ }
3511
+ }
3512
+ removeNode(node) {
3513
+ if (node.prev) {
3514
+ node.prev.next = node.next;
3515
+ }
3516
+ else {
3517
+ this.head = node.next;
3518
+ }
3519
+ if (node.next) {
3520
+ node.next.prev = node.prev;
3521
+ }
3522
+ else {
3523
+ this.tail = node.prev;
3524
+ }
3525
+ }
3526
+ removeTail() {
3527
+ if (this.tail) {
3528
+ this.removeNode(this.tail);
3529
+ }
3530
+ }
3531
+ moveToHead(node) {
3532
+ if (node === this.head)
3533
+ return;
3534
+ this.removeNode(node);
3535
+ this.addToHead(node);
3536
+ }
3537
+ }
3538
+
3329
3539
  class GlyphGeometryBuilder {
3330
3540
  constructor(cache, loadedFont) {
3331
3541
  this.fontId = 'default';
@@ -3338,6 +3548,16 @@ class GlyphGeometryBuilder {
3338
3548
  this.collector = new GlyphContourCollector();
3339
3549
  this.drawCallbacks = new DrawCallbackHandler();
3340
3550
  this.drawCallbacks.createDrawFuncs(this.loadedFont, this.collector);
3551
+ this.contourCache = new LRUCache({
3552
+ maxEntries: 1000,
3553
+ calculateSize: (contours) => {
3554
+ let size = 0;
3555
+ for (const path of contours.paths) {
3556
+ size += path.points.length * 16; // Vec2 = 2 floats * 8 bytes
3557
+ }
3558
+ return size + 64; // bounds overhead
3559
+ }
3560
+ });
3341
3561
  }
3342
3562
  getOptimizationStats() {
3343
3563
  return this.collector.getOptimizationStats();
@@ -3482,30 +3702,37 @@ class GlyphGeometryBuilder {
3482
3702
  };
3483
3703
  }
3484
3704
  getContoursForGlyph(glyphId) {
3705
+ const cached = this.contourCache.get(glyphId);
3706
+ if (cached) {
3707
+ return cached;
3708
+ }
3485
3709
  this.collector.reset();
3486
3710
  this.collector.beginGlyph(glyphId, 0);
3487
3711
  this.loadedFont.module.exports.hb_font_draw_glyph(this.loadedFont.font.ptr, glyphId, this.drawCallbacks.getDrawFuncsPtr(), 0);
3488
3712
  this.collector.finishGlyph();
3489
3713
  const collected = this.collector.getCollectedGlyphs()[0];
3490
- // Return empty contours for glyphs with no paths (e.g., space, zero-width characters)
3491
- if (!collected) {
3492
- return {
3493
- glyphId,
3494
- paths: [],
3495
- bounds: {
3496
- min: { x: 0, y: 0 },
3497
- max: { x: 0, y: 0 }
3498
- }
3499
- };
3500
- }
3501
- return collected;
3714
+ const contours = collected || {
3715
+ glyphId,
3716
+ paths: [],
3717
+ bounds: {
3718
+ min: { x: 0, y: 0 },
3719
+ max: { x: 0, y: 0 }
3720
+ }
3721
+ };
3722
+ this.contourCache.set(glyphId, contours);
3723
+ return contours;
3502
3724
  }
3503
3725
  tessellateGlyphCluster(paths, depth, isCFF) {
3504
3726
  const processedGeometry = this.tessellator.process(paths, true, isCFF);
3505
3727
  return this.extrudeAndPackage(processedGeometry, depth);
3506
3728
  }
3507
3729
  extrudeAndPackage(processedGeometry, depth) {
3730
+ perfLogger.start('Extruder.extrude', {
3731
+ depth,
3732
+ upem: this.loadedFont.upem
3733
+ });
3508
3734
  const extrudedResult = this.extruder.extrude(processedGeometry, depth, this.loadedFont.upem);
3735
+ perfLogger.end('Extruder.extrude');
3509
3736
  // Compute bounding box from vertices
3510
3737
  const vertices = extrudedResult.vertices;
3511
3738
  let minX = Infinity, minY = Infinity, minZ = Infinity;
@@ -3547,6 +3774,7 @@ class GlyphGeometryBuilder {
3547
3774
  pathCount: glyphContours.paths.length
3548
3775
  });
3549
3776
  const processedGeometry = this.tessellator.process(glyphContours.paths, removeOverlaps, isCFF);
3777
+ perfLogger.end('GlyphGeometryBuilder.tessellateGlyph');
3550
3778
  return this.extrudeAndPackage(processedGeometry, depth);
3551
3779
  }
3552
3780
  updatePlaneBounds(glyphBounds, planeBounds) {
@@ -3619,6 +3847,7 @@ class TextShaper {
3619
3847
  let currentClusterText = '';
3620
3848
  let clusterStartPosition = new Vec3();
3621
3849
  let cursor = new Vec3(lineInfo.xOffset, -lineIndex * scaledLineHeight, 0);
3850
+ // Apply letter spacing between glyphs (must match what was used in width measurements)
3622
3851
  const letterSpacingFU = letterSpacing * this.loadedFont.upem;
3623
3852
  const spaceAdjustment = this.calculateSpaceAdjustment(lineInfo, align, letterSpacing);
3624
3853
  for (let i = 0; i < glyphInfos.length; i++) {