Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 44 additions & 23 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,23 +1,22 @@
# pmndrs/text
# @pmndrs/text

Unicode-aware text for Three.js and React Three Fiber, with portable font baking and explicit Bitmap, MTSDF, and Slug
renderers.
Portable, Unicode-aware text for 3D and canvas rendering engines, with Three.js and React Three Fiber integrations today.

> [!IMPORTANT]
> `pmndrs/text` is in active development toward a public v1 API. The implementation is substantially complete and usable
> `@pmndrs/text` is in active development toward a public v1 API. The implementation is substantially complete and usable
> from this workspace, but the packages are still private and have not been published to npm.

The engine shapes text once with HarfRust, lays it out as a paragraph, and renders the same positioned glyphs through the
raster technique selected by the application. Font artifacts can be prepared ahead of time or generated in a Worker when a
baked asset is unavailable.
The public core loads fonts, shapes Unicode with HarfRust, lays out paragraphs, resolves paint, and exposes raster lifecycle
contracts for renderer integrations. A Three.js implementation with React Three Fiber support is available today.

- Native ESM for modern JavaScript runtimes.
- Framework-neutral `THREE.Group` text objects and a thin React Three Fiber component.
- Portable shaping, layout, paint, artifact, and raster-technique foundations.
- Unicode 17 bidi, line breaking, grapheme segmentation, complex-script shaping, and horizontal CJK layout.
- Bitmap strikes, MTSDF atlases, and analytic Slug outlines over one shaping and layout result.
- Baked-first delivery with authenticated runtime fallback.
- Retained glyph storage for warm text, layout, paint, font, and raster updates.
- Public raster and baker contracts that third-party packages can implement without importing core internals.
- A Three.js integration and thin React Three Fiber component.
- WebGPU and WebGL2 product paths exercised by the benchmark and Presentation application.

The [roadmap](docs/roadmap/roadmap.md) records exact milestone status. The v1 renderer and API milestone is closed in the
Expand All @@ -38,7 +37,9 @@ pnpm dev
`pnpm dev` starts the benchmark and Presentation app. Mise is the easiest way to install the exact tool versions, but the
same pnpm commands work when compatible versions are already installed.

## Render text
## Render text today

The implemented rendering path targets Three.js directly or through React Three Fiber.

### React Three Fiber

Expand Down Expand Up @@ -74,7 +75,7 @@ await useFont.preload(uiFont);

### Three.js

The core `Text` class owns a normal Three.js lifecycle. Its asynchronous generation becomes renderable through ordinary
The Three.js `Text` class owns a normal engine lifecycle. Its asynchronous generation becomes renderable through ordinary
matrix updates, and warm property changes retain the object while the replacement generation is prepared.

```ts
Expand Down Expand Up @@ -143,14 +144,17 @@ Use `pnpm bake --help` for CLI options. The Node API is available from `@pmndrs/

## How the pieces fit

```text
source font ──► font baker ──► authenticated core GLB ──► HarfRust shaping
│ │
└────────► selected raster baker ──► raster GLB/pages ▼
paragraph layout
Three.js Text / React Text
```mermaid
flowchart LR
Font["Font source or baked GLB"] --> Load["defineFont<br/>FontLoader + FontRegistry"]
Load --> Shape["createRuntimeShaper"]
Shape --> Layout["createParagraphEngine<br/>ParagraphLayout"]
Load --> Raster["RasterRuntime<br/>RasterModule"]
Layout --> Stage["RasterBatchStage"]
Raster --> Stage
Stage --> Integration["Renderer integration"]
Integration --> Three["Three.js + R3F"]
Integration -.-> Other["Other engines"]
```

The core artifact owns shaping data, font metrics, provenance, and the font-local glyph identity space. Raster artifacts own
Expand All @@ -161,6 +165,23 @@ Third-party raster implementations use the same public contracts as the built-in
[raster and baker plugin guide](docs/planning/raster-baker-plugin.md); the private
[`@pmndrs/text-glyph-example-raster`](packages/glyph-example-raster) package is the executable external-package proof.

## Core and renderer integrations

The public APIs below are available today; see the [API contract](docs/planning/api-shapes.md) for the complete surface.

| API | Role |
| ----------------------------------------------- | -------------------------------------------------------------------------------- |
| `defineFont`, `FontLoader`, `FontRegistry` | Declare, authenticate, cache, and own font artifacts |
| `createRuntimeShaper`, `createParagraphEngine` | Produce synchronous measurements and positioned `ParagraphLayout` glyph data |
| `defineRaster`, `RasterRuntime`, `RasterModule` | Define, load, decode, prepare, and dispose a raster technique |
| `RasterBatchStage`, `RasterDrawBatch` | Stage complete renderer-owned batches, then commit or abort them transactionally |
| `Text`, `@pmndrs/text/react` | Use the current Three.js and React Three Fiber integration |

A new renderer consumes `ParagraphLayout`, implements the generic raster resource and batch types, and owns its transforms,
GPU resources, ordering, publication, and device lifecycle. The [renderer-agnostic core plan](docs/planning/engine-integration-boundary.md)
tracks the WIP generation boundary, and the [raster plugin guide](docs/planning/raster-baker-plugin.md) shows a working external
technique.

## Repository commands

The contributor-facing command surface is intentionally small:
Expand Down Expand Up @@ -197,16 +218,16 @@ The README is the short path into the project. Deeper documentation is organized
- **Look up:** use the [workspace package catalog](docs/packages/index.md),
[renderer capability matrix](docs/planning/renderer-capabilities.md), and
[`PMNDRS_font` extension schemas](docs/planning/extensions/index.md).
- **Understand:** read the [architecture](docs/planning/architecture.md), [canonical roadmap](docs/roadmap/roadmap.md), and
[attributed research](RESEARCH.md).
- **Understand:** read the [architecture](docs/planning/architecture.md),
[renderer-agnostic core plan](docs/planning/engine-integration-boundary.md), [canonical roadmap](docs/roadmap/roadmap.md),
and [attributed research](RESEARCH.md).

The documentation under [`docs/`](docs/index.md) is also an Open Knowledge Format v0.2 bundle with package-source freshness
checks, provenance, and progressive-disclosure indexes.

## Current scope

The workspace already implements the v1 shaping, horizontal paragraph, delivery, Three.js/React, and three-raster foundation.
The roadmap keeps post-v1 work explicit: editorial flow regions, mixed-font fallback, large-coverage CJK raster paging, color
emoji, expanded effects, and vertical writing.
The renderer-agnostic core and additional engine integrations remain WIP alongside the roadmap's later layout and raster work.

`pmndrs/text` is MIT licensed. Contributions are welcome while the public v1 surface is being stabilized.
`@pmndrs/text` is MIT licensed. Contributions are welcome while the public v1 surface is being stabilized.
1 change: 0 additions & 1 deletion apps/benchmarks/scripts/build.mts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import { buildRuntimePackages, isMainModule, runNodeScript } from './support/com

export async function runBenchmarkBuild(options: { readonly runtimePackagesReady?: boolean } = {}): Promise<void> {
if (!options.runtimePackagesReady) await buildRuntimePackages();
await runNodeScript('scripts/measure-package-sizes.mts');
await runNodeScript('node_modules/vite/bin/vite.js', ['build']);
await runNodeScript('scripts/check-font-notices.mts');
}
Expand Down
2 changes: 1 addition & 1 deletion apps/benchmarks/src/benchmark/package-size-budgets.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
export const packageSizeBudgets = {
'browser-core': {
rawBytes: 341_000,
minifiedBytes: 258_000,
minifiedBytes: 258_500,
gzipBytes: 75_000,
brotliBytes: 57_500,
},
Expand Down
30 changes: 15 additions & 15 deletions apps/benchmarks/src/benchmark/package-sizes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,10 +68,10 @@ describe('independent package-size report', () => {
it('bounds accumulated renderer growth from the pre-coverage baseline', () => {
const coverageGrowth = {
'browser-core': {
rawBytes: { baseline: 324_269, maximumGrowth: 16_000 },
minifiedBytes: { baseline: 247_205, maximumGrowth: 10_500 },
gzipBytes: { baseline: 72_108, maximumGrowth: 2_250 },
brotliBytes: { baseline: 55_251, maximumGrowth: 1_900 },
rawBytes: { baseline: 324_269, maximumGrowth: 17_000 },
minifiedBytes: { baseline: 247_205, maximumGrowth: 11_000 },
gzipBytes: { baseline: 72_108, maximumGrowth: 2_500 },
brotliBytes: { baseline: 55_251, maximumGrowth: 2_100 },
},
'bitmap-baker-js': {
rawBytes: { baseline: 17_478, maximumGrowth: 5_700 },
Expand All @@ -86,10 +86,10 @@ describe('independent package-size report', () => {
brotliBytes: { baseline: 173_552, maximumGrowth: 7_000 },
},
'bitmap-runtime-js': {
rawBytes: { baseline: 361_809, maximumGrowth: 25_500 },
minifiedBytes: { baseline: 271_005, maximumGrowth: 15_750 },
gzipBytes: { baseline: 78_673, maximumGrowth: 3_450 },
brotliBytes: { baseline: 60_857, maximumGrowth: 2_950 },
rawBytes: { baseline: 361_809, maximumGrowth: 27_000 },
minifiedBytes: { baseline: 271_005, maximumGrowth: 16_500 },
gzipBytes: { baseline: 78_673, maximumGrowth: 3_750 },
brotliBytes: { baseline: 60_857, maximumGrowth: 3_200 },
},
'mtsdf-baker-wasm': {
rawBytes: { baseline: 534_709, maximumGrowth: 18_500 },
Expand All @@ -104,10 +104,10 @@ describe('independent package-size report', () => {
brotliBytes: { baseline: 4_176, maximumGrowth: 800 },
},
'mtsdf-runtime-js': {
rawBytes: { baseline: 370_255, maximumGrowth: 25_650 },
minifiedBytes: { baseline: 275_271, maximumGrowth: 15_600 },
gzipBytes: { baseline: 79_993, maximumGrowth: 3_600 },
brotliBytes: { baseline: 62_081, maximumGrowth: 3_050 },
rawBytes: { baseline: 370_255, maximumGrowth: 27_000 },
minifiedBytes: { baseline: 275_271, maximumGrowth: 16_500 },
gzipBytes: { baseline: 79_993, maximumGrowth: 3_800 },
brotliBytes: { baseline: 62_081, maximumGrowth: 3_300 },
},
} as const;
const fields = ['rawBytes', 'minifiedBytes', 'gzipBytes', 'brotliBytes'] as const;
Expand All @@ -126,15 +126,15 @@ describe('independent package-size report', () => {
const retainedCapacityGrowth = {
'bitmap-runtime-js': {
baseline: { rawBytes: 382_060, minifiedBytes: 283_898, gzipBytes: 81_435, brotliBytes: 63_146 },
maximumGrowth: { rawBytes: 5_200, minifiedBytes: 2_850, gzipBytes: 700, brotliBytes: 650 },
maximumGrowth: { rawBytes: 6_500, minifiedBytes: 3_500, gzipBytes: 900, brotliBytes: 850 },
},
'mtsdf-runtime-js': {
baseline: { rawBytes: 389_761, minifiedBytes: 287_629, gzipBytes: 82_721, brotliBytes: 64_286 },
maximumGrowth: { rawBytes: 6_100, minifiedBytes: 3_200, gzipBytes: 850, brotliBytes: 825 },
maximumGrowth: { rawBytes: 7_500, minifiedBytes: 4_000, gzipBytes: 1_050, brotliBytes: 1_050 },
},
'slug-runtime-js': {
baseline: { rawBytes: 390_276, minifiedBytes: 286_600, gzipBytes: 82_730, brotliBytes: 64_271 },
maximumGrowth: { rawBytes: 9_400, minifiedBytes: 5_050, gzipBytes: 1_300, brotliBytes: 1_275 },
maximumGrowth: { rawBytes: 10_750, minifiedBytes: 5_750, gzipBytes: 1_500, brotliBytes: 1_450 },
},
} as const;
const fields = ['rawBytes', 'minifiedBytes', 'gzipBytes', 'brotliBytes'] as const;
Expand Down
1 change: 1 addition & 0 deletions apps/benchmarks/src/benchmark/scenarios.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ function externalRasterProofValidation(values: readonly import('./contracts').Be
metrics.retainedObject !== 1 ||
metrics.retainedGeometry !== 1 ||
(metrics.litPixels ?? 0) < 100 ||
(metrics.layeringPixels ?? 0) < 100 ||
(metrics.backendWebGpu ?? 0) + (metrics.backendWebGl2 ?? 0) !== 1
) {
throw new Error('External raster proof did not preserve its visible retained draw contract');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ interface ExternalRasterResources {
readonly camera: THREE.OrthographicCamera;
readonly text: Text;
readonly font: import('@pmndrs/text').RegisteredFont;
readonly orderingGeometry: THREE.PlaneGeometry;
readonly orderingMaterial: THREE.MeshBasicNodeMaterial;
readonly retainedObject: THREE.Object3D;
readonly retainedGeometry: THREE.BufferGeometry;
readonly glyphCount: number;
Expand Down Expand Up @@ -61,6 +63,8 @@ export function createExternalRasterProofTarget(backend: RendererBackend): Bench
state = { kind: 'empty' };
resources.text.dispose();
resources.font.dispose();
resources.orderingGeometry.dispose();
resources.orderingMaterial.dispose();
resources.target.dispose();
if (resources.ownedRenderer !== undefined) await disposeConfiguredRenderer(resources.ownedRenderer);
},
Expand Down Expand Up @@ -88,6 +92,8 @@ async function createResources(
let target: THREE.RenderTarget | undefined;
let text: Text | undefined;
let font: import('@pmndrs/text').RegisteredFont | undefined;
let orderingGeometry: THREE.PlaneGeometry | undefined;
let orderingMaterial: THREE.MeshBasicNodeMaterial | undefined;
try {
const physicalWidth = Math.round(WIDTH * dpr);
const physicalHeight = Math.round(HEIGHT * dpr);
Expand Down Expand Up @@ -139,8 +145,31 @@ async function createResources(
if (text.layout === undefined)
throw new Error('warm external raster update did not publish during object traversal');
text.position.set(32, -36, 0);
text.renderOrder = 600;
text.updateMatrixWorld();
if (Number(retainedMesh.renderOrder) !== 600)
throw new Error('warm external raster did not apply the Text render-order base');
text.renderOrder = 0;
text.updateMatrixWorld();
if (Number(retainedMesh.renderOrder) !== 0)
throw new Error('warm external raster did not resynchronize the Text render-order base');
const scene = new THREE.Scene();
scene.add(text);
const coverGroup = new THREE.Group();
coverGroup.renderOrder = 100;
orderingGeometry = new THREE.PlaneGeometry(WIDTH, HEIGHT);
orderingMaterial = new THREE.MeshBasicNodeMaterial({
color: 0x7f1734,
depthTest: false,
depthWrite: false,
transparent: true,
});
const cover = new THREE.Mesh(orderingGeometry, orderingMaterial);
cover.position.set(WIDTH / 2, -HEIGHT / 2, 0);
coverGroup.add(cover);
const textGroup = new THREE.Group();
textGroup.renderOrder = 200;
textGroup.add(text);
scene.add(coverGroup, textGroup);
const camera = new THREE.OrthographicCamera(0, WIDTH, 0, -HEIGHT, 0.1, 10);
camera.position.z = 1;
camera.updateProjectionMatrix();
Expand All @@ -154,13 +183,17 @@ async function createResources(
camera,
text,
font,
orderingGeometry,
orderingMaterial,
retainedObject,
retainedGeometry,
glyphCount: text.layout.glyphIds.length,
};
} catch (error) {
text?.dispose();
font?.dispose();
orderingGeometry?.dispose();
orderingMaterial?.dispose();
target?.dispose();
if (ownedRenderer !== undefined) await disposeConfiguredRenderer(ownedRenderer);
throw error;
Expand All @@ -169,28 +202,56 @@ async function createResources(

async function renderResources(resources: ExternalRasterResources, signal?: AbortSignal): Promise<TargetRunOutput> {
signal?.throwIfAborted();
const bytes = await withRendererStateRestored(resources.renderer, async () => {
const { coverBytes, bytes } = await withRendererStateRestored(resources.renderer, async () => {
const { renderer, target } = resources;
const physicalWidth = Math.round(WIDTH * resources.dpr);
const physicalHeight = Math.round(HEIGHT * resources.dpr);
renderer.setRenderTarget(target);
renderer.setClearColor(0x000000, 1);
resources.text.visible = false;
let coverFrame: Uint8Array;
try {
renderer.clear();
renderer.render(resources.scene, resources.camera);
const baselinePixels = await renderer.readRenderTargetPixelsAsync(target, 0, 0, physicalWidth, physicalHeight);
coverFrame = compactRgba8Readback(
new Uint8Array(baselinePixels.buffer, baselinePixels.byteOffset, baselinePixels.byteLength),
physicalWidth,
physicalHeight,
resources.backend === 'webgl2' ? 'bottom-to-top' : 'top-to-bottom',
);
} finally {
resources.text.visible = true;
}
renderer.clear();
renderer.render(resources.scene, resources.camera);
const pixels = await renderer.readRenderTargetPixelsAsync(target, 0, 0, physicalWidth, physicalHeight);
return compactRgba8Readback(
new Uint8Array(pixels.buffer, pixels.byteOffset, pixels.byteLength),
physicalWidth,
physicalHeight,
resources.backend === 'webgl2' ? 'bottom-to-top' : 'top-to-bottom',
);
return {
coverBytes: coverFrame,
bytes: compactRgba8Readback(
new Uint8Array(pixels.buffer, pixels.byteOffset, pixels.byteLength),
physicalWidth,
physicalHeight,
resources.backend === 'webgl2' ? 'bottom-to-top' : 'top-to-bottom',
),
};
});
signal?.throwIfAborted();
let litPixels = 0;
let layeringPixels = 0;
for (let offset = 0; offset < bytes.byteLength; offset += 4) {
if (bytes[offset] !== 0 || bytes[offset + 1] !== 0 || bytes[offset + 2] !== 0) litPixels += 1;
if (
bytes[offset] !== coverBytes[offset] ||
bytes[offset + 1] !== coverBytes[offset + 1] ||
bytes[offset + 2] !== coverBytes[offset + 2] ||
bytes[offset + 3] !== coverBytes[offset + 3]
) {
layeringPixels += 1;
}
}
if (litPixels < 100) throw new Error('external raster proof produced no visible glyph frames');
if (layeringPixels < 100) throw new Error('external raster proof did not honor its caller-owned parent Group order');
const liveObject = exactlyOne(resources.text.children, 'retained external raster draw object');
const liveMesh = exactlyOne(liveObject.children, 'retained external raster mesh');
if (
Expand All @@ -210,6 +271,7 @@ async function renderResources(resources: ExternalRasterResources, signal?: Abor
glyphCount: resources.glyphCount,
drawCount: 1,
litPixels,
layeringPixels,
retainedObject: 1,
retainedGeometry: 1,
renderTargetGpuBytes: bytes.byteLength,
Expand Down
Loading