diff --git a/README.md b/README.md index 739f664f..22831bb8 100644 --- a/README.md +++ b/README.md @@ -1,233 +1,381 @@ # @pmndrs/text -Portable, Unicode-aware text for 3D and canvas rendering engines, with Three.js and React Three Fiber integrations today. +Portable font baking, Unicode shaping, paragraph layout, and batched text rendering for every Canvas. -> [!IMPORTANT] -> `@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. +This README specifies the target v1 API. The repository's merged implementation is v0; v1 is declared only after the core API and +engine integrations below are implemented and pass their portability gates. -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. +## Render text with React Three Fiber -- Native ESM for modern JavaScript runtimes. -- 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. +```tsx +import { Text, TextGroup, useFont } from '@pmndrs/text-r3f'; +import { mtsdf } from '@pmndrs/text/raster/mtsdf'; -The [roadmap](docs/roadmap/roadmap.md) records exact milestone status. The v1 renderer and API milestone is closed in the -workspace; release packaging, documentation, and API stabilization are still in progress. +function Labels() { + const Inter = useFont({ + input: { baked: '/fonts/Inter.font.glb' }, + raster: { technique: mtsdf }, + }); -## Quick start + return ( + + Hello, world! + + ); +} +``` -Run the repository from source with the pinned Node.js, pnpm, and Rust toolchains: +## Render text with Three.js -```sh -git clone git@github.com:pmndrs/text.git -cd text -mise install -pnpm install -pnpm dev +```ts +import { FontLoader, Text, TextGroup } from '@pmndrs/text-three'; +import { mtsdf } from '@pmndrs/text/raster/mtsdf'; + +const loader = new FontLoader(); +const Inter = await loader.loadAsync({ + input: { baked: '/fonts/Inter.font.glb' }, + raster: { technique: mtsdf }, +}); + +const labels = new TextGroup({ technique: mtsdf }); +labels.add(new Text({ font: Inter, text: 'Hello, world!' })); + +scene.add(labels); ``` -`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. +Both integrations load fonts explicitly and add one same-technique text batch to the scene. Three.js owns shaping and buffer synchronization inside its normal render lifecycle. -## Render text today +## Batch text with `TextGroup` -The implemented rendering path targets Three.js directly or through React Three Fiber. +```ts +import { createFontStack } from '@pmndrs/text'; +import { FontLoader, Text, TextGroup, span, txt, type SpanStyle } from '@pmndrs/text-three'; +import { mtsdf } from '@pmndrs/text/raster/mtsdf'; + +const loader = new FontLoader(); +const loadMSDF = (baked: string) => + loader.loadAsync({ + input: { baked }, + raster: { technique: mtsdf }, + }); + +const [Inter, Noto, IconFont] = await Promise.all([ + loadMSDF('/fonts/Inter.font.glb'), + loadMSDF('/fonts/NotoSans.font.glb'), + loadMSDF('/fonts/Icons.font.glb'), +]); + +const BodyFont = createFontStack(Inter, Noto); + +const labels = new TextGroup({ + technique: mtsdf, +}); -### React Three Fiber +scene.add(labels); +``` -`@pmndrs/text/react` uses React Suspense for cold font, shaper, and raster loading. Nested `Text` elements become styled -spans in one paragraph and one Three.js object. +Create retained `Text` objects, then add them through the ordinary Three scene graph: -```tsx -import { Suspense } from 'react'; -import { defineFont } from '@pmndrs/text'; -import { Text } from '@pmndrs/text/react'; -import { msdf } from '@pmndrs/text/raster/msdf'; +```ts +const body = new Text({ + font: BodyFont, + text: 'This paragraph uses Noto when Inter is missing a glyph.', + contentBox: { + width: { mode: 'at-most', size: 480 }, + wrap: 'word', + }, +}); +const score = new Text({ font: Inter, text: 'Player 1' }); -const uiFont = defineFont('/fonts/Inter-Regular.ttf', msdf); +labels.add(body, score); -export function Label() { - return ( - - - Fast, accurate text. - - - ); -} +score.position.set(0, 2, 0); +score.rotation.y = Math.PI / 4; ``` -Preload a font token before a route or scene transition when the application knows it will be needed: +`TextGroup.add()` binds a `Text` to the batch; `TextGroup.remove()` unbinds it without disposing the retained object. Internal glyph slots are created later, when Three synchronizes the group for rendering. + +Grouped glyph buffers belong to `TextGroup`, not to each `Text`. Moving `score` lets the old group recycle its slot and gives the destination a new paragraph membership; the old group's retained buffer stays alive for its other text and future reuse. + +```ts +const overlayLabels = new TextGroup({ technique: mtsdf }); + +overlayLabels.add(score); // Three removes it from labels and binds it to overlayLabels + +score.removeFromParent(); // reusable desired state; no batch membership while detached +score.dispose(); // permanent: score cannot be added again +``` + +Disposing a populated group destroys the batch, not its retained `Text` children: ```ts -import { useFont } from '@pmndrs/text/react'; +labels.dispose(); + +body.disposed; // false +body.bound; // false -await useFont.preload(uiFont); +overlayLabels.add(body); // creates fresh membership; no old buffer or paragraph transfers ``` -### Three.js +`body` keeps its desired state, transform, glyph overrides, and font leases. The disposed group cannot be reused. Destination validation happens before reparenting, so an incompatible technique or invalid font leaves `body` unbound instead of partially moving it. + +Call `dispose()` when the public `Text` will never be reused. It releases text-owned cached state and any standalone batch, but it does not dispose a group's shared buffers or the loaded font. Dispose the `TextGroup` when that render phase is done, then dispose fonts after no retained `Text` or core `Paragraph` holds them. A `FontStack` is an immutable selection value, not an owner; retaining the value does not prevent font disposal, and a stack containing a disposed font cannot be used again. -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. +Construction alone owns no renderer resource. A `Text` gets a text-owned implicit batch only after it is attached outside a `TextGroup` and synchronized for rendering. Moving that rendered standalone text into a group publishes new group membership before retiring its implicit target at a GPU-safe boundary. Calling `dispose()` after removal is therefore not a no-op or a defensive convention: it cancels pending work, clears retained caches and references, marks the object permanently disposed, and prevents it from being attached again. + +## Text spans + +Compose typed spans without managing UTF-16 ranges by hand: ```ts -import { Text, defineFont } from '@pmndrs/text'; -import { msdf } from '@pmndrs/text/raster/msdf'; - -const uiFont = defineFont('/fonts/Inter-Regular.ttf', msdf); -const label = new Text({ - font: uiFont, - text: 'Fast, accurate text.', - width: 4, - fontSize: 0.24, - color: 'white', -}); +const AlertStyle = { + color: '#ffddff', + fontSize: 18, +} satisfies SpanStyle; -scene.add(label); -await label.ready; +const alert = span(Noto, AlertStyle); -label.setProperties({ text: 'Updated without replacing the Text object.' }); +score.text = txt` + Player ${alert`Two`} +`; +``` + +`span(inter)`, `span(uiFont)`, `span(importantStyle)`, and `span(inter, { color: '#ffddff' })` are the same composition path. +A style-only span inherits its surrounding font. The Three entry point re-exports the renderer-neutral `txt`, `span`, and +style types from `@pmndrs/text`. A plain string remains valid anywhere a formatted text literal is accepted. + +Keep the inputs as a tuple when they need to be extended before binding: -// Later, when removing it from the scene: -label.dispose(); +```ts +const AlertFormat = [Inter, AlertStyle] as const; +const alertFormat = span(...AlertFormat); ``` -Passing a source-font URL uses baked-first delivery: the loader probes the canonical sibling font artifact, validates it, -and falls back to the package-owned Worker baker when necessary. Use `{ baked: '/fonts/Inter.font.glb' }` when an application -must require a prebuilt artifact and never fall back to source. +An unattached `Text` stores desired state without shaping. When it is added, the nearest `TextGroup` allocates it before the +first shape and render. Moving it to another group removes its old paragraph allocation and adds a new allocation while +retaining the same `Text` object, properties, and transform. -## Choose a renderer +```ts +score.text = 'First value'; +score.text = 'Second value'; +score.text = 'Player 2'; -Raster selection is explicit. The package does not silently exchange visual techniques at runtime. +renderer.render(scene, camera); // shapes only "Player 2" +``` -| Renderer | Use it for | Import | -| -------- | ----------------------------------------------------------------- | ---------------------------- | -| MTSDF | General-purpose scalable UI and scene text | `@pmndrs/text/raster/msdf` | -| Bitmap | Tiny text at known pixel sizes or intentionally raster typography | `@pmndrs/text/raster/bitmap` | -| Slug | Large text, extreme zoom, and accurate monochrome outlines | `@pmndrs/text/raster/slug` | +One `TextGroup` is one intentional text render phase. Create separate groups for simultaneous scene placements, different +renderer lifetimes, or places where non-text draws must appear between text draws. Ordinary Three reparenting may move one +group between scenes. Every `Text` owns its `Font` or `FontStack`; every effective font must use the group's rendering +technique. + +## Preallocate glyph buffers when it matters + +Capacity is optional. A `TextGroup` defaults to 4,096-glyph chunks if unspecified. Ordinary applications do not need to size batches up front. Paragraph handles and their metadata are not capacity-limited. -Bitmap strikes and their bake-time coverage are declared with the font: +Set capacity when a workload has a known upper bound or needs a different overflow policy: ```ts -import { defineFont } from '@pmndrs/text'; -import { bitmap } from '@pmndrs/text/raster/bitmap'; +const denseLabels = new TextGroup({ + technique: mtsdf, + capacity: { size: 20_000, policy: 'chunk' }, +}); +``` -const bodyFont = defineFont( - '/fonts/Inter-Regular.ttf', - bitmap({ - strikes: [16, 32], - coverage: { text: 'The text and icons this application ships.' }, - }), -); +- `size` is the number of glyph-instance slots in each physical raster-resource buffer. +- `policy: chunk` preserves existing buffers and allocates another when one fills. +- `policy: grow` replaces a full buffer and doubles its capacity until the pending glyphs fit. +- `policy: fixed` turns `size` into a hard limit. + +Core preserves paragraph order when text crosses physical buffers. + +`TextGroup.add()` validates ownership and technique compatibility, but it does not shape and therefore cannot know final glyph +demand. A fixed-capacity overflow is discovered by synchronization: core reports a typed `capacity-exceeded` preparation +error before publication and keeps the prior revision current. The Three.js integration catches that failure inside its +render synchronization, keeps the last complete text visible, and exposes it through the owning +`TextGroup.error` plus a deferred `onError` callback. + +Applications that manage fixed capacity resize explicitly: + +```ts +const overflow = labels.error; +if (overflow?.kind !== 'capacity-exceeded') throw new Error('No fixed-capacity overflow to resize'); + +labels.setCapacity({ size: overflow.required, policy: 'fixed' }); ``` -See the [renderer capability matrix](docs/planning/renderer-capabilities.md) for supported content, effects, and constraints. +## Control batch render order -## Bake fonts ahead of time +A `TextGroup` is an `Object3D`, so its program-compiled draws naturally retain the nearest parent Three `Group` order: -The workspace CLI discovers statically declared fonts and raster requirements and writes authenticated GLB artifacts: +```ts +const hud = new THREE.Group(); +hud.renderOrder = 100; -```sh -pnpm bake --project-root . --entry src/text.ts --asset-root public --output-root public +const labels = new TextGroup({ technique: mtsdf }); + +hud.add(labels); // text draws use groupOrder 100 +scene.add(hud); ``` -Use `pnpm bake --help` for CLI options. The Node API is available from `@pmndrs/text/bake` for custom build systems; the -[API contract](docs/planning/api-shapes.md) describes discovery, loading, caching, Workers, and artifact ownership. +Set the batch's secondary render-order base through the ordinary Three property: + +```ts +labels.renderOrder = 10; +``` -## How the pieces fit +Core sorts each `Text.renderOrder` inside the batch. The integration assigns the program's ordered physical draws +consecutive Three render orders beginning at `TextGroup.renderOrder`. Use separate `TextGroup`s when unrelated Three draws +must appear between text phases. -```mermaid -flowchart LR - Font["Font source or baked GLB"] --> Load["defineFont
FontLoader + FontRegistry"] - Load --> Shape["createRuntimeShaper"] - Shape --> Layout["createParagraphEngine
ParagraphLayout"] - Load --> Raster["RasterRuntime
RasterModule"] - Layout --> Stage["RasterBatchStage"] - Raster --> Stage - Stage --> Integration["Renderer integration"] - Integration --> Three["Three.js + R3F"] - Integration -.-> Other["Other engines"] +## Core API + +Baking, loading, shaping, layout, and physical glyph batching are renderer-neutral core concepts. + +### Bake fonts + +```ts +import { rasterBake } from '@pmndrs/text'; +import { bakeFont } from '@pmndrs/text/bake'; +import mtsdfBaker from '@pmndrs/text/raster/mtsdf/baker'; + +await bakeFont({ + input: new URL('./Inter-Regular.ttf', import.meta.url), + output: new URL('./Inter.font.glb', import.meta.url), + font: { fontFaceIndex: 0 }, + rasters: [ + rasterBake(mtsdfBaker, { + packaging: { artifact: 'embedded', pages: 'embedded' }, + options: undefined, + }), + ], +}); ``` -The core artifact owns shaping data, font metrics, provenance, and the font-local glyph identity space. Raster artifacts own -only technique-specific GPU data and bind back to that core identity. Applications can package them together or fetch them -independently without reshaping the paragraph for each renderer. +Baking creates font metrics, glyph records, and technique resources before the application runs. Development fallback can perform the same work in a Worker. Loading remains explicit either way. -Third-party raster implementations use the same public contracts as the built-in techniques. Start with the -[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. +### Load, shape, and render -## Core and renderer integrations +```ts +import { createFontStack, createTextRuntime } from '@pmndrs/text'; +import { mtsdf } from '@pmndrs/text/raster/mtsdf'; -The public APIs below are available today; see the [API contract](docs/planning/api-shapes.md) for the complete surface. +const runtime = await createTextRuntime({ + async: { + createWorker: () => new Worker(new URL('./text-worker.js', import.meta.url)), + }, +}); -| 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 | +const Inter = await runtime.loadFont({ + input: { baked: '/fonts/Inter.font.glb' }, + raster: { technique: mtsdf }, +}); +const Noto = await runtime.loadFont({ + input: { baked: '/fonts/NotoSans.font.glb' }, + raster: { technique: mtsdf }, +}); -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. +const UiFont = createFontStack(Inter, Noto); -## Repository commands +const paragraphs = runtime.createParagraphBatch({ + technique: mtsdf, +}); -The contributor-facing command surface is intentionally small: +const label = paragraphs.add({ + font: UiFont, + text: 'Player 1', +}); +``` -| Command | Purpose | -| -------------- | --------------------------------------------------------------- | -| `pnpm bake` | Build and run the workspace font-baking CLI. | -| `pnpm dev` | Start the benchmark and Presentation application. | -| `pnpm build` | Build every package and application. | -| `pnpm test` | Run deterministic package and product tests. | -| `pnpm check` | Run the complete merge gate, including tests and documentation. | -| `pnpm scripts` | Discover specialized maintenance and evidence workflows. | +Change desired state, then choose the synchronization boundary explicitly: -Specialized commands explain their own requirements and outputs: +```ts +label.text = 'Player 2'; -```sh -pnpm scripts list -pnpm scripts list presentation -pnpm scripts show benchmark:presentation -pnpm scripts run benchmark:presentation +const revision = runtime.update(); +// or: const outcome = await runtime.updateAsync(); ``` -Hardware WebGPU/WebGL2 screenshots and performance measurements remain explicit local workflows because hosted CI cannot -provide representative GPU timing or driver coverage. Deterministic package, browser, conformance, build, and documentation -checks run through `pnpm check`. +Core returns technique-specific canonical CPU storage, exact adjacent-revision dirty ranges, and ordered glyph runs carrying +the paragraph/span render variant. An integration maps those ranges into its own buffers and compiles compatible runs into +engine draws; it never reshapes, re-sorts source text, or rediscovers physical resource membership. + +## How a rendering engine uses core + +Call core once after application text changes and before the engine submits text. Everything marked `RENDERER` is the thin +technique adapter the engine implements for Bitmap, MTSDF, or Slug. + +```text +CREATE target implementing ParagraphBatchTarget for the selected raster technique + +target.stage(previous, prepared): + FOR EACH glyphBatch IN prepared.glyphBatches: + RENDERER create or reuse safe unpublished buffers for: + glyphBatch.key + glyphBatch.capacity + glyphBatch.storage fields defined by GlyphBatchStorageOf + + IF previous.sourceRevision is the immediately preceding batch revision: + ranges = glyphBatch.dirtyRanges + ELSE: + ranges = the live ranges for glyphBatch named by prepared.glyphRuns + + FOR EACH range IN ranges: + RENDERER upload that range from every glyphBatch.storage field + + RENDERER realize glyphBatch.binding from glyphBatch.font.data + RENDERER retain those font resources with the instance buffers + RASTER TECHNIQUE defines the portable data, binding, and instance semantics + RASTER PROGRAM defines shader and pipeline semantics for this renderer backend -## Documentation + RASTER PROGRAM compile prepared.glyphRuns into ordered compatible draws + it may coalesce adjacent compatible variants or split for engine limits + it must preserve order unless its compositing policy proves another order equivalent + RETURN a ready ParagraphBatchTargetStage + commit() publishes this complete target revision + abort() releases only this unpublished target revision -The README is the short path into the project. Deeper documentation is organized by what the reader needs next: +target.dispose(): + RENDERER retire target resources after in-flight work finishes -- **Learn:** run the [benchmark and Presentation app](docs/packages/benchmarks.md) and follow the examples above. -- **Use:** consult the [API contract](docs/planning/api-shapes.md), [raster plugin guide](docs/planning/raster-baker-plugin.md), - and [uikit integration guidance](docs/planning/uikit-integration.md). -- **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), - [renderer-agnostic core plan](docs/planning/engine-integration-boundary.md), [canonical roadmap](docs/roadmap/roadmap.md), - and [attributed research](RESEARCH.md). +CALL attachment = paragraphs.attach(target) once -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. +BEFORE EACH TEXT RENDER PHASE: + CALL runtime.update() + core shapes every dirty paragraph across the runtime + core publishes prepared paragraph batches atomically + attachments record their newest source revision without touching renderer resources -## Current scope + CALL attachment.prepare() + calls target.stage(previous, prepared) only for this observed render phase + repeated calls are no-ops when that source revision is already prepared -The workspace already implements the v1 shaping, horizontal paragraph, delivery, Three.js/React, and three-raster foundation. -The renderer-agnostic core and additional engine integrations remain WIP alongside the roadmap's later layout and raster work. + CALL attachment.commit() + publishes a ready renderer revision at this safe frame boundary + + READ prepared = paragraphs.current + READ live = attachment.current + + FOR EACH paragraph IN prepared.paragraphs: + RENDERER update the current engine transform for paragraph.paragraph + transform-only changes do not call runtime.update() + + FOR EACH draw IN live.draws, in the compiled order: + RENDERER select the physical buffers and resources identified by draw + RENDERER bind the raster program, variant data, and pipeline + RENDERER encode the draw +``` + +Read the complete [Three.js API](docs/planning/three-api.md), [core API](docs/planning/core-api.md), +[engine integration contract](docs/planning/engine-integration-contract.md), and +[raster technique boundary](docs/planning/raster-technique-api.md), +[TypeGPU program and engine API](docs/planning/typegpu-api.md), the +[TypeGPU-first shader authority research](docs/planning/typegpu-first-shader-authority.md), then the +[implementation plan](docs/planning/engine-integration-boundary.md). + +```sh +mise install +pnpm install +pnpm dev +``` -`@pmndrs/text` is MIT licensed. Contributions are welcome. +`@pmndrs/text` is ESM-only and MIT licensed. diff --git a/docs/index.md b/docs/index.md index a94fb872..3ef37290 100644 --- a/docs/index.md +++ b/docs/index.md @@ -9,12 +9,19 @@ okf_version: '0.2' - [Project README](../README.md) — product overview, API preview, implementation order, and local setup. - [Project brief](planning/project-brief.md) — product outcome, scope, non-goals, and success criteria. - [Canonical roadmap](roadmap/roadmap.md) — implementation sequence, issue-sized milestones, dependencies, and exit gates. -- [Runtime and bake API V0](planning/api-shapes.md) — accepted V1 public API, package boundaries, and explicitly deferred additions. -- [Raster and baker plugin guide](planning/raster-baker-plugin.md) — build an external technique through the public runtime, baker, artifact, discovery, and lifecycle contracts. +- [Merged v0 runtime and bake API](planning/api-shapes.md) — migration fixture for the implemented, unreleased package boundaries and explicitly deferred additions. +- [Three.js text API](planning/three-api.md) — authoritative Three-native loader, explicit `TextGroup` batching, reusable text across group disposal, retained non-throwing errors, ordering, and lifecycle contract. +- [Core text API](planning/core-api.md) — authoritative API for ordered font stacks, batch-owned paragraph handles, identity-preserving capacity changes, fixed-capacity failure, synchronized updates, and renderer-ready glyph batches. +- [Engine integration contract](planning/engine-integration-contract.md) — exact storage, batching, submission, ownership, staging, and frame-publication boundary for custom renderers. +- [Raster technique and engine resource API](planning/raster-technique-api.md) — portable artifact loading, CPU raster data, glyph-resource binding, reusable shader-backend programs, and engine target ownership. +- [TypeGPU-first shader authority](planning/typegpu-first-shader-authority.md) — exploratory TypeGPU-first shader/program architecture, Three and gpucat bridge limits, fallback authority models, and proof gates. +- [Merged v0 raster and baker plugin guide](planning/raster-baker-plugin.md) — build against the implemented combined runtime/renderer module before the target v1 extraction replaces it. +- [External gpucat integration fitness plan](planning/gpucat-integration.md) — source-validated proof plan for consuming the target v1 core without private imports or core changes. ## Architecture and data contracts - [Architecture](planning/architecture.md) — ownership, loading, shaping, paragraph, and raster boundaries. +- [Renderer-neutral core and engine plan](planning/engine-integration-boundary.md) — WIP extraction sequence and proof gates for Three.js and Wayfare. - [Shaping data contract V0](planning/shaping-data-contract.md) — retained SFNT profile, Wasm ABI, validation, and conformance. - [Raster data contract V0](planning/raster-data-contract.md) — bitmap, MSDF, and Slug records and resources. - [glTF extension drafts](planning/extensions/index.md) — `PMNDRS_font` and raster companion schemas. diff --git a/docs/log.md b/docs/log.md index f4fa1e44..c3cd79ed 100644 --- a/docs/log.md +++ b/docs/log.md @@ -1,7 +1,26 @@ # pmndrs/text documentation update log +## 2026-08-07 + +- **Full external-review disposition** — Audited every blocker, high, medium, low, and unresolved item in the retained Claude Opus report. Separated runtime publication from renderer work: attachments now record the newest source revision, while an observed engine explicitly calls `prepare()` and `commit()`, preventing a Three scene traversal from staging another renderer's resources. Required the standard Three target to stage synchronously for its same-frame guarantee. Replaced universal underspecified Three shader contexts with exact per-technique associated types and anchored effect inference to the selected shader. Removed duplicate run order and batch chunk fields, defined capacity replacement as a new physical-key generation, documented run-derived transform indices, async variant-generation mapping, state flags, and missing public core types, and assigned unique decision IDs. The reviewed TypeGPU bridge is now recorded precisely as nullary WGSL injection with no proven Slug-resource or WebGL2 path; Wayfare reuse is withdrawn until source/execution proof. Re-ran gpucat's full suite (256/260 passing) and recorded the four upstream checkout failures without treating them as text evidence. Added a finding-by-finding disposition ledger to the TypeGPU research plan. +- **TypeGPU-first shader authority review** — Ran a read-only Claude Opus adversarial review, independently checked its boundary findings against the merged v0 shaders, pinned Three source, official TypeGPU documentation, and the reviewed gpucat source, and added a falsifiable TypeGPU-first research plan. The core batching/revision/attachment model remains renderer-neutral; the specs now expose pre-update raster density, stable batch-key identity, interface-safe exact storage typing, and synchronous target copying without an invented target font lease. Complete shader authority now includes vertex work and resource access, not only fragment coverage. The current `@typegpu/three` bridge is recorded as WebGPU-only and unproven for real Slug/Bitmap resources; native TSL remains the flagship path while TypeGPU is evaluated as a reusable WebGPU program package that does not require a full scene engine. Gpucat's GLSL-companion and render-order-interval limitations are explicit proof gates. +- **Merged v0, target v1, and external gpucat fitness** — Corrected the planning vocabulary: milestones through 10 produced the merged, unreleased v0 implementation, while milestone 11 implements the target v1 API and earns the first public release only after core and integration gates pass. Recast the pre-extraction API and raster-plugin guide as v0 migration fixtures instead of misnaming the merged implementation. Made Three, R3F, and TypeGPU independent integration-package boundaries over public core exports. Reviewed gpucat at pinned commit `11cf91b`, mapped public typed buffers, texture resources, partial update ranges, multi-draw meshes, transforms, render ordering, and scene synchronization onto the target contract, and added an isolated external-package proof gate. Core API fitness passes by source inspection; reusable canonical Slug shader access and visible three-technique output remain executable gates rather than inferred claims. + +## 2026-08-06 + +- **Variants, reusable raster shaders, and direct TypeGPU engine** — Replaced the over-constrained core “submission” contract with ordered `PreparedGlyphRun` values carrying resolved opaque batch/paragraph/span variants. Core still owns shaping, fallback, source order, physical resource partitioning, slots, canonical storage, and dirty/live ranges; engine programs now explicitly own variant compatibility and final draw splitting/coalescing. Split canonical Bitmap/MTSDF/Slug GPU evaluation into reusable backend `RasterShader` values so custom gradients/effects compose over the hard technique algorithm instead of rewriting it. Kept `TextEffect` as optional Three/TSL convenience over the default variant while admitting fully custom Three programs. Added the complete direct TypeGPU engine contract: caller-owned root/device/passes/RAF, explicit sync/async updates, retained paragraphs and transforms, exact-typed shader/program/variant associations, pass encoding, Wayfare program reuse, and optional `toTSL()` adaptation. Reconciled README, core, target, raster, Three, extraction, roadmap, decisions, and navigation around the new boundary. +- **TypeGPU-authored Three shader experiment** — Recorded `@typegpu/three` `toTSL()` as a real shader-authoring bridge rather than treating TypeGPU and TSL as necessarily independent implementations. The optional path can share TypeGPU-authored raster evaluation with raw WebGPU and Wayfare while the Three adapter continues to own nodes/accessors, materials, pipeline state, rendering, and lifecycle. It remains an experiment behind an explicit export subpath until the pinned Three.js proof inspects generated shaders, establishes Bitmap/Slug render parity, and measures tree-shaken transfer, graph-build, and shader-compilation cost. +- **Raster technique type preservation** — Replaced the proposed `RasterTechnique` erasure with an inferred concrete technique definition and a non-generic common identity constraint. Concrete techniques retain exact options, descriptor, decoded data, resource binding, and glyph-storage relationships; heterogeneous registries expose associated values as `unknown` and must narrow before technique-specific operations. No public helper silently degrades a failed inference to `any`. +- **Portable raster and engine resource split** — Split the next raster contract into a portable baker, portable runtime technique, optional shader-backend program, and engine target. Explicit font loading now ends with validated retained CPU raster data, including embedded or authenticated external page bytes; it creates no GPU object. Core asks the technique to select glyph resource bindings and populate canonical instance storage, then exposes the typed binding on each prepared glyph batch. Engine targets realize textures, buffers, materials/pipelines, transforms, passes, draws, fences, and retirement without rediscovering page membership. A TypeGPU program may be shared by compatible WebGPU hosts that expose device/pass interop, while TSL remains a Three.js-specific program over the same portable technique data. Updated the extraction plan and navigation around this boundary. +- **Renderer attachment verification** — Kept `ParagraphBatch.attach(target)` as the standard batch-scoped lifecycle coordinator while proving it needs no private shaping or allocation state. The public batch observer now replays the current revision, reports later publications, and completes on disposal, allowing custom publication policy to be built from the same contract. Defined dirty ranges as adjacent-revision deltas; late, skipped, or superseded target revisions initialize the live ranges already named by the current submission plan, while adjacent revisions retain the narrow upload path. Target staging failures remain observable without replacing the live target, and pending engine work must copy canonical ranges during the synchronous stage call rather than retaining mutable views across later publications. Added `attach()` to the complete core surface and removed an undefined revision-delta placeholder from the engine contract. +- **Identity-preserving capacity changes** — Removed core paragraph-batch cloning and Three `TextGroup` capacity cloning. `ParagraphBatch.setCapacity()`, `TextGroup.setCapacity()`, and standalone `Text.setCapacity()` now preserve every public and core handle while staging canonical and target storage replacement through the next synchronization. The setter records capacity intent; allocation occurs at synchronization. Fixed capacity means no automatic growth, not permanent immutability. The previous complete revision remains live through failure and target retirement; `TextGroup.clone()` and `copy()` are explicitly unsupported because recursive copying cannot safely preserve application refs, listener state, batch membership, or renderer ownership. +- **Three.js synchronization ownership** — Removed the speculative `Text.updateMode` and `TextGroup.updateMode` properties. One loader-cache domain owns one hidden core runtime; each standalone text or group reconciles membership, invokes the runtime-wide update, commits its staged target revision, delegates ordinary world-matrix traversal, and writes glyph transforms through its `updateMatrixWorld()` override before Three constructs the render list. Core dirty ranges become Three attribute update ranges there; WebGPURenderer performs the actual GPU writes while preparing the internal submission meshes. The core's allocation-free clean path makes repeated calls no-ops, and grouped text disables only its standalone preparation branch without changing caller-owned Three matrix flags. +- **Separate Three.js API contract** — Split the renderer-neutral core and Three.js consumer surfaces. The README now leads with minimal React Three Fiber and imperative Three.js paths that load one font, create one `TextGroup`, add `Text`, and attach the batch to the scene, then shows only real core calls and prepared-revision fields while marking renderer-owned buffer allocation, dirty-range upload, raster resource/shader binding, transform composition, ordered submission, and retirement as integration pseudocode. The authoritative Three specification keeps core handles private, lazily initializes cached shaping from the loader, late-binds unattached `Text` objects before first shaping, exposes only ordinary Three `add()` / `remove()` membership rather than a duplicate allocation shortcut, and assigns shared paragraph slots, buffers, and targets to the effective `TextGroup`. Detached text retains desired state without batch resources; reparenting recycles old membership and creates destination membership; direct scene attachment owns an implicit batch; and permanent text disposal remains distinct from scene removal, group disposal, and font disposal. The integration synchronizes from the Three render lifecycle while renderer-bound GPU targets stay isolated beneath one shared core runtime. `TextGroup` remains a non-Group `Object3D`, preserving the nearest real Three Group's primary order while supplying the secondary order for its physical submissions. Async supersession and cancellation resolve as handled outcomes; only preparation failures reject. +- **Canonical paragraph batching API** — Froze the next core API from the maintainer review and rewrote the README, full API specification, engine contract, extraction plan, decisions, and roadmap around it. `TextRuntime` coordinates explicit loading plus per-call sync/async synchronization; immutable same-technique `FontStack` values carry ordered missing-glyph behavior as one logical font choice; every paragraph owns its font selection; and technique-declared `ParagraphBatch` values preserve intentional render phases. Typed `txt` and variadic `span(...formats)` tags compile imperative literals and React nested text into the same UTF-16 source/span snapshot; reusable format tuples compose fonts, font stacks, layout style, and paint with deterministic left-to-right replacement. Core paragraph handles remain permanently batch-owned: batch disposal cascades, movement is snapshot plus destination creation, and retained paragraph/font leases make early font disposal fail instead of producing missing glyphs. Core owns shaping, fallback, sorting, raster-resource partitioning, stable slots, overflow chunks, canonical technique CPU instance arrays, adjacent dirty ranges, live-range recovery, and ordered submissions. Optional capacity now means only glyph-instance slots per physical resource buffer: explicit batches default to lazy 4,096-glyph chunks, while paragraph handles are unbounded metadata. The non-resizing policy is `fixed`, whose exact overflow is detected after shaping and fails transactionally before publication; Three retains and reports that failure without throwing from rendering or retrying an unchanged failed generation every frame. Identity-preserving capacity setters provide explicit larger-capacity recovery without transferring handles, buffers, targets, or public `Text` identities. Engine targets synchronize selected ranges into their own layouts and retain transforms, scene composition, GPU publication, and retirement. The specification rejects a retained public font group, runtime-wide preparation mode, update mutation callbacks, mixed-technique batches, and target-owned glyph regrouping. Implementation remains the next change. + ## 2026-08-05 +- **Renderer-neutral batch API hardening** — Replaced the provisional one-text-generation extraction sketch with one explicit many-item batch contract for paragraphs, labels, and font-backed icons. The draft API separates asynchronous loading from synchronous or Worker paragraph preparation, adds stable item handles, caller-requested growable/fixed capacity, deterministic item ordering, touched-item atomic updates, renderer-owned physical chunking, and owned glyph snapshots with reversible displayed-origin writes. The execution plan now compares the current Three.js API directly, sequences the portable technique split and headless batch before Three migration, requires Bitmap/MTSDF/Slug through a Wayfare/TypeGPU proof, and rejects TypeGPU shader canvas as the primary engine proof because its current public surface is fullscreen-fragment-only. - **Renderer portability orientation** — Reframed Three.js/TSL and React Three Fiber as the first integrations over portable text foundations, added a compact public-core-to-adapter graph, and marked the serialized renderer-agnostic core plan as WIP. ## 2026-08-04 @@ -14,7 +33,7 @@ ## 2026-08-03 - **Benchmark verification consolidation** — Folded the standalone Presentation-control smoke into the maintained all-workloads route probe, preserving Icon Grid slider, outside-dismissal, workload-label, missing-glyph, and Off-axis layout-width checks while deleting the duplicate entrypoint. Removed a brittle conformance source-string test in favor of the exact persistent-host, renderer-state transaction, target-resolution, and dual-backend product gates; shared one source-boundary scanner across the remaining static dependency rules and removed a duplicate registry workload list. -- **Milestone 10 release review closure** — Replaced stale “planned” renderer guidance with evidence-backed V1 Bitmap, MTSDF, and Slug roles while labeling color, expanded-effect, and mixed-raster work as additive rather than shipped. Added a from-scratch external raster and baker plugin how-to covering identity, descriptor normalization, companion artifacts, Node and runtime bakers, static discovery, renderer-neutral `stageBatch` transactions, Three.js attachment, cold preparation, and lifecycle proof. The public API reference now distinguishes accepted V1 surfaces from explicitly deferred proposals. The nine-workload consumer audit found no missing common API: bounded capacity stays raster-owned, matrix traversal remains the one warm publication path, and diagnostics stay out of production graphs. Complete deterministic and live browser gates, the corrected 59.84–60.17 FPS Advanced Shaping observation, exact package-size checks, zero-warning OKF validation, GitHub-verified signed history, reviewed PR descriptions, and green stacked CI close Milestone 10. +- **Milestone 10 v0 review closure** — Replaced stale “planned” renderer guidance with evidence-backed merged v0 Bitmap, MTSDF, and Slug roles while labeling color, expanded-effect, and mixed-raster work as additive rather than implemented. Added a from-scratch external raster and baker plugin how-to covering identity, descriptor normalization, companion artifacts, Node and runtime bakers, static discovery, renderer-neutral `stageBatch` transactions, Three.js attachment, cold preparation, and lifecycle proof. The public API fixture distinguishes merged v0 surfaces from explicitly deferred proposals. The nine-workload consumer audit found no missing common API: bounded capacity stays raster-owned, matrix traversal remains the one warm publication path, and diagnostics stay out of production graphs. Complete deterministic and live browser gates, the corrected 59.84–60.17 FPS Advanced Shaping observation, exact package-size checks, zero-warning OKF validation, GitHub-verified signed history, reviewed PR descriptions, and green stacked CI close Milestone 10 without publishing a release. - **Benchmark public-API and ownership audit** — Traced all nine live workloads through their public `Text`, font loader/registry, raster, and runtime-bake entry points and found no package-internal import or missing common API. Each example now owns a `workloads//{definition,scene}.ts` directory; consumers import the exact file they need, the root catalog only aggregates definitions, and shared/comparison-only contracts have explicit homes. Direct Wasm remains isolated to ABI conformance. Moved benchmark-specific Bitmap, MTSDF, and Slug persistent scenes plus their metadata into explicit `techniques` subtrees; generic renderer infrastructure no longer depends on live technique or target code. The 1,300-line retained comparison host moved from `workloads` to `surfaces/benchmark/scenes`, leaving workload modules responsible only for authored construction, layout, animation, and retained configuration. The root `app.tsx` is now a 37-line route entry; one shared `HarnessRoute` identity delegates URL and transition state to `controllers`, renderer-aware composition to `surfaces/harness`, and application chrome to `components`. Boundary tests reject separate Main/Presentation component types and preserve one runtime-world and persistent-renderer generation. The API audit explicitly rejected public buffer-slack controls, a second `Text.flush()` publication path, and shipped update diagnostics because retained capacity, Three.js lifecycle publication, and benchmark-local telemetry already satisfy the observed consumers. Reclassified the retained raster comparison as a conformance surface scene and the isolated comparison preview as probe infrastructure rather than executable targets. The live lane exposed and fixed two real state/lifecycle faults: Advanced Shaping discarded an explicitly selected compatible font, and the retained comparison scene measured layouts before a font replacement generation became ready. The performance probe also mislabeled a pre-populated frame-history length as twelve steady reports; it now observes twelve distinct 250 ms telemetry publications per case. The isolated corrected run held every Advanced Shaping case at 59.84–60.17 FPS, with CJK at 60.02 FPS, 0.77 ms CPU p95, and 3.82 ms GPU p95. Vitest now builds the large-font replay inspector once in global setup, before parallel test-file contention, instead of allowing cold `cargo run` compilation to consume an individual test's deadline. All 321 unit tests pass. All nine workloads then completed sequentially across Bitmap, MTSDF, and Slug; the timed demo observed its complete schedule with one renderer, returned to Off-axis / 3D, and measured Icon Grid at 60.25 FPS. React Doctor 0.7.2 reported no findings; its remote score endpoint was unavailable for the final route-only pass. - **Final benchmark boundary locality** — Moved the deterministic TSL baseline and its tests from `renderer` into the conformance target tree, with the lazy registry and core-text browser probe resolving their true target and low-level readback owners. Moved the latest-value async queue beside the three React viewport controllers that exclusively consume it. Focused boundary/queue/TSL tests, strict type checking, the complete 317-test unit lane, and the real WebGPU/WebGL live browser probe passed. - **Workload-owned retained comparison scene** — Moved the remaining 1,573-line multi-technique workload implementation and its 661-line focused test beside the authored workload definitions under `workloads/comparison`. Removed its standalone renderer, RAF, GPU-timer, and telemetry branch. Three Slug performance probes now use an 80-line measurement-owned adapter that creates one `PersistentRenderHost` and activates the same retained scene, completing all 40 fixed-32 browser runs across WebGPU/WebGL2, Inter/CJK, and both candidates. The complete 317-test gate passed; all 42 Presentation cells stayed visible with one renderer, and both timed demos returned to Off-axis / 3D at 59.90/60.02 Icon Grid FPS. @@ -158,7 +177,7 @@ ## 2026-07-27 -- **Renderer integration sequencing** — Kept Three.js/TSL as the V1 implementation through Slug, then scheduled the renderer-neutral direct integration extraction for Milestone 10 when all three rasters have executable resource, batching, composition, and lifetime requirements. Three.js remains a supported adapter; raw WebGPU and a possible TypeGPU adapter sit above the same boundary rather than entering shaping or layout. +- **Renderer integration sequencing** — Kept Three.js/TSL as the merged v0 implementation through Slug, then scheduled the renderer-neutral direct integration extraction after all three rasters had executable resource, batching, composition, and lifetime requirements. Three.js remains a supported adapter; raw WebGPU and a possible TypeGPU adapter sit above the same boundary rather than entering shaping or layout. - **Compiler-derived ABI layouts** — Closed the ABI portion of item 8.6 without pulling forward selective baking or performance experiments. The font baker, shaper, Bitmap baker, and MTSDF generator/artifact boundary now derive published sizes, alignments, and field offsets from fixed-width `#[repr(C)]` Rust types. Build-only Rust generators emit the portable JSON and exact typed `as const` TypeScript modules from those facts; production hosts import the generated modules, CI rejects stale output, and production Wasm carries no duplicate JSON or ABI-pointer bootstrap. - **MTSDF acceleration scope** — Clarified that the rejected SIMD result covered one-texel four-channel quantization, not adjacent-texel curve evaluation. Item 8.6 may compare equivalent scalar and true multi-texel SIMD tile kernels after phase instrumentation, and may research a lazy TypeGPU compute baker with explicit WGSL, scalar-Wasm fallback, same-device resident output, and measured Worker readback costs. - **Milestone 8.6 planning** — Added bounded runtime Bitmap/MTSDF atlas options, compiler-derived `#[repr(C)]` Wasm ABI layouts, complete ABI/Wasm/fixture regeneration, phase-level baker profiling, and measured allocator selection as required pre-closure work. Current evidence attributes the long complete-face MTSDF bake primarily to serial per-texel edge-distance evaluation—45.38 seconds cold and 48.13 seconds warm for the independent 2,915-glyph Inter kernel, versus 95–109 seconds for the 39,111,736-texel artifact path—while requiring instrumentation before assigning the remaining time to packing, serialization, or copies. @@ -212,7 +231,7 @@ - **Hardening** — Completed the item-8.4 dual-backend MTSDF renderer and base-level scalar conformance oracle; added reviewed error envelopes and negative controls, causal Bitmap/MSDF workload probes, explicit dynamic-reflow timing, and the existing Paint & Effects workload's live per-word hue with opacity plus MSDF-only stroke. - **Implementation** — Published six authenticated full-face Bitmap/MTSDF benchmark fixture families, exact NPOT mip-chain accounting, payload/page/GPU inspection, downloadable font notices, and streaming artifact identity tests; DotGothic16 and Amiri remain labeled stress fixtures pending paging. - **CJK showcase fidelity** — Replaced DotGothic16 as the Advanced Shaping visual default with a deterministic HarfBuzz 13 subset of the authored Noto Sans CJK JP corpus. Matching one-page Bitmap and MTSDF artifacts keep shaping and raster glyph identity together, while the UI exposes all baked fonts and marks each case recommendation. DotGothic16 remains available as an explicitly pixel-styled stress fixture; complete CJK still belongs to chunked paging. -- **Vertical-writing research** — Scheduled a post-V1 Japanese vertical-writing milestone after complete CJK paging, with explicit OpenType vertical metrics/features, Unicode cluster orientation, right-to-left column geometry, interaction coordinates, shared-renderer gates, and a preserved horizontal fast path. +- **Vertical-writing research** — Scheduled a post-v1 Japanese vertical-writing milestone after complete CJK paging, with explicit OpenType vertical metrics/features, Unicode cluster orientation, right-to-left column geometry, interaction coordinates, shared-renderer gates, and a preserved horizontal fast path. - **Hardening** — Enforced serial lazy module Workers for runtime raster bakers, bounded segmented Wasm artifact transfer, unsigned ABI normalization, asynchronous Worker preparation failures, registry subscription release, authenticated collection-face reuse, generator-only MTSDF host compatibility, and removal of obsolete packed ABI V0 output. - **Benchmark payload terminology** — Renamed transferred font data as font assets and labeled atlas allocation as GPU texture memory. Per-page rows now distinguish download bytes from GPU bytes without implying that every resident texture is uncompressed. - **MTSDF runtime and validator** — Closed roadmap item 8.3 with the optional fixed renderer and isolated strict validator. One dependency-light implementation now owns lossless KTX2 structure/data-format metadata and dense 20-byte record rules for bitmap and MTSDF runtime and standalone paths; Khronos/Ajv remain validator-only. Canonical Inter proves all ten pages in embedded and external forms, authenticated page identity, generated-mip residency, fill/outline/shadow batch updates, disposal, field-level mutations, and KTX2 DFD corruption without importing baker Wasm into rendering. @@ -225,7 +244,7 @@ - **Shared lossless raster artifacts** — Began item 8.2 by extracting the canonical 20-byte glyph records, checked shelf atlas, lossless linear R8/RGBA8 KTX2 encoder, GLB framing, SHA-256 identities, and packaging enums into one `no_std + alloc` Rust support crate shared by bitmap and MTSDF bakers. Exact Inter bitmap records, pages, GLBs, and reports remain byte-identical. The bitmap baker grows by 8,749 raw / 1,735 Brotli bytes for the checked shared boundary; those bytes remain inside optional baker Wasm and never enter shaping or rendering bundles. - **MTSDF generator admission closed** — Compared isolated scalar, compiler-auto-vectorized, and explicit-four-lane Wasm kernels through seven exact native-oracle identities, a Fontations-emitted 2,937-slot Inter corpus, alternating Node and GPU-enabled Chromium calls, instrumented warm allocation counts, steady-state memory growth, and raw/optimized/gzip/Brotli size. This initial pre-correction packet selected scalar as the one production artifact; the corrected-kernel identities, sizes, and workload split are superseded by the current 2026-07-29 closure entry. Edge-color corner storage now reuses generator scratch, and the explicit SIMD implementation stays test-only for reproducibility. - **MTSDF package integration** — Promoted the scalar generator from an internal ABI proof into the `@pmndrs/text` production build: one Binaryen-optimized Wasm and its Rust-generated JSON contract ship as package resources behind a strict direct-memory TypeScript host. The admission kernel remains zero-import; the full artifact baker adds one contract-declared progress callback for observable Worker bakes. All seven native-msdfgen candidate hashes survive the host; malformed values and outlines, forged and stale allocation ownership, ABI drift, borrowed-result copying, and transactional cleanup are named regressions. Independent size evidence is maintained by the package-size lane. A hash-gated local Node 24 arm64 observation separates compile, initialization, cold-corpus, and warm execution costs; item 8.1 remains open only for the scalar/auto-vectorized/explicit-SIMD shipping decision. -- **Planning** — Scheduled post-V1 Milestone 11 for responsive editorial flow regions and a live mixed-raster composition: native-strike bitmap body copy, an MTSDF pull quote, and a Slug display treatment share one authoritative shaped and positioned layout around columns and explicit exclusions. The accompanying research distinguishes Pretext's proven dynamic obstacle wrapping from pmndrs/text's proposed exact complex-script GPU pipeline, treats performance as a phase-by-phase hypothesis, and defers a public API, contour-tight wrapping, and arbitrary rendered-pixel occlusion until integration evidence exists. Later additive milestones move forward one number; large-coverage CJK raster paging and icons are now Milestone 13. +- **Planning** — Scheduled post-v1 responsive editorial flow regions and a live mixed-raster composition: native-strike bitmap body copy, an MTSDF pull quote, and a Slug display treatment share one authoritative shaped and positioned layout around columns and explicit exclusions. The accompanying research distinguishes Pretext's proven dynamic obstacle wrapping from pmndrs/text's proposed exact complex-script GPU pipeline, treats performance as a phase-by-phase hypothesis, and defers a public API, contour-tight wrapping, and arbitrary rendered-pixel occlusion until integration evidence exists. Later roadmap reconciliation assigned this work to Milestone 12 and large-coverage CJK raster paging/icons to Milestone 14. - **MTSDF direct-memory ABI** — Split host mechanics from geometry: `mtsdf-core` remains allocator-agnostic `no_std + alloc`, while `mtsdf-baker` owns `dlmalloc`, a Rust-generated JSON contract, and a checked C ABI over exact active allocations and borrowed RGBA8 results. The zero-import direct-memory integration test proves contract access, generation identity, release, and stale-pointer rejection. The complete Binaryen-optimized boundary is 44,368 bytes (17,930 gzip; 14,865 Brotli). - **Owned MTSDF evidence** — Implemented the `no_std + alloc` Rust geometry core with typed outline errors, reusable scratch, AoS-to-SoA lowering, true signed line/curve distances, contour-aware overlap resolution, and nonzero-fill sign correction. All seven native-msdfgen oracle cases now have zero coverage mismatches and 0.472–0.549-byte mean alpha error. The optimized no-import admission module is 42,607 bytes (18,318 gzip; 15,333 Brotli); 2,915 Inter glyphs are cold/warm checksum-stable, and the 40.173-second scalar median establishes the optimization baseline. A deterministic 1,000-run cargo-fuzz smoke completes without a crash. diff --git a/docs/packages/text.md b/docs/packages/text.md index 0629e655..4b970b5d 100644 --- a/docs/packages/text.md +++ b/docs/packages/text.md @@ -142,7 +142,7 @@ sources: title: Unicode analysis implementation generated: by: openai-codex/gpt-5.6 - at: '2026-08-04T19:04:36Z' + at: '2026-08-07T01:16:02Z' --- # Package reference: `@pmndrs/text` @@ -192,7 +192,7 @@ The geometry core is independent of its host boundary. A sibling `mtsdf-baker` c Milestone 8.2 composed that kernel into the original fixed `@pmndrs/text/bakers/msdf` artifact path. One shared Fontations adapter supplies maintained unscaled line, quadratic, and cubic outlines to both admission evidence and the baker; no second parser or outline bridge exists. Its 64 px/em, full-eight-pixel-range descriptor hashes to `e944ba8d…fe93`. Item 8.6 now exposes `emSize` and full `pixelRange` as authenticated integer bake options in `1..=1022` and `1..=1020`. Omitted or partial options resolve against 64/8; explicit effective 64/8 canonicalizes to the legacy fieldless descriptor and raster key, while every non-default descriptor carries both effective values. `planeUnitsPerEm` equals `emSize`, and each glyph is evaluated only over its tight source-outline rectangle plus `ceil(pixelRange / 2)` field-padding texels on that global plane grid. Correction operates over the same glyph-local rectangle before copying into a 1024-pixel atlas page. Real 155-glyph subset bakes at 32/4 and 32/6 pass artifact validation, establishing the control path without changing the recommended default before quality and payload benchmarking. Bitmap and MTSDF descriptors may additionally authenticate bounded raster coverage while retaining the full source-local glyph namespace and dense record table. Standalone validation derives the expected coverage only from that authenticated descriptor; its public context has no second coverage field that could silently disagree. Degenerate non-rendering selected glyphs become exact absent records, while malformed command streams remain typed failures. The shared TypeScript direct-memory host owns allocation, response framing, nested metadata validation, copying, and transactional cleanup for both bitmap and MTSDF bakers. -Direct raster-baker ABI V1 keeps ordinary responses contiguous and moves oversized results through bounded borrowed windows: the host reads metadata once, copies each window while Wasm owns it, and explicitly releases that ownership before the Worker transfers exact result buffers. Every Wasm pointer, status, length, and count is normalized as unsigned at the JavaScript boundary. The generator-only no-default-feature MTSDF module remains valid because artifact-baker fields are optional to the generator host, while the published baker requires and validates them. MTSDF quality options travel in the authenticated descriptor and do not change the low-level Wasm ABI. Build output removes obsolete ABI V0 files before packing. +Direct raster-baker ABI revision 1 keeps ordinary responses contiguous and moves oversized results through bounded borrowed windows: the host reads metadata once, copies each window while Wasm owns it, and explicitly releases that ownership before the Worker transfers exact result buffers. Every Wasm pointer, status, length, and count is normalized as unsigned at the JavaScript boundary. The generator-only no-default-feature MTSDF module remains valid because artifact-baker fields are optional to the generator host, while the packaged baker requires and validates them. MTSDF quality options travel in the authenticated descriptor and do not change the low-level Wasm ABI. Build output removes obsolete ABI revision 0 files before packing. Bitmap and MTSDF runtime fallback share one serial ESM module-Worker host. The same normalized descriptor options drive deliberate Node baking and missing-artifact fallback; Bitmap's Worker normalizer retains both strikes and coverage instead of projecting coverage away. Each dynamically imported baker receives an owned source copy, one active job uses the reusable Worker, queued jobs remain FIFO, cancellation replaces active ownership safely, and an idle Worker terminates. Preparation failures reject through the promised asynchronous API rather than escaping synchronously. Core provenance now retains the authenticated collection-face index; legacy artifacts may default only when their descriptor hash proves face zero, and runtime raster baking always reuses that selected face. Registry subscriptions are released when their final tracked font is disposed. diff --git a/docs/planning/api-shapes.md b/docs/planning/api-shapes.md index ee5d0044..fcffc8c0 100644 --- a/docs/planning/api-shapes.md +++ b/docs/planning/api-shapes.md @@ -1,7 +1,7 @@ --- type: API Reference -title: Runtime and bake API fixture V0 -description: Defines the canonical accepted V1 package, loader, baker, shaper, paragraph, raster, and cache interfaces plus explicitly deferred additions. +title: Merged v0 runtime and bake API fixture +description: Records the merged v0 package, loader, baker, shaper, paragraph, raster, and cache interfaces for migration and regression comparison while the target v1 API is built. tags: [api, loader, baker, shaping, paragraph, raster] sources: - id: 'citation-1' @@ -22,17 +22,28 @@ sources: - id: 'raster-technique-comparison' resource: '../../apps/benchmarks/src/surfaces/conformance/scenes/raster-technique-comparison.ts' title: 'Retained MSDF and Slug comparison scene' + - id: 'core-api' + resource: 'core-api.md' + title: 'Core text API' + - id: 'engine-integration-contract' + resource: 'engine-integration-contract.md' + title: 'Proposed engine integration data contract' generated: by: openai-codex/gpt-5.6 - at: '2026-08-03T15:29:54Z' + at: '2026-08-07T01:16:02Z' --- -# Runtime and bake API fixture V0 +# Merged v0 runtime and bake API fixture -Status: accepted V1 surfaces are implemented; sections labeled deferred remain proposals +Status: merged v0 surfaces are implemented but unreleased; sections labeled deferred remain proposals Scope: baked-first loading, lazy Worker baking, HarfRust Wasm shaping, JavaScript paragraph layout, and explicit raster loading +> [!NOTE] +> This page is retained for migration and regression comparison. The root [README](../../README.md), +> [core text API](core-api.md), and [engine integration contract](engine-integration-contract.md) define the +> authoritative extraction API. + ## Milestone 0.1 acceptance evidence This table reports contract evidence; it does not turn implementation or prose into maintainer acceptance. The [canonical roadmap checklist](../roadmap/roadmap.md#milestone-0--accept-contracts-and-versions) is the only closure gate, and the [decision register](decision-register.md#product-and-public-api) records approval state. @@ -48,7 +59,7 @@ This table reports contract evidence; it does not turn implementation or prose i ## Benchmark consumer API discovery The Milestone-10 benchmark cleanup treats every live workload as executable consumer evidence. A public API candidate is -admitted here only when the desired consumer snippet cannot be expressed through the shipped package, the missing +admitted here only when the desired consumer snippet cannot be expressed through the merged v0 package, the missing constraint has a distinguishing test, and runtime-size, Worker, renderer, and type consequences are stated. Benchmark telemetry, fixture authentication, renderer ownership, and direct-ABI measurement do not become product APIs merely because the harness needs them. @@ -68,7 +79,7 @@ because the harness needs them. | Direct baker/shaper ABI targets | Published Wasm/package entry points behind one lazily selected target adapter | Exact ABI timing and byte-level conformance | Keep isolated under benchmark conformance/measurement targets | | Retained MSDF / Slug comparison | Two independently transactional public `Text` objects coordinated by the scene | Paired offscreen-target publication and rollback after a delayed peer | Keep coordination local; no ordinary consumer proves a grouped public transaction | -The workload pass also tested three plausible additions and found no consumer failure that would justify shipping them: +The workload pass also tested three plausible additions and found no consumer failure that would justify merging them: | Candidate | Evidence | Decision | | ---------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | @@ -77,7 +88,7 @@ The workload pass also tested three plausible additions and found no consumer fa | Public retained-update diagnostics | Reuse, ranged upload, overflow, and replacement are covered by raster tests and benchmark-only telemetry; applications do not need those classifications to render correctly | Keep investigation/profiling signals outside the thin runtime so production builds retain zero diagnostic cost | This audit rejects new loader telemetry, generic raster-statistics, Three-specific, and React-specific APIs: each would add -coupling or shipped code without a demonstrated consumer failure. It also rejects exporting the first-party capacity and dirty- +coupling or merged code without a demonstrated consumer failure. It also rejects exporting the first-party capacity and dirty- range helpers: the portable `stageBatch` contract already lets an external raster own an equivalent policy without inheriting Three-specific storage. The delayed-peer failure is real, but its required atomicity belongs to one comparison product over two independent render targets. The private retained-target solution closes that consumer failure without adding renderer- @@ -1148,7 +1159,7 @@ Coverage seeds are normalized, bounded, and authenticated in the raster descript The optional bitmap presentation helpers snapshot copied font handles, glyph IDs, UTF-16 clusters, exact font-size bits, occurrence ordinals, and currently displayed instance origins without retaining a `Text`, batch, texture, or geometry. A transition matches only the same complete glyph identity and updates the target batch's existing origin arrays. New or reshaped glyphs remain at their authoritative target positions; sizes, UVs, paint, shaping, line breaks, and `ParagraphLayout` never interpolate. Progress is finite and bounded to `[0, 1]`, stale or disposed batches reject mutation, and `finish`/`dispose` are idempotent. Target-origin storage is allocated only when a consumer creates a transition. The existing TSL graph still performs the final physical-pixel snap. -The resource and draw-batch types are owned by their optional raster packages. `defineRaster` captures the literal `kind` and associated types from the module value; consumers do not supply generic arguments. Core has no closed raster-kind union and does not assume which raster packages are installed or shipped. Each optional package owns its literal kind and companion data contract. Adding a first-party or external raster requires no change to the core type declarations. The shared package depends only on `RasterModule` and never imports concrete engines. +The resource and draw-batch types are owned by their optional raster packages. `defineRaster` captures the literal `kind` and associated types from the module value; consumers do not supply generic arguments. Core has no closed raster-kind union and does not assume which raster packages are installed or present. Each optional package owns its literal kind and companion data contract. Adding a first-party or external raster requires no change to the core type declarations. The shared package depends only on `RasterModule` and never imports concrete engines. `RasterDrawBatch` is the portable disposal contract. Renderer adapters refine it without changing that core boundary: `RasterObjectDrawBatch` adds one host scene object, and the public `ThreeRasterDrawBatch` alias binds that object to diff --git a/docs/planning/architecture.md b/docs/planning/architecture.md index 191f4ee1..ebf67b64 100644 --- a/docs/planning/architecture.md +++ b/docs/planning/architecture.md @@ -16,15 +16,28 @@ sources: - id: 'citation-4' resource: 'https://registry.khronos.org/KTX/specs/2.0/ktxspec.v2.html' title: 'KTX 2.0 specification' + - id: core-api + resource: core-api.md + title: Core text API + - id: raster-technique + resource: raster-technique-api.md + title: Raster technique and engine resource API + - id: typegpu-api + resource: typegpu-api.md + title: TypeGPU raster programs and text engine + - id: gpucat-integration + resource: gpucat-integration.md + title: External gpucat integration fitness plan generated: by: 'openai-codex/gpt-5.6' - at: '2026-07-27T23:09:57Z' + at: '2026-08-07T01:16:02Z' --- # Proposed architecture -Status: proposed; the [API contract](api-shapes.md) owns exact public interface shapes. +Status: proposed; the [core API](core-api.md), [engine contract](engine-integration-contract.md), and engine-specific API +specifications own exact public interface shapes. ## System boundaries @@ -35,12 +48,16 @@ flowchart TD Bake --> Bytes["canonical PMNDRS_font bytes"] Bytes --> Registry["validator + font registry"] Registry --> Shaper["HarfRust Wasm"] - Registry --> Raster["selected raster"] + Registry --> Technique["portable raster technique"] Shaper --> Paragraph["JavaScript paragraph engine"] - Paragraph --> Object["Three.js Text object"] - Raster --> Object - React["@pmndrs/text/react"] -. "reconciles props and spans" .-> Object - Object --> GPU["GPU"] + Paragraph --> Batch["canonical glyph batches + ordered variant runs"] + Technique --> Batch + Batch --> Three["Three program + target"] + Batch --> TypeGPU["TypeGPU program + engine"] + Batch --> Other["other engine target"] + Three --> GPU["GPU"] + TypeGPU --> GPU + Other --> GPU ``` The loader first probes the canonical baked core font. Only a core miss dynamically imports the runtime baker library and its font-bake Worker. Rasters may be embedded in that GLB or loaded as independently addressable GLBs. A selected raster with no artifact invokes that raster module's optional lazy runtime-baker capability; the raster package owns its Worker/import details. The Node host and runtime libraries use the same bake cores and emit the same records. Subsetting, remapping, compiled IR, and SIMD specialization remain later compiler units. @@ -137,7 +154,7 @@ The arrows back into the shared bake units express code and record parity, not a - `PMNDRS_font` writing and validation; - deterministic core-font diagnostics. -The shared core does not define, generate, serialize, validate, decode, or render raster artifacts. V0 emits the closed shaping-only static SFNT profile and exposes the font context used by the separately imported bitmap baker for the integration proof. Later milestones add separately owned MTSDF-backed MSDF and Slug packages before V1 can ship. Subsetting, closure, dense remapping, compiled lookups, and color-emoji/SVG-icon extensions remain separate later work. +The shared core does not define, generate, serialize, validate, decode, or render raster artifacts. The merged v0 implementation emits the closed shaping-only static SFNT profile and exposes the font context used by separately imported Bitmap, MTSDF, and Slug bakers. The target v1 work separates their portable techniques from engine realization before any public release. Subsetting, closure, dense remapping, compiled lookups, and color-emoji/SVG-icon extensions remain separate later work. ### Each raster package owns @@ -145,10 +162,17 @@ The shared core does not define, generate, serialize, validate, decode, or rende - bake options and serialized descriptor schema; - generator, artifact writer, and deterministic diagnostics; - companion extension schema, binary records, texture/resource formats, and validator; -- runtime artifact decoding, GPU upload, batching, shader implementation, and disposal; +- renderer-neutral runtime artifact decoding, resource selection, canonical glyph storage layout, and packing; +- reusable backend-specific canonical technique shaders where appropriate; +- engine-specific programs/targets for resources, variants, pipelines/materials, final draws, and disposal; - technique-specific fixtures, payload reports, and visual/performance gates. -The generic Node and Worker hosts dynamically load raster packages, pass them the read-only font context, and compose returned artifacts into embedded or external delivery. They treat descriptor and artifact bodies as opaque package-owned values. Our raster packages use TSL internally, but the core interface does not name TSL, TypeGPU, WebGPU, WebGL, shader nodes, or pipeline types. An external package may use any implementation that can fulfill the small raster lifecycle. +The generic Node and Worker hosts dynamically load raster packages, pass them the read-only font context, and compose +returned artifacts into embedded or external delivery. They treat descriptor and artifact bodies as opaque package-owned +values. Portable technique entry points name no TSL, TypeGPU, WebGPU, WebGL, shader-node, or pipeline type. First-party +packages may expose native TSL or TypeGPU shader/program subpaths without pulling those dependencies into baking, loading, +shaping, or core batching. An external package may use any implementation that satisfies the portable technique and target +contracts. ### Node host owns @@ -198,38 +222,39 @@ break opportunities come from `@cto.af/linebreak` 4.0.3. Generated tables and official conformance fixtures are build/test inputs; application code does not consult ambient `Intl` or a browser-dependent ICU version. -### Raster modules own +### Engine targets own -- the runtime half of their package-owned artifact contract; -- validation of technique-specific ranges; +- synchronization from canonical technique CPU storage into engine buffers; - GPU resource creation and direct upload; -- instance generation and renderer submission. +- shaders/programs, transforms, scene/pass placement, renderer submission, fences, and retirement. -### Three.js text object owns +### Three.js integration owns -- the public framework-neutral text lifecycle; -- one paragraph instance and selected raster resource; +- the public `FontLoader`, `TextGroup`, and `Text` lifecycle over private core handles; - standard `Object3D` transforms, scene attachment, bounds, visibility, and disposal; -- mapping property changes to paint-only updates, reflow, or reshaping. +- target synchronization through the Three render lifecycle; +- TSL materials and program-owned draw compilation. -### React subpath owns +### React Three Fiber integration owns -- Suspense-backed font loading through the core loader; -- reconciling root props onto the core Three.js text object; +- Suspense-backed font loading through the Three integration loader; +- reconciling props onto the Three integration's retained text objects; - flattening nested `` children into one source string and inline spans; - ref forwarding and React lifecycle disposal. -The React subpath owns no shaping, line-breaking, baking, raster decoding, shaders, or GPU formats. +The React integration owns no shaping, line-breaking, baking, raster decoding, shaders, or GPU formats. ## Dependency and import graph ```mermaid flowchart LR - React["@pmndrs/text/react"] --> Core["@pmndrs/text"] + React["@pmndrs/text-r3f"] --> Three["@pmndrs/text-three"] --> Core["@pmndrs/text"] + TypeGPU["@pmndrs/text-typegpu"] --> Core + Gpucat["@pmndrs/text-gpucat"] --> Core Core --> Registry["asset validator / registry"] Core --> Shaper["shaper bridge"] Core --> Paragraph["paragraph engine"] - Core --> Interfaces["raster interfaces"] + Core --> Interfaces["portable raster techniques"] Bake["@pmndrs/text/bake"] --> Node["Node host"] --> Shared["font bake core"] Runtime["@pmndrs/text/runtime-bake"] --> Worker["Worker host"] --> Shared Node -. "dynamic" .-> Generator["selected raster baker package"] @@ -237,6 +262,9 @@ flowchart LR Bitmap["raster/bitmap"] --> Interfaces Msdf["raster/msdf
MTSDF resource"] --> Interfaces Slug["raster/slug"] --> Interfaces + Three --> Interfaces + TypeGPU --> Interfaces + Gpucat --> Interfaces ``` A baked asset hit must not make the main module graph reach the runtime baker library or generator modules. diff --git a/docs/planning/autoresearch.md b/docs/planning/autoresearch.md index 1962399f..35e6ae72 100644 --- a/docs/planning/autoresearch.md +++ b/docs/planning/autoresearch.md @@ -5,7 +5,7 @@ description: Governs evidence-based optimization experiments that cannot trade a tags: [optimization, benchmarks, quality] generated: by: openai-codex/gpt-5.6 - at: '2026-07-26T20:20:41Z' + at: '2026-08-07T01:16:02Z' --- # Autoresearch optimization protocol @@ -21,7 +21,7 @@ Its job is not to produce the largest benchmark number. Its job is to discover c The first raster target should be Slug because: -- it has the highest V1 optimization difficulty; +- it has the highest optimization difficulty among the merged v0 techniques; - Three Flatland already supplies measured hypotheses and rejected experiments; - its fill-bound shader has meaningful room for representation, code-generation, and workload-specific improvements; - bitmap and MTSDF-backed MSDF runtime paths are comparatively conventional. diff --git a/docs/planning/benchmark-plan.md b/docs/planning/benchmark-plan.md index 49957bd2..8ac22195 100644 --- a/docs/planning/benchmark-plan.md +++ b/docs/planning/benchmark-plan.md @@ -17,12 +17,12 @@ sources: resource: 'https://github.com/drawcall-ai/vitexec' title: 'Vitexec' - id: 'citation-5' - resource: '../../README.md#benchmark-harness-wireframe' + resource: '../assets/benchmark-harness-wireframe.png' title: 'Repository benchmark-harness wireframe' generated: by: 'openai-codex/gpt-5.6' - at: '2026-07-27T10:26:00Z' + at: '2026-08-07T01:16:02Z' --- # Benchmark plan @@ -74,7 +74,7 @@ Status key: ✅ specified or available · 🟡 partial or conditional · ⬜ not | -------------------------------------------- | :----: | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | Canonical architecture and scenario contract | ✅ | This plan owns one target registry, one scenario registry, and one runner contract for interactive and headless surfaces. | | Portable baker target | ✅ | `packages/font-baker` and the app run immutable Inter 4.1 bytes through the direct-memory Wasm API with deterministic GLB evidence. | -| Lab shell under `apps/benchmarks` | ✅ | The responsive token/component shell defaults to the human-facing live benchmark with mode, technique, backend, and workload URL state; finite visual conformance is separate. Fixed histories report renderer-callback CPU time, FPS, and real WebGPU/WebGL2 GPU timestamps when supported, while capture/export snapshots the live contract on demand. Causal product checks own label fit, control density, horizontal overflow, and mobile/tablet/desktop flow at 390, 1,024, and 1,280 CSS pixels. | +| Lab shell under `apps/benchmarks` | ✅ | The responsive token/component shell defaults to the human-facing live benchmark with mode, technique, backend, and workload URL state; finite visual conformance is separate. Fixed histories report renderer-callback CPU time, FPS, and real WebGPU/WebGL2 GPU timestamps when supported, while capture/export snapshots the live contract on demand. Causal product checks own label fit, control density, horizontal overflow, and mobile/tablet/desktop flow at 390, 1,024, and 1,280 CSS pixels. | | Headless product E2E | 🟡 | A browser CLI, Vitexec, and Playwright call the same strict registry execution module. The bounded CI-safe conformance suite includes synthetic, forced-WebGL2 TSL and bitmap rendering, public React `Text` reconciliation, direct-baker, loader/Worker, HarfRust, paragraph, bidi/policy/uikit, and item-5.4 CJK lanes. Hardware-WebGPU and pending-Suspense probes remain maintainer-local, and Milestone 6 awaits its closure review. | | Package-size lane | ✅ | Independent library-mode entries produce nonzero raw/minified/gzip/Brotli initial-core, Unicode 17 analysis, lazy-validator, runtime-host, runtime-Worker, baker, and shaper JavaScript sizes plus raw/gzip/Brotli Wasm. Rollup static closures exclude dynamic chunks; the browser-core lane externalizes declared `three`, React, and R3F peers, while Worker and shaper JavaScript exclude separately measured Wasm assets. The record names its measurement host: same-host output stays exact, while every foreign-host entry must satisfy the shared complete reviewed budgets. Unicode analysis is 139,936 bytes minified and the Darwin arm64 shaper record is 32,778 bytes minified JavaScript plus 680,312 bytes optimized Wasm. | | Browser visual reference | 🟡 | Exact font/text/style/viewport inputs, Chromium 149.0.7827.55, Playwright 1.61.1, PNG hash, and regeneration command are pinned; renderer candidates and diffs land with rendering. | @@ -88,16 +88,16 @@ The completed paragraph-policy scenario consumes the generated `paragraph-bidi-l ## Application stack -| Concern | Settled harness choice | -| ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Application | A Vite browser application under `apps/benchmarks`; it remains locally runnable and statically publishable. | -| UI runtime | React 19 with the React Compiler enabled from the first implementation. | -| Async React | Suspense-backed resources, `use`, transitions, and action-style mutations where they match the lifecycle. Async target/scenario loading is modeled as explicit resources rather than effect-driven fetch orchestration. | -| Components | The project-owned custom shadcn-derived component set represented by the Figma design. Existing components and tokens are reused; generic generated replacements are not accepted. | -| Source design | The node-specific [benchmark harness Figma wireframe routed from the repository README](../../README.md#benchmark-harness-wireframe) and its extracted component/token context. The file is visual and token input, not a product contract; the implemented information architecture may diverge to make consumer cost and correctness legible. | -| Formatting and linting | Oxfmt and Oxlint are authoritative. Oxlint runs React Compiler analysis, Rules of Hooks, accessibility checks, and the Oxlint-compatible `react-you-might-not-need-an-effect` rules as errors; effect-only event logic uses `useEffectEvent` instead of render-time refs. | -| Tests | Vitest covers contracts and reusable assertions; a committed erasable-TypeScript Vitexec probe exercises the live Vite runner; Playwright exercises fixed mobile viewports and remains reusable for representative headed/GPU lanes. Browser console errors fail the wrapper even when the Vitexec CLI exits successfully. | -| TypeScript | Strict project references extending the repository base configuration. App and probe code remains erasable TypeScript unless a build-tool configuration explicitly requires otherwise. | +| Concern | Settled harness choice | +| ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Application | A Vite browser application under `apps/benchmarks`; it remains locally runnable and statically publishable. | +| UI runtime | React 19 with the React Compiler enabled from the first implementation. | +| Async React | Suspense-backed resources, `use`, transitions, and action-style mutations where they match the lifecycle. Async target/scenario loading is modeled as explicit resources rather than effect-driven fetch orchestration. | +| Components | The project-owned custom shadcn-derived component set represented by the Figma design. Existing components and tokens are reused; generic generated replacements are not accepted. | +| Source design | The node-specific [benchmark harness Figma wireframe](../assets/benchmark-harness-wireframe.png) and its extracted component/token context. The file is visual and token input, not a product contract; the implemented information architecture may diverge to make consumer cost and correctness legible. | +| Formatting and linting | Oxfmt and Oxlint are authoritative. Oxlint runs React Compiler analysis, Rules of Hooks, accessibility checks, and the Oxlint-compatible `react-you-might-not-need-an-effect` rules as errors; effect-only event logic uses `useEffectEvent` instead of render-time refs. | +| Tests | Vitest covers contracts and reusable assertions; a committed erasable-TypeScript Vitexec probe exercises the live Vite runner; Playwright exercises fixed mobile viewports and remains reusable for representative headed/GPU lanes. Browser console errors fail the wrapper even when the Vitexec CLI exits successfully. | +| TypeScript | Strict project references extending the repository base configuration. App and probe code remains erasable TypeScript unless a build-tool configuration explicitly requires otherwise. | The visual shell reuses the Figma token system and appropriate project-owned shadcn-derived primitives without treating the mockup hierarchy as immutable. Semantic CSS variables feed Tailwind utilities, so visual values remain centralized instead of becoming scattered literals. Target adapters, scenarios, runner state, validation, and result schemas stay UI-independent. React components subscribe to those contracts; they do not own benchmark execution policy or create a second result model. @@ -217,7 +217,7 @@ Like the reference project, bundle sizes are produced from independent import en - runtime baker loader and bake Wasm; - each raster runtime; - each raster generator; -- combined V1 application path. +- combined target v1 application path. Report raw, minified, gzip, and Brotli JavaScript; raw, gzip, and Brotli Wasm; and every substantial dynamically imported validator, Worker host, generator, or transcoder separately. The initial entry measurement follows only static chunk imports and never adds dynamic chunks merely because they are reachable. The interactive lab reads generated result JSON rather than estimating sizes from the development bundle. @@ -379,7 +379,7 @@ CJK and icon benchmarks share one page-stress lane. The same pinned sources are - cancellation and stale-generation behavior when text changes during preparation; - selected icon subset versus complete icon-library stress case. -The synthetic maximum-cardinality contract fixture runs early to protect the format. Full-face raster generation, long page walks, and device residency measurements belong to scheduled/manual jobs and Milestone 13; they do not block the Latin-first V1 renderer gate. Item 5.4 consumes the complete CJK source for shaping but does not create these raster tiers. +The synthetic maximum-cardinality contract fixture runs early to protect the format. Full-face raster generation, long page walks, and device residency measurements belong to scheduled/manual jobs and Milestone 14; they do not block the Latin-first target v1 renderer gate. Item 5.4 consumes the complete CJK source for shaping but does not create these raster tiers. ## Raster benchmarks diff --git a/docs/planning/conformance-plan.md b/docs/planning/conformance-plan.md index bb3e80f8..aa2f1750 100644 --- a/docs/planning/conformance-plan.md +++ b/docs/planning/conformance-plan.md @@ -37,7 +37,7 @@ sources: generated: by: 'openai-codex/gpt-5.6' - at: '2026-07-29T15:36:00Z' + at: '2026-08-07T01:16:02Z' --- # Shaping and layout conformance plan @@ -174,7 +174,7 @@ call; an unrelated subsequent shaper call proves the layout owns its arrays. | Controls | LF, CRLF, paragraph separator, tabs policy, default ignorables, ZWJ/ZWNJ, soft hyphen | | Invalid input | unpaired UTF-16 surrogates and replacement policy at JS boundary | -The CJK row is split across two gates. Roadmap item 5.4 now proves exact horizontal CJK source/reduced HarfRust and HarfBuzz agreement, UTF-16 clustering, language-sensitive substitutions, variation handling, and paragraph layout. It conditionally retains source `BASE`, `VORG`, `vhea`, and `vmtx` without fabrication while leaving vertical layout deferred. It does not require CJK raster coverage. Milestone 13 later combines large-coverage CJK raster paging with icon paging, residency, and payload stress; that later work does not block the Latin-first bitmap/MSDF/Slug V1 renderer gate. Before those raster contracts freeze, a synthetic 65,535-glyph fixture still validates glyph-ID width, dense-record lengths, logical page indexes, external page sources, and multi-page batching without claiming full CJK rendering support. +The CJK row is split across two gates. Roadmap item 5.4 now proves exact horizontal CJK source/reduced HarfRust and HarfBuzz agreement, UTF-16 clustering, language-sensitive substitutions, variation handling, and paragraph layout. It conditionally retains source `BASE`, `VORG`, `vhea`, and `vmtx` without fabrication while leaving vertical layout deferred. It does not require CJK raster coverage. Milestone 14 later combines large-coverage CJK raster paging with icon paging, residency, and payload stress; that later work does not block the Latin-first Bitmap/MTSDF/Slug target v1 renderer gate. Before those raster contracts freeze, a synthetic 65,535-glyph fixture still validates glyph-ID width, dense-record lengths, logical page indexes, external page sources, and multi-page batching without claiming full CJK rendering support. ### Large-coverage page invariants diff --git a/docs/planning/core-api.md b/docs/planning/core-api.md new file mode 100644 index 00000000..4476f557 --- /dev/null +++ b/docs/planning/core-api.md @@ -0,0 +1,1152 @@ +--- +type: API Specification +title: Core text API +description: Canonical API and rationale for loading fonts, composing ordered same-technique font stacks, editing paragraphs, synchronizing shaping, and producing renderer-ready glyph batches. +documentation_type: reference +tags: [api, fonts, shaping, paragraphs, batching, rendering, async] +status: stable +sources: + - id: decision-register + resource: decision-register.md + title: Accepted architectural decisions + - id: engine-contract + resource: engine-integration-contract.md + title: Engine integration contract + - id: raster-technique + resource: raster-technique-api.md + title: Raster technique and engine resource API + - id: extraction-plan + resource: engine-integration-boundary.md + title: Renderer-neutral extraction plan + - id: three-api + resource: three-api.md + title: Three.js text API + - id: typegpu-api + resource: typegpu-api.md + title: TypeGPU raster programs and text engine + - id: gpucat-integration + resource: gpucat-integration.md + title: External gpucat integration fitness plan + - id: current-api + resource: api-shapes.md + title: Existing API migration fixture + - id: current-shaper + resource: ../../packages/text/src/shaper.ts + title: Current synchronous shaper + - id: current-paragraph + resource: ../../packages/text/src/paragraph.ts + title: Current paragraph implementation + - id: current-raster + resource: ../../packages/text/src/raster.ts + title: Current raster transaction contract +generated: + by: openai-codex/gpt-5.6 + at: '2026-08-07T03:25:58Z' +--- + +# Core text API + +This is the canonical public API and the authority for implementation. + +```ts +fontFile + -> bakeFont() // optional build-time work + -> runtime.loadFont() // explicit asynchronous loading + -> createFontStack() // optional ordered missing-glyph resolution + -> runtime.createParagraphBatch()// one intentional render phase + -> paragraph.text = next // cheap desired-state mutation + -> runtime.update() // synchronous synchronization point + // or runtime.updateAsync() // asynchronous synchronization point + -> PreparedGlyphBatch[] // core-partitioned GPU instance data + -> PreparedGlyphRun[] // ordered text runs with resolved render intent + -> engine draw compiler // compatible pipelines, effects, and final draws +``` + +## The complete API + +```ts +interface TextRuntime { + readonly current: TextRuntimeRevision; + readonly hasPendingChanges: boolean; + readonly isPreparing: boolean; + readonly disposed: boolean; + + loadFont( + request: LoadedFontRequest, + options?: { readonly signal?: AbortSignal }, + ): Promise>; + + createParagraphBatch( + options: ParagraphBatchOptions, + ): ParagraphBatch; + + update(): TextRuntimeRevision; + + updateAsync(options?: AsyncTextUpdateOptions): Promise; + updateAsync(callback: TextUpdateCallback): void; + updateAsync(options: AsyncTextUpdateOptions, callback: TextUpdateCallback): void; + + subscribe(listener: (revision: TextRuntimeRevision) => void): () => void; + dispose(): void; +} + +interface TextRuntimeOptions { + readonly registry?: FontRegistry; + readonly shaper?: RuntimeShaper; + readonly async?: Readonly<{ + readonly worker?: TextPreparationWorker; + readonly createWorker?: () => TextPreparationWorker; + }>; +} + +interface LoadedFontRequest { + readonly input: + | { readonly baked: string | URL } + | { readonly source: string | URL; readonly runtimeBake: RuntimeFontBake }; + readonly raster: { + readonly technique: Technique; + readonly options?: RasterOptionsOf; + }; +} + +interface RuntimeFontBakeRequest { + readonly source: Uint8Array; + readonly sourceUrl: string; + readonly bakedUrl?: string; + readonly signal?: AbortSignal; +} + +type RuntimeFontBake = (request: RuntimeFontBakeRequest) => Promise; + +interface TextPreparationWorker { + postMessage(message: unknown, transfer?: readonly Transferable[]): void; + addEventListener(type: 'message', listener: (event: MessageEvent) => void): void; + removeEventListener(type: 'message', listener: (event: MessageEvent) => void): void; + addEventListener(type: 'error', listener: (event: ErrorEvent) => void): void; + removeEventListener(type: 'error', listener: (event: ErrorEvent) => void): void; + terminate(): void; +} + +declare function createTextRuntime(options?: TextRuntimeOptions): Promise; +``` + +Runtime options provision capabilities. They do not choose whether every update is synchronous or asynchronous. That +choice belongs to each `update()` or `updateAsync()` call. `createTextRuntime()` takes exclusive lifecycle ownership of an +injected registry, shaper, worker, or worker produced by `createWorker`; callers must not share those objects with another +runtime or dispose them independently. + +`AnyRasterTechnique`, `RasterDataOf`, `RasterBindingOf`, and `GlyphBatchStorageOf` come from the portable +[raster technique API](raster-technique-api.md). A technique owns artifact decoding, physical glyph-resource selection, and +canonical CPU instance packing without importing a rendering engine. + +## Bake and load explicitly + +```ts +import { rasterBake } from '@pmndrs/text'; +import { bakeFont } from '@pmndrs/text/bake'; +import mtsdfBaker from '@pmndrs/text/raster/mtsdf/baker'; + +await bakeFont({ + input: new URL('./Inter-Regular.ttf', import.meta.url), + output: new URL('./Inter.font.glb', import.meta.url), + font: { fontFaceIndex: 0 }, + rasters: [ + rasterBake(mtsdfBaker, { + packaging: { artifact: 'embedded', pages: 'embedded' }, + options: undefined, + }), + ], +}); +``` + +`@pmndrs/text/raster/mtsdf` is the intentional target-v1 name. The merged v0 package still exports the historical +`@pmndrs/text/raster/msdf` spelling even though its artifact is MTSDF; migration removes that alias when the v1 surface +lands. + +Baking produces font metrics, glyph records, and technique resources before the application runs. Runtime fallback may +perform the same bake in a Worker, but loading remains explicit in either case. + +```ts +import { createFontStack, createTextRuntime, span, txt } from '@pmndrs/text'; +import { mtsdf } from '@pmndrs/text/raster/mtsdf'; + +const runtime = await createTextRuntime({ + async: { + createWorker: () => new Worker(new URL('./text-worker.js', import.meta.url)), + }, +}); + +const inter = await runtime.loadFont({ + input: { baked: '/fonts/Inter.font.glb' }, + raster: { technique: mtsdf }, +}); +``` + +```ts +interface LoadedFont { + readonly runtime: TextRuntime; + readonly font: RegisteredFont; + readonly technique: Technique; + readonly raster: RegisteredRaster>; + readonly data: RasterDataOf; + readonly disposed: boolean; + dispose(): void; +} +``` + +`loadFont()` completes after shaping data and the selected technique data are decoded into renderer-neutral CPU state. It +does not create textures, buffers, pipelines, materials, meshes, entities, or scene objects. + +## Compose one logical font with fallback + +A `FontStack` is one immutable logical font choice. Its first concrete font is primary; later fonts resolve missing glyphs +in order. A single loaded font already satisfies the same text-facing contract and needs no wrapper. + +```ts +const noto = await runtime.loadFont(notoMtsdfRequest); +const amiri = await runtime.loadFont(amiriMtsdfRequest); +const iconMtsdf = await runtime.loadFont(iconMtsdfRequest); + +const uiFont = createFontStack(inter, noto, amiri); +const iconFont = iconMtsdf; +``` + +```ts +type FontSelection = LoadedFont | FontStack; + +interface FontStack { + readonly technique: Technique; + readonly fonts: readonly [LoadedFont, ...LoadedFont[]]; +} + +declare function createFontStack( + primary: LoadedFont, + ...fallback: readonly LoadedFont>[] +): FontStack; +``` + +Every concrete font must use the same technique. TypeScript rejects a mixed stack through `NoInfer`; runtime validation +provides the same guarantee to JavaScript and untrusted boundaries. The immutable stack owns no font lifecycle. Adding a +paragraph acquires a lease on every concrete font in its selection until that paragraph or its owning batch is disposed. +`LoadedFont.dispose()` fails while any live paragraph lease remains, so disposal can never silently turn fallback into a +missing glyph. A stack containing a successfully disposed member is rejected when used to create or update a paragraph. + +```ts +createFontStack(interMtsdf, iconBitmap); // compile-time error and runtime rejection +``` + +A renderer that combines Bitmap and Slug data is a new technique with its own artifacts, instance schema, resource +bindings, and shader. It is not a font stack that mixes the existing Bitmap and Slug techniques. + +## Create an intentional paragraph batch + +A paragraph batch contains paragraphs that the application permits core to order and submit as one render phase. + +```ts +const worldText = runtime.createParagraphBatch({ + technique: mtsdf, +}); +``` + +```ts +interface ParagraphBatchOptions { + readonly technique: Technique; + readonly capacity?: GlyphBufferCapacity; + readonly rasterPixelRatio?: number; + readonly renderVariant?: Variant; +} + +interface GlyphBufferCapacity { + readonly size: number; + readonly policy: 'grow' | 'chunk' | 'fixed'; +} + +interface ParagraphBatch { + readonly runtime: TextRuntime; + readonly technique: Technique; + readonly capacity: GlyphBufferCapacity; + readonly current: PreparedParagraphBatchRevision; + readonly paragraphCount: number; + readonly hasPendingChanges: boolean; + readonly preparationError: TextPreparationError | undefined; + readonly disposed: boolean; + + rasterPixelRatio: number; + renderVariant: Variant | undefined; + + add(properties: ParagraphProperties): Paragraph; + setCapacity(capacity: GlyphBufferCapacity): void; + has(paragraph: Paragraph): boolean; + subscribe(observer: ParagraphBatchObserver): () => void; + attach( + target: ParagraphBatchTarget, + ): ParagraphBatchAttachment; + dispose(): void; +} + +interface ParagraphBatchObserver { + next(revision: PreparedParagraphBatchRevision): void; + complete(): void; +} +``` + +`subscribe()` synchronously replays `current`, then reports each later published batch revision exactly once. Disposing the +batch calls `complete()` exactly once; unsubscribing is idempotent and prevents later `next()` or `complete()` calls. This +public observation contract is sufficient to build renderer coordination without access to shaping, allocation, or other +batch internals. + +`attach()` is the retained convenience for that coordination. It validates technique compatibility, records published +source revisions, exposes explicit renderer-owned `prepare()` and `commit()` boundaries, and couples attachment disposal +to batch disposal. It is policy built on the +same public revisions and lifecycle events, not a second shaping or batching API. The exact target contract is specified in +the [engine integration contract](engine-integration-contract.md). + +### Use the default or preallocate explicitly + +Omitting `capacity` uses `{ size: 4_096, policy: 'chunk' }`. Core allocates storage lazily when the first glyph resolves to +a physical font resource. Paragraph handles and paragraph metadata grow normally; only glyph-instance storage has a +capacity policy. + +```ts +const denseText = runtime.createParagraphBatch({ + technique: mtsdf, + capacity: { size: 20_000, policy: 'chunk' }, +}); +``` + +`size` applies independently to every physical technique/resource buffer produced beneath the logical paragraph batch. It +is not a total glyph limit for the paragraph batch. Under `chunk`, core preserves existing storage and allocates another +`size`-slot buffer when one fills. Under `grow`, core transactionally replaces a full buffer and doubles its capacity until +the pending glyphs fit. Under `fixed`, exceeding `size` fails preparation and preserves the last published revision. +Ordered glyph runs make cross-buffer paragraph and fallback-font order explicit. + +`ParagraphBatch.add()` cannot reject a capacity overflow because fallback, shaping, wrapping, and later mutations determine +the physical per-resource glyph demand. `update()` or `updateAsync()` discovers overflow after shaping but before +publication. Fixed overflow returns a typed `capacity-exceeded` preparation failure with the batch, configured limit, the +maximum per-resource requirement, and every overflowing physical resource. One resize to `error.required` +therefore satisfies the complete shaped generation rather than revealing overflows one at a time. The complete prior +runtime revision remains current; on a first update no partial revision becomes visible. Desired state remains available +for correction or an explicit capacity change. + +The first failing synchronization throws or rejects and records the error on `batch.preparationError`. That exact failed +desired generation is then latched rather than remaining eligible work: `batch.hasPendingChanges` is false when its only +unpublished state is the unchanged failure, and later runtime updates may publish other dirty batches. Any relevant +paragraph or membership mutation clears the latch and schedules a new attempt. Successful publication clears +`preparationError`. Calling `setCapacity()` with a different normalized capacity also clears the latch and schedules one +new attempt while the last committed revision remains live. + +`runtime.hasPendingChanges` and `batch.hasPendingChanges` report unpublished desired work that the next synchronization may +attempt. A latched unchanged failure reports false; its retained `preparationError` is the observable state. `isPreparing` +is true only while an asynchronous candidate is actively shaping or awaiting its Worker result. It becomes false on +publication, failure, abort, or supersession and is independent of a latched error. + +Resize a batch when an application wants to replace a fixed allocation explicitly: + +```ts +worldText.setCapacity({ size: 40_000, policy: 'fixed' }); +runtime.update(); + +worldText.has(label); // true: batch and paragraph identity did not change +``` + +`setCapacity()` validates and records the normalized requested capacity synchronously but does not mutate published +canonical storage. The next `update()` or `updateAsync()` reuses compatible shaping and layout results, stages replacement +storage, and atomically publishes it only when complete. Failure preserves the previous revision and every handle. +Existing attachments record the new source revision. Each target stages replacement engine buffers when its owner next +calls `prepare()`, commits at its safe frame boundary, and retires old buffers after its fences. The `ParagraphBatch`, its `Paragraph` handles, +subscriptions, attachments, desired state, order, glyph overrides, and font leases never change identity. + +Changing from `fixed` to `grow` or `chunk`, growing a fixed size, and deliberately shrinking are all explicit capacity +changes. A shrink that cannot hold the desired generation reports `capacity-exceeded` at synchronization and retains the +previous complete revision. Passing the current normalized capacity is a no-op and does not retry a latched failure. + +Every non-no-op capacity change creates a new physical-allocation generation at synchronization. Core repacks all live +slots, retires every old `GlyphBatchKey`, increments `GlyphBatchKey.generation`, and interns fresh keys. This +gives targets one unambiguous replacement signal. `chunk` numbers are reassigned densely from zero per resource in the new +generation; semantic paragraph, batch, subscription, and attachment identities remain unchanged. + +Create another paragraph batch when text must be rendered in another phase, even if it uses the same technique. + +```ts +const overlayText = runtime.createParagraphBatch({ + technique: mtsdf, +}); +``` + +Core never merges `worldText` and `overlayText`. The application may place non-text draws between them or give them +different depth, stencil, clipping, compositing, lifetime, or render-pass policies. + +`renderVariant` is the batch's optional inherited render intent. Core treats it as an opaque, exactly typed value: it does +not know whether the value represents an effect graph, material binding, palette entry, clipping mode, or application +state. Paragraph and span values may override it. `undefined` means inherit; an integration that needs an explicit “no +effect” choice defines that as an ordinary member of its own variant type. A variant never changes the declared raster +technique and does not by itself require another physical glyph buffer, pipeline, or draw. + +Core retains an opaque variant value and compares replacements with `Object.is`; it cannot clone or inspect integration +objects. Treat ordinary variant records as immutable snapshots and assign a replacement to change run identity. A stable +binding object may expose integration-owned mutable parameters, but changing those parameters does not mark core dirty; +the owning program must update its sidecar/uniform storage directly. Disposing a binding still referenced by a live batch, +paragraph, or span is an integration lifecycle error. + +## Everything added is a paragraph + +A paragraph is one independently shaped and laid-out sequence. A multiline block, a one-line label, and a font-backed +icon use the same API. + +```ts +const body = worldText.add({ + font: uiFont, + text: 'A paragraph resolves missing glyphs through its FontStack.', + contentBox: { + width: { mode: 'at-most', size: 480 }, + wrap: 'word', + }, +}); + +const label = worldText.add({ + font: inter, + text: 'Player 1', +}); + +const icon = worldText.add({ + font: iconFont, + text: '\uf013', +}); +``` + +Every paragraph owns a concrete `Font` or `FontStack`. A Bitmap font cannot appear in an MTSDF paragraph batch. Supporting +both resource types in one paragraph requires a technique expressly designed to render both. + +```ts +interface ParagraphBaseProperties { + readonly font: FontSelection; + readonly contentBox?: ParagraphContentBox; + readonly style?: ParagraphStyle; + readonly paint?: GlyphPaintInput; + readonly rasterPixelRatio?: number; + readonly order?: number; + readonly renderVariant?: Variant; +} + +type ParagraphAxisConstraint = + | { readonly mode: 'unconstrained' } + | { readonly mode: 'at-most'; readonly size: number } + | { readonly mode: 'exact'; readonly size: number }; + +interface ParagraphContentBox { + readonly width?: ParagraphAxisConstraint; + readonly height?: ParagraphAxisConstraint; + readonly maxLines?: number; + readonly wrap?: 'none' | 'word' | 'character'; + readonly align?: 'start' | 'center' | 'end' | 'justify'; + readonly overflow?: 'visible' | 'clip' | 'ellipsis'; +} + +type LinearRgba = readonly [number, number, number, number]; +type ColorInput = string | LinearRgba; + +interface GlyphPaintInput { + readonly color?: ColorInput; + readonly opacity?: number; + readonly outline?: { readonly color: ColorInput; readonly width: number }; + readonly shadow?: { readonly color: ColorInput; readonly offset: readonly [number, number] }; +} + +type ParagraphContentProperties = + | Readonly<{ + text: string; + spans?: readonly ParagraphSpan[]; + }> + | Readonly<{ + text: FormattedText; + spans?: never; + }>; + +type ParagraphProperties = ParagraphBaseProperties< + Technique, + Variant +> & + ParagraphContentProperties; + +interface ParagraphSpan { + readonly start: number; + readonly end: number; + readonly font?: FontSelection; + readonly style?: ParagraphStyle; + readonly paint?: GlyphPaintInput; + readonly renderVariant?: Variant; +} + +type FormattedText = TextLiteral | TextLiteral; + +type TextInput = string | FormattedText; + +declare const textLiteralTechnique: unique symbol; + +interface TextLiteral { + readonly [textLiteralTechnique]: (technique: Technique) => Technique; + readonly text: string; + readonly spans: readonly ParagraphSpan[]; +} + +declare const textSpanFragmentTechnique: unique symbol; + +interface TextSpanFragment { + readonly [textSpanFragmentTechnique]: (technique: Technique) => Technique; + readonly text: string; + readonly spans: readonly ParagraphSpan[]; + readonly properties: Omit, 'start' | 'end'>; +} + +type TextTemplateValue = + | string + | number + | TextLiteral + | TextLiteral + | TextSpanFragment + | TextSpanFragment; + +type SpanStyle = Readonly; + +type SpanFormat = FontSelection | SpanStyle; + +interface SpanTag { + (strings: TemplateStringsArray, ...values: readonly TextTemplateValue[]): TextSpanFragment; +} + +interface UnboundSpanTag { + ( + strings: TemplateStringsArray, + ...values: readonly TextTemplateValue[] + ): TextSpanFragment; +} + +declare function txt( + strings: TemplateStringsArray, + ...values: readonly TextTemplateValue[] +): TextLiteral; + +declare function span(...styles: readonly [SpanStyle, ...SpanStyle[]]): UnboundSpanTag; + +declare function span( + font: FontSelection, + ...formats: readonly SpanFormat>[] +): SpanTag; +``` + +The paragraph font and every explicit span font must match the paragraph batch technique. A span without `font` inherits +the paragraph selection. A `FontStack` resolves missing glyphs in its own stored order; batch membership never changes a +paragraph's shaping semantics. + +The renderer-neutral `txt` and `span` tags compose the same string-plus-range representation without parsing an embedded +markup language. `span()` accepts a `SpanStyle` by itself, or a concrete `Font` / `FontStack` followed by any number of +same-technique font selections and styles. A style-only tag inherits the surrounding paragraph or span font. A `SpanStyle` +flattens paragraph style and glyph paint for concise authoring; the helper normalizes it back into the canonical nested +`ParagraphSpan.style` and `ParagraphSpan.paint` snapshot. + +A fragment or literal containing no font-bearing value carries `never` as its technique marker and is explicitly accepted +by `TextTemplateValue` and `FormattedText`. The first font-bearing fragment fixes the literal +technique; until then the composition remains neutral and inherits its eventual paragraph font. + +Formats merge from left to right. When a font is supplied it is the first argument, allowing that selection to fix the +technique; a later same-technique font replaces it. Later style fields replace earlier fields. Nested values such as +`features`, `outline`, and `shadow` replace as complete values; the helper does not deep-merge them. `NoInfer` makes +TypeScript reject mixed-technique later fonts in addition to unknown properties and invalid value types. Core snapshots +the formats when `span()` is called, then computes UTF-16 ranges and offsets for nested fragments. + +```ts +const importantStyle = { + color: '#ffddff', + fontSize: 18, +} satisfies SpanStyle; + +const important = span(amiri, importantStyle); + +const title = txt`Fast ${important`accurate`} text`; + +label.text = title; +label.text = 'Plain text'; // replaces the source and clears spans +``` + +The returned `SpanTag` is reusable. When format inputs must remain independently composable, keep them in a readonly tuple +and bind them later: + +```ts +const importantFormat = [amiri, importantStyle] as const; +const importantAmiri = span(...importantFormat); +``` + +Assigning a `TextLiteral` replaces text and spans atomically. Passing a formatted literal together with separate `spans` +is a type error. Manual `{ text: string, spans }`, `setSpan()`, and `removeSpan()` remain available when an integration +already owns explicit UTF-16 ranges. + +`SpanStyle` deliberately contains portable layout and paint only. Set an opaque `renderVariant` through explicit +`ParagraphSpan` values or `setSpan()`; an integration such as React Three Fiber may normalize its own nested variant props +into those spans. The renderer-neutral `txt` tag never captures an engine object accidentally. + +## Mutate handles; synchronize later + +`add()` returns the retained interface for that paragraph. Setters change desired state and mark the paragraph dirty; they +do not shape immediately. + +```ts +interface Paragraph { + readonly id: ParagraphId; + readonly batch: ParagraphBatch; + readonly disposed: boolean; + readonly committed: PreparedParagraph | undefined; + + font: FontSelection; + get text(): string; + set text(value: TextInput); + spans: readonly ParagraphSpan[]; + contentBox: ParagraphContentBox; + style: ParagraphStyle; + paint: GlyphPaintInput; + rasterPixelRatio: number; + order: number; + renderVariant: Variant | undefined; + + set(properties: ParagraphUpdate): void; + setSpan(index: number, span: ParagraphSpan): void; + removeSpan(index: number): void; + + snapshotGlyphs(): GlyphSnapshot; + setGlyphOrigins(update: GlyphOriginUpdate): void; + clearGlyphOriginOverrides(): void; + snapshotProperties(): ParagraphSnapshot; + + dispose(): void; +} + +declare const paragraphIdBrand: unique symbol; +type ParagraphId = number & { readonly [paragraphIdBrand]: true }; + +interface ParagraphSnapshot { + readonly font: FontSelection; + readonly text: string; + readonly spans: readonly ParagraphSpan[]; + readonly contentBox: ParagraphContentBox; + readonly style: ParagraphStyle; + readonly paint: GlyphPaintInput; + readonly rasterPixelRatio: number; + readonly order: number; + readonly renderVariant: Variant | undefined; +} + +type ParagraphUpdate = + | (Partial> & + Readonly<{ + text?: string; + spans?: readonly ParagraphSpan[]; + }>) + | (Partial> & + Readonly<{ + text: FormattedText; + spans?: never; + }>); +``` + +```ts +label.text = 'First value'; +label.text = 'Second value'; +label.text = 'Player 2'; +label.contentBox = { width: { mode: 'exact', size: 320 } }; +``` + +Those mutations create one dirty paragraph. The next synchronization shapes only `Player 2` with the final content box. + +Nested configuration values are immutable snapshots. Replace `paragraph.contentBox` or call `paragraph.set()`; mutating +`paragraph.contentBox.width.size` is not observable and is unsupported. + +Creation and disposal are staged in the same way: + +```ts +const pending = worldText.add({ font: inter, text: 'Not shaped yet' }); +pending.dispose(); + +runtime.update(); // coalesces the add and removal to no work +``` + +## Paragraph handles never move between batches + +A `Paragraph` belongs permanently to the `ParagraphBatch` that created it. Core has no detach, reparent, or handle-transfer +operation. Snapshot desired properties, create a destination handle, then dispose the source handle: + +```ts +const desired = label.snapshotProperties(); +const movedLabel = overlayText.add(desired); +label.dispose(); + +runtime.update(); // destination addition and source removal publish atomically +``` + +`snapshotProperties()` returns immutable normalized desired state, not membership, prepared glyph storage, target +attachments, or ownership. The snapshot itself acquires no font lease. The destination must belong to the same runtime, +must use the same technique, and must receive still-live fonts. Glyph-origin snapshots remain topology-bound and are +reapplied separately after the destination has a compatible shaped topology. + +Disposing a paragraph releases only that handle, its dirty work, cached paragraph state, and font leases. It marks +`paragraph.disposed`, removes it from `batch.has()`, and makes every method except idempotent `dispose()` fail. + +Disposing a paragraph batch is terminal and cascades only through objects it owns: + +```ts +const desired = label.snapshotProperties(); + +worldText.dispose(); + +worldText.disposed; // true +label.disposed; // true: the batch owned this core handle + +const replacement = overlayText.add(desired); // a new handle; never the old label +``` + +`ParagraphBatch.dispose()` cancels its pending work, disposes every owned paragraph handle, releases their font leases, +removes the batch from future runtime revisions, and retires its canonical storage and target attachments. It does not +dispose the runtime or loaded fonts. `add()` and subscriptions on a disposed batch fail; `dispose()` remains idempotent. +Any snapshots required for recreation must be taken before disposal. + +## Synchronize now + +```ts +label.text = 'Ready for this frame'; +body.contentBox = { width: { mode: 'at-most', size: 360 }, wrap: 'word' }; + +const revision = runtime.update(); +``` + +```ts +interface TextRuntimeRevision { + readonly revision: number; + readonly paragraphBatches: readonly PreparedParagraphBatchRevision[]; +} +``` + +`update()` snapshots every currently dirty paragraph across every paragraph batch in the runtime, performs the required +shaping and layout synchronously, updates prepared glyph batches, publishes one atomic runtime revision, and returns it. +When nothing is dirty it returns `runtime.current` without allocating or notifying subscribers. + +Runtime and paragraph-batch revision numbers advance only when a new complete revision publishes. A clean call, a failed +preparation, an aborted request, and a superseded asynchronous candidate do not consume a published revision number. + +Public `add()` and mutation methods reject invalid values, disposed handles, and technique incompatibility immediately. +All data required by synchronous shaping must also have been loaded already. Missing preparation data, fixed-capacity +overflow, or another preparation failure throws from `update()` before publication and leaves the prior revision current. + +## Synchronize asynchronously + +The same runtime can choose Worker preparation for any update. + +```ts +label.text = 'Prepare this away from the caller'; +const outcome = await runtime.updateAsync(); + +if (outcome.status === 'published') { + useRevision(outcome.value); +} +``` + +Promise-free callback form: + +```ts +label.text = 'Avoid a Promise for this hot path'; + +runtime.updateAsync({ signal: controller.signal }, (result) => { + if (!result.ok) { + handleUpdateError(result.error); + return; + } + + if (result.value.status === 'published') { + publish(result.value.value); + } +}); +``` + +```ts +interface AsyncTextUpdateOptions { + readonly signal?: AbortSignal; + readonly priority?: 'background' | 'normal' | 'urgent'; + readonly onProgress?: (progress: TextUpdateProgress) => void; +} + +interface TextUpdateProgress { + readonly revision: number; + readonly preparedParagraphs: number; + readonly totalParagraphs: number; + readonly stagedGlyphs: number; +} + +type TextUpdateCallback = (result: TextUpdateResult) => void; + +type TextUpdateResult = + | { readonly ok: true; readonly value: TextUpdateOutcome } + | { readonly ok: false; readonly error: TextPreparationError }; + +type TextUpdateOutcome = + | { readonly status: 'published'; readonly value: TextRuntimeRevision } + | { readonly status: 'superseded'; readonly revision: number; readonly byRevision: number } + | { readonly status: 'aborted'; readonly revision: number; readonly reason?: unknown }; + +type TextPreparationError = + | { + readonly kind: 'capacity-exceeded'; + readonly batch: ParagraphBatch; + readonly capacity: number; + readonly required: number; + readonly overflows: readonly GlyphCapacityOverflow[]; + } + | { + readonly kind: 'preparation-failed'; + readonly cause: unknown; + }; + +interface GlyphCapacityOverflow { + readonly resourceKey: GlyphBatchKey; + readonly required: number; +} +``` + +The callback form constructs no public Promise and runs exactly once asynchronously. Supersession and cancellation are +handled synchronization outcomes, not errors. The Promise resolves them and the callback returns them through its `ok` +branch. The Promise rejects only for an actual preparation failure; the callback reports the same failure through its +`error` branch. + +An asynchronous executor may stream completed paragraph work into unpublished staging storage and report bounded progress +through `onProgress`. Streaming never publishes a partial runtime or paragraph-batch revision; every affected batch becomes +current together only after the complete synchronization succeeds. + +Both forms snapshot dirty state when called. Later property mutations remain dirty for the next synchronization: + +```ts +label.text = 'A'; +const preparingA = runtime.updateAsync(); + +label.text = 'B'; // pending for the next update; not folded into A +``` + +A newer synchronization supersedes any older asynchronous candidate that has not published: + +```ts +label.text = 'A'; +const preparingA = runtime.updateAsync(); + +label.text = 'B'; +runtime.update(); // publishes B before returning + +const outcomeA = await preparingA; +// { status: 'superseded', revision: A, byRevision: B } +``` + +`B` is the correct final state. The superseded result only explains why the older request did not publish; callers may +ignore it when they do not need update diagnostics. + +## Dirty state selects the work + +```ts +type ParagraphDirtyChannel = + | 'text' + | 'font' + | 'features' + | 'content-box' + | 'paint' + | 'raster-pixel-ratio' + | 'origins' + | 'order' + | 'variant'; +``` + +```ts +const WorkByChannel = { + text: 'shape-layout-partition', + font: 'shape-layout-partition', + features: 'shape-layout-partition', + 'content-box': 'reflow-and-boundary-reshape', + paint: 'rewrite-instance-paint', + 'raster-pixel-ratio': 'reselect-resources-and-repack', + origins: 'rewrite-instance-origins', + order: 'rebuild-glyph-runs', + variant: 'rebuild-glyph-runs', +} as const; +``` + +Core keeps a dirty set rather than scanning every paragraph. Repeated writes to the same field coalesce. Paint, origin, +order, and render-variant changes do not reshape text. + +## Core produces real glyph batches + +One paragraph can resolve glyphs through several fonts. Those fonts use one technique but may bind different GPU +resources. Core partitions and packs them before the renderer sees the revision. + +```ts +interface PreparedParagraphBatchRevision { + readonly paragraphBatch: ParagraphBatch; + /** Contiguous and monotonic within this paragraph batch. */ + readonly revision: number; + readonly technique: Technique; + readonly paragraphs: readonly PreparedParagraph[]; + readonly glyphBatches: readonly PreparedGlyphBatch[]; + readonly glyphRuns: readonly PreparedGlyphRun[]; +} + +interface PreparedGlyphBatch { + readonly key: GlyphBatchKey; + readonly technique: Technique; + readonly font: LoadedFont; + readonly capacity: number; + readonly instanceCount: number; + readonly binding: RasterBindingOf; + readonly storage: GlyphBatchStorageOf; + readonly dirtyRanges: readonly GlyphRange[]; +} + +declare const rasterTechniqueIdBrand: unique symbol; +type RasterTechniqueId = string & { readonly [rasterTechniqueIdBrand]: true }; + +declare const rasterResourceIdBrand: unique symbol; +type RasterResourceId = string & { readonly [rasterResourceIdBrand]: true }; + +interface GlyphBatchKey { + readonly technique: RasterTechniqueId; + readonly resource: RasterResourceId; + readonly pipelineVariant: number; + readonly generation: number; + readonly chunk: number; +} + +interface PreparedGlyphRun { + readonly batch: GlyphBatchKey; + readonly paragraph: ParagraphId; + readonly renderVariant: Variant | undefined; + readonly start: number; + readonly count: number; +} +``` + +`rasterPixelRatio` is renderer-supplied physical density, not layout scale. It defaults to the batch value, which defaults +to `1`; a paragraph may override it. Changing it never reshapes, but techniques such as Bitmap may reselect a strike and +repack affected storage. Because selection is part of the prepared core revision, one paragraph batch cannot represent two +different density choices for the same paragraph and revision across two attached targets. Paragraph overrides may still +partition one batch across several strikes. Render the same logical paragraph simultaneously at different target densities +with separate batches, or update the value before the synchronization that prepares that render phase. + +Spans do not override `rasterPixelRatio`. Density describes the target-space realization of one laid-out paragraph, while +spans describe source-local shaping and paint. A visual subsection that truly needs another density is a separate paragraph +(and, when it belongs to another render target, a separate batch). + +`RasterTechniqueId` and `RasterResourceId` are opaque branded strings whose values are stable and unique within a runtime. +Core interns and freezes one `GlyphBatchKey` object for each live physical glyph batch and reuses that object in +`PreparedGlyphBatch.key`, `PreparedGlyphRun.batch`, and adjacent revisions until the physical batch retires. Integrations +may therefore use the object as a `Map` key. The tuple `(technique, resource, pipelineVariant, generation, chunk)` is also its stable +diagnostic and deterministic ordering value; consumers must not manufacture keys. + +Given the resolved font sequence `Inter -> Noto -> Inter`, core may retain one Inter buffer and one Noto buffer while +emitting three ordered glyph runs: + +```ts +revision.glyphRuns = [ + { batch: interBatch.key, paragraph: label.id, renderVariant: plain, start: 0, count: 8 }, + { batch: notoBatch.key, paragraph: label.id, renderVariant: warning, start: 0, count: 3 }, + { batch: interBatch.key, paragraph: label.id, renderVariant: plain, start: 8, count: 5 }, +]; +``` + +Core resolves batch → paragraph → span variant inheritance, then segments the ordered glyph sequence whenever the physical +batch, paragraph, or effective variant changes. The renderer does not inspect glyphs to rediscover technique, +raster-resource, capacity, source order, or variant boundaries. Each glyph batch also carries the technique-defined +`binding` that selects the required pages, buffers, or other decoded font data from `glyphBatch.font.data`. + +`PreparedGlyphRun` is not a promised draw call. It is the smallest ordered core-authored range an integration may need to +classify. The target may split a run, or coalesce adjacent compatible runs, when compiling engine draws. It must preserve +the supplied order and compositing semantics unless its documented depth/blend policy proves another ordering equivalent. +It may not redo shaping, fallback, resource selection, or slot allocation. Array position is the authoritative run order; +there is no duplicate numeric run-order field. Every live physical glyph slot appears in exactly one run. A technique that +needs several passes for one run expands them in its program and keeps those passes adjacent unless equivalent ordering is +proven. + +Render variants remain on the calling thread. `updateAsync()` snapshots immutable text/span input and its resolved variant +table under one candidate generation ID before posting shaping/layout input to a Worker. The Worker never receives renderer +objects or variants. On return, core maps source clusters against that same candidate's span table—not current desired +state—then publishes only if the candidate is still current. A newer synchronous or asynchronous publication supersedes +and discards the older candidate before variant mapping can become visible. A +variant boundary does not split a shaping cluster or ligature. The cluster receives the variant of the span containing its +first UTF-16 code unit. Exact partial-ligature styling requires an authored shaping boundary or a shader masking technique. + +## Core retains canonical instance storage + +Core must retain paragraph input, shaping/layout results, glyph allocation metadata, shaped origins, and optional origin +overrides. It also owns one canonical packed CPU representation for each prepared glyph batch. + +```ts +interface PreparedGlyphBatch { + readonly storage: GlyphBatchStorageOf; + readonly dirtyRanges: readonly GlyphRange[]; +} +``` + +`dirtyRanges` is the coalesced delta from the immediately preceding revision of this paragraph batch. When a target has +that exact predecessor, it uploads only those ranges. A newly attached target, or a target whose committed +`sourceRevision` is older than that predecessor, initializes every range referenced by `glyphRuns`; those are the live +instance ranges for the current revision. It may coalesce overlapping or adjacent upload ranges without altering the +ordered run sequence. + +The technique defines the canonical structure-of-arrays fields and writes changed slots into them. Those arrays are the +portable synchronization boundary. They remain available for multiple targets, late attachment, inspection, Worker result +integration, target recovery, and deterministic tests. + +Published array contents remain readable until the next revision of that paragraph batch publishes. A target must consume +or copy its selected ranges during its synchronous `stage()` call; pending engine work cannot retain a canonical typed-array +view and read it after that call returns. Core can therefore reuse its CPU shadow without allocating an immutable full-buffer +snapshot for every publication. + +On an adjacent revision, an integration synchronizes only `dirtyRanges`. When its engine layout matches, this is a direct +range copy or upload. When its layout differs, it maps only those canonical fields and ranges into its own interleaved or +technique-specific buffer. First and gapped synchronization use the live glyph-run ranges described above. The integration +still performs no shaping, source sorting, raster-resource partitioning, or slot allocation. It does compile the ordered +runs into its own minimum compatible draw sequence because only the integration knows its program, variant, pass, and +material compatibility. + +This CPU copy deliberately decouples core publication from inaccessible or in-flight GPU memory. The target owns its engine +buffers, upload commands, double/triple buffering, frame publication, fences, and retirement. + +## Move glyphs without reshaping + +```ts +declare const glyphTopologyBrand: unique symbol; +type GlyphTopology = number & { readonly [glyphTopologyBrand]: true }; + +interface GlyphSnapshot { + readonly topology: GlyphTopology; + readonly glyphIds: Uint32Array; + readonly clusters: Uint32Array; + readonly fontSlots: Uint16Array; + readonly shapedX: Float32Array; + readonly shapedY: Float32Array; + readonly displayedX: Float32Array; + readonly displayedY: Float32Array; +} + +interface GlyphOriginUpdate { + readonly topology: GlyphTopology; + readonly start: number; + readonly x: ArrayLike; + readonly y: ArrayLike; +} +``` + +```ts +const snapshot = label.snapshotGlyphs(); +const x = snapshot.displayedX.slice(); +const y = snapshot.displayedY.slice(); + +simulateGlyphs(x, y, delta); + +label.setGlyphOrigins({ + topology: snapshot.topology, + start: 0, + x, + y, +}); + +runtime.update(); // writes origins only +``` + +`topology` identifies the committed glyph sequence to which indices apply. It changes whenever shaping, fallback, glyph +count/order, or font-slot assignment changes; paint, order, variant, transform, and origin-only updates preserve it. +`setGlyphOrigins()` rejects a stale topology synchronously and leaves desired state unchanged. A later reshape preserves an +override only when the resulting topology is identical; otherwise core clears the override and publishes the newly shaped +origins. + +Clear the override to return to the current shaped positions: + +```ts +label.clearGlyphOriginOverrides(); +runtime.update(); +``` + +Reshaping updates the authoritative target positions. The application may snapshot them again and interpolate from its +current displayed positions. + +## Three.js is a separate public surface + +Three.js applications use `FontLoader`, `TextGroup`, and `Text` from `@pmndrs/text-three`. That integration owns these core +objects privately and synchronizes them during Three's render lifecycle; it never asks an application to create core +paragraphs and wrap them in adapter objects. + +See the authoritative [Three.js text API](three-api.md). The mapping is intentionally direct: + +```ts +FontLoader -> cached TextRuntime/shaper initialization + loaded fonts +TextGroup -> technique-specific ParagraphBatch + Three renderer target +Text -> desired paragraph state + late-bound Paragraph + Object3D transform +``` + +## Implement another engine + +The engine consumes already partitioned storage and ordered glyph runs: + +```ts +for (const glyphBatch of revision.glyphBatches) { + const gpuBatch = target.ensureBatch({ + key: glyphBatch.key, + technique: glyphBatch.technique, + font: glyphBatch.font, + binding: glyphBatch.binding, + capacity: glyphBatch.capacity, + storage: glyphBatch.storage, + }); + + const ranges = isAdjacentTargetRevision + ? glyphBatch.dirtyRanges + : liveGlyphRunRanges(revision.glyphRuns, glyphBatch.key); + gpuBatch.upload(ranges); + gpuBatch.setCount(glyphBatch.instanceCount); +} + +const draws = program.compileRuns(revision.glyphRuns, revision.glyphBatches); +for (const draw of draws) target.draw(draw); +``` + +Core owns shaping, fallback, layout, sorting, resource partitioning, slot allocation, overflow chunking, instance packing, +dirty ranges, and the ordered variant-bearing text runs. The engine owns compatible-run coalescing/splitting, final draw +planning, transforms, visibility, scene composition, GPU objects, render-pass placement, command encoding, frame +publication, fences, and resource retirement. + +## Dispose + +```ts +worldText.dispose(); +overlayText.dispose(); +inter.dispose(); +runtime.dispose(); +``` + +Dispose from the narrowest retained owner outward: paragraphs when individually finished, paragraph batches when a render +phase is finished, fonts after their paragraph leases are gone, and the runtime last. A successful dispose is idempotent; +using a disposed handle otherwise fails. + +`TextRuntime.dispose()` is the one intentional cascade root. It cancels asynchronous preparation and unpublished staging, +disposes every remaining paragraph batch and paragraph, releases loaded fonts after those leases are gone, notifies +attachments, and disposes the runtime-owned registry, shaper, and Worker. It invalidates every handle created by that +runtime and does not publish another revision. Targets release GPU resources only after their engine knows no in-flight +frame still references them. + +## Why these boundaries exist + +```ts +const Decisions = { + oneParagraphAPI: 'A label or icon is still a paragraph.', + explicitBatchTechnique: 'The technique fixes canonical buffer layouts and rejects incompatible text before shaping.', + fontStacksAreFonts: 'A FontStack is one ordered font selection with missing-glyph behavior.', + explicitParagraphBatches: 'Only the application knows where text render phases must remain separate.', + coreOwnedPhysicalBatching: 'Every target would otherwise duplicate grouping, sorting, packing, and dirty tracking.', + handleOwnedMutation: 'Repeated writes debounce naturally before a synchronization call.', + perUpdateScheduling: 'The same runtime must switch between immediate and Worker preparation.', + canonicalCpuStorage: 'Targets synchronize adjacent deltas or live ranges from one stable portable representation.', + orderedGlyphRuns: 'Fallback and render variants preserve source order without pretending every run is a draw.', +} as const; +``` + +The old public `createParagraphEngine()` path, runtime-wide sync/Worker mode, mutation callback passed to `update()`, mixed- +technique logical batch, and renderer-owned reshaping or physical glyph repartitioning are explicitly not part of this API. diff --git a/docs/planning/decision-register.md b/docs/planning/decision-register.md index 6c5130d7..49f703ed 100644 --- a/docs/planning/decision-register.md +++ b/docs/planning/decision-register.md @@ -8,7 +8,7 @@ sources: resource: 'benchmark-plan.md' title: 'Benchmark plan' - id: 'citation-2' - resource: '../../README.md#benchmark-harness-wireframe' + resource: '../assets/benchmark-harness-wireframe.png' title: 'Repository benchmark-harness wireframe' - id: 'msdf-paper' resource: 'https://dcgi.fel.cvut.cz/wp-content/wpallimport-dist/publications/pdf/publications-2018-sloup-cgf-msdf-paper.pdf' @@ -28,10 +28,22 @@ sources: - id: 'definitelytyped-node-extras' resource: 'https://github.com/DefinitelyTyped/DefinitelyTyped/pull/75246' title: 'NodeExtras lookup-map fix' + - id: 'core-api' + resource: 'core-api.md' + title: 'Core text API' + - id: 'engine-boundary' + resource: 'engine-integration-boundary.md' + title: 'Renderer-neutral core, batching, and engine integration plan' + - id: 'engine-integration-contract' + resource: 'engine-integration-contract.md' + title: 'Engine integration data contract' + - id: gpucat-integration + resource: gpucat-integration.md + title: External gpucat integration fitness plan generated: by: openai-codex/gpt-5.6 - at: '2026-08-01T20:19:48Z' + at: '2026-08-07T03:25:58Z' --- # Decision register @@ -55,8 +67,8 @@ Implementation and passing fixtures are evidence, not approval. A proposed row c | ----- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :------------: | | D-001 | `pmndrs/text` is the product | Accepted | | D-002 | Slug is one raster, not the shaping or package identity. | Accepted | -| D-003 | V1 targets horizontal LTR/RTL text and static font instances. | Accepted | -| D-004 | `@pmndrs/text` is Three.js-first; `@pmndrs/text/react` is a thin optional wrapper. | Accepted | +| D-003 | Target v1 supports horizontal LTR/RTL text and static font instances. | Accepted | +| D-004 | Merged v0 is Three.js-first and exposes `@pmndrs/text/react` as a thin optional wrapper. Target v1 supersedes that package topology through D-144 without changing the recorded v0 fact. | Accepted | | D-005 | React uses one root `` with nested `` inline spans and direct props. | Accepted | | D-006 | A canonical source-font URL infers its `.font.glb` sibling; `.glb` is baked-only, while `{ source, baked: null }` explicitly suppresses sibling discovery for that request. | Accepted | | D-007 | Every package entry point is native ESM; no CommonJS build or `require` export ships. | Accepted | @@ -79,19 +91,19 @@ D-004/005 follow the established uikit split: the core owns every feature and Re | D-012 | Clusters are UTF-16 offsets; Unicode lookup uses scalar values. | Accepted | | D-013 | Shaped output is structure-of-arrays with font-scoped glyph IDs. | Accepted | | D-014 | V0 retains a closed shaping-only SFNT; compiled lookup data is later. | Accepted | -| D-015 | Browser JIT, per-font AOT Wasm, and MLIR are outside V1. | Deferred | +| D-015 | Browser JIT, per-font AOT Wasm, and MLIR are outside target v1. | Deferred | | D-040 | Paragraph policy and caches live in JavaScript; shaping lives in Wasm. | Accepted | | D-041 | Width changes always reflow, but do not reshape the whole paragraph. | Accepted | | D-042 | Breaks use source coordinates, Unicode opportunities, clusters, and safety flags. | Accepted | -| D-043 | V1 uses greedy word/character wrapping; balanced wrap and hyphenation are later. | Accepted | +| D-043 | Target v1 uses greedy word/character wrapping; balanced wrap and hyphenation are later. | Accepted | | D-044 | Third-party layout systems consume allocation-light synchronous `measure` results and request positioned `layout` output only for a box that needs drawing. | Accepted | | D-045 | Paragraph axes model unconstrained, at-most, and exact sizing without importing a host layout vocabulary; host adapters own translation, invalidation, padding, transforms, and clipping. | Accepted | | D-069 | uikit owns an incremental adapter from its current `CustomLayouting` and content-box signals; no uikit, Yoga, or Preact Signal types enter core. | Accepted | | D-072 | The JavaScript paragraph engine owns UAX #9, #14, #24, and #29 using Unicode data pinned to the core font provenance version. | Accepted | | D-085 | Roadmap item 5.4 makes horizontal CJK bake, source/reduced HarfRust equivalence, independent HarfBuzz agreement, and paragraph layout a pre-render gate; raster paging, CJK rendering coverage, fallback, and vertical layout remain separate later work. | Accepted | | D-088 | V0 conditionally retains source `BASE`, `VORG`, `vhea`, and `vmtx` tables without fabrication so baking does not destroy vertical-form data; vertical shaping and paragraph layout remain deferred. | Accepted | -| D-098 | Post-V1 Milestone 11 adds responsive multi-column flow regions and explicit exclusions over the existing universal shaping result, then proves native-strike bitmap, MTSDF, and Slug in one editorial composition. The first implementation keeps rectangular layout as the common path, uses conservative two-dimensional exclusions, and defers a frozen public API, contour-tight wrapping, arbitrary rendered-pixel occlusion, balanced columns, hyphenation, and vertical flow until evidence exists. | Accepted | -| D-100 | Post-V1 Milestone 18 adds Japanese vertical writing after large-coverage CJK paging. The first slice uses top-to-bottom shaping, right-to-left columns, OpenType vertical metrics/features, Unicode cluster orientation, interaction geometry, and shared Bitmap/MTSDF/Slug output while preserving a zero-overhead horizontal fast path; tate-chū-yoko, ruby, warichū, Mongolian, and vertical exclusion flow remain deferred. | Accepted | +| D-098 | Post-v1 Milestone 12 adds responsive multi-column flow regions and explicit exclusions over the existing universal shaping result, then proves native-strike bitmap, MTSDF, and Slug in one editorial composition. The first implementation keeps rectangular layout as the common path, uses conservative two-dimensional exclusions, and defers a frozen public API, contour-tight wrapping, arbitrary rendered-pixel occlusion, balanced columns, hyphenation, and vertical flow until evidence exists. | Accepted | +| D-100 | Post-v1 Milestone 19 adds Japanese vertical writing after large-coverage CJK paging. The first slice uses top-to-bottom shaping, right-to-left columns, OpenType vertical metrics/features, Unicode cluster orientation, interaction geometry, and shared Bitmap/MTSDF/Slug output while preserving a zero-overhead horizontal fast path; tate-chū-yoko, ruby, warichū, Mongolian, and vertical exclusion flow remain deferred. | Accepted | The [shaping contract](shaping-data-contract.md), [API contract](api-shapes.md), [uikit integration](uikit-integration.md), and [conformance plan](conformance-plan.md) define the consequences and fixtures. @@ -107,9 +119,9 @@ The [shaping contract](shaping-data-contract.md), [API contract](api-shapes.md), | D-062 | Core and raster schemas are identical whether embedded or split. | Accepted | | D-089 | Raster artifact filenames bind both the font's shaping hash and the package-owned raster key; bitmap V0 accepts only atlas-representable `1..=1022` ppem strikes. | Accepted | | D-090 | Runtime font baking defaults to one active FIFO Worker job; queued work shares that instance, active cancellation replaces it, and a parallel Worker pool requires representative multi-font throughput and memory evidence before adoption. | Accepted | -| D-100 | Every dynamically loaded first-party raster baker executes behind one package-owned serial ESM module Worker. Source bytes are copied before transfer, result buffers transfer back exactly once, idle Workers terminate, and bundlers resolve the static `new URL(..., import.meta.url)` boundary. | Accepted | -| D-101 | Direct raster-baker ABI V1 returns ordinary artifacts inline when bounded and large artifacts through explicit metadata plus fixed-size borrowed windows. The Worker copies each window, releases Wasm ownership before transfer, and callers never retain a view into Wasm memory. | Settled for V0 | -| D-102 | Core-font provenance carries the selected collection face index and authenticates it against the descriptor hash. Legacy artifacts may omit the field only when their descriptor proves face zero; runtime raster baking must reuse the retained face rather than defaulting silently. | Settled for V0 | +| D-146 | Every dynamically loaded first-party raster baker executes behind one package-owned serial ESM module Worker. Source bytes are copied before transfer, result buffers transfer back exactly once, idle Workers terminate, and bundlers resolve the static `new URL(..., import.meta.url)` boundary. | Accepted | +| D-147 | Direct raster-baker ABI revision 1 returns ordinary artifacts inline when bounded and large artifacts through explicit metadata plus fixed-size borrowed windows. The Worker copies each window, releases Wasm ownership before transfer, and callers never retain a view into Wasm memory. | Settled for V0 | +| D-148 | Core-font provenance carries the selected collection face index and authenticates it against the descriptor hash. Legacy artifacts may omit the field only when their descriptor proves face zero; runtime raster baking must reuse the retained face rather than defaulting silently. | Settled for V0 | Rasters attach only when shaping hash, glyph count, glyph-ID width, raster key, and extension version match. See the [`PMNDRS_font` extension family](extensions/) and [registration draft](gltf-extension-registration.md). @@ -135,52 +147,75 @@ The [architecture](architecture.md) owns loading behavior and dependency rules. ## Raster -| ID | Decision | Status | -| ----- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :-----------------: | -| D-050 | V1 ships bitmap, MSDF, and Slug; bitmap alone is only the proof. | Accepted | -| D-051 | Rasters never duplicate advances, kerning, or shaping behavior. | Accepted | -| D-052 | Direct-to-GPU means no reconstruction/repacking, not zero upload. | Accepted | -| D-053 | The MSDF raster uses linear MTSDF RGBA8; padding stays in raster bounds. | Accepted | -| D-054 | Deterministic unhinted bitmap oversampling is the baseline candidate. | Experiment | -| D-055 | Recommend MSDF generally, but require an explicit raster module. | Accepted | -| D-056 | Windfoil is research prior art, not a planned text backend. | Accepted | -| D-057 | Post-slice Slug includes color-emoji vector paint; safe OpenType-SVG and standalone-SVG icon baking lands in the large-coverage CJK/icon milestone. | Accepted | -| D-058 | Fill, opacity, outline, and hard shadow are baseline game-text styles. | Accepted | -| D-059 | Payload reports separate shaping, transport, decoded, and GPU bytes. | Accepted | -| D-061 | Slug bands compress exactly; curve compression remains quality-gated. | Accepted | -| D-064 | V1 does not support plain MSDF assets or parallel MSDF/MTSDF batches. | Accepted | -| D-065 | First-party raster packages use TSL internally; the core raster API is shader-system and backend agnostic. | Accepted | -| D-073 | V1 assigns one selected raster per font slot; per-glyph raster mixing is additive color/SVG work after the first release. | Accepted | -| D-075 | Latin remains the V1 rendering and raster-coverage priority. Pre-render CJK shaping/layout conformance may harden universal core assumptions, but CJK raster paging and icon coverage remain a post-V1 milestone and do not expand the Latin-first renderer exit gate. | Accepted | -| D-076 | Raster page indexes are logical IDs; page payloads may be embedded or independently addressed, and raster modules own preparation, residency, eviction, and backend batching. | Accepted | -| D-091 | Bitmap plane bounds preserve the rasterizer's integer pixel placement with `planeUnitsPerEm = strike ppem`; the shared TSL vertex graph snaps projected quad edges to physical framebuffer pixels so native rendering maps one atlas texel to one device pixel. | Accepted | -| D-092 | Hinted grayscale strikes and optional four-phase grayscale packing remain measured research. LCD/ClearType subpixel rendering, panel-order assumptions, runtime hint interpreters, and distance-field reconstruction are out of scope. | Experiment | -| D-093 | Bitmap V0 renders fill and opacity only and rejects outline or shadow through the raster paint-validation seam; MTSDF owns those distance-based effects rather than silently degrading them. | Accepted | -| D-096 | Bitmap presentation transitions are optional `@pmndrs/text/raster/bitmap` helpers over copied glyph identities and instance origins. Shaping and layout commit discretely; only identity-matched glyph positions interpolate before the existing physical-pixel snap, and unused consumers pay no target-origin allocation. | Settled for V0 | -| D-097 | Milestone 8 owns a purpose-built `no_std + alloc` Rust MTSDF core under `packages/text/rust`, with repository-defined types, limits, reusable scratch storage, data-oriented scalar/SIMD experiments, typed errors, and the generated direct-memory C ABI/JSON boundary. The scalar path is a correctness oracle; if `simd128` wins the complete quality, full-font-time, and size comparison, it is the single default shipped Wasm kernel rather than a baker option. The core may retain proven design ideas from reviewed implementations but is not a copied Klyff fork. Pinned native Chlumsky `msdfgen` remains the canonical test-only oracle and port reference; Klyff, OxiText, Rust bindings, and UIKit/Zappar remain research evidence rather than product dependencies. | Accepted | -| D-099 | MTSDF V0 originally fixed one opinionated bake: 64 plane units per em, a full eight-pixel encoded distance range, four field-padding texels, one atlas-gap texel, 1024-pixel pages, dense 20-byte records, and lossless linear RGBA8 KTX2. The published baker composes the admitted scalar kernel with the shared Fontations provider and lossless artifact primitives; standalone generator evidence remains separately measurable but is not a second published Wasm. | Superseded by D-110 | -| D-100 | Text size is logical CSS geometry. A rendering integration supplies an explicit raster pixel ratio; bitmap targets `CSS size × ratio`, deterministically selects the nearest declared physical strike, and never changes paragraph geometry to compensate for DPR. The core installs no DOM or gesture listeners. | Accepted | -| D-101 | Bitmap density strikes remain independent grayscale record/texture sets rather than RGB(A)-channel packing. A combined artifact may carry several strikes, while Milestone 13 adds independently fetched and evictable strike pages. | Accepted | -| D-102 | Language delivery is exact-coverage-first and locale-aware. A family directory may route grapheme-safe runs to font-local units, but language labels alone never prove coverage and units never split contextual shaping runs. Compiler-produced shaping closure/remapping remains Milestone 17. | Accepted | -| D-103 | Explicit and fallback runtime raster baking accept normalized bounded coverage and raster options through the same Worker-only path. Coverage may be seeded by Unicode ranges, authored text, or exact font-local glyph IDs, but it reduces atlas generation only: it does not subset the shaping font, remap glyph IDs, or claim transitive shaping closure. | Accepted | -| D-104 | Every direct-memory Wasm ABI layout is represented by fixed-width `#[repr(C)]` Rust types. Build-only Rust generators derive published JSON and exact `as const` TypeScript contracts from `size_of`, `align_of`, and `offset_of!`; production hosts import those generated facts, and production Wasm embeds no duplicate contract or ABI-pointer bootstrap. WebAssembly direct memory uses its guaranteed little-endian order; portable GLB, KTX2, SFNT, and extension encodings retain their format-defined byte order. | Accepted | -| D-105 | V1 retains the current Three.js/TSL integration through Slug so its real shader, resource, batching, and lifetime requirements define the abstraction. After Slug lands, Milestone 10 extracts one renderer-neutral direct integration beneath Bitmap, MSDF, and Slug; Three.js becomes a supported adapter over it, while raw WebGPU and a possible TypeGPU adapter remain independently selectable integrations. Optional TypeGPU compute-baker research may proceed earlier but cannot force the renderer refactor or enter unrelated runtime graphs. | Accepted | -| D-106 | Slug V0 artifacts retain exact R16UI reference grids. The Three.js 0.185.1 adapter may pair-pack those values into R32UI texels at decode time because its WebGL TSL backend does not declare an unsigned sampler for `UnsignedShortType`; this preserves reference identity and two-byte density plus at most one terminal padding value. Other adapters remain free to upload R16UI directly, and the exception does not redefine the portable artifact. | Accepted | -| D-107 | Repository TypeScript commands execute the installed native compiler through one bounded runner that first proves its kill/reap path with a synthetic allocator, supervises the native PID rather than a shell or Node shim, caps aggregate tracked RSS, enforces a wall-time limit, and reports no success while a compiler survives. TSL changes compile reduced operation fixtures and a narrow graph before package or application projects; free functions remain the first mitigation but exact-version pathological overloads use one proven concrete compatibility boundary. | Superseded by D-114 | -| D-108 | MTSDF V0 uploads only the authenticated base level and uses bilinear field sampling plus screen derivatives for reconstruction. Conventional GPU mip generation and trilinear cross-level sampling are rejected: averaging encoded MSDF channels is not a distance-field-preserving operation, and the primary MSDF paper plus official generators provide no affirmative mipmap guidance. Runtime, standalone validation, fixtures, and the inspector report the exact padded base texture-array allocation. Any future size-specific representation is an independently authored atlas layer or strike, not a conventional mip chain. | Accepted | -| D-109 | Slug V0 implemented a centered exact-distance outline in one specialized fill-plus-outline draw. Retained measurements later showed `2.44×–4.33×` fill-only GPU time, and generated-shader inspection found duplicated traversal, curve loads, closest-point refinement, and a derivative inside divergent control flow. | Superseded by D-111 | -| D-110 | MTSDF V0 exposes `emSize` and full `pixelRange` as authenticated integer bake options. `emSize` is limited to `1..=1022`, `pixelRange` to `1..=1020`, `planeUnitsPerEm` equals `emSize`, and field padding is `ceil(pixelRange / 2)`. Omitted or partial options resolve against the 64/8 compatibility defaults; explicit effective 64/8 canonicalizes to the legacy fieldless descriptor and raster key, while every non-default descriptor contains both effective values. The low-level Wasm ABI is unchanged. Passing 32/4 and 32/6 155-glyph subset bakes proves the control path, not a new recommended default; quality and payload benchmarking owns that decision. | Accepted | -| D-111 | Remove the dynamic exact-distance Slug outline rather than ship an expensive fallback. Slug V0 supports fill and opacity and rejects every runtime outline or shadow property. The generic text outline API remains because MTSDF owns it. | Accepted | -| D-112 | Research one bounded Slug outline approximation that reuses ordinary fill traversal and screen derivatives without closest-point solving or independent halo traversal. It ships only if its quality is no worse than the MTSDF outline corpus and its median GPU time is at most `1.15×` a same-expanded-quad fill control on both WebGPU and forced WebGL2; otherwise Slug remains fill-only. | Experiment | -| D-114 | Carry the upstream `NodeExtras` lookup-map rewrite as a version-pinned pnpm patch for `@types/three` 0.185.1. A focused compile-only regression owns every previously explosive TSL operation. Package and application scripts invoke the pinned compiler directly; the native-process memory guard and its repository-wide invocation requirement are removed because they contained a dependency type-graph defect now corrected at its declaration boundary. | Accepted | -| D-115 | The benchmark app uses Koota as an application-boundary state manager. Coherent singleton world traits own live controls and published telemetry; direct world reads/writes coordinate capture and renderer state. The world instance is exported from a dedicated HMR-stable module. Koota does not enter core text, shaping, layout, baking, raster, or public package APIs, and entities/queries are reserved for data that genuinely has collection lifecycle. | Accepted | -| D-116 | Interactive benchmark overlays use official shadcn components backed by Base UI rather than application-owned dismissal, focus, portal, or keyboard machinery. Repository semantic tokens theme those checked-in components. Koota remains the single owner of runtime control values; shadcn/Base UI owns interaction behavior only. | Accepted | -| D-117 | Each benchmark route owns one persistent render host per backend generation. The host owns the canvas, renderer, animation loop, GPU timing, telemetry history, viewport, and serialized scene/job lifecycle. React Suspense owns cold asset readiness; scene, technique, delivery, and font selections preload and commit with React transitions so the last complete scene remains visible until an atomic replacement is ready. Compatible font changes retain the active `Text` objects and registry. | Accepted | -| D-118 | Milestone 10 replaces `buildBatches`, optional retained updates, and separate repaint mutation with one required renderer-neutral `stageBatch(previous, layout, resource, fontSlot, paint, rasterPixelRatio)` transaction. A stage owns one unpublished target batch, may retain or replace the previous batch, cannot mutate committed state before publication, commits synchronously and infallibly, and aborts idempotently; committed batch disposal is also idempotent. The portable contract exposes no Three.js, TSL, WebGPU, WebGL, or first-party raster-kind union. Three.js object attachment is an adapter requirement enforced by `Text`, not part of `RasterDrawBatch`. | Accepted | -| D-119 | Once a font, shared shaper, decoded raster resource, and layout-required raster pages are resident, `Text.setProperties` shapes, lays out, plans paint, and stages synchronously while retaining the previous complete generation. The Three.js adapter publishes that candidate at the start of `updateMatrixWorld` or `updateWorldMatrix`, before child traversal; the React adapter explicitly invalidates its R3F root after a core-property update. `ready` remains an observation channel for cold work and queued publication, not a consumer coordination requirement for warm React updates. Synchronous validation, shaping, preparation, or staging faults throw from `setProperties` without cancelling an earlier candidate or live generation; asynchronous preparation and defensive commit-contract faults reject `ready`. A raster `prepare` implementation returns `void` when its requirement is resident and one shared idempotent Promise only for genuinely cold work. | Accepted | -| D-120 | First-party raster batches allocate deterministic 25% glyph-instance slack capped at 256 instances and track logical count separately from capacity. Bitmap, MTSDF, and Slug retain their complete parallel instance records when compatible content fits; shrinks and exact-capacity growth publish authoritative draw counts, while overflow and incompatible ordered Bitmap/Slug page-run topology replace transactionally. Dirty uploads use 32-instance buckets, at most eight disjoint ranges, and a logical full-range fallback; pending renderer ranges carry forward until consumed. This reuse is per batch and does not introduce automatic batching across independent `Text` objects. | Accepted | -| D-121 | The public extension proof is a private `glyphExample` package that owns its kind, descriptor, companion artifact, embedded/external records, baker, runtime generator, decoder, Three.js/TSL adapter, retained capacity, dirty uploads, overflow, abort, and disposal using only published `@pmndrs/text` entry points plus its renderer dependency. Portable batches stay renderer-neutral through `RasterObjectDrawBatch`; `ThreeRasterDrawBatch` documents the `Text` adapter requirement. Static discovery requires the imported factory export name, package manifest key, and default baker kind to match. | Accepted | -| D-122 | Framework-neutral `Text` is a composite `Object3D`, not a `Group`, so a caller-owned parent Group remains Three.js's primary `groupOrder`. `Text.renderOrder` is the secondary paragraph base and each drawable receives that base plus its first-glyph/page-run-local order. Three raster batches implement `setRenderOrderBase`, use neutral non-`Group` roots, and preserve the base across retained commits; the adapter applies it before cold publication, resynchronizes later caller changes during ordinary matrix traversal, and rejects a nested raster Group that would replace the inherited primary key. Nested React Text remains source/span composition and does not create nested scene objects. | Accepted | +| ID | Decision | Status | +| ----- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :-----------------: | +| D-050 | The merged, unreleased v0 implementation contains Bitmap, MTSDF, and Slug; Bitmap alone was only the first integration proof. The target v1 must preserve all three behind the renderer-neutral contract. | Accepted | +| D-051 | Rasters never duplicate advances, kerning, or shaping behavior. | Accepted | +| D-052 | Direct-to-GPU means no reconstruction/repacking, not zero upload. | Accepted | +| D-053 | The MSDF raster uses linear MTSDF RGBA8; padding stays in raster bounds. | Accepted | +| D-054 | Deterministic unhinted bitmap oversampling is the baseline candidate. | Experiment | +| D-055 | Recommend MSDF generally, but require an explicit raster module. | Accepted | +| D-056 | Windfoil is research prior art, not a planned text backend. | Accepted | +| D-057 | Post-slice Slug includes color-emoji vector paint; safe OpenType-SVG and standalone-SVG icon baking lands in the large-coverage CJK/icon milestone. | Accepted | +| D-058 | Fill, opacity, outline, and hard shadow are baseline game-text styles. | Accepted | +| D-059 | Payload reports separate shaping, transport, decoded, and GPU bytes. | Accepted | +| D-061 | Slug bands compress exactly; curve compression remains quality-gated. | Accepted | +| D-064 | Merged v0 and target v1 do not support plain MSDF assets or parallel MSDF/MTSDF batches. | Accepted | +| D-065 | First-party raster packages use TSL internally; the core raster API is shader-system and backend agnostic. | Accepted | +| D-073 | Target v1 assigns one selected raster per font slot; per-glyph raster mixing is additive color/SVG work after the first release. | Accepted | +| D-075 | Latin remains the target v1 rendering and raster-coverage priority. Pre-render CJK shaping/layout conformance may harden universal core assumptions, but CJK raster paging and icon coverage remain a post-v1 milestone and do not expand the Latin-first renderer exit gate. | Accepted | +| D-076 | Raster page indexes are logical IDs; page payloads may be embedded or independently addressed, and raster modules own preparation, residency, eviction, and backend batching. | Accepted | +| D-091 | Bitmap plane bounds preserve the rasterizer's integer pixel placement with `planeUnitsPerEm = strike ppem`; the shared TSL vertex graph snaps projected quad edges to physical framebuffer pixels so native rendering maps one atlas texel to one device pixel. | Accepted | +| D-092 | Hinted grayscale strikes and optional four-phase grayscale packing remain measured research. LCD/ClearType subpixel rendering, panel-order assumptions, runtime hint interpreters, and distance-field reconstruction are out of scope. | Experiment | +| D-093 | Bitmap V0 renders fill and opacity only and rejects outline or shadow through the raster paint-validation seam; MTSDF owns those distance-based effects rather than silently degrading them. | Accepted | +| D-096 | Bitmap presentation transitions are optional `@pmndrs/text/raster/bitmap` helpers over copied glyph identities and instance origins. Shaping and layout commit discretely; only identity-matched glyph positions interpolate before the existing physical-pixel snap, and unused consumers pay no target-origin allocation. | Settled for V0 | +| D-097 | Milestone 8 owns a purpose-built `no_std + alloc` Rust MTSDF core under `packages/text/rust`, with repository-defined types, limits, reusable scratch storage, data-oriented scalar/SIMD experiments, typed errors, and the generated direct-memory C ABI/JSON boundary. The scalar path is a correctness oracle; if `simd128` wins the complete quality, full-font-time, and size comparison, it is the single default shipped Wasm kernel rather than a baker option. The core may retain proven design ideas from reviewed implementations but is not a copied Klyff fork. Pinned native Chlumsky `msdfgen` remains the canonical test-only oracle and port reference; Klyff, OxiText, Rust bindings, and UIKit/Zappar remain research evidence rather than product dependencies. | Accepted | +| D-099 | MTSDF V0 originally fixed one opinionated bake: 64 plane units per em, a full eight-pixel encoded distance range, four field-padding texels, one atlas-gap texel, 1024-pixel pages, dense 20-byte records, and lossless linear RGBA8 KTX2. The published baker composes the admitted scalar kernel with the shared Fontations provider and lossless artifact primitives; standalone generator evidence remains separately measurable but is not a second published Wasm. | Superseded by D-110 | +| D-149 | Text size is logical CSS geometry. A rendering integration supplies an explicit raster pixel ratio; bitmap targets `CSS size × ratio`, deterministically selects the nearest declared physical strike, and never changes paragraph geometry to compensate for DPR. The core installs no DOM or gesture listeners. | Accepted | +| D-150 | Bitmap density strikes remain independent grayscale record/texture sets rather than RGB(A)-channel packing. A combined artifact may carry several strikes, while Milestone 13 adds independently fetched and evictable strike pages. | Accepted | +| D-151 | Language delivery is exact-coverage-first and locale-aware. A family directory may route grapheme-safe runs to font-local units, but language labels alone never prove coverage and units never split contextual shaping runs. Compiler-produced shaping closure/remapping remains Milestone 17. | Accepted | +| D-103 | Explicit and fallback runtime raster baking accept normalized bounded coverage and raster options through the same Worker-only path. Coverage may be seeded by Unicode ranges, authored text, or exact font-local glyph IDs, but it reduces atlas generation only: it does not subset the shaping font, remap glyph IDs, or claim transitive shaping closure. | Accepted | +| D-104 | Every direct-memory Wasm ABI layout is represented by fixed-width `#[repr(C)]` Rust types. Build-only Rust generators derive published JSON and exact `as const` TypeScript contracts from `size_of`, `align_of`, and `offset_of!`; production hosts import those generated facts, and production Wasm embeds no duplicate contract or ABI-pointer bootstrap. WebAssembly direct memory uses its guaranteed little-endian order; portable GLB, KTX2, SFNT, and extension encodings retain their format-defined byte order. | Accepted | +| D-105 | Merged v0 retained the Three.js/TSL integration through Slug so real shader, resource, batching, and lifetime requirements could inform the abstraction. Target v1 extracts one renderer-neutral core beneath Bitmap, MTSDF, and Slug; Three.js, TypeGPU, Wayfare, and other engines become independently selectable integrations. Optional TypeGPU compute-baker research cannot enter unrelated runtime graphs. | Accepted | +| D-106 | Slug V0 artifacts retain exact R16UI reference grids. The Three.js 0.185.1 adapter may pair-pack those values into R32UI texels at decode time because its WebGL TSL backend does not declare an unsigned sampler for `UnsignedShortType`; this preserves reference identity and two-byte density plus at most one terminal padding value. Other adapters remain free to upload R16UI directly, and the exception does not redefine the portable artifact. | Accepted | +| D-107 | Repository TypeScript commands execute the installed native compiler through one bounded runner that first proves its kill/reap path with a synthetic allocator, supervises the native PID rather than a shell or Node shim, caps aggregate tracked RSS, enforces a wall-time limit, and reports no success while a compiler survives. TSL changes compile reduced operation fixtures and a narrow graph before package or application projects; free functions remain the first mitigation but exact-version pathological overloads use one proven concrete compatibility boundary. | Superseded by D-114 | +| D-108 | MTSDF V0 uploads only the authenticated base level and uses bilinear field sampling plus screen derivatives for reconstruction. Conventional GPU mip generation and trilinear cross-level sampling are rejected: averaging encoded MSDF channels is not a distance-field-preserving operation, and the primary MSDF paper plus official generators provide no affirmative mipmap guidance. Runtime, standalone validation, fixtures, and the inspector report the exact padded base texture-array allocation. Any future size-specific representation is an independently authored atlas layer or strike, not a conventional mip chain. | Accepted | +| D-109 | Slug V0 implemented a centered exact-distance outline in one specialized fill-plus-outline draw. Retained measurements later showed `2.44×–4.33×` fill-only GPU time, and generated-shader inspection found duplicated traversal, curve loads, closest-point refinement, and a derivative inside divergent control flow. | Superseded by D-111 | +| D-110 | MTSDF V0 exposes `emSize` and full `pixelRange` as authenticated integer bake options. `emSize` is limited to `1..=1022`, `pixelRange` to `1..=1020`, `planeUnitsPerEm` equals `emSize`, and field padding is `ceil(pixelRange / 2)`. Omitted or partial options resolve against the 64/8 compatibility defaults; explicit effective 64/8 canonicalizes to the legacy fieldless descriptor and raster key, while every non-default descriptor contains both effective values. The low-level Wasm ABI is unchanged. Passing 32/4 and 32/6 155-glyph subset bakes proves the control path, not a new recommended default; quality and payload benchmarking owns that decision. | Accepted | +| D-111 | Remove the dynamic exact-distance Slug outline rather than ship an expensive fallback. Slug V0 supports fill and opacity and rejects every runtime outline or shadow property. The generic text outline API remains because MTSDF owns it. | Accepted | +| D-112 | Research one bounded Slug outline approximation that reuses ordinary fill traversal and screen derivatives without closest-point solving or independent halo traversal. It ships only if its quality is no worse than the MTSDF outline corpus and its median GPU time is at most `1.15×` a same-expanded-quad fill control on both WebGPU and forced WebGL2; otherwise Slug remains fill-only. | Experiment | +| D-114 | Carry the upstream `NodeExtras` lookup-map rewrite as a version-pinned pnpm patch for `@types/three` 0.185.1. A focused compile-only regression owns every previously explosive TSL operation. Package and application scripts invoke the pinned compiler directly; the native-process memory guard and its repository-wide invocation requirement are removed because they contained a dependency type-graph defect now corrected at its declaration boundary. | Accepted | +| D-115 | The benchmark app uses Koota as an application-boundary state manager. Coherent singleton world traits own live controls and published telemetry; direct world reads/writes coordinate capture and renderer state. The world instance is exported from a dedicated HMR-stable module. Koota does not enter core text, shaping, layout, baking, raster, or public package APIs, and entities/queries are reserved for data that genuinely has collection lifecycle. | Accepted | +| D-116 | Interactive benchmark overlays use official shadcn components backed by Base UI rather than application-owned dismissal, focus, portal, or keyboard machinery. Repository semantic tokens theme those checked-in components. Koota remains the single owner of runtime control values; shadcn/Base UI owns interaction behavior only. | Accepted | +| D-117 | Each benchmark route owns one persistent render host per backend generation. The host owns the canvas, renderer, animation loop, GPU timing, telemetry history, viewport, and serialized scene/job lifecycle. React Suspense owns cold asset readiness; scene, technique, delivery, and font selections preload and commit with React transitions so the last complete scene remains visible until an atomic replacement is ready. Compatible font changes retain the active `Text` objects and registry. | Accepted | +| D-118 | Milestone 10 replaces `buildBatches`, optional retained updates, and separate repaint mutation with one required renderer-neutral `stageBatch(previous, layout, resource, fontSlot, paint, rasterPixelRatio)` transaction. A stage owns one unpublished target batch, may retain or replace the previous batch, cannot mutate committed state before publication, commits synchronously and infallibly, and aborts idempotently; committed batch disposal is also idempotent. The portable contract exposes no Three.js, TSL, WebGPU, WebGL, or first-party raster-kind union. Three.js object attachment is an adapter requirement enforced by `Text`, not part of `RasterDrawBatch`. | Accepted | +| D-119 | Once a font, shared shaper, decoded raster resource, and layout-required raster pages are resident, `Text.setProperties` shapes, lays out, plans paint, and stages synchronously while retaining the previous complete generation. The Three.js adapter publishes that candidate at the start of `updateMatrixWorld` or `updateWorldMatrix`, before child traversal; the React adapter explicitly invalidates its R3F root after a core-property update. `ready` remains an observation channel for cold work and queued publication, not a consumer coordination requirement for warm React updates. Synchronous validation, shaping, preparation, or staging faults throw from `setProperties` without cancelling an earlier candidate or live generation; asynchronous preparation and defensive commit-contract faults reject `ready`. A raster `prepare` implementation returns `void` when its requirement is resident and one shared idempotent Promise only for genuinely cold work. | Accepted | +| D-120 | First-party raster batches allocate deterministic 25% glyph-instance slack capped at 256 instances and track logical count separately from capacity. Bitmap, MTSDF, and Slug retain their complete parallel instance records when compatible content fits; shrinks and exact-capacity growth publish authoritative draw counts, while overflow and incompatible ordered Bitmap/Slug page-run topology replace transactionally. Dirty uploads use 32-instance buckets, at most eight disjoint ranges, and a logical full-range fallback; pending renderer ranges carry forward until consumed. This reuse is per batch and does not introduce automatic batching across independent `Text` objects. | Accepted | +| D-121 | The public extension proof is a private `glyphExample` package that owns its kind, descriptor, companion artifact, embedded/external records, baker, runtime generator, decoder, Three.js/TSL adapter, retained capacity, dirty uploads, overflow, abort, and disposal using only published `@pmndrs/text` entry points plus its renderer dependency. Portable batches stay renderer-neutral through `RasterObjectDrawBatch`; `ThreeRasterDrawBatch` documents the `Text` adapter requirement. Static discovery requires the imported factory export name, package manifest key, and default baker kind to match. | Accepted | +| D-122 | Framework-neutral `Text` is a composite `Object3D`, not a `Group`, so a caller-owned parent Group remains Three.js's primary `groupOrder`. `Text.renderOrder` is the secondary paragraph base and each drawable receives that base plus its first-glyph/page-run-local order. Three raster batches implement `setRenderOrderBase`, use neutral non-`Group` roots, and preserve the base across retained commits; the adapter applies it before cold publication, resynchronizes later caller changes during ordinary matrix traversal, and rejects a nested raster Group that would replace the inherited primary key. Nested React Text remains source/span composition and does not create nested scene objects. | Accepted | +| D-123 | The target v1 core has three retained public objects: `TextRuntime`, `ParagraphBatch`, and `Paragraph`. A paragraph batch declares exactly one raster technique, capacity policy, and application render-phase boundary within which core may order and submit paragraphs; it is not a one-draw promise. Every paragraph owns its complete font selection. A multiline block, label, or font-backed icon is always a paragraph. Separate public font-group, paragraph-engine, label, icon, text-item, and mixed-technique logical-batch lifecycles are rejected. | Accepted | +| D-124 | Paragraph handles own desired text, spans, content box, style, paint, finite order, and reversible glyph-origin overrides. Observable top-level setters and indexed methods mark dirty channels without shaping; nested option records are immutable replacements. Repeated writes coalesce naturally. `TextRuntime.update()` snapshots every dirty paragraph across all paragraph batches and synchronously shapes, lays out, partitions, packs, and atomically publishes the final desired state. `updateAsync()` snapshots the same state for asynchronous preparation. A no-op synchronous update returns the current revision without allocation. | Accepted | +| D-125 | Sync versus async is selected per synchronization call, not when creating the runtime. Runtime options only provision a synchronous shaper and optional lazily created asynchronous executor. `updateAsync()` has a Promise form and a callback form that creates no public Promise; both complete asynchronously. Worker results may stream into unpublished staging storage and report bounded progress, but publication remains atomic. Mutations after an update snapshot remain dirty for the next synchronization. A newer sync or async synchronization supersedes any unpublished older asynchronous generation, which can never replace newer state. Published, superseded, and aborted requests are resolved outcomes; only an actual preparation failure rejects the Promise or enters the callback error branch. | Accepted | +| D-126 | Core owns fallback resolution, paragraph sorting, technique/resource partitioning, stable instance slots, capacity growth/chunking, canonical instance packing, dirty ranges, resolved opaque render variants, and ordered `PreparedGlyphRun` values. One same-technique paragraph batch may produce several resource buffers and repeated ordered runs from one buffer. A run is not a promised draw. Engine programs may split or coalesce adjacent compatible runs and own final draw planning, but may not reshape, resort source text, reselect resources, or reallocate core slots; they preserve order unless a documented compositing policy proves another order equivalent. | Accepted | +| D-127 | Core retains one canonical technique-defined structure-of-arrays CPU representation for every prepared glyph batch and reports exact coalesced dirty ranges. Matching targets copy/upload those ranges 1:1; different engine layouts map only those fields and ranges. First or gapped synchronization initializes live ranges referenced by the current glyph runs. Targets never reshape, source-sort, resource-partition, or allocate core slots. The CPU shadow decouples core revisions from inaccessible or in-flight GPU memory and supports multiple or late targets; targets own engine staging, final draw compilation, GPU publication, fences, and retirement. | Accepted | +| D-128 | Bitmap, MTSDF, Slug, and any external raster remain distinct techniques and cannot coexist in one `FontStack` or `ParagraphBatch`. A renderer that deliberately combines data from two existing techniques is a new technique with its own artifacts, compatibility key, instance schema, resource bindings, and shaders. Different fonts within one technique normally remain distinct GPU resource batches; a technique may opt fonts into one physical key only when it actually provides a shared addressable GPU resource. | Accepted | +| D-129 | The Three.js surface is `FontLoader`, `TextGroup`, and transform-bearing `Text`; it privately owns every core runtime, paragraph batch, paragraph, revision, attachment, and target. First loader use lazily initializes one cached runtime/shaper. `TextGroup` declares one technique, one construction-time `ThreeRasterProgram`, and one render phase; every `Text` owns a same-technique `Font` or `FontStack`, and standalone text derives an implicit batch. `updateMatrixWorld()` reconciles membership, invokes allocation-free-when-clean runtime update, commits the staged target, runs ordinary world-matrix traversal, and writes changed glyph transforms before render-list construction. The target copies core ranges, the program compiles glyph runs into draw meshes, and WebGPURenderer performs GPU writes/draws. `TextGroup` remains an `Object3D`, preserving the nearest real Group's primary `groupOrder`; its mutable `renderOrder` is the secondary base across compiled draws. | Accepted | +| D-130 | `FontStack` is an immutable ordered logical font selection, not a plural eligibility group: its first concrete font is primary and later same-technique fonts resolve missing glyphs. Every text-facing `font` field accepts a concrete `Font` or `FontStack`; batches and `TextGroup` never declare fonts or fallback. Core exports typed `txt` and `span` template helpers that flatten nested fragments into immutable UTF-16 string/span snapshots without parsing markup. The Three entry point re-exports those helpers directly, and React nested `` composition uses the same composer. Plain strings remain valid and clear spans when assigned. | Accepted | +| D-131 | Paragraph and text counts are not public capacity dimensions. Optional `GlyphBufferCapacity` has only `size` and `policy`, applied as glyph-instance slots independently to each physical technique/resource buffer. Explicit batches default to lazy `{ size: 4_096, policy: 'chunk' }`; standalone Three text defaults to `{ size: 256, policy: 'grow' }`. Chunk preserves buffers and adds fixed chunks, grow transactionally doubles until pending glyphs fit, and fixed makes `size` a hard per-buffer limit. Fixed overflow is knowable only after shaping, fails before publication, and is retained by Three rather than escaping render. Paragraph metadata grows normally, and core preserves logical order through glyph runs across every resulting buffer. | Accepted | +| D-132 | Three.js exposes no `TextGroup.allocate()` or second text-creation path. `new Text(properties)` creates one retained late-bound object; inherited `Object3D.add()` / `remove()` are its only membership operations, and removal does not dispose it. Glyph-slot allocation is an internal synchronization result. Core retains `ParagraphBatch.add(properties)` because `Paragraph` is not independently constructible. | Accepted | +| D-133 | `span()` accepts a renderer-neutral `SpanStyle` alone, or a same-technique `Font` / `FontStack` first followed by styles and compatible font overrides. `SpanStyle` combines paragraph style and glyph paint. Inputs merge left-to-right; later scalar fields or fonts replace earlier ones, while nested features, outline, and shadow replace as units. The returned immutable tag is reusable; readonly tuples preserve inputs for later binding. A style-only tag remains technique-neutral and inherits its surrounding font. | Accepted | +| D-134 | A detached Three.js `Text` owns reusable desired state but no core paragraph, batch, or GPU target. Direct scene rendering creates a text-owned implicit batch; moving into a group publishes destination membership before GPU-safe retirement of that target. Explicit-group slots and buffers belong to `TextGroup`, so removal recycles membership without disposing shared capacity. Even while detached, `Text.dispose()` is permanent: it cancels work, clears caches/references, and prevents reattachment. It neither mutates the scene graph nor disposes group buffers or fonts. Group disposal releases group resources without disposing children or fonts. | Accepted | +| D-135 | Core `Paragraph` handles are permanently owned by their creating `ParagraphBatch`; batch disposal cascades through those handles, while paragraph disposal never disposes its batch, runtime, or fonts. Core moves desired state by immutable snapshot plus destination `add()`, never by transferring a handle. Paragraphs and Three `Text` objects lease every selected concrete font for their full retained lifetime, and font disposal fails while leases remain. Three `Text` owns its desired snapshot and leases independently of group binding, so disposing a populated `TextGroup` unbinds but does not dispose its text; each live compatible text may create fresh membership elsewhere. A disposed group left in the scene graph remains a terminal non-rendering boundary rather than falling through to an ancestor or implicit batch. | Accepted | +| D-136 | Fixed capacity forbids automatic growth, not an explicit owner-directed capacity change. Core `ParagraphBatch.setCapacity(capacity)` preserves the batch, every paragraph handle, subscriptions, and attachments; it clears a latched capacity failure only when the normalized value changes, stages replacement canonical storage at the next synchronization, publishes atomically, and leaves the prior revision live on failure. Existing attachments record that source; each target stages replacement engine buffers on its owner's next `prepare()` and retires old buffers after its fences. Three `TextGroup.setCapacity()` and standalone `Text.setCapacity()` preserve public object identity and forward to their effective or retained implicit batch. The setter records capacity intent; it does not promise immediate allocation. `TextGroup.clone()` and `copy()` are unsupported because recursive copying would silently duplicate identity-bearing text, refs, listeners, membership, and renderer state. | Accepted | +| D-137 | `ParagraphBatch.attach(target)` is the standard retained renderer coordinator, not a privileged preparation API. The public observer replays `current`, reports later revisions, and completes on disposal, so another coordinator needs no private shaping/allocation access. Publication only records the newest attachment source; the observing engine calls `attachment.prepare()` to stage its own target and `commit()` at its safe boundary. `attach()` owns technique validation, cancellation, retained target failure, and cascading disposal. `dirtyRanges` is an adjacent-revision delta: first or gapped synchronization initializes live ranges named by current glyph runs, while adjacent synchronization uploads only the delta. Targets consume or copy canonical ranges during synchronous `stage()` and never retain mutable views across later publications. | Accepted | +| D-138 | The next API splits the current combined `RasterModule` into a renderer-neutral `RasterTechnique` and engine-owned `ParagraphBatchTarget`. One portable technique owns artifact decoding, hash-validated external resource resolution, retained CPU page/table data, glyph-to-resource binding, canonical instance schema, and packing. Every prepared glyph batch exposes that typed binding, so targets create textures/buffers without rediscovering page or resource membership. A concrete technique definition infers and preserves its exact options, descriptor, decoded data, binding, and storage types; the common heterogeneous boundary exposes those associated values as `unknown` rather than erasing them with `any`, and requires narrowing before technique-specific work. GPU resource creation, shaders, pipelines/materials, scene/pass integration, submission, fences, and retirement remain outside core. An optional adapter-level `RasterProgram` may share shader/resource realization across engines using the same backend: TypeGPU programs can be reused where hosts prove compatible WebGPU device/pass interop, while TSL programs remain Three.js-specific. Bakers and portable technique entry points import no engine or shader backend. | Accepted | +| D-139 | Evaluate TypeGPU functions as an optional source for shared WebGPU raster logic. At `three@0.185.1`, `typegpu@0.11.9`, and `@typegpu/three@0.11.0`, `toTSL()` injects a nullary WGSL closure through Three's WebGPU builder; it is not native TSL conversion, has no forced-WebGL2 route, and has not carried Slug's sampleable resources. A TypeGPU `RasterProgram` may still serve direct WebGPU hosts. This remains isolated external experimentation: acceptance requires exact-version compilation, real resource/loop/vertex proofs, generated-shader inspection, Bitmap and Slug parity, backend coverage, and measured transfer/graph/compilation cost. Core and portable techniques import neither TypeGPU nor TSL. | Experiment | +| D-140 | `renderVariant` is a generic core value resolved by batch → paragraph → span inheritance and copied onto ordered glyph runs after shaping. Core never imports TSL, TypeGPU, material, or effect types. Variants do not split shaping clusters, physical glyph storage, pipelines, or draws by definition; a cluster receives the variant covering its first UTF-16 code unit. Variant changes rebuild run planning without reshaping, while stable integration-owned parameter bindings may update outside core. | Accepted | +| D-141 | The reusable GPU seam is a backend-specific `RasterShader` containing the complete canonical Bitmap, MTSDF, or Slug stage algorithm—including required vertex expansion/snapping/dilation, resource access, and fragment evaluation—followed by a `RasterProgram` that owns resources, application variants, pipeline/material compatibility, and final draw compilation. Custom programs normally call the canonical shader and alter resolved output; full shader replacement is only a low-level escape hatch. Concrete shader and program helpers infer exact associated types; heterogeneous boundaries widen to `unknown`, never `any`, and require narrowing. | Accepted | +| D-142 | `TextEffect` is optional Three/TSL convenience layered over the default `ThreeRenderVariant`, not a core API and not the only customization surface. Effect definitions compose in order after canonical raster evaluation; definition identity determines graph compatibility while parameter values live in text/span-local sidecar bindings. A custom `ThreeRasterProgram` may define another variant type and batching policy while reusing the same technique shader. Native TSL effects remain Three-specific; TypeGPU-authored pure WebGPU math may adapt only within the exact capabilities proven for the pinned `toTSL()` bridge. | Accepted | +| D-143 | The direct TypeGPU engine accepts a caller-owned `TgpuRoot` and owns one hidden core runtime, paragraph-batch attachments, canonical-range synchronization, transform/visibility buffers, program revisions, and draw encoding. It owns no adapter/device request, canvas, scene graph, RAF, render-pass creation, queue submission, or device-loss recovery. Applications mutate retained TypeGPU paragraph handles, explicitly call `update()` or `updateAsync()`, and call `encode(pass, frame)` on any live batch. TypeGPU programs expose typed canonical technique shaders, exact variant codecs, resources, pipelines, and draw compilers; another WebGPU host may reuse them only after its device/pass interop and lifecycle are source-inspected and executed. | Accepted | +| D-144 | Target v1 engine integrations are independent packages that consume only public core and technique exports. `@pmndrs/text-three`, `@pmndrs/text-r3f`, `@pmndrs/text-typegpu`, and a gpucat proof package require no privileged `@pmndrs/text/` subpaths. A packed external fixture must reject deep imports. The reviewed gpucat surface fits core revisions, typed buffers, dirty ranges, textures, transforms, and ordered instanced draws without a core change; canonical Slug shader reuse remains a separate executable shader-package gate. | Proposed | +| D-145 | Explore TypeGPU as the authoritative complete-stage raster implementation without adding GPU-framework types to core. The experiment begins with the cheapest `@typegpu/three` capability gate and may end in one of three documented outcomes: TypeGPU authority across hosts, TypeGPU authority for WebGPU with native engine fallbacks, or an authoritative semantic raster/resource/stage specification with separately verified TypeGPU, TSL, and gpucat implementations. The currently documented Three bridge is WebGPU-only; native TSL cannot retire while the flagship Three package promises WebGL2. A reusable TypeGPU shader/program package is valuable independently of a full direct scene engine. | Experiment | The [raster contract](raster-data-contract.md) owns records. The [capability matrix](renderer-capabilities.md), [payload budget](payload-budget.md), and [compression analysis](gpu-compression.md) own evidence and limitations. @@ -208,3 +243,4 @@ The [benchmark plan](benchmark-plan.md), [conformance plan](conformance-plan.md) 2. ✅ HarfRust, HarfBuzz, Unicode, glTF schema, validator, ABI, format, and initial generator versions are fixed in the [version contract](version-contract.md). 3. ⬜ Assign an authorized maintainer to submit the accepted provisional `PMNDRS` prefix request. 4. ✅ Inter Regular 4.1 and the target Chromium/GPU matrix are pinned; Amiri and Noto CJK add complex-script and universality evidence without changing the first rendering fixture. +5. ✅ D-123–D-138 and D-140–D-143, the code-first README, separate core/Three/TypeGPU specifications, prepared-revision handoff, raster shader/program split, render variants, and engine ownership boundaries are accepted for the extraction PR; D-139 and D-145 remain experiments pending complete-stage bridge evidence, and D-144's independent integration-package topology remains proposed pending maintainer acceptance and the gpucat fixture. diff --git a/docs/planning/decisions/0003-raster-and-container-contracts.md b/docs/planning/decisions/0003-raster-and-container-contracts.md index 304f340d..9059579d 100644 --- a/docs/planning/decisions/0003-raster-and-container-contracts.md +++ b/docs/planning/decisions/0003-raster-and-container-contracts.md @@ -16,7 +16,7 @@ sources: title: glTF extension drafts generated: by: openai-codex/gpt-5.6 - at: '2026-07-26T19:51:43Z' + at: '2026-08-07T01:16:02Z' --- # ADR 0003: Raster and container contracts @@ -31,7 +31,7 @@ Shaping data and raster payloads have different lifecycles, coverage, sizes, and ## Decision -The provisional `PMNDRS_font` GLB family separates one shaping core from typed raster companions. Embedded and external forms use the same schema and reciprocal identity. Raster packages own final fixed-stride GPU records and lossless KTX2 payloads; direct-to-GPU means no semantic reconstruction or record repacking. V1 contains bitmap, linear RGBA8 MTSDF, and Slug modules. Bitmap proves the path at native device-pixel strikes; MTSDF is the general recommendation; Slug owns outline-accurate large and zoomed text. +The provisional `PMNDRS_font` GLB family separates one shaping core from typed raster companions. Embedded and external forms use the same schema and reciprocal identity. Raster packages own final fixed-stride GPU records and lossless KTX2 payloads; direct-to-GPU means no semantic reconstruction or record repacking. Merged v0 contains Bitmap, linear RGBA8 MTSDF, and Slug modules; target v1 preserves all three behind the renderer-neutral boundary. Bitmap proves the path at native device-pixel strikes; MTSDF is the general recommendation; Slug owns outline-accurate large and zoomed text. ## Alternatives considered diff --git a/docs/planning/editorial-flow-layout.md b/docs/planning/editorial-flow-layout.md index 5b5d727b..344a5ee7 100644 --- a/docs/planning/editorial-flow-layout.md +++ b/docs/planning/editorial-flow-layout.md @@ -1,7 +1,7 @@ --- type: Design Research title: Responsive editorial flow and mixed-raster composition -description: Defines the post-V1 layout model and benchmark for responsive multi-column text around exclusions rendered with bitmap, MTSDF, and Slug. +description: Defines the post-v1 layout model and benchmark for responsive multi-column text around exclusions rendered with Bitmap, MTSDF, and Slug. tags: [layout, benchmark, typography, exclusions, bitmap, mtsdf, slug] sources: - id: 'pretext' @@ -19,16 +19,16 @@ sources: generated: by: openai-codex/gpt-5.6 - at: '2026-07-27T01:29:13Z' + at: '2026-08-07T01:16:02Z' --- # Responsive editorial flow and mixed-raster composition -Status: accepted post-V1 direction; API and performance conclusions remain evidence-gated +Status: accepted post-v1 direction; API and performance conclusions remain evidence-gated ## Recommendation -Add responsive flow regions and a mixed-raster editorial benchmark after Slug and the V1 release gate. Do not expand V1's box-constrained paragraph contract to fit this work prematurely. +Add responsive flow regions and a mixed-raster editorial benchmark after the target v1 release gate. Do not expand target v1's box-constrained paragraph contract to fit this work prematurely. The benchmark should be a typographic composition that needs all three first-party techniques: @@ -62,7 +62,7 @@ The showcase should exercise all four lanes and label them separately. Repeated ## Current boundary -V1 lays out horizontal text in a rectangular `ParagraphConstraints` box. It can reshape changed line boundaries efficiently, but it does not represent columns, holes, floats, arbitrary contours, or more than one usable interval on a baseline. An oversized letter can be rendered beside a box today, but body text cannot correctly flow around its contour. +Target v1 lays out horizontal text in a rectangular content box. It can reshape changed line boundaries efficiently, but it does not represent columns, holes, floats, arbitrary contours, or more than one usable interval on a baseline. An oversized letter can be rendered beside a box today, but body text cannot correctly flow around its contour. The shaping engine is not the missing piece. The missing piece is a flow planner between paragraph measurement and final positioning. @@ -152,7 +152,7 @@ Compare both a static first layout and deterministic dynamic updates. Keep appro ## Milestone gates -Milestone 11 begins only after Slug and the V1 renderer-set gate. Before accepting a public flow API it must prove: +Milestone 12 begins only after the target v1 renderer-set gate. Before accepting a public flow API it must prove: - rectangular layout remains the zero-overhead common path; - the same prepared paragraph can produce box and exclusion-region layouts; diff --git a/docs/planning/engine-integration-boundary.md b/docs/planning/engine-integration-boundary.md index 8d5d95c6..fc822d5b 100644 --- a/docs/planning/engine-integration-boundary.md +++ b/docs/planning/engine-integration-boundary.md @@ -1,306 +1,473 @@ --- type: Implementation Plan -title: 'WIP: Renderer-agnostic core and engine integration boundary' -description: WIP plan for portable text-generation, raster-technique, GPU-authoring, and game-engine seams that do not couple core to one engine. -tags: [architecture, rendering, game-engines, typegpu, threejs, raster, packages, wip] +title: Renderer-neutral core and engine integration +description: Implementation and proof plan for technique-declared paragraph batches, ordered font stacks, synchronized updates, core-owned glyph batching, and thin engine targets. +documentation_type: explanation +tags: [planning, api, shaping, batching, threejs, typegpu, wayfare] status: draft sources: - - id: current-text - resource: ../../packages/text/src/text.ts - title: Current Three.js Text host - - id: current-raster - resource: ../../packages/text/src/raster.ts - title: Current raster capability and transaction contracts - - id: bitmap-adapter - resource: ../../packages/text/src/raster/bitmap.ts - title: Current Bitmap Three.js and TSL implementation - - id: bitmap-baker - resource: ../../packages/text/src/bakers/bitmap.ts - title: Current renderer-independent Bitmap baker + - id: core-api + resource: core-api.md + title: Canonical core text API + - id: engine-contract + resource: engine-integration-contract.md + title: Engine integration contract + - id: raster-technique + resource: raster-technique-api.md + title: Raster technique and engine resource API + - id: three-api + resource: three-api.md + title: Three.js text API + - id: typegpu-api + resource: typegpu-api.md + title: TypeGPU raster programs and text engine + - id: gpucat-integration + resource: gpucat-integration.md + title: External gpucat integration fitness plan - id: roadmap resource: ../roadmap/roadmap.md - title: Canonical implementation roadmap - - id: typegpu-scope - resource: https://docs.swmansion.com/TypeGPU/why-typegpu/ - title: Why TypeGPU? - - id: typegpu-three - resource: https://docs.swmansion.com/TypeGPU/ecosystem/typegpu-three/ - title: TypeGPU integration with Three.js and TSL - - id: typegpu-webgpu - resource: https://docs.swmansion.com/TypeGPU/integration/webgpu-interoperability/ - title: TypeGPU WebGPU interoperability + title: Canonical implementation order + - id: decision-register + resource: decision-register.md + title: Architectural decisions + - id: current-api + resource: api-shapes.md + title: Existing API migration fixture + - id: wayfare + resource: https://github.com/iwoplaza/wayfare + title: Wayfare engine proof target + - id: typegpu-shader-canvas + resource: https://github.com/AlexJWayne/typegpu-shader-canvas + title: Raw TypeGPU proof target generated: by: openai-codex/gpt-5.6 - at: '2026-08-05T14:29:49Z' + at: '2026-08-07T03:25:58Z' --- -# WIP: Renderer-agnostic core and engine integration boundary +# Renderer-neutral core and engine integration -Status: **work in progress**. This draft proposes the next additive milestone; it does not define a published API, authorize -implementation, or change the canonical roadmap order. +## Outcome -## Decision sought +Replace the current one-Three-object/one-paragraph ownership model with this pipeline: -Make the reusable center of `@pmndrs/text` independent of Three.js, TSL, TypeGPU, React, and any scene graph. Keep Bitmap, MSDF, and Slug artifact contracts and bakers as first-party portable techniques. Move scene attachment, transforms, sorting, GPU residency, draw submission, and frame-boundary publication into explicit engine integrations. - -The plan must prove two independent forms of portability: - -1. one engine can use more than one GPU-authoring layer, beginning with **Three.js + TSL** and **Three.js + TypeGPU**; -2. one portable text-generation and raster contract can drive more than one engine host, including at least one host that does not use Three.js. - -## Corrected terminology - -“Framework” is too ambiguous for this boundary. The architecture has three orthogonal axes: - -| Axis | Responsibility | Examples | -| ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------- | -| Engine or rendering host | Scene ownership, transforms, cameras, visibility, ordering, batching policy, render passes, frame scheduling, device/canvas lifecycle | Three.js, Babylon.js, PlayCanvas, PixiJS, a custom game engine | -| GPU-authoring layer | Typed resources, bindings, shader composition, pipeline construction, raw GPU interoperability | TSL, TypeGPU, WGSL/WebGPU | -| Application binding | Reconcile application state into an engine-owned text instance | Imperative code, React Three Fiber, another engine-specific reconciler | - -React, Vue, and similar UI frameworks are not the portability target. They may wrap an engine integration, but they do not define the rendering boundary. - -## What TypeGPU is and is not - -TypeGPU is a typed, modular abstraction over WebGPU resources, bindings, pipelines, and shader programs. Its documentation explicitly positions it as building blocks for a framework, a custom renderer, GPU computation, or incremental use inside another solution—not as a scene graph or complete rendering engine.[^typegpu-scope] - -Without Three.js or another engine, an application using TypeGPU still needs something to own: - -- cameras, transforms, bounds, and visibility; -- scene or world traversal; -- transparent and opaque ordering; -- batch construction and draw submission; -- render-pass and target orchestration; -- frame scheduling and presentation; -- canvas, adapter, device, and loss recovery. - -That “something” may be a full game engine, a small purpose-built renderer, or a toolkit built on TypeGPU. TypeGPU can also participate inside Three.js through `@typegpu/three`, translating TypeGPU functions into TSL nodes. That path currently requires WebGPU and does not preserve Three.js's WebGL fallback.[^typegpu-three] - -TypeGPU therefore belongs on the GPU-authoring axis. It is not inherently a peer of Three.js on the engine axis. - -## Target dependency direction - -```mermaid -flowchart TD - App["Application"] --> Binding["Optional application binding"] - Binding --> Engine["Engine integration"] - App --> Engine - - Engine --> Runtime["Portable text-generation state machine\nname not yet accepted"] - Engine --> Technique["Portable raster technique"] - Engine --> GPU["GPU-authoring adapter"] - - Runtime --> Font["Font registry + HarfRust shaping"] - Runtime --> Paragraph["Paragraph layout + paint"] - Runtime --> Technique - - Technique --> Contract["Descriptor + artifact data contract"] - Technique -. "lazy import" .-> Baker["Optional runtime baker"] - - GPU --> TSL["TSL"] - GPU --> TypeGPU["TypeGPU"] - GPU --> Raw["Raw WGSL / WebGPU"] - - Engine --> Three["Three.js host"] - Engine --> Other["Other game engine"] - Engine --> Custom["Custom renderer"] +```ts +LoadedFont[] + -> Font | FontStack // one logical font selection + -> ParagraphBatch[] // application-declared render phases + -> Paragraph handles // desired-state mutation + -> TextRuntime.update*() // one synchronization point + -> PreparedGlyphBatch[] // core partitions and packs + -> PreparedGlyphRun[] // core preserves order and resolved variants + -> engine target // storage, compatible draws, transform, submit ``` -Imports point downward only. The portable runtime and technique contracts must have no type or runtime edge to an engine, GPU-authoring library, or application reconciler. - -## Proposed capability boundaries - -### Portable text generation - -Own the behavior currently embedded in the Three.js `Text` object that is independent of `Object3D`: - -- normalized text, spans, shaping, layout, and paint properties; -- cold preparation and warm invalidation classification; -- cancellation and stale-generation rejection; -- retained current generation and fully prepared replacement; -- readiness and failure state; -- deterministic resource retention and disposal policy; -- publication eligibility, without choosing an engine frame hook. - -Do not accept `TextController` as the API name. During planning, call this the **portable text-generation state machine**. Name it only after the non-Three proof establishes whether consumers experience it as a runtime, instance, model, pipeline, or another abstraction. - -### Portable raster technique - -Bitmap, MSDF, and Slug each own a canonical, renderer-independent technique capability: - -- literal kind, extension, version, and descriptor normalization; -- raster identity and artifact records; -- hostile-input validation and portable decoded views; -- optional offline and runtime baker capabilities; -- coverage and missing-glyph rules; -- portable resource and payload accounting. - -The artifact contract and baker are tightly coupled and should remain versioned as one first-party technique. The baker implementation must remain independently importable and dynamically loadable. “First-party core concern” does not mean “eager root-bundle dependency.” - -### Engine integration - -An engine integration owns host concepts: - -- transform and hierarchy attachment; -- camera and viewport inputs; -- render order and draw-local ordering; -- engine-native object identity; -- frame-boundary publication of a prepared generation; -- scene removal and device-loss cleanup; -- integration-specific retained batch updates. - -The engine interface receives opaque, portable generation and technique data. It must not reshape text, redefine artifact identity, or own baker policy. - -### GPU-authoring adapter - -GPU code is separable from engine ownership where the host allows it: - -- resource and pipeline creation; -- vertex/instance storage layouts; -- bind groups, textures, and samplers; -- shader implementation and composition; -- dirty-range upload and draw encoding; -- GPU resource disposal. - -For Three.js, TSL remains the existing cross-WebGPU/WebGL implementation. A Three.js + TypeGPU proof uses `@typegpu/three` and is WebGPU-only unless that integration gains a WebGL path. A non-Three TypeGPU proof uses TypeGPU resources and pipelines directly or through the selected engine's supported integration. - -### Application binding - -React Three Fiber and any later bindings remain thin engine-specific adapters. They may own Suspense, transitions, prop reconciliation, invalidation, and component disposal, but they do not become the portable engine API. - -## Package and export strategy - -Exact names remain provisional. The dependency boundaries should be testable regardless of whether they ship as subpath exports or separate workspace packages. - -| Candidate surface | Purpose | Loading rule | -| --------------------------------------- | ------------------------------------------------------------------------------------------- | -------------------------------------------------------------- | -| `@pmndrs/text` | Portable font, shaping, paragraph, paint, generation, and shared capability contracts | Browser-safe base graph; no engine or GPU-authoring dependency | -| `@pmndrs/text/bitmap`, `/msdf`, `/slug` | Portable first-party technique contracts, validators, decoded views, and lazy baker loaders | Baker Wasm and workers remain behind `import()` | -| `@pmndrs/text/three` | Three.js text host and engine lifecycle | Imports Three.js, not React | -| `@pmndrs/text/three/tsl/*` | Existing Bitmap, MSDF, and Slug GPU implementations | Keeps WebGPU and WebGL behavior | -| `@pmndrs/text/three/typegpu/*` | TypeGPU-authored Three.js shader implementations or bridges | WebGPU-only unless proven otherwise | -| separate non-Three integration package | Selected game-engine or custom-renderer proof | Imports public portable surfaces only | -| `@pmndrs/text/react-three-fiber` | React binding over the Three.js host | Imports R3F and the Three.js integration | +The [core API](core-api.md) is the public authority. The [engine contract](engine-integration-contract.md) is the exact +integration boundary. This document owns implementation order and proof. + +## Settled invariants + +```ts +const Invariants = { + explicitBakeAndLoad: true, + everyTextUnitIsAParagraph: true, + fontStackMayResolveThroughMultipleFonts: true, + paragraphBatchDeclaresOneTechnique: true, + oneParagraphBatchIsOneIntentionalRenderPhase: true, + oneParagraphBatchMayProduceManyGlyphBatchesAndRuns: true, + paragraphHandlesOwnDesiredStateMutation: true, + runtimeUpdateIsTheSynchronizationPoint: true, + syncOrAsyncIsChosenPerUpdate: true, + coreOwnsSortingPartitioningPackingAndDirtyRanges: true, + coreRetainsCanonicalCpuInstanceStorage: true, + coreVariantsAreOpaqueRenderIntent: true, + rasterArtifactsAndCpuDataAreEngineNeutral: true, + engineTargetsRealizeGpuResources: true, + targetsOwnTransformsGpuLifetimeAndSceneComposition: true, +} as const; +``` -One ergonomic technique export may compose contract, renderer adapter, and lazy baker loader. Composition must not erase the internal dependency boundaries or pull baker code into the initial bundle. +The following shapes are rejected: + +```ts +const Rejected = { + separatePublicParagraphEngine: true, + labelsOrIconsAsDifferentTextKinds: true, + runtimeWideSyncOrWorkerMode: true, + editCallbackPassedToUpdate: true, + fontStackContainingSeveralTechniques: true, + logicalMixedTechniqueBatchRepartitionedByEveryRenderer: true, + rendererOwnedShapingOrResourcePartitioning: true, + targetOwnedCanonicalGlyphStorage: true, +} as const; +``` -## Proposed milestone slices +## Why each public object exists -### 11.1 — accept the boundary and proof matrix +### `TextRuntime` -- inventory every Three.js, TSL, React, DOM, canvas, and GPU type reachable from current public and internal contracts; -- classify each responsibility as portable generation, technique, engine, GPU-authoring, or application binding; -- record accepted dependency rules and provisional package surfaces; -- add import-graph and type-level fixtures that can fail before code moves; -- capture current artifact identity, bundle, visual, lifecycle, and performance baselines. +Owns loaded-font identity, the synchronous shaper, optional asynchronous executor, dirty paragraph registry, cross-batch +shape aggregation, revision numbers, supersession, and atomic publication. -Exit: the maintainer accepts the terminology, dependency direction, proof hosts, and non-goals. No implementation name is accepted without evidence from a second host. +```ts +paragraphA.text = nextA; +paragraphB.contentBox = nextB; +runtime.update(); +``` -### 11.2 — separate portable technique data from renderer resources +Both paragraphs shape at one synchronization point even when they belong to different paragraph render phases. -- move Bitmap, MSDF, and Slug descriptors, contracts, validators, and portable record decoding out of Three-shaped modules; -- retain the existing lazy runtime-baker import boundary and exact artifact bytes; -- make Three.js textures, TSL materials, geometry, and draw batches consume the portable technique outputs; -- prove that importing a technique contract does not load Three.js, TSL, TypeGPU, workers, or baker Wasm. +### `FontStack` -Exit: all three techniques expose renderer-neutral authenticated data; offline/runtime bake identity and initial bundle size remain accounted for. +Defines one immutable logical font selection. Its first concrete font is primary and later fonts resolve missing glyphs in +order. Every concrete font must use the same technique. A single `LoadedFont` already satisfies the same selection contract. -### 11.3 — extract the portable text-generation state machine +```ts +const uiFont = createFontStack(interMtsdf, notoMtsdf, amiriMtsdf); +``` -- move shaping/layout/paint invalidation, cancellation, generation replacement, readiness, and disposal out of `Object3D`; -- publish immutable or explicitly owned generation inputs for engine adapters; -- keep publication timing host-driven rather than adding a second arbitrary flush API; -- exercise the state machine headlessly through success, failure, abort, overflow replacement, retained updates, and disposal. +Different font resources may still require different glyph buffers and submits. Core produces those divisions. -Exit: the complete lifecycle runs with no Three.js object and no GPU. The current Three.js behavior is not yet removed. +### `ParagraphBatch` -### 11.4 — rebuild the existing Three.js + TSL product as an integration +Declares where the application permits core to order and submit text together. It is not a promise of one draw. -- make the Three.js object delegate portable work to the extracted state machine; -- retain Object3D behavior, parent transforms, render-order composition, batching, warm updates, and matrix-lifecycle publication; -- retain WebGPU and WebGL2 support and current React behavior; -- compare artifact, layout, visual, lifecycle, allocation, package-size, and GPU evidence with the pre-extraction baseline. +```ts +const worldText = runtime.createParagraphBatch({ technique: mtsdf }); +const overlayText = runtime.createParagraphBatch({ technique: mtsdf }); +``` -Exit: the shipped behavior is an adapter over the public or publishable portable boundary, with no unexplained regression. +Core does not merge these phases. The engine may place particles, meshes, post-processing, or UI work between them. -### 11.5 — prove Three.js + TypeGPU is an orthogonal GPU path +### `Paragraph` -- implement one technique first, likely Bitmap, through `@typegpu/three` without changing the Three.js engine host; -- establish how transforms, attributes, uniforms, texture resources, and material publication cross the bridge; -- compare visual output and retained updates against the TSL implementation; -- repeat with Slug before declaring the GPU-authoring interface sufficient. +Owns one desired text/layout/paint state and one stable identity. A multiline block, one-line label, and font-backed icon are +all paragraphs. -Exit: one Three.js engine integration can select TSL or TypeGPU without changing shaping, layout, artifact, or scene-lifecycle ownership. The TypeGPU path is labeled WebGPU-only if that remains true. +```ts +paragraph.text = nextText; +paragraph.contentBox = nextBox; +paragraph.paint = nextPaint; +``` -### 11.6 — prove a non-Three engine host +Repeated setters before the next runtime update coalesce naturally. -- first create a minimal TypeGPU/raw-WebGPU proof host to expose hidden Three.js assumptions cheaply; -- then select one real canvas/game-engine integration based on public extension hooks, WebGPU maturity, package cost, and maintainability; -- implement the adapter in a private workspace package using only published portable contracts; -- require the second engine to consume the same prepared generations and raster artifacts without core changes. +### `PreparedGlyphBatch` and `PreparedGlyphRun` -Exit: a second engine renders retained text, replaces generations transactionally, recovers from abort/failure/device or navigation lifecycle, and does not import Three.js or TSL. +These are the concrete rendering outputs. Core groups compatible glyph instances into stable storage and separately emits +the ordered variant-bearing ranges the integration compiles into draws. -### 11.7 — stabilize exports, guidance, and release gates +```ts +for (const batch of revision.glyphBatches) { + upload(isAdjacentTargetRevision ? batch.dirtyRanges : liveSubmissionRanges(batch.key)); +} +for (const draw of program.compileRuns(revision.glyphRuns)) draw(draw); +``` -- name the portable state machine from observed usage in both engine hosts; -- decide subpaths versus separate packages from dependency and bundle evidence; -- publish an engine-integration guide distinct from the raster/baker technique guide; -- reduce examples to canonical portable, Three.js + TSL, Three.js + TypeGPU, and non-Three engine paths; -- update API, architecture, package, decision, and roadmap concepts; -- run deterministic, browser, GPU, package-size, documentation, and OKF gates. +## Ownership boundary + +| Layer | Owns | +| ------------------- | ------------------------------------------------------------------------------------------------------------------- | +| Baking | reduced font data, glyph records, technique artifacts, deterministic packaging | +| Loading | validation, decoding, registered font and technique identity | +| Text runtime | dirty aggregation, sync/async scheduling, shaping calls, supersession, atomic publication | +| Font stack | one immutable ordered missing-glyph policy over same-technique concrete fonts | +| Paragraph batch | declared technique, application render phase, capacity policy, paragraph order domain | +| Paragraph | batch-owned handle, font leases, desired source, spans, content box, style, paint, order, glyph-origin overrides | +| Core batch compiler | fallback runs, layout, resource partitioning, stable slots, canonical CPU instances, dirty ranges, ordered variants | +| Technique | artifact decoding, resource selection/bindings, canonical instance schema and writing, data meaning | +| Raster shader | reusable backend implementation of canonical Bitmap, MTSDF, or Slug evaluation | +| Raster program | shader composition, variant compatibility, resources, pipelines, and final draw compilation | +| Engine target | engine buffers, dirty-range synchronization, transforms, visibility, pass placement, encoding, retirement | + +## Current system versus target + +| Merged v0 behavior | Target v1 behavior | +| ------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------- | +| `Text` combines paragraph, raster, and Three ownership | Paragraph state and batch compilation live in core; Three is one target | +| Property changes can prepare one `Text` immediately | Handle setters only mark desired state dirty | +| Async behavior follows object/runtime lifecycle | Every synchronization chooses `update()` or `updateAsync()` | +| One paragraph stages one raster-owned Three draw object | One paragraph batch produces resource-compatible storage and ordered variant-bearing glyph runs | +| Renderer raster modules decide glyph grouping | Core and technique modules decide grouping and populate canonical CPU instance storage | +| Raster modules combine CPU decode, Three textures, TSL, and draw objects | Portable techniques end at CPU data/bindings; engine targets realize GPU resources and draws | +| React/Three lifecycle defines publication boundaries | Runtime revision and target stage/commit define publication independently of any engine | +| Existing `Paragraph` is a separately callable subsystem | Paragraph implementation remains internal to the one retained handle API | + +The migration must preserve shaping, layout, paint, raster validation, transactional failure, and disposal behavior while +moving Three-specific ownership behind the target boundary. + +## Implementation sequence + +### 1. Extract desired-state paragraphs + +- Add stable `Paragraph` handles owned by a `ParagraphBatch`. +- Move public text, span, content-box, style, paint, order, and glyph-origin mutation onto handle setters. +- Treat nested option records as immutable replacement values. +- Track dirty handles once with channel bitsets; do not scan every paragraph. +- Coalesce add followed by dispose before synchronization to no work. +- Keep every core paragraph handle permanently owned by its creating batch. Recreate desired state elsewhere through an + immutable snapshot plus destination `add()`, never handle transfer. +- Make batch disposal cascade through owned paragraphs and attachments without disposing the runtime or fonts; make + runtime disposal the explicit outer cascade root. + +Proof: + +```ts +paragraph.text = 'A'; +paragraph.text = 'B'; +paragraph.text = 'C'; +runtime.update(); + +expect(shapeInputs).toEqual(['C']); +``` -Exit: a third party can identify the correct extension seam without copying core orchestration or importing an unrelated engine. +### 2. Implement fonts and same-technique font stacks -## Proof matrix +- Keep a single loaded font valid anywhere text accepts a font selection. +- Create immutable non-empty stacks whose first font is primary and later fonts resolve missing glyphs in order. +- Reject duplicate logical membership where ambiguous and any different technique identity. +- Require every paragraph to own a font selection and validate it against its batch's declared technique. +- Lease every selected concrete font for the retained paragraph lifetime and make early font disposal fail rather than + publishing missing-glyph substitutions. +- Reuse the renderer-neutral `txt` and `span` composer for imperative literals and React nested-text flattening. +- Pass all changed paragraphs through Unicode analysis and batched shaping with font-slot identity intact. +- Prove missing glyph fallback across at least Latin, Arabic, and CJK cases. -| Proof | Engine host | GPU-authoring layer | What it establishes | -| ----------------------- | --------------------------- | ------------------------------------------------ | ------------------------------------------------------------------------ | -| Existing product | Three.js | TSL | Baseline behavior, dual WebGPU/WebGL output, compatibility | -| Orthogonal shader proof | Three.js | TypeGPU through `@typegpu/three` | GPU-authoring choice does not define engine ownership | -| Boundary probe | Minimal custom host | TypeGPU or raw WebGPU interop | Core has no hidden scene-graph dependency | -| External-engine proof | Selected game/canvas engine | Engine-native WGSL, TypeGPU, or supported bridge | Another engine can consume the public generation and technique contracts | -| Application binding | Three.js | TSL and TypeGPU where supported | R3F remains a thin wrapper rather than the portable abstraction | +Proof: -Supporting every named engine is not an exit gate. One independent, production-shaped non-Three proof plus a public adapter contract is stronger evidence than several shallow wrappers. +```ts +expect(() => createFontStack(interMtsdf, iconBitmap)).toThrow('mixed-technique-font-stack'); +``` -## Non-negotiable gates +### 3. Implement runtime synchronization -- `@pmndrs/text` portable imports contain no Three.js, TSL, TypeGPU, React, DOM scene, or renderer types. -- Bitmap, MSDF, and Slug artifact bytes, identity, validation, and baker parity do not change accidentally during renderer extraction. -- Runtime bakers, workers, validation graphs, and Wasm remain outside initial consumer bundles. -- The portable state machine has deterministic headless lifecycle coverage. -- The Three.js + TSL adapter preserves WebGPU and WebGL2 behavior, public examples, retained updates, render ordering, and recovery. -- Three.js + TypeGPU and non-Three proofs use the same shaping/layout/generation outputs. -- Adding the second engine does not require edits to portable core for engine-specific behavior. -- Engine adapters restore or release all owned renderer state after success, failure, abort, device loss, and disposal. -- Performance comparisons report identical work, artifact, viewport, DPR, draw count, and timing scope; unexplained regressions block acceptance. -- Package and documentation checks prove import isolation and make each extension seam discoverable. +- `update()` snapshots every current dirty handle and completes synchronously. +- `updateAsync()` snapshots the same state and uses the asynchronous executor. +- Implement Promise and callback overloads without constructing a Promise in callback form. +- Stream completed Worker results into unpublished staging storage with optional progress while retaining atomic publication. +- Return the current revision without allocation for a no-op synchronous update. +- Let a newer synchronization supersede an unpublished older asynchronous generation. Resolve supersession and + cancellation as handled outcomes; reject only actual preparation failures. +- Publish every affected paragraph batch atomically or none of them. +- Keep writes after a snapshot dirty for the next synchronization. -## Explicit non-goals +Proof: -- building a general-purpose game engine; -- replacing Three.js as the first supported engine; -- making TypeGPU itself look like a scene graph; -- writing React/Vue/Svelte wrappers as evidence of renderer portability; -- extending or replacing HarfRust shaping; -- auto-batching across unrelated text instances; -- changing Bitmap, MSDF, or Slug artifact formats without independent evidence; -- promising every GPU-authoring layer on every engine or backend. +```ts +paragraph.text = 'A'; +const old = runtime.updateAsync(); +paragraph.text = 'B'; +const current = runtime.update(); -## Roadmap placement +await expect(old).resolves.toMatchObject({ status: 'superseded' }); +expect(currentParagraph(current).text).toBe('B'); +``` -If accepted, insert this as the new Milestone 11 before editorial-flow work. Shift the current additive milestone numbers only in the acceptance change so references remain deterministic. The engine boundary should be proven before new layout and paging features create more Three-shaped integration work. +### 4. Compile physical glyph batches in core + +- Sort paragraphs by finite application order and stable insertion order. +- Resolve every glyph to one same-technique font resource. +- Allocate stable instance slots by technique/resource/pipeline variant/chunk. +- Treat capacity as glyph-instance slots per physical resource buffer; paragraph handles have no capacity limit. +- Name the non-resizing policy `fixed`. Detect its overflow during synchronization after exact shaping, preserve the prior + revision, and require render-loop adapters to retain and report the typed failure without throwing from rendering. +- Latch an unchanged failed batch generation after reporting it once. Exclude that generation from later runtime updates + until relevant mutation so unrelated batches and explicit replacements can publish without retry churn. +- Let a caller change paragraph-batch capacity explicitly while preserving the batch, every paragraph handle, and every + attachment. Reuse compatible semantic caches, stage canonical and target replacement storage transactionally, and retire + old buffers only after publication and engine fences. +- Default explicit batches to lazy 4,096-glyph chunks and support explicit `size` plus `grow`, `chunk`, or `fixed` policy. +- Pack technique-specific instance attributes once. +- Compute coalesced dirty ranges per storage channel. +- Emit glyph runs that preserve paragraph, variant, and technique compositing order across resource changes. +- Never make targets inspect glyphs to choose a batch. + +Proof: + +```ts +expect(resolveFonts('Inter -> Noto -> Inter')).toProduce({ + glyphBatches: ['Inter', 'Noto'], + glyphRuns: ['Inter[0..8]', 'Noto[0..3]', 'Inter[8..13]'], +}); +``` -## Questions the proof must answer +### 5. Add canonical CPU instance storage + +- Define one canonical structure-of-arrays instance contract per technique. +- Let core and the technique populate and retain those arrays. +- Report exact coalesced adjacent-revision dirty ranges for every changed glyph batch. +- Let matching targets copy or upload the selected ranges 1:1 and different engine layouts map only those ranges. +- Initialize a late or gapped target from the live ranges already named by the current glyph-run plan. +- Allow several targets and late attachment to consume the same prepared storage without reshaping. +- Prove targets never reshape, repartition physical storage, or change non-equivalent glyph-run order while synchronizing. + +### 5a. Split portable techniques from engine raster programs + +- Keep every baker free of runtime engine imports. +- Move artifact validation, external resource resolution, CPU page/table decoding, coverage checks, glyph-resource + selection, and canonical instance packing into a renderer-neutral `RasterTechnique`. +- Expose the technique-authored `binding` on every prepared glyph batch so targets receive the exact page/buffer selection + instead of rediscovering it from glyph IDs. +- Move Three textures, attributes, TSL materials, scene objects, and renderer disposal into Three raster targets. +- Permit an optional `RasterProgram` seam when multiple engines share a shader/resource backend such as TypeGPU and raw + WebGPU interop; do not require an artificial universal shader interface in core. +- Permit an optional external Three/TypeGPU experiment only for capabilities the exact-version bridge proves. At the + reviewed versions it is nullary WGSL injection, WebGPU-only, and not a complete Slug/Bitmap resource bridge. Keep + Three-owned accessors, materials, pipeline state, and lifecycle in that adapter; neither core nor the portable technique + imports TypeGPU or TSL. +- Retain decoded CPU page/table bytes through loaded-font lifetime so several targets and late attachment need no refetch or + decode. + +Proof: + +```ts +expect(mtsdfBaker).not.toImportAnyRenderer(); +expect(mtsdfTechnique).not.toImportAnyRenderer(); +expect(threeMtsdfTarget.technique).toBe(mtsdfTechnique); +expect(typeGpuMtsdfProgram.technique).toBe(mtsdfTechnique); +expect(typeGpuThreeMtsdfProgram.technique).toBe(mtsdfTechnique); +``` -1. Is the portable state machine best exposed as a runtime, instance, model, pipeline, or a lower-level generation store? -2. Does portable artifact decoding produce immutable typed views, a resource factory input, or technique-owned prepared data? -3. Which publication operations are universal transactions, and which remain engine hooks? -4. Can Three.js + TypeGPU reuse the same instance buffers and resource lifetime as Three.js + TSL, or only the shader logic? -5. Which non-Three game engine has the smallest honest adapter surface while still exercising ordering, transforms, retained batches, and device lifecycle? -6. Should first-party techniques be subpath exports of `@pmndrs/text` or separate packages with shared contract-only entries? +### 6. Rebuild the Three.js public surface over hidden core objects + +- Keep Three.js applications on `FontLoader`, `TextGroup`, and transform-bearing `Text`; never expose a core runtime, + paragraph batch, paragraph handle, prepared revision, or target adapter to ordinary Three users. +- Make the Three `FontLoader` lazily initialize and cache the core shaper on its first load while preserving explicit + callback/Promise loading and Three `LoadingManager` behavior. +- Make each `TextGroup` declare one technique and own one explicit scene render phase backed by a core paragraph batch and + renderer target. Require every `Text` to carry a same-technique `Font` or `FontStack`. Make an ungrouped `Text` own an + implicit batch of one derived from that selection. +- Keep `Text` unbound until scene attachment. Reconcile direct and nested scene membership before the first + shaping call so resident text renders in the first observing frame. +- Treat movement between batches as an atomic removal of the old paragraph allocation plus allocation of retained desired + state in the destination; do not add a movable core paragraph contract. +- Let `Text` own desired state and font leases independently of membership. Disposing a populated `TextGroup` unbinds all + member text and retires group resources without disposing children or fonts; each live compatible `Text` can bind fresh + membership in another group. Treat a disposed group that remains in the scene graph as a terminal non-rendering boundary, + never as permission to fall through into an ancestor group or implicit standalone batch. +- Make `TextGroup` an `Object3D`, not a Group, so Three naturally carries the nearest real ancestor Group's `groupOrder` + through it. Map `TextGroup.renderOrder` plus the program draw ordinal onto the physical draw objects' secondary + render orders; do not add a hidden Group or an inheritance API. +- Let Three own sync/async core calls, dirty-range mapping, transform updates, and publication during the render lifecycle. + The application calls only `renderer.render(scene, camera)`. +- Expose explicit capacity changes on `TextGroup` and standalone `Text` while preserving their identities and hidden + paragraph handles. Reject `TextGroup.clone()` and `copy()` because recursive scene copying cannot safely duplicate batch + membership, external refs, subscriptions, and renderer resources. +- Bind a group to one renderer target lifetime. Require separate groups for simultaneous scene placements, intentional + render phases, or different renderers; ordinary Three reparenting may move one group between scenes. +- Export each canonical technique shader and let custom programs compose final TSL output without rewriting Bitmap, + MTSDF, or Slug. Keep the optional `TextEffect` helper at the Three layer; core carries only generic variants. +- Remove Three-owned shaping, source sorting, resource partitioning, and slot allocation. Retain Three-owned variant + compatibility, final draw compilation, materials, and render-list integration. + +### 7. Rebuild React Three Fiber binding + +- Let declarative components create retained `Text` and `TextGroup` objects without exposing core handles. +- Let R3F reconciliation update desired state; Three's matrix/render lifecycle performs the same group synchronization as + the imperative API. +- Preserve nested spans as paragraph data, not independent render objects. +- Preserve Suspense for loading only; warm shaping does not require a readiness Promise. +- Make synchronous versus asynchronous synchronization an integration policy selectable per frame/update. + +### 8. Implement and prove the external TypeGPU engine + +Implement the complete [TypeGPU API](typegpu-api.md), then build the smallest application in +`AlexJWayne/typegpu-shader-canvas` that proves: + +- explicit baked artifact loading; +- one same-technique `FontStack` with ordered missing-glyph resolution; +- more than one paragraph in one paragraph batch; +- caller-owned `TgpuRoot`, device, render pass, queue submission, and frame loop; +- reusable typed Bitmap, MTSDF, and Slug shaders plus program-owned resources, variants, pipelines, and draw compilation; +- core-owned canonical instance storage, adjacent dirty ranges, and current live glyph-run ranges; +- at least two physical raster-resource batches and ordered variant-bearing glyph runs; +- one program that batches several parameterized variants into one draw and one program that deliberately splits them; +- paragraph transforms, visibility, and effect parameters without reshaping; +- one synchronous update and one asynchronous update; +- no Three.js import or Three-derived adapter logic. + +### 8a. Falsify or narrow the TypeGPU-to-Three bridge + +Start from the reviewed baseline `three@0.185.1`, `typegpu@0.11.9`, and `@typegpu/three@0.11.0`: `toTSL()` accepts a nullary +closure, injects resolved WGSL through Three's WebGPU builder, has no forced-WebGL2 route, and has not carried Slug's +sampleable resources. Build a minimal exact-version fixture before adapting text. Only if that fixture proves real Bitmap +and Slug resources, dependent loads, vertex work, structured results, and every promised backend should the experiment +inspect parity and measure transfer/graph/compilation cost. Otherwise narrow the optional external package to the pure +WebGPU math it actually supports; native TSL remains authoritative for Three. + +### 9. Prove Wayfare + +First inspect and pin `iwoplaza/wayfare` source to establish its public device, pass, resource, transform, and lifecycle +hooks; no current document treats compatibility as already proven. Then build the smallest application that proves the +same contract. The Wayfare adapter may own scene integration but must not reshape, repartition physical storage, resort +source text, or recompute canonical packing. Reuse a TypeGPU program only if the pinned source and execution proof establish +compatible WebGPU device/pass interop; otherwise implement a Wayfare-native program against the same semantic raster ABI. + +### 10. Prove an external gpucat package + +Implement the [gpucat fitness plan](gpucat-integration.md) in an isolated package that installs packed public core and +technique artifacts. Prove typed buffer/texture realization, dirty-range uploads, transforms, ordered instanced draws, +resource disposal, and Bitmap/MTSDF/Slug output without changing core or importing private source. Treat canonical Slug +shader reuse as its own gate: a failed shader-sharing experiment may require a gpucat-native program, but not a core API. + +### 11. Verify all techniques + +Run Bitmap, MTSDF, and Slug independently through: + +```ts +await proveHeadlessCore(); +await proveThree({ backends: ['webgpu', 'webgl2'] }); +await proveRawTypeGpu(); +await proveWayfare(); +await proveGpucatExternalPackage(); +``` -[^typegpu-scope]: TypeGPU documentation, “Why TypeGPU?” +No test combines techniques inside one font stack or paragraph batch. A technique-specific proof may use several fonts and +must verify exact core glyph-run order and the minimum draw count permitted by its program compatibility. + +## Performance evidence + +Measure separately: + +```ts +interface CoreBatchMetrics { + dirtyParagraphs: number; + shapedParagraphs: number; + shapedGlyphs: number; + layoutMilliseconds: number; + partitionMilliseconds: number; + packedGlyphs: number; + packedBytes: number; + dirtyRangeCount: number; + glyphBatchCount: number; + glyphRunCount: number; + compiledDrawCount: number; + capacityGrowths: number; + overflowChunks: number; +} +``` -[^typegpu-three]: TypeGPU documentation, “@typegpu/three.” +The proof must distinguish semantic CPU state, canonical packed CPU storage, target-owned CPU/staging storage, decoded technique resources, GPU +resources, glyph-run count, and compiled draw count. It must show that warm handle mutations do not allocate per setter and that +callback-form asynchronous updates do not allocate a public Promise. + +## Exit gates + +- The README and exported declarations match the [core API](core-api.md). +- One public paragraph-handle API covers multiline text, labels, and font-backed icons. +- A same-technique `FontStack` shapes one paragraph across multiple concrete fonts exactly. +- Mixed-technique font stacks and paragraph additions fail before shaping. +- Repeated handle writes coalesce before synchronization. +- Sync and async calls alternate on one runtime without copying runtime state or font registrations. +- No-op `update()` is allocation-free. +- Newer synchronization prevents stale async publication. +- Core owns source sorting, resource partitioning, slot allocation, packing, dirty ranges, resolved variants, and ordered + glyph runs. Programs own variant compatibility and final draw compilation. +- Core retains canonical packed CPU storage. Targets synchronize adjacent dirty ranges, or current live glyph-run ranges + when first attached or recovering across a revision gap, into their own buffers. +- Separate paragraph batches remain separate render phases. +- Explicit capacity changes preserve batch, paragraph, attachment, and Three object identities; core and Three batch + cloning remain unsupported. +- Three.js, raw TypeGPU, Wayfare, and the external gpucat package execute the same core output for Bitmap, MTSDF, and Slug. +- Core, portable techniques, and bakers import no Three.js, TypeGPU, Wayfare, or gpucat code; integration packages pass a + packed-public-package test without deep imports. +- Full repository checks, package-size gates, and documentation validation pass. diff --git a/docs/planning/engine-integration-contract.md b/docs/planning/engine-integration-contract.md new file mode 100644 index 00000000..52f197bc --- /dev/null +++ b/docs/planning/engine-integration-contract.md @@ -0,0 +1,600 @@ +--- +type: API Reference +title: Engine integration contract +description: Exact storage, batching, ordering, transform, publication, and lifetime boundary between core text preparation and an engine renderer. +documentation_type: reference +tags: [api, engine, rendering, batching, storage, revisions] +status: stable +sources: + - id: core-api + resource: core-api.md + title: Canonical core text API + - id: raster-technique + resource: raster-technique-api.md + title: Raster technique and engine resource API + - id: current-layout + resource: ../../packages/text/src/layout.ts + title: Current paragraph layout contract + - id: current-paint + resource: ../../packages/text/src/paint.ts + title: Current glyph paint contract + - id: current-raster + resource: ../../packages/text/src/raster.ts + title: Current raster transaction contract + - id: current-text + resource: ../../packages/text/src/text.ts + title: Current Three.js text lifecycle + - id: extraction-plan + resource: engine-integration-boundary.md + title: Renderer-neutral extraction plan + - id: three-api + resource: three-api.md + title: Three.js text API + - id: typegpu-api + resource: typegpu-api.md + title: TypeGPU raster programs and text engine +generated: + by: openai-codex/gpt-5.6 + at: '2026-08-07T03:25:58Z' +--- + +# Engine integration contract + +An engine integration receives already partitioned glyph storage and ordered, variant-bearing glyph runs. + +This is the low-level contract implemented privately by the [Three.js API](three-api.md). Three users do not receive or +stage these values directly; `FontLoader`, `TextGroup`, and `Text` own the corresponding core objects and target lifecycle. + +```ts +type EngineInput = PreparedParagraphBatchRevision< + Technique, + Variant +>; +``` + +It must not shape text, resolve fallback, lay out lines, repartition glyphs by resource, sort paragraphs, or allocate core +slots. It must compile the ordered runs into engine draws, preserving order unless its documented compositing policy proves +another ordering equivalent. + +## Attach to one paragraph batch + +```ts +interface ParagraphBatch { + readonly current: PreparedParagraphBatchRevision; + + subscribe(observer: ParagraphBatchObserver): () => void; + + attach( + target: ParagraphBatchTarget, + ): ParagraphBatchAttachment; +} + +interface ParagraphBatchObserver { + next(revision: PreparedParagraphBatchRevision): void; + complete(): void; +} +``` + +One attachment represents one engine's resources for one intentional paragraph render phase. A paragraph batch may be +attached to more than one target. They share core's canonical CPU arrays while each target owns its engine-specific buffers +and GPU lifetime. + +```ts +interface ParagraphBatchAttachment< + Technique extends AnyRasterTechnique, + Variant, + TargetRevision extends ParagraphBatchTargetRevision, +> { + readonly source: PreparedParagraphBatchRevision; + readonly current: TargetRevision | undefined; + readonly candidate: ParagraphBatchTargetStage | undefined; + readonly error: ParagraphBatchTargetError | undefined; + + prepare(): void; + commit(): TargetRevision | undefined; + retry(): void; + dispose(): void; +} + +interface ParagraphBatchTargetError { + readonly kind: 'target-failed'; + readonly sourceRevision: number; + readonly cause: unknown; +} +``` + +Targets may attach before or after the first update. Attachment and later source publication update `source` synchronously, +but do not call `target.stage()`. The observing engine calls `prepare()` at its own render boundary; a late attachment then +prepares current storage and glyph runs without reshaping. Repeated `prepare()` calls are no-ops while the current source +revision is already staged, pending, or committed. + +`attach()` is the standard lifecycle coordinator, not a privileged preparation operation. Its behavior can be implemented +entirely with `current`, `subscribe()`, and the target interfaces in this document: subscription synchronously records the +current revision, records every later publication, and completes when the batch is disposed. `prepare()` is the explicit +policy boundary that calls `target.stage(current, source)`. The method belongs on the +batch because that retained relationship terminates with the batch and technique compatibility can be rejected at the +call. An integration needing different publication policy may subscribe directly and build its own coordinator; it does +not receive access to private shaping or allocation state by doing so. + +A synchronous throw from `target.stage()` during `prepare()` or rejection of its pending `ready` Promise becomes +`attachment.error`. It does +not roll back the published core revision, replace `attachment.current`, or escape through `attachment.commit()`. A later +success clears the error. Engine adapters decide how to surface that retained target failure to their own users. +`retry()` clears no live state and marks the current source revision eligible for one new attempt; the target is called by +the owner's next `prepare()`, never by `retry()` itself. It is a no-op while an attempt for that revision is already pending. + +Ownership is one-way. Disposing an attachment releases only that target's staged/live engine resources; it does not dispose +the source paragraph batch, paragraphs, or fonts. Disposing the source `ParagraphBatch` invalidates every owned paragraph +handle and notifies every attachment to cancel staging and begin target retirement. GPU objects remain alive only as long +as required by the engine's in-flight-frame fences, but no disposed source can publish another target revision. + +An integration must not transfer a core `Paragraph` handle between batches. An engine-level retained object such as +Three.js `Text` owns its desired-state snapshot independently, creates a new destination paragraph, and lets disposal remove +the source handle. This adapter ownership is what permits scene-object reuse without weakening core batch ownership. + +For explicit capacity replacement, `ParagraphBatch.setCapacity(capacity)` changes the requested allocation without changing +the paragraph batch, any paragraph handle, or any attachment. Core may reuse semantic shaping and layout caches, stages new +canonical capacity-bound storage, and publishes it atomically while the source revision remains live. Existing attachments +record the replacement source. A failed resize preserves the live revision and every identity. Each engine stages the +replacement when that attachment's owner next calls `prepare()`. + +## Consume canonical CPU storage + +```ts +interface MtsdfGlyphBatchStorage { + readonly origins: Float32Array; + readonly fontSizes: Float32Array; + readonly glyphRecords: Uint32Array; + readonly paintIndices: Uint32Array; +} +``` + +Each technique defines one canonical structure-of-arrays storage contract. Core owns these arrays and updates them before +publishing the prepared revision. + +An integration whose buffers have the same layout can synchronize an adjacent revision's exact ranges directly: + +```ts +for (const range of batch.dirtyRanges) { + gpuOrigins.upload(batch.storage.origins, range); + gpuFontSizes.upload(batch.storage.fontSizes, range); + gpuGlyphRecords.upload(batch.storage.glyphRecords, range); + gpuPaintIndices.upload(batch.storage.paintIndices, range); +} +``` + +An integration with interleaved or otherwise different engine storage maps the same selected ranges: + +```ts +for (const range of batch.dirtyRanges) { + mapMtsdfRangeToInterleavedEngineBuffer(batch.storage, engineBuffer, range); +} +``` + +This mapping is an engine-layout synchronization step, not text batching. The integration does not inspect paragraph glyphs +to choose resources, slots, source order, or variant boundaries; it derives final draws from the provided runs. + +## Retain canonical CPU state + +Core retains enough state to reshape, reflow, rebuild a target, and address stable instance slots: + +```ts +interface CoreRetainedParagraphState { + readonly input: ParagraphProperties; + readonly layout: ParagraphLayout; + readonly allocationsByBatch: ReadonlyMap; + readonly shapedX: Float32Array; + readonly shapedY: Float32Array; + readonly overrideX?: Float32Array; + readonly overrideY?: Float32Array; +} +``` + +Core's canonical arrays are the CPU shadow of renderable instance state. Targets own any engine-specific CPU staging or GPU +copies. This separation lets core publish independently of inaccessible or in-flight GPU memory and lets several targets +consume the same prepared revision. + +## Read one prepared paragraph batch + +```ts +interface PreparedParagraphBatchRevision { + /** Stable author-declared render-phase identity. */ + readonly paragraphBatch: ParagraphBatch; + + /** Contiguous and monotonic within this paragraph batch. */ + readonly revision: number; + + /** Every font in this revision uses this technique. */ + readonly technique: Technique; + + /** Sorted authoring and layout views for inspection, measurement, and transforms. */ + readonly paragraphs: readonly PreparedParagraph[]; + + /** Resource-compatible instance storage populated by core. */ + readonly glyphBatches: readonly PreparedGlyphBatch[]; + + /** Ordered core-authored ranges the target compiles into engine draws. */ + readonly glyphRuns: readonly PreparedGlyphRun[]; +} +``` + +```ts +interface PreparedParagraph { + readonly paragraph: Paragraph; + readonly insertionOrder: number; + readonly order: number; + readonly topology: GlyphTopology; + readonly rasterPixelRatio: number; + readonly layout: ParagraphLayout; + readonly paint: PreparedGlyphPaint; + readonly fontSlots: readonly PreparedFontSlot[]; + readonly origins: PreparedGlyphOrigins; +} + +interface PreparedGlyphPaint { + readonly paintIndices: Uint16Array; + readonly palette: readonly PreparedLinearPaint[]; +} + +interface PreparedLinearPaint { + readonly color: readonly [number, number, number, number]; + readonly outline?: { readonly color: readonly [number, number, number, number]; readonly width: number }; + readonly shadow?: { + readonly color: readonly [number, number, number, number]; + readonly offset: readonly [number, number]; + }; +} + +interface PreparedGlyphOrigins { + readonly shapedX: Float32Array; + readonly shapedY: Float32Array; + readonly displayedX: Float32Array; + readonly displayedY: Float32Array; +} + +interface PreparedFontSlot { + readonly slot: number; + readonly font: LoadedFont; +} +``` + +Paragraph-local coordinates originate at the content-box top-left. Positive X points right, positive Y points down, +clusters index UTF-16 code units, glyph IDs are local to their font slot, and paint values are linear RGBA. + +## Read physical glyph batches + +```ts +interface PreparedGlyphBatch { + readonly key: GlyphBatchKey; + readonly technique: Technique; + readonly font: LoadedFont; + readonly capacity: number; + readonly instanceCount: number; + readonly binding: RasterBindingOf; + readonly storage: GlyphBatchStorageOf; + readonly dirtyRanges: readonly GlyphRange[]; +} +``` + +```ts +interface GlyphBatchKey { + readonly technique: RasterTechniqueId; + readonly resource: RasterResourceId; + readonly pipelineVariant: number; + readonly generation: number; + readonly chunk: number; +} + +interface GlyphRange { + readonly start: number; + readonly count: number; +} +``` + +`dirtyRanges` transforms the immediately preceding paragraph-batch revision into this revision. It is not a timeless +description of every live slot. If `previous?.sourceRevision === next.revision - 1`, a target uploads those deltas. If +`previous` is absent or names an older revision, the target initializes every live range referenced by `next.glyphRuns`. +It may coalesce overlapping or adjacent upload ranges for the same physical batch without changing glyph-run order. This +makes late attachment, a superseded pending stage, and recovery after a skipped revision correct without retaining a +private core change journal. + +A paragraph-batch revision number increments only when that batch publishes changed prepared content. Runtime no-ops, +failed preparation, superseded candidates, and updates that publish only other batches do not advance it. Therefore numeric +adjacency means the target has the exact canonical predecessor required by `dirtyRanges`. + +Published canonical arrays remain readable through the next paragraph-batch publication. `stage()` therefore consumes or +copies every selected CPU range synchronously before returning. A pending target update may await allocation, compilation, +queue completion, or another engine operation, but it must not retain a canonical typed-array view and read it later after +`stage()` returns. This keeps core's one canonical CPU shadow reusable without forcing immutable full-buffer snapshots. + +`binding` is the technique-authored renderer-neutral selection of pages, tables, buffers, or other values from +`font.data`. The target uses it to realize GPU resources; it never re-derives resource selection from glyph IDs. + +Core interns and freezes each `GlyphBatchKey` for the lifetime of its physical batch. The identical key object appears in +the prepared batch, every referencing run, and adjacent revisions until that physical batch retires, so targets may use it +as a `Map` key. Its branded technique/resource IDs and numeric pipeline/generation/chunk fields form the deterministic diagnostic +tuple; integrations never construct keys themselves. + +`generation` is monotonic for replacement storage at the same technique/resource/pipeline/chunk position. A new overflow +chunk begins at generation zero; grow-mode replacement increments only the replaced chunk; an explicit capacity change +rebuilds all chunks and increments their generations. A retired key object and tuple never become live again. + +One key represents compatible GPU storage, not necessarily one submit. Public capacity has only two settings: glyph-slot +`size` per physical resource buffer and `policy`. Explicit paragraph batches default to lazily allocated +`{ size: 4_096, policy: 'chunk' }`; standalone Three.js text defaults to `{ size: 256, policy: 'grow' }`. Paragraph handles +and metadata have no capacity limit. + +Chunk overflow creates another fixed-size `chunk` instead of reallocating existing published storage. Grow mode +transactionally replaces a full buffer and doubles its capacity until the pending glyphs fit. Fixed mode treats `size` as +a hard per-buffer limit, preserves the prior revision, and reports typed `capacity-exceeded` failure after shaping reveals +the exact physical resource demand but before any target publication. + +Different techniques can never appear in one `PreparedParagraphBatchRevision`. Different raster resources normally produce +different `GlyphBatchKey` values even when they use the same technique. One physical batch names exactly one `LoadedFont`; +its resource ID is runtime-unique for that font/resource selection. Targets may still share immutable GPU atlas/table +objects between physical batches when their technique binding proves the resource compatible, but core never hides several +font owners behind the singular `PreparedGlyphBatch.font` field. + +## Compile ordered glyph runs into draws + +```ts +interface PreparedGlyphRun { + readonly batch: GlyphBatchKey; + readonly paragraph: ParagraphId; + readonly renderVariant: Variant | undefined; + readonly start: number; + readonly count: number; +} +``` + +Array position is the only run-order value. Core sorts paragraphs by ascending finite `paragraph.order`, then stable +insertion order, and emits each paragraph's visual glyph sequence from shaping and layout. It resolves batch, paragraph, +and span variant inheritance, then segments the sequence +whenever its physical batch, paragraph, or effective variant changes. + +```ts +// Resolved font sequence: Inter -> Noto -> Inter +revision.glyphRuns = [ + { batch: interBatch.key, paragraph: label.id, renderVariant: plain, start: 0, count: 8 }, + { batch: notoBatch.key, paragraph: label.id, renderVariant: warning, start: 0, count: 3 }, + { batch: interBatch.key, paragraph: label.id, renderVariant: plain, start: 8, count: 5 }, +]; +``` + +Every live glyph slot is named by exactly one run. Technique-internal multi-pass work expands inside the program's draw +compiler rather than duplicating a core run; all passes expanded from one run remain adjacent unless the program proves an +equivalent blend/depth ordering. The run list is not a one-run/one-draw prescription. The target may split a run or coalesce adjacent runs when its program +declares them compatible. A program that places variant parameters in indexed sidecar storage may render many variants in +one draw; a program whose variant changes material or pipeline state must split them. The target must preserve the provided +sequence unless its documented depth/compositing policy proves another order equivalent. + +The application creates separate paragraph batches when another engine draw must occur between text phases: + +```ts +drawWorld(); +drawParagraphBatch(worldTextAttachment); +drawParticles(); +drawParagraphBatch(overlayTextAttachment); +drawUi(); +``` + +Core never composes separate paragraph batches with non-text scene objects. + +## Upload without repartitioning glyph storage + +```ts +function stageRevision( + previous: MyTargetRevision | undefined, + revision: PreparedParagraphBatchRevision, +) { + for (const batch of revision.glyphBatches) { + const gpu = ensureGpuBatch(batch.key, batch.font, batch.binding, batch.capacity, batch.storage); + + const ranges = rangesForTarget(batch, revision.glyphRuns, previous?.sourceRevision, revision.revision); + for (const range of ranges) { + gpu.upload(range); + } + + gpu.setCount(batch.instanceCount); + } + + return program.compileRuns(revision.glyphRuns, revision.glyphBatches); +} +``` + +`rangesForTarget` above names renderer policy, not another core operation: it selects `batch.dirtyRanges` for an adjacent +revision and otherwise selects that batch's ranges from `revision.glyphRuns`. + +An integration that loops through every paragraph glyph to choose a resource or sort key is violating this contract. + +## Compose paragraph transforms + +Core positions glyphs in paragraph-local space. The engine owns the transform for each paragraph handle. + +```ts +for (const prepared of revision.paragraphs) { + const matrix = engine.transformFor(prepared.paragraph); + target.updateParagraphTransform(prepared.paragraph, matrix); +} +``` + +An integration that needs a per-glyph transform index derives it without inspecting or repartitioning glyphs: scan +`revision.glyphRuns` in order and write the target-owned index for `run.paragraph` across `[run.start, run.start + +run.count)` in the storage selected by `run.batch`. Because every live slot appears in exactly one run, this is the complete +instance-to-paragraph mapping; it is target storage, not another core column. + +The target may repeat a matrix per glyph, use one transform index per instance, store a transform table, or create separate +draws. Core does not shape a 3D transform. A transform change does not require reshaping unless an integration deliberately +feeds a transformed content constraint back into the paragraph API. + +## Stage and commit safely + +```ts +interface ParagraphBatchTarget< + Technique extends AnyRasterTechnique, + Variant, + TargetRevision extends ParagraphBatchTargetRevision, +> { + readonly technique: Technique; + + stage( + previous: TargetRevision | undefined, + next: PreparedParagraphBatchRevision, + options?: { readonly signal?: AbortSignal }, + ): ParagraphBatchTargetUpdate; + + dispose(): void; +} + +interface ParagraphBatchTargetRevision { + readonly sourceRevision: number; + dispose(): void; +} + +interface MyTargetRevision extends ParagraphBatchTargetRevision { + readonly draws: readonly EngineDraw[]; +} + +type ParagraphBatchTargetUpdate = + | { + readonly status: 'ready'; + readonly stage: ParagraphBatchTargetStage; + } + | { + readonly status: 'pending'; + readonly ready: Promise>; + cancel(reason?: unknown): void; + }; + +interface ParagraphBatchTargetStage { + readonly sourceRevision: number; + + /** Synchronous and infallible after all fallible work has staged. */ + commit(): TargetRevision; + + /** Idempotently release an unpublished candidate. */ + abort(): void; +} +``` + +`stage()` may allocate, upload, and fail. It must not mutate the live target revision or storage used by an in-flight frame. +`commit()` only swaps staged ownership at the engine's safe frame boundary. + +`previous` is always the attachment's committed target revision, never an unpublished candidate. When a newer source +revision arrives, the attachment only records it. On the owner's next `prepare()`, the coordinator cancels or aborts any +older candidate before staging the latest source. A stale pending result that resolves first is aborted and never becomes +committable. The full-range rule +above therefore covers every skipped candidate without replaying obsolete target work. + +On commit, the attachment asks the prior target revision to retire only after the replacement is live; its `dispose()` +implementation may defer physical release until engine fences permit it. Disposing the attachment aborts its unpublished +candidate, retires its current target revision, and calls `target.dispose()` exactly once. Batch completion performs that +same idempotent attachment disposal path. + +```ts +function beforeRender() { + attachment.prepare(); + const next = attachment.commit(); + if (next !== undefined) live = next; + + for (const drawCall of live?.draws ?? []) { + draw(drawCall); + } +} +``` + +The target retires the previous revision only after the engine proves that no queued GPU work references it. + +## Observe runtime synchronization + +```ts +runtime.subscribe((runtimeRevision) => { + for (const paragraphBatchRevision of runtimeRevision.paragraphBatches) { + queueAttachedTargets(paragraphBatchRevision); + } +}); +``` + +One runtime update may change several paragraph batches. Subscribers observe all affected revisions together after shaping, +layout, partitioning, and instance writes succeed. They never observe half of a runtime synchronization. + +A newer synchronous or asynchronous synchronization supersedes an unpublished asynchronous candidate. Targets must abort +stages derived from a superseded source revision. + +## Ownership boundary + +```ts +interface CoreOwns { + readonly fontAndGlyphIdentity: true; + readonly fallbackResolution: true; + readonly unicodeShaping: true; + readonly paragraphLayout: true; + readonly paragraphOrdering: true; + readonly resourcePartitioning: true; + readonly rasterResourceBindings: true; + readonly instanceSlotAllocation: true; + readonly capacityChunking: true; + readonly techniqueInstancePacking: true; + readonly dirtyRanges: true; + readonly orderedGlyphRuns: true; + readonly resolvedRenderVariants: true; +} + +interface EngineOwns { + readonly paragraphTransforms: true; + readonly visibilityAndCulling: true; + readonly sceneComposition: true; + readonly variantCompatibility: true; + readonly finalDrawPlanning: true; + readonly gpuResources: true; + readonly renderPassPlacement: true; + readonly commandEncoding: true; + readonly framePublication: true; + readonly fencesAndRetirement: true; +} +``` + +The portable technique decodes font raster data, selects each glyph's physical binding, and populates canonical instance +storage. The engine target realizes those bindings as textures, buffers, bind groups, pipelines, or materials. See the +[raster technique and engine resource API](raster-technique-api.md). + +## Failure contract + +```ts +type CoreMutationRejection = 'invalid-paragraph-input' | 'font-outside-group' | 'mixed-technique-font-stack'; + +type CorePreparationFailure = 'capacity-exceeded' | 'preparation-failed'; + +type CoreHandledOutcome = 'published' | 'aborted' | 'superseded'; + +type TargetFailure = + | 'unsupported-technique' + | 'unsupported-instance-schema' + | 'gpu-resource-failed' + | 'engine-limit-exceeded' + | 'allocation-failed'; +``` + +Mutation rejection leaves desired state unchanged. Core preparation failure leaves the prior runtime and paragraph-batch +revisions current. Target failure leaves the prior target revision live. Abortion and supersession are handled outcomes, +not failures. A core preparation failure latches the exact failed desired generation on its paragraph batch and excludes it +from later updates until relevant mutation or an explicit capacity resize, allowing other batches to publish without retry +churn. A target failure is retained only by that attachment; other targets remain independent, and `retry()` stages the +current source revision once without requiring another core publication. No boundary exposes a partially prepared +generation. + +## Integration checklist + +```ts +const IntegrationMust = { + acceptOneTechniquePerParagraphBatch: true, + consumeTechniqueAuthoredBindings: true, + synchronizeAdjacentDirtyOrCurrentLiveRanges: true, + executeCoreSubmissionOrder: true, + resolveTransformsFromParagraphHandles: true, + stageBeforeMutatingLiveResources: true, + commitAtASafeFrameBoundary: true, + retireAfterGpuCompletion: true, + avoidGlyphRegroupingAndResorting: true, +} as const; +``` diff --git a/docs/planning/gpu-compression.md b/docs/planning/gpu-compression.md index 44d48fcf..24834d75 100644 --- a/docs/planning/gpu-compression.md +++ b/docs/planning/gpu-compression.md @@ -52,7 +52,7 @@ sources: generated: by: 'openai-codex/gpt-5.6' - at: '2026-07-27T19:47:03Z' + at: '2026-08-07T01:16:02Z' --- # GPU compression and compact Slug storage @@ -281,4 +281,4 @@ Acceptance rules: 5. Headline size claims include dynamic transcoder bytes and report transport and GPU savings separately. 6. No compression path is loaded when the selected raster/assets do not require it. -Plain RGB MSDF is not a V1 storage option. A smaller RGB-native compressed variant is an experiment only: it must include the loss of true-distance effects, additional format/shader/batch complexity, platform coverage, transport bytes, and GPU bytes in the comparison. It cannot replace the MTSDF baseline from an isolated texture-size result. +Plain RGB MSDF is not a merged v0 or target v1 storage option. A smaller RGB-native compressed variant is an experiment only: it must include the loss of true-distance effects, additional format/shader/batch complexity, platform coverage, transport bytes, and GPU bytes in the comparison. It cannot replace the MTSDF baseline from an isolated texture-size result. diff --git a/docs/planning/gpucat-integration.md b/docs/planning/gpucat-integration.md new file mode 100644 index 00000000..afc1dade --- /dev/null +++ b/docs/planning/gpucat-integration.md @@ -0,0 +1,255 @@ +--- +type: Integration Fitness Plan +title: External gpucat integration fitness plan +description: Validates the target v1 public core against gpucat and defines the remaining external-package, lifecycle, ordering, resource, and shader-reuse proof. +documentation_type: explanation +tags: [planning, api, gpucat, webgpu, external-package, shaders, batching] +status: draft +sources: + - id: gpucat + resource: https://github.com/isaac-mason/gpucat/tree/11cf91b5172cc5143f68ff6ebf01c5e815de4e94 + title: gpucat at the reviewed revision + - id: gpucat-object3d + resource: https://github.com/isaac-mason/gpucat/blob/11cf91b5172cc5143f68ff6ebf01c5e815de4e94/src/core/object3d.ts + title: gpucat Object3D lifecycle + - id: gpucat-buffer + resource: https://github.com/isaac-mason/gpucat/blob/11cf91b5172cc5143f68ff6ebf01c5e815de4e94/src/core/gpu-buffer.ts + title: gpucat typed GPU buffer and update ranges + - id: gpucat-mesh + resource: https://github.com/isaac-mason/gpucat/blob/11cf91b5172cc5143f68ff6ebf01c5e815de4e94/src/objects/mesh.ts + title: gpucat mesh and multi-draw contract + - id: gpucat-render-list + resource: https://github.com/isaac-mason/gpucat/blob/11cf91b5172cc5143f68ff6ebf01c5e815de4e94/src/renderer/core/render-list.ts + title: gpucat render-list ordering + - id: core-api + resource: core-api.md + title: Target v1 core API + - id: engine-contract + resource: engine-integration-contract.md + title: Target v1 engine integration contract + - id: raster-technique + resource: raster-technique-api.md + title: Target v1 raster technique boundary +generated: + by: openai-codex/gpt-5.6 + at: '2026-08-07T03:25:58Z' +--- + +# External gpucat integration fitness plan + +## Verdict + +The reviewed gpucat public surface can consume the target v1 core without a core API change. That conclusion is based on +source inspection and a successful gpucat build at commit `11cf91b`; it is not yet a rendered-text proof.[^gpucat] + +The remaining uncertainty is narrower: gpucat can author its own Bitmap, MTSDF, and Slug nodes, but the current review does +not prove that the canonical Slug GPU algorithm can be shared with TypeGPU and Three without a gpucat-specific translation. +That belongs to the raster shader package and adapter proof, not core shaping, layout, batching, or variants. + +| Boundary | Result | Evidence or remaining gate | +| --------------------------------------- | ------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Public core revisions and attachments | Fits | `ParagraphBatchTarget.stage()` receives complete batches, runs, storage, bindings, and dirty ranges. | +| Instance buffers and partial uploads | Fits | `GpuBuffer` owns a typed CPU array and `addUpdateRange(start, count)` queues component ranges for renderer upload.[^gpucat-buffer] | +| Bitmap/MTSDF texture realization | Fits | gpucat publicly exports array/data texture resources and partial texture updates. | +| Slug curve/header/reference storage | Fits | gpucat storage buffers and typed node access can represent the technique bindings. | +| Many physical draws per paragraph batch | Fits | A `Mesh` can carry ordered instanced `draws`; incompatible material/pipeline runs can use consecutive meshes.[^gpucat-mesh] | +| Paragraph transforms and visibility | Fits | Adapter-owned sidecar buffers can index paragraph transforms without reshaping. | +| Scene synchronization | Fits with adapter policy | gpucat applications explicitly update world matrices before `render()`; the text group can synchronize before render-list collection.[^gpucat-object3d] | +| Stable cross-mesh ordering | Conditional | gpucat currently hard-codes `groupOrder` to zero. Consecutive `Mesh.renderOrder` values preserve internal text order but cannot reserve an interval against unrelated objects.[^gpucat-render-list] | +| Canonical Slug shader reuse | Not proven | Prove a shared typed WGSL ABI or generated WGSL artifact through gpucat before accepting the shader-package design; gpucat WebGL also requires a GLSL companion for raw `wgslFn()` code. | +| Visible Bitmap/MTSDF/Slug parity | Not proven | Implement the external proof application and compare output, ordering, updates, and disposal. | + +## Keep the integration outside core + +The package boundary is: + +```ts +// renderer-neutral API and data contracts +import { + createTextRuntime, + type ParagraphBatchTarget, + type PreparedGlyphBatch, + type PreparedGlyphRun, +} from '@pmndrs/text'; + +// gpucat-native retained objects and GPU realization +import { GpucatText, GpucatTextGroup } from '@pmndrs/text-gpucat'; +``` + +`@pmndrs/text-gpucat` may live in another repository. It must compile using only documented package exports and a gpucat +peer dependency. It must not import `@pmndrs/text/src/*`, workspace-relative source, or an engine-specific core subpath. + +The same rule applies to the target v1 integrations: + +```txt +@pmndrs/text core, loading, shaping, layout, paragraph batches, target protocol +@pmndrs/text-three Three.js integration +@pmndrs/text-r3f React Three Fiber integration over @pmndrs/text-three +@pmndrs/text-typegpu TypeGPU programs and direct engine +@pmndrs/text-gpucat gpucat objects, resources, programs, and target +``` + +These package names make dependency direction mechanically visible. Repository location is an ownership choice; package +independence is the contract. A monorepo workspace integration still needs a packed-package test that installs public +tarballs into an isolated fixture so workspace path aliases cannot hide a private import. + +## Map a core batch onto gpucat + +One gpucat text group owns one core paragraph batch, one attachment, and one or more hidden meshes: + +```ts +class GpucatParagraphBatchTarget implements ParagraphBatchTarget< + typeof technique, + GpucatVariant, + GpucatTargetRevision +> { + readonly technique = technique; + + stage(previous, next) { + const buffers = stageBuffers(previous, next.glyphBatches); + const resources = stageFontResources(next.glyphBatches); + const draws = program.compileRuns(next.glyphRuns, next.glyphBatches); + const meshes = stageOrderedMeshes(draws, buffers, resources); + + return readyStage(next.revision, { buffers, resources, meshes }); + } +} +``` + +The helper names above are adapter implementation work, not proposed core methods. Their inputs already exist on the public +target contract: + +```ts +function stageBuffers(previous, nextBatches) { + for (const batch of nextBatches) { + const buffer = getOrCreateGpuBuffer(batch.key, batch.storage); + copyCanonicalRanges(buffer.array, batch.storage, rangesFor(previous, batch)); + + for (const range of rangesFor(previous, batch)) { + buffer.addUpdateRange(toComponentOffset(range), toComponentCount(range)); + } + } +} +``` + +The target copies core's canonical CPU storage into gpucat-owned typed arrays during `stage()`. Core remains the batching +authority; gpucat does not re-sort source text, resolve fallback again, or repartition glyphs by font resource. The adapter +may change the physical buffer layout and compile one ordered core run into one or several compatible engine draws. + +## Synchronize before render-list collection + +Gpucat does not own an application RAF. Its examples update the scene tree and then render: + +```ts +score.text = 'Score 2'; + +scene.updateWorldMatrix(); +renderer.render(scene, camera); +``` + +`GpucatTextGroup.updateWorldMatrix()` can perform the retained coordination before delegating ordinary traversal: + +```ts +override updateWorldMatrix(): void { + this.reconcileMembership(); + this.runtime.update(); // allocation-free no-op when no paragraph is dirty + this.commitReadyTargetRevision(); + + super.updateWorldMatrix(); + this.writeChangedParagraphTransforms(); +} +``` + +This is gpucat adapter behavior, not a new core lifecycle. The first matrix update after adding text creates hidden meshes +before `renderer.render()` collects its render list, so the text does not intentionally lag a frame. Applications that do +not use scene matrix traversal can call an explicit integration-level `textEngine.update()` before rendering; both paths +invoke the same dirty/revision guard. + +A node-level `onBeforeRender` callback is too late for membership publication because gpucat has already collected the +render list before it evaluates shader nodes. The integration must not depend on that callback to add first-frame meshes. + +## Preserve transforms and draw order + +Core glyph origins remain paragraph-local. The integration writes one paragraph transform index into each glyph instance +and keeps matrices in an adapter-owned sidecar buffer: + +```ts +paragraph matrixWorld + -> transform sidecar slot + -> glyph instance transformIndex + -> gpucat vertex program +``` + +A matrix, visibility, or effect-parameter change updates only its sidecar range. It does not call the shaper. A text or +content-box change dirties the core paragraph and is synchronized through `TextRuntime.update*()`. + +Gpucat sorts render items by `groupOrder`, `renderOrder`, depth, and stable identity, but its current traversal supplies +`groupOrder = 0` for every mesh. One integration paragraph batch can assign consecutive `renderOrder` values: + +```ts +for (const [index, mesh] of orderedMeshes.entries()) { + mesh.renderOrder = textGroup.renderOrder + index; +} +``` + +This preserves order among the hidden meshes, but it does not reserve the numeric interval: an unrelated object may choose +the same or an intermediate value and interleave. The integration must document that limitation, expose distinct render +phases, collapse the batch into one aggregate render item that performs its internal ordered draws, or prove an +engine-level ordering allocator before claiming atomic group ordering. The default may not describe consecutive values as +an atomic `TextGroup`. Compatible adjacent runs may +become several entries in one `Mesh.draws`. A different material, pipeline, transparency +class, or pass becomes another hidden `Mesh`. The program must preserve `PreparedGlyphRun` order across both forms. If an +application needs unrelated engine draws between text draws, it creates separate text groups/render phases; core does not +guess that scene-composition boundary. + +## Prove shader reuse separately + +No user should rewrite Slug to add a gradient. The portable Slug technique must remain independent of every engine, while a +shader package exposes the canonical evaluation algorithm plus a typed resource/input/output ABI. + +Gpucat publicly exposes a typed node language and `wgslFn()`, so two implementation candidates are plausible: + +1. Publish one technique-owned WGSL kernel and typed ABI that TypeGPU, gpucat, and raw WebGPU programs wrap. +2. Author the kernel in TypeGPU and publish a deterministic generated WGSL artifact plus ABI that gpucat wraps. For + gpucat's WebGL backend, the wrapper must also supply and verify the required GLSL companion; otherwise the integration is + explicitly WebGPU-only. + +Neither candidate is accepted by source inspection alone. The proof must compile the real Slug loops and bindings, inspect +the emitted WGSL, render the same glyph corpus, compare output against the Three/TSL and TypeGPU paths, and measure added +runtime/build cost. If both candidates fail, a gpucat-native Slug shader is allowed, but that outcome still does not justify +adding gpucat or TypeGPU types to core. + +Core `renderVariant` remains sufficient for effects. A gpucat program may encode many variants in one material and draw, or +split runs where its pipeline compatibility requires it. Variant data changes can update adapter sidecars without +reshaping; variant topology changes only rebuild the ordered run plan. + +## Acceptance gate + +The final external fitness fixture must: + +- install packed public `@pmndrs/text` and technique packages, plus gpucat pinned to the reviewed commit or later accepted + release; +- reject every private/deep `@pmndrs/text` import through a package-boundary test; +- render one multiline paragraph, many labels, and an icon grid through Bitmap, MTSDF, and Slug; +- prove font fallback, span variants, fixed-capacity overflow/recovery, dirty-range updates, transforms, visibility, and + text-group ordering; +- move retained text between groups without transferring stale paragraph, buffer, texture, or renderer ownership; +- exercise synchronous and asynchronous updates without an intentional one-frame delay; +- dispose text, groups, font resources, and the renderer in every valid order without missing glyphs, use-after-dispose, or + leaked GPU resources; +- prove the selected canonical shader-sharing path, or explicitly record a gpucat-native shader as the only failed fitness + dimension; +- require no change to `@pmndrs/text` core APIs while the fixture is implemented. + +The core API fitness test passes only when that last condition is true in executable code. This review establishes that the +required public surfaces exist in the design; it does not substitute for the application proof. + +[^gpucat]: Reviewed at commit `11cf91b5172cc5143f68ff6ebf01c5e815de4e94`. The repository build passed. A full re-run on 2026-08-06 passed 256 of 260 tests. One failure directly exposed process-global generated-symbol instability (`storage183` versus `storage226`); three golden snapshots expected pre-flip-Y shader output. These are upstream checkout/test-state evidence, not text-integration evidence, and no gpucat integration claim relies on the suite being green. + +[^gpucat-buffer]: gpucat update ranges use flat component offsets and counts; the adapter must convert core glyph ranges through the concrete buffer schema instead of assuming byte offsets. + +[^gpucat-mesh]: `Mesh.draws` supports several indexed or non-indexed instanced draws over one compatible geometry/material pair. + +[^gpucat-object3d]: gpucat's `Object3D.updateWorldMatrix()` is recursive and overridable; reviewed examples call scene matrix update explicitly before render. + +[^gpucat-render-list]: The reviewed render-list traversal passes zero as `groupOrder`, then sorts on each mesh's `renderOrder`, depth, and stable object identity. diff --git a/docs/planning/implementation-difficulty.md b/docs/planning/implementation-difficulty.md index 1261ae1d..9661c8fb 100644 --- a/docs/planning/implementation-difficulty.md +++ b/docs/planning/implementation-difficulty.md @@ -18,8 +18,8 @@ sources: title: 'Research bibliography' generated: - by: 'openai-codex/gpt-5' - at: '2026-07-25T01:24:00Z' + by: openai-codex/gpt-5.6 + at: '2026-08-07T01:16:02Z' --- # Rendering implementation difficulty @@ -49,7 +49,7 @@ Scores are relative planning estimates from 1 (lowest effort/risk) to 5 (highest The minimum useful implementation rasterizes a canonical outline at selected ppem values, crops it, packs it into an atlas, and emits plane/atlas bounds keyed by the shared glyph ID. An unhinted grayscale implementation is comparatively direct. -The difficulty rises if V1 requires TrueType hinting, LCD/subpixel output, native/Wasm byte-identical output, or authored bitmap-font ingestion. Those are separate capability decisions rather than reasons to complicate the first strike generator. +The difficulty rises if target v1 requires TrueType hinting, LCD/subpixel output, native/Wasm byte-identical output, or authored bitmap-font ingestion. Those are separate capability decisions rather than reasons to complicate the first strike generator. ### Make it performant diff --git a/docs/planning/index.md b/docs/planning/index.md index ae956d02..6b88e00f 100644 --- a/docs/planning/index.md +++ b/docs/planning/index.md @@ -2,11 +2,18 @@ ## Product, API, and execution -- [Project brief](project-brief.md) — product intent, current integration slice, V1, and later horizon. -- [Runtime and bake API V0](api-shapes.md) — public and internal TypeScript contract fixture. -- [Raster and baker plugin guide](raster-baker-plugin.md) — build an external technique through the public runtime, baker, artifact, discovery, and lifecycle contracts. +- [Project brief](project-brief.md) — product intent, merged v0, target v1, and later horizon. +- [Merged v0 runtime and bake API](api-shapes.md) — public and internal TypeScript migration fixture. +- [Three.js text API](three-api.md) — authoritative `FontLoader`, `TextGroup`, and `Text` surface, including late binding, group disposal/rebinding, font leases, retained non-throwing errors, ordering, and render-loop synchronization. +- [Core text API](core-api.md) — authoritative API and rationale for ordered font stacks, batch-owned paragraph handles, identity-preserving capacity changes, font leases, fixed-capacity failure, physical batching, and cascading disposal. +- [Engine integration contract](engine-integration-contract.md) — exact prepared glyph-batch/run, variant, target storage, transform, draw compilation, staging, attachment ownership, and disposal contract. +- [Raster technique and engine resource API](raster-technique-api.md) — authoritative split between portable baker/artifact/CPU technique data, reusable backend technique shaders, variant-aware programs, and engine GPU targets. +- [TypeGPU raster programs and text engine](typegpu-api.md) — complete direct TypeGPU API for typed technique shaders, programs, variants, caller-owned render passes, transforms, synchronization, and disposal. +- [TypeGPU-first shader authority](typegpu-first-shader-authority.md) — exploratory package shape and falsifiable proof ladder for sharing complete raster kernels with direct WebGPU hosts, Three.js, and gpucat without changing core. +- [Merged v0 raster and baker plugin guide](raster-baker-plugin.md) — build against the implemented combined runtime/renderer module before the target v1 extraction replaces it. - [Architecture](architecture.md) — system ownership, import boundaries, and runtime flow. -- [Renderer-agnostic core and engine integration boundary](engine-integration-boundary.md) — WIP plan separating portable text generation, raster techniques, GPU-authoring layers, and canvas/game-engine hosts. +- [Renderer-neutral core, batching, and engine integration](engine-integration-boundary.md) — WIP extraction and proof plan for the batched core API, Three.js migration, direct TypeGPU engine, and Wayfare adapter. +- [External gpucat integration fitness plan](gpucat-integration.md) — public-surface mapping, external-package boundary, ordering/lifecycle plan, and remaining shader-reuse proof for gpucat. - [Canonical roadmap](../roadmap/roadmap.md) — authoritative implementation order and exit gates. - [uikit integration](uikit-integration.md) — third-party retained-layout integration boundary. @@ -32,14 +39,14 @@ - [Shaping compilation and execution research](shaping-compilation-research.md) — closed-corpus baking, semantic bytecode, per-font CPU/Wasm specialization, and WebGPU execution research. - [Language-aware font units and physical bitmap strikes](language-and-strike-bundles.md) — coverage-first language delivery, CJK units, DPR selection, and independent strike residency. -- [Responsive editorial flow and mixed-raster composition](editorial-flow-layout.md) — post-V1 exclusion regions, responsive columns, and a bitmap/MTSDF/Slug benchmark. +- [Responsive editorial flow and mixed-raster composition](editorial-flow-layout.md) — post-v1 exclusion regions, responsive columns, and a bitmap/MTSDF/Slug benchmark. ## Rendering analysis - [MTSDF generation research](mtsdf-generation-research.md) — primary literature, implementation/license survey, owned Rust boundary, and data-oriented optimization gates. - [Grayscale bitmap hinting research](bitmap-hinting-research.md) — native pixel placement, hinted strikes, and four-phase grayscale packing gates. - [Renderer capabilities](renderer-capabilities.md) — feature matrix and developer guidance. -- [Composable text effects over TSL](text-effect-composition.md) — research proposal for ordered node effects, object-local uniforms, shared-material safety, and dual-backend admission. +- [Three.js text effect composition](text-effect-composition.md) — optional TSL convenience over generic core variants and canonical raster shaders. - [Implementation difficulty](implementation-difficulty.md) — relative correctness and performance effort. - [Payload budget](payload-budget.md) — serialized, decoded, and resident cost model. - [GPU compression and Rust container ownership](gpu-compression.md) — transport/GPU compression constraints plus the GLB/KTX2 serializer decision. diff --git a/docs/planning/mtsdf-generation-research.md b/docs/planning/mtsdf-generation-research.md index 433fdcd6..32a0d5ca 100644 --- a/docs/planning/mtsdf-generation-research.md +++ b/docs/planning/mtsdf-generation-research.md @@ -42,7 +42,7 @@ sources: title: TypeGPU functions and WGSL integration generated: by: openai-codex/gpt-5.6 - at: '2026-08-01T06:26:44Z' + at: '2026-08-07T01:16:02Z' --- # MTSDF generation research @@ -110,11 +110,11 @@ The scalar kernel is both the correctness oracle and the selected production imp Item 8.6 now measures the complete artifact pipeline rather than extrapolating from the generator microcorpus. The native phase observer uses the same optimized Rust pipeline; direct and Worker columns use the shipped optimized Wasm. Times are Apple arm64 observations, not portable thresholds. `Wasm copy` is the exact owned response copy from linear memory, `Worker transfer` is delivery after the Worker's complete marker, linear memory is the retained Wasm high-water mark, and RSS is the isolated Node process lifetime peak. -| Coverage | Selected/generated glyphs | Texels | Edge visits | Native texel/total ms | Wasm bake/copy ms | Worker total/transfer ms | Linear/RSS peak bytes | Output bytes | -| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | -| Small authored text | 39 / 38 | 71,341 | 1,694,576 | 245.40 / 248.32 | 511.96 / 0.69 | 520.88 / 0.46 | 6,488,064 / 109,002,752 | 595,752 | -| U+0020–U+024F | 524 / 522 | 1,289,496 | 36,939,819 | 4,429.17 / 4,445.46 | 9,075.97 / 0.74 | 9,109.36 / 0.61 | 36,634,624 / 147,177,472 | 7,074,796 | -| Complete Inter | 2,937 / 2,915 | 7,233,197 | 227,327,416 | 25,871.57 / 25,957.06 | 52,420.37 / 4.51 | 52,860.21 / 0.47 | 227,737,600 / 343,343,104 | 39,175,608 | +| Coverage | Selected/generated glyphs | Texels | Edge visits | Native texel/total ms | Wasm bake/copy ms | Worker total/transfer ms | Linear/RSS peak bytes | Output bytes | +| ------------------- | ------------------------: | --------: | ----------: | --------------------: | ----------------: | -----------------------: | ------------------------: | -----------: | +| Small authored text | 39 / 38 | 71,341 | 1,694,576 | 245.40 / 248.32 | 511.96 / 0.69 | 520.88 / 0.46 | 6,488,064 / 109,002,752 | 595,752 | +| U+0020–U+024F | 524 / 522 | 1,289,496 | 36,939,819 | 4,429.17 / 4,445.46 | 9,075.97 / 0.74 | 9,109.36 / 0.61 | 36,634,624 / 147,177,472 | 7,074,796 | +| Complete Inter | 2,937 / 2,915 | 7,233,197 | 227,327,416 | 25,871.57 / 25,957.06 | 52,420.37 / 4.51 | 52,860.21 / 0.47 | 227,737,600 / 343,343,104 | 39,175,608 | Texel generation accounts for 98.8%, 99.6%, and 99.7% of measured native pipeline time. Packing, texture encoding, serialization, Wasm response copying, and Worker delivery are not plausible dominant-phase optimizations. Direct Wasm and Worker artifacts are byte-identical in every case. Small and medium native artifacts also match; complete native arm64 and Wasm artifact hashes are retained separately because target floating-point output diverges at full-face scale. The shipped Wasm identity remains authoritative. This evidence admits the adjacent-texel experiment described below and rejects packaging or transfer tuning as the next optimization. @@ -147,7 +147,7 @@ Admission requires byte-identical or independently bounded output against the sa The runtime text renderer has a different boundary. Its hot work is already one instanced draw sampling a resident atlas; a pre-render compute pass would add dispatch and synchronization without removing the fragment samples. No compute branch is proposed for Bitmap or MTSDF rendering. -V1 sequencing deliberately postpones the renderer-neutral extraction until Slug lands. Slug first ports through the current Three.js/TSL integration so its real curve-resource, shader-composition, batching, and lifetime requirements are executable rather than guessed. Milestone 10 then extracts the common direct integration contract beneath all three rasters: core shaping and layout remain renderer-neutral, raster plugins expose backend-neutral prepared batches and resources, a direct WebGPU integration owns raw devices and pipelines, and Three.js becomes one supported adapter over that boundary. A future TypeGPU renderer adapter may reuse the same contract, but the compute-baker experiment can proceed independently and must not force this refactor before Slug provides the missing requirements. +The merged v0 sequence deliberately retained Three.js/TSL through Slug so its real curve-resource, shader-composition, batching, and lifetime requirements were executable rather than guessed. Target v1 Milestone 11 now extracts the common integration contract beneath all three rasters: core shaping and layout remain renderer-neutral, portable techniques expose prepared batches and resources, and Three.js, TypeGPU, Wayfare, and gpucat remain independently selectable integrations. The compute-baker experiment can proceed independently and must not enter unrelated runtime graphs. [^valve-sdf]: Green, _Improved Alpha-Tested Magnification for Vector Textures and Special Effects_, 2007. diff --git a/docs/planning/open-questions.md b/docs/planning/open-questions.md index 47653327..08223c74 100644 --- a/docs/planning/open-questions.md +++ b/docs/planning/open-questions.md @@ -5,7 +5,7 @@ description: Tracks unresolved decisions, blockers, and prototype questions for tags: [questions, governance, blockers] generated: by: openai-codex/gpt-5.6 - at: '2026-07-25T18:00:06Z' + at: '2026-08-07T01:16:02Z' --- # Open questions @@ -24,7 +24,7 @@ Status: unresolved unless marked otherwise. 1. Should subsetting use Skera/Fontations, HarfBuzz subset in native tooling, or a project-owned closure pass? 2. What deterministic outline representation feeds Slug, MSDF, and bitmap generation? 3. [x] Own a purpose-built `no_std + alloc` Rust MTSDF core with pinned native `msdfgen` as its test-only oracle; [D-097](decision-register.md), the [admission conclusion](mtsdf-generator-admission.md), and [generation research](mtsdf-generation-research.md) define the boundary and proof. -4. Does V1 bitmap rendering include TrueType hinting, or use deterministic unhinted oversampling? +4. Does target v1 Bitmap rendering include TrueType hinting, or preserve deterministic unhinted oversampling? 5. What are default runtime-bake glyph ranges, time limits, memory limits, and atlas limits? 6. Can WOFF2 decoding remain out of the always-loaded shaper module and live only in the baker? 7. Which GLB writer details could prevent full byte identity even when authoritative Node/Worker sections are identical? @@ -49,12 +49,12 @@ The [shaping compilation research note](shaping-compilation-research.md) owns th ## Paragraph engine -V1 owns UAX #9 bidi analysis/reordering, UAX #14 break opportunities, UAX #24 script itemization, and UAX #29 grapheme boundaries in the JavaScript paragraph engine. +Target v1 owns UAX #9 bidi analysis/reordering, UAX #14 break opportunities, UAX #24 script itemization, and UAX #29 grapheme boundaries in the JavaScript paragraph engine. 1. Which UAX #14 implementation and tailoring strategy should be used in JS? 2. How much surrounding context is necessary when reshaping final line slices? 3. Which scripts always trigger boundary reshaping versus relying on unsafe-break flags? -4. Is balanced wrapping a post-V1 strategy behind the same interface? +4. Is balanced wrapping a post-v1 strategy behind the same interface? 5. What is the font-fallback unit: code point, grapheme, shaping cluster, or script run? 6. How are selections, carets, and hit testing represented in the first public layout output? 7. What is the emergency-break policy for a single cluster wider than the region? @@ -62,7 +62,7 @@ V1 owns UAX #9 bidi analysis/reordering, UAX #14 break opportunities, UAX #24 sc ## Rasters 1. Which pieces of Three Flatland Slug are legally and technically suitable to port? -2. How are missing glyph rasters reported and substituted within the V1 per-font-slot raster assignment? +2. How are missing glyph rasters reported and substituted within the target v1 per-font-slot raster assignment? 3. Which safe OpenType-SVG subset and standalone-SVG manifest contract must the large-coverage CJK/icon milestone accept? ## Product and package shape diff --git a/docs/planning/payload-budget.md b/docs/planning/payload-budget.md index 81be7b1a..150cba90 100644 --- a/docs/planning/payload-budget.md +++ b/docs/planning/payload-budget.md @@ -22,7 +22,7 @@ sources: generated: by: openai-codex/gpt-5.6 - at: '2026-07-27T01:29:13Z' + at: '2026-08-07T01:16:02Z' --- # Font payload budget @@ -42,7 +42,7 @@ flowchart TD Total --> Runtime["runtime memory
Wasm font state and GPU resources"] ``` -The payloads for bitmap, MSDF, and Slug are alternatives unless an asset deliberately contains more than one raster. The shaping data is paid once and is shared by every raster. V1 MSDF resources are always MTSDF-encoded RGBA8. +The payloads for Bitmap, MSDF, and Slug are alternatives unless an asset deliberately contains more than one raster. The shaping data is paid once and is shared by every raster. Merged v0 and target v1 MSDF resources are always MTSDF-encoded RGBA8. The HarfRust Wasm shaper is shared application code, not repeated per font. Its current pre-build envelope is 250–600 KiB raw / 90–250 KiB compressed and must be replaced by the first compiled artifact report. Raster modules, KTX2 transcoders, and renderer adapters are likewise reported as independently loaded code chunks rather than charged to every font. @@ -273,4 +273,4 @@ The benchmark corpus must eventually produce this report for: No modeled number becomes a product claim until a checked-in generator, descriptor, source hash, visual reference, and raw report reproduce it. -Plain RGB MSDF is not part of the V1 totals. A later compression experiment may compare an RGB-capable native block format against the MTSDF baseline, including transport bytes, GPU residency, visual error, effect loss, and extra batch/module complexity. It becomes a supported encoding only if that complete comparison proves a material win. +Plain RGB MSDF is not part of the merged v0 or target v1 totals. A later compression experiment may compare an RGB-capable native block format against the MTSDF baseline, including transport bytes, GPU residency, visual error, effect loss, and extra batch/module complexity. It becomes a supported encoding only if that complete comparison proves a material win. diff --git a/docs/planning/project-brief.md b/docs/planning/project-brief.md index bdc59941..d118a7e0 100644 --- a/docs/planning/project-brief.md +++ b/docs/planning/project-brief.md @@ -1,11 +1,11 @@ --- type: Project Brief title: Project brief -description: Defines the product outcome, users, current one-font slice, later product horizon, non-goals, and success criteria. +description: Defines the product outcome, users, merged v0 baseline, target v1 boundary, later product horizon, non-goals, and success criteria. tags: [product, scope, roadmap] generated: - by: 'openai-codex/gpt-5' - at: '2026-07-25T01:24:00Z' + by: openai-codex/gpt-5.6 + at: '2026-08-07T01:16:02Z' --- # Project brief @@ -13,19 +13,30 @@ generated: Status: proposed Audience: pmndrs maintainers and initial contributors -Current execution is a one-font slice with a required minimal bake path: one font core runs behind generic Node and Worker hosts and emits the canonical `PMNDRS_font` asset; a separately owned bitmap package emits the first raster artifact. HarfRust Wasm shapes the retained font data, the JS paragraph engine reflows one paragraph, and the bitmap raster proves the Three.js and React boundaries. Advanced compiler work—subsetting/remapping, compiled IR, SIMD, and additional generators—remains later. The [canonical roadmap](../roadmap/roadmap.md) is authoritative for order and scope. +The repository's merged v0 implementation proves portable baking, HarfRust Wasm shaping, JavaScript paragraph layout, and +Bitmap, MTSDF, and Slug rendering through the original Three.js-oriented API. It has not been published as a release. The +current work extracts the target v1 core API and proves that API through independent engine integrations. Advanced compiler +work—subsetting/remapping, compiled IR, and additional generators—remains later. The +[canonical roadmap](../roadmap/roadmap.md) is authoritative for order and scope. The interactive/headless benchmark harness is the first executable artifact. It exists before the font pipeline, and each implementation step enters through its shared adapters and scenarios. The bitmap slice's first rendered output is therefore already a measured, reproducible harness scenario rather than a throwaway demo. -The bitmap slice is an internal end-to-end proof, not the minimum shippable product. The first release requires bitmap, MSDF, and Slug raster engines to pass their format, quality, and performance gates. The MSDF engine uses MTSDF atlas encoding. +The original bitmap slice was the first internal end-to-end proof. The merged v0 implementation subsequently added MTSDF +and Slug, but completing raster engines did not by itself stabilize a release API. The first public v1 release additionally +requires clean batching, loading, synchronization, resource, customization, and external-engine boundaries. The MSDF engine +uses MTSDF atlas encoding. -Terminology in the planning set is strict: the **integration slice** is the pre-release bitmap proof; **V1** is the first shippable release containing all three raster engines. +Terminology in the planning set is strict: **v0** is the merged, unreleased implementation; **target v1** is the API and +integration design being implemented now; **v1** names the first public release only after those shapes pass their gates. ## Product statement -`pmndrs/text` will be a Three.js-first, raster-independent text system for JavaScript, WebGPU, and WebGL. It will shape modern Unicode text once, lay it out within application-controlled regions, and render the resulting glyph stream through interchangeable Slug, MSDF, or bitmap raster modules. +`pmndrs/text` will be a renderer-neutral, raster-independent text system for JavaScript and WebGPU. It will shape modern +Unicode text once, lay it out within application-controlled regions, and expose batched glyph data through interchangeable +Slug, MSDF, or bitmap techniques. Three.js, React Three Fiber, TypeGPU, and other engines consume that public core through +separate integrations. -The package is the shipping product informed by the text/font work explored in Three Flatland's Slug package. Selected Slug algorithms and formats may be adapted or reimplemented from that prior art. uikit is a required consumer through a small adapter around its existing `CustomLayouting` and resolved content-box signals. Core remains independent of Yoga, Preact Signals, and uikit rendering types. +The intended public package is informed by the text/font work explored in Three Flatland's Slug package. Selected Slug algorithms and formats may be adapted or reimplemented from that prior art. uikit is a required consumer through a small adapter around its existing `CustomLayouting` and resolved content-box signals. Core remains independent of Yoga, Preact Signals, and uikit rendering types. ## Problem @@ -61,7 +72,7 @@ We need: 7. Declare a font and raster once in application source, then let Node pre-baking and Worker fallback derive the same package-owned descriptor. 8. Let any retained layout system synchronously measure a prepared paragraph without producing glyph arrays, then request positioned output for its final content box. Validate that neutral contract against current uikit. -## Current one-font slice +## Merged v0 baseline - one statically selected, pinned OpenType font; - horizontal LTR and RTL shaping supported by the pinned HarfRust baseline; @@ -76,7 +87,7 @@ We need: - WebGPU and WebGL2 first-frame proof; - conformance, package-graph, and benchmark evidence. -## Product horizon after the slice +## Target v1 and later horizon - horizontal LTR and RTL shaping; - full Unicode scalar input with UTF-16 cluster offsets; @@ -85,13 +96,13 @@ We need: - optional dense packed glyph-ID remapping after source subsetting and shaping closure are proven; - pre-baked GLB and lazy worker fallback; - Slug, MTSDF-backed MSDF, and generated bitmap rasters; -- post-V1 large-coverage paging for CJK, private-use icon fonts, OpenType-SVG icon fonts, and manifest-backed standalone SVG icon sets; +- post-v1 large-coverage paging for CJK, private-use icon fonts, OpenType-SVG icon fonts, and manifest-backed standalone SVG icon sets; - later Slug and bitmap support for color emoji through baked vector paint/layer and image records; - JS paragraph engine with greedy wrapping, alignment, height/max-lines, clipping, and ellipsis; - batched boundary reshaping; - conformance fixtures and benchmark harnesses. -## Explicit non-goals for the current slice +## Explicit non-goals for the target v1 extraction - replacing HarfRust script shaping; - browser-time JIT or MLIR; @@ -143,14 +154,14 @@ We need: - Correct line-boundary shaping and bidi behavior can invalidate overly aggressive JS-side slicing. - Three raster generators increase fixture and visual-regression cost. -## First decision gate +## Original decision gate Before production code, maintainers should accept or revise: 1. HarfRust as the reference shaper. 2. GLB plus the `PMNDRS_font` extension family as the container. 3. JS paragraph policy with coarse Wasm shaping calls. -4. Static font instances in V1. +4. Static font instances in v0 and the target v1. 5. The worker fallback as a required product feature. 6. The initial raster set: Slug, MSDF, and generated grayscale bitmap strikes. 7. The Three.js-first `Text` object and nested-text React API. diff --git a/docs/planning/raster-baker-plugin.md b/docs/planning/raster-baker-plugin.md index 50007c9d..9c467bd2 100644 --- a/docs/planning/raster-baker-plugin.md +++ b/docs/planning/raster-baker-plugin.md @@ -37,11 +37,16 @@ sources: generated: by: 'openai-codex/gpt-5.6' - at: '2026-08-04T12:55:07Z' + at: '2026-08-07T01:16:02Z' --- # Build a raster and baker plugin +> [!NOTE] +> This guide documents the merged, unreleased v0 `RasterModule` surface. The target v1 extraction API is authoritative in +> the [raster technique and engine resource specification](raster-technique-api.md); this guide will be rewritten against +> that split when implementation replaces the v0 module. + Use this guide to create an ESM package that adds a raster technique to `pmndrs/text` without changing or importing its internals. The finished package will own: @@ -53,7 +58,7 @@ internals. The finished package will own: The exact interfaces remain authoritative in the [API reference](api-shapes.md#raster-module-boundary). The private [`@pmndrs/text-glyph-example-raster`](../../packages/glyph-example-raster) workspace package is a complete external proof using -only published entry points. +only public package entry points. ## 1. Create separate runtime and baker entry points diff --git a/docs/planning/raster-data-contract.md b/docs/planning/raster-data-contract.md index 6d6b7083..4e1652f0 100644 --- a/docs/planning/raster-data-contract.md +++ b/docs/planning/raster-data-contract.md @@ -22,13 +22,13 @@ sources: generated: by: openai-codex/gpt-5.6 - at: '2026-07-29T11:22:07Z' + at: '2026-08-07T01:16:02Z' --- # Raster data contract V0 Status: settled V0; changes require an explicit companion-extension version revision -Scope: independently loadable bitmap, MSDF, and Slug rasters sharing one font-local glyph space; V1 MSDF resources use MTSDF encoding +Scope: independently loadable Bitmap, MSDF, and Slug rasters sharing one font-local glyph space; merged v0 and target v1 MSDF resources use MTSDF encoding ## Logical resources, not a mandatory file split @@ -221,7 +221,7 @@ For the non-subsetted V0 fixtures, records are 58,740 bytes for the pinned 2,937 ## `PMNDRS_font_distance_field` V0 -This extension is the serialized resource for the public MSDF raster module. V1 supports one encoding: MTSDF in linear RGBA8. RGB stores the multi-channel signed-distance field and alpha stores true signed distance. The lossless GPU baseline is already four-channel because WebGPU has no ordinary `rgb8unorm` sampled texture format; discarding alpha would not reduce that baseline's GPU residency. +This extension is the serialized resource for the public MSDF raster module. Merged v0 and target v1 support one encoding: MTSDF in linear RGBA8. RGB stores the multi-channel signed-distance field and alpha stores true signed distance. The lossless GPU baseline is already four-channel because WebGPU has no ordinary `rgb8unorm` sampled texture format; discarding alpha would not reduce that baseline's GPU residency. ```ts interface MsdfRasterV0 { @@ -239,7 +239,7 @@ MSDF glyph records are the same 20-byte plane/atlas/page/flags layout as bitmap The package default remains 64/8 and preserves its established raster key. These controls permit smaller or larger authored fields without implying that a lower-cost setting is universally preferable. Passing validation at 32/4 and 32/6 establishes format and implementation support; source-outline quality, transport, residency, and rendering evidence must select any future recommended default. -One resource and one batch family serve both ordinary text and distance effects. Fill coverage uses the median of RGB; outline, shadow, glow, or another effect may use alpha where true geometric distance is required. A fill-only shader may ignore alpha, but it consumes the same MTSDF atlas. Paint/material differences may still split draws; field encoding never creates separate MSDF and MTSDF batches. V1 does not generate or attach a second plain-MSDF atlas. +One resource and one batch family serve both ordinary text and distance effects. Fill coverage uses the median of RGB; outline, shadow, glow, or another effect may use alpha where true geometric distance is required. A fill-only shader may ignore alpha, but it consumes the same MTSDF atlas. Paint/material differences may still split draws; field encoding never creates separate MSDF and MTSDF batches. Merged v0 and target v1 do not generate or attach a second plain-MSDF atlas. The required baseline is lossless linear `rgba8unorm` KTX2. UASTC/native BC7, ETC2 RGBA, and ASTC variants are allowed only as `quality-gated` variants because channel error moves reconstructed edges. Their post-GPU-decode images must pass the visual/error corpus; they do not replace the lossless baseline by declaration alone. @@ -365,7 +365,7 @@ The caller explicitly selects a configured raster definition, normally through a 6. creates GPU resources in bulk without per-glyph object reconstruction; 7. attaches the resource to `(FontHandle, rasterKey)`. -Switching or attaching a raster does not reshape text and cannot change paragraph measurement. Multiple raster artifacts may be attached concurrently, but V1 never attaches both plain-MSDF and MTSDF versions of the same MSDF raster. +Switching or attaching a raster does not reshape text and cannot change paragraph measurement. Multiple raster artifacts may be attached concurrently, but merged v0 and target v1 never attach both plain-MSDF and MTSDF versions of the same MSDF raster. Bitmap and distance-field glyph records are CPU-consumed typed-array data used to gather quad/UV/page values during bulk instance generation. They require no per-glyph objects, but their 20-byte layout is not claimed as a direct GPU metadata format. Slug headers/references and all selected texture variants are upload-formatted resources. diff --git a/docs/planning/raster-technique-api.md b/docs/planning/raster-technique-api.md new file mode 100644 index 00000000..d0b9ec7a --- /dev/null +++ b/docs/planning/raster-technique-api.md @@ -0,0 +1,503 @@ +--- +type: API Specification +title: Raster technique and engine resource API +description: Canonical boundary between portable raster baking and decoding, core glyph packing, reusable shader backends, and engine-specific GPU targets. +documentation_type: reference +tags: [api, raster, baking, resources, shaders, engines, typegpu, tsl] +status: stable +sources: + - id: core-api + resource: core-api.md + title: Core text API + - id: engine-contract + resource: engine-integration-contract.md + title: Engine integration contract + - id: typegpu-api + resource: typegpu-api.md + title: TypeGPU raster programs and text engine + - id: gpucat-integration + resource: gpucat-integration.md + title: External gpucat integration fitness plan + - id: current-raster + resource: ../../packages/text/src/raster.ts + title: Current combined raster module + - id: current-bake + resource: ../../packages/text/src/bake.ts + title: Current portable raster baker contract + - id: current-mtsdf + resource: ../../packages/text/src/raster/msdf.ts + title: Current MTSDF decoder and Three.js target + - id: external-proof + resource: ../../packages/glyph-example-raster/src/raster.ts + title: Current external raster proof + - id: typegpu-bindings + resource: https://docs.swmansion.com/TypeGPU/apis/bind-groups/ + title: TypeGPU bind groups and raw WebGPU resource interop + - id: typegpu-pipelines + resource: https://docs.swmansion.com/TypeGPU/apis/pipelines/ + title: TypeGPU pipelines and raw WebGPU pipeline interop + - id: typegpu-three + resource: https://docs.swmansion.com/TypeGPU/ecosystem/typegpu-three/ + title: TypeGPU to TSL integration +generated: + by: openai-codex/gpt-5.6 + at: '2026-08-07T03:25:58Z' +--- + +# Raster technique and engine resource API + +One raster should not be one engine plugin. The stable split is: + +```ts +source font + -> RasterBaker // portable, build time or Worker + -> raster artifact + page artifacts // portable bytes + -> RasterTechnique.decode() // portable validated CPU data + -> TextRuntime.update*() // portable partitioned instance storage + -> RasterShader // reusable backend technique algorithm + -> RasterProgram // engine resources, variants, and draw policy + -> ParagraphBatchTarget // engine lifecycle, GPU resources, draws +``` + +Bitmap, MTSDF, and Slug each need one baker and one portable technique implementation. They do not need to duplicate +baking, artifact validation, external-page fetching, fallback resolution, glyph partitioning, or canonical CPU instance +packing for every engine. + +Every engine still needs a target, but that target is an ordinary external consumer package rather than a core subpath. +Technique-specific GPU realization and shader code can be shared when several engines +expose the same shader/resource backend, but scene traversal, render-pass placement, transforms, submission, fences, and +retirement remain engine-specific. + +## Bake portable artifacts + +The baker knows font outlines and the serialized raster format. It knows nothing about a renderer, GPU device, material, +scene, or draw call. + +```ts +interface RasterBakerModule { + readonly kind: Kind; + readonly extension: string; + readonly version: number; + + descriptor(options: Options): Descriptor; + bake(request: RasterBakeRequest): Promise>; +} +``` + +```ts +interface RasterBakeArtifact { + readonly rasterKey: RasterKey; + readonly kind: Kind; + readonly extension: string; + readonly version: number; + readonly artifacts: readonly BakeArtifact[]; +} + +interface BakeArtifact { + readonly role: 'raster' | 'raster-page'; + readonly id: string; + readonly bytes: Uint8Array; + readonly sha256: Sha256Hex; +} +``` + +A bitmap baker may emit strike textures, MTSDF may emit atlas pages, and Slug may emit curve, header, and reference pages. +Embedded versus external packaging changes where those bytes live, not which engine can consume them. + +## Load portable CPU data + +`runtime.loadFont()` owns the complete asynchronous loading boundary: + +```ts +const font = await runtime.loadFont({ + input: { baked: '/fonts/Inter.font.glb' }, + raster: { technique: mtsdf }, +}); +``` + +Before it resolves, core has loaded and validated the shaping artifact, found the selected raster companion, resolved every +required embedded resource or hash-validated external resource, and called the technique decoder. No GPU resource exists +yet. + +```ts +interface RegisteredRaster { + readonly rasterKey: RasterKey; + readonly kind: Kind; + readonly extensionData: JsonValue; + + view(bufferView: number): Uint8Array; + resource(source: RasterResourceSource, signal?: AbortSignal): Promise; +} + +type RasterResourceSource = + | { readonly type: 'bufferView'; readonly bufferView: number } + | { + readonly type: 'external'; + readonly uri: string; + readonly byteLength: number; + readonly artifactHash: Sha256Hex; + }; +``` + +Raster authors use `view()` for embedded bytes and `resource()` for either embedded or SHA-256-validated external page +bytes. Applications normally do not call either method; the selected technique's `decode()` does. + +```ts +interface LoadedFont { + readonly technique: Technique; + readonly raster: RegisteredRaster>; + readonly data: RasterDataOf; +} +``` + +`font.data` is retained renderer-neutral CPU state. For example: + +```ts +interface MtsdfData { + readonly records: Uint8Array; + readonly pages: readonly { + readonly width: number; + readonly height: number; + readonly format: 'rgba8unorm'; + readonly bytes: Uint8Array; + }[]; + readonly emSize: number; + readonly pixelRange: number; +} +``` + +The exact data type belongs to the technique. Core retains it until the loaded font is disposed so another target can +attach later without fetching or decoding the font again. Engine GPU copies may coexist with this CPU source by design. + +## Let the portable technique partition and pack + +The current `RasterModule` combines portable decoding with Three.js textures, TSL material creation, instance allocation, +and scene objects. The replacement separates those responsibilities: + +```ts +interface RasterTechnique< + Id extends RasterTechniqueId, + Kind extends string, + Options, + Descriptor extends JsonValue, + Data, + Binding, + Storage extends GlyphBatchStorageShape, +> extends AnyRasterTechnique { + readonly [rasterTechniqueTypes]?: RasterTechniqueTypeMap; + + readonly id: Id; + readonly kind: Kind; + readonly extension: string; + readonly version: number; + readonly runtimeBaker?: RuntimeRasterBakerLoader; + + descriptor(options: RasterOptionsArgument): Descriptor; + decode(font: RegisteredFont, raster: RegisteredRaster, signal?: AbortSignal): Promise; + + select(input: RasterGlyphInput): RasterGlyphSelection; + createStorage(capacity: number): Storage; + writeStorage(storage: Storage, range: GlyphRange, input: RasterGlyphWriteInput): void; + validatePaint?(paint: GlyphPaint): void; + dispose(data: Data): void; +} + +interface RasterGlyphInput { + readonly data: Data; + readonly glyphId: number; + readonly fontSize: number; + readonly rasterPixelRatio: number; + readonly paint: ResolvedPaint; +} + +interface RasterGlyphWriteInput { + readonly data: Data; + readonly glyphs: readonly RasterGlyphInput[]; +} + +type GlyphBatchStorageShape = { + readonly [Field in keyof Storage]: ArrayBufferView; +}; + +type GlyphBatchStorage = Readonly>; + +declare const rasterTechniqueTypes: unique symbol; + +interface RasterTechniqueTypeMap< + Options = unknown, + Descriptor extends JsonValue = JsonValue, + Data = unknown, + Binding = unknown, + Storage extends GlyphBatchStorageShape = GlyphBatchStorage, +> { + readonly options: Options; + readonly descriptor: Descriptor; + readonly data: Data; + readonly binding: Binding; + readonly storage: Storage; +} + +interface AnyRasterTechnique { + readonly id: RasterTechniqueId; + readonly kind: string; + readonly extension: string; + readonly version: number; + readonly [rasterTechniqueTypes]?: RasterTechniqueTypeMap; +} + +type RasterTechniqueTypesOf = NonNullable; +type RasterOptionsOf = RasterTechniqueTypesOf['options']; +type RasterDataOf = RasterTechniqueTypesOf['data']; +type RasterBindingOf = RasterTechniqueTypesOf['binding']; +type GlyphBatchStorageOf = RasterTechniqueTypesOf['storage']; + +declare function defineRasterTechnique< + const Id extends RasterTechniqueId, + const Kind extends string, + Options, + Descriptor extends JsonValue, + Data, + Binding, + Storage extends GlyphBatchStorageShape, +>( + technique: RasterTechnique, +): RasterTechnique; +``` + +`AnyRasterTechnique` contains only the common identity shape. It does not instantiate the generic technique with `any`, and +it cannot be used to perform typed decode, selection, or storage writes. Its associated types intentionally widen to +`unknown` / `GlyphBatchStorage` at a heterogeneous boundary. Concrete values retain their complete relationships: + +```ts +const mtsdf = defineRasterTechnique({ + id: MTSDF_TECHNIQUE_ID, + kind: 'mtsdf', + extension: 'PMNDRS_font_distance_field', + version: 0, + descriptor: mtsdfDescriptor, + decode: decodeMtsdf, + select: selectMtsdfGlyph, + createStorage: createMtsdfStorage, + writeStorage: writeMtsdfStorage, + dispose: disposeMtsdfData, +}); + +type Data = RasterDataOf; // MtsdfData +type Binding = RasterBindingOf; // MtsdfBinding +type Storage = GlyphBatchStorageOf; // MtsdfGlyphBatchStorage +``` + +The helper's generic parameters are inference variables in its declaration; raster authors do not supply them. An unresolved +associated type remains `unknown`, which blocks technique-specific use until the author supplies enough type information. It +never silently degrades to `any`. + +`select()` returns the physical resource and pipeline division for one resolved glyph. Core uses it while building stable +glyph batches; a target never repeats this selection. `resource` must be a stable technique/runtime identity, while +`binding` is an immutable value that describes how to address that resource. Implementations need not allocate either value +per glyph. + +```ts +interface RasterGlyphSelection { + readonly resource: RasterResourceId; + readonly pipelineVariant: number; + readonly binding: Binding; +} + +interface PreparedGlyphBatch { + readonly key: GlyphBatchKey; + readonly font: LoadedFont; + readonly binding: RasterBindingOf; + readonly storage: GlyphBatchStorageOf; + readonly dirtyRanges: readonly GlyphRange[]; +} + +interface GlyphBatchKey { + readonly technique: RasterTechniqueId; + readonly resource: RasterResourceId; + readonly pipelineVariant: number; + readonly generation: number; + readonly chunk: number; +} +``` + +`binding` is renderer-neutral, typed selection data. Bitmap can identify one strike and page; MTSDF can identify an atlas +view; Slug can identify one curve/header/reference page set. The target receives the answer instead of inspecting glyph IDs +or font records to derive it again. + +`GlyphBatchKey.pipelineVariant` is a technique-authored physical-storage/resource constraint—for example a record format +that requires another vertex layout. It is not the generic paragraph/span `renderVariant` and is not an application effect +key. A raster program combines this technique value with its own variant compatibility key when compiling final draws. + +The technique also defines the canonical structure-of-arrays storage and writes it during core synchronization. This is +where origin, size, glyph-record index, page index, paint index, or other technique values become renderer-ready CPU fields. + +## Realize resources in an engine target + +The target turns portable font data and bindings into resources usable by one renderer: + +```ts +class ThreeMtsdfTarget implements ParagraphBatchTarget { + readonly technique = mtsdf; + + stage(previous, next) { + for (const batch of next.glyphBatches) { + const resources = this.resources.getOrCreate(batch.font, batch.binding, () => ({ + atlas: createThreeDataArrayTexture(batch.font.data, batch.binding), + material: createThreeMtsdfTslMaterial(batch.binding), + })); + + copySelectedRangesToThreeAttributes(batch.storage, resources, previous, next); + } + + return stageThreeDraws(program.compileRuns(next.glyphRuns, next.glyphBatches)); + } +} +``` + +That target owns Three textures, attributes, TSL materials, internal draw objects, render-list placement, and GPU-safe +retirement. A Wayfare target owns Wayfare entities/passes instead. Neither owns artifact decoding, font fallback, shaping, +glyph-resource selection, physical glyph storage partitioning, or source order. Each target does own the final compatible +draw plan for its program. + +GPU resources should normally be cached by the engine integration using loaded-font identity plus the technique binding. +Several paragraph batches can then share one atlas or curve buffer while retaining separate instance buffers and draw +plans. During synchronous `stage()`, a target copies every CPU page/table value it needs into independently owned engine +resources. Disposing a batch attachment releases its batch resources; disposing the last engine cache entry releases shared +font GPU resources. Core font leases belong to live paragraphs only. A target does not retain `font.data` after `stage()` +returns, so it neither needs nor receives a hidden target lease. + +## Reuse the technique shader without surrendering the program + +Shader authoring is not inherently engine-neutral. It is backend-neutral only when the consuming engines agree on the +shader compiler, binding schema, resource handles, vertex/instance layout, and render-pass handoff. + +```ts +interface RasterStage { + readonly input: Input; + readonly output: Output; + readonly evaluate: Evaluate; +} + +interface RasterShader { + readonly technique: Technique; + readonly vertex: Vertex; + readonly fragment: Fragment; + readonly resources: Resources; +} + +interface RasterProgram { + readonly technique: Technique; + readonly shader: Shader; + + createResources(device: Device, font: LoadedFont, binding: RasterBindingOf): Resources; + createPipeline(device: Device, pipelineVariant: number): Pipeline; + compileRuns( + runs: readonly PreparedGlyphRun[], + batches: readonly PreparedGlyphBatch[], + ): readonly Draw[]; + disposeResources(resources: Resources): void; + disposePipeline(pipeline: Pipeline): void; +} +``` + +`RasterShader` is a backend implementation of the complete hard technique algorithm: vertex expansion and pixel snapping, +resource access, Bitmap sampling, MTSDF distance and coverage reconstruction, or Slug dilation, curve traversal, and +coverage. Each concrete stage retains its backend-authored input/output/function types; the contract never replaces its +arguments or result with `any`. The resource schema names the semantic pages, tables, buffers, sampling rules, and +coordinate spaces those stages consume. A shader returns resolved vertex outputs and fragment values such as linear +premultiplied color, opacity, and coverage. It does not own a material, pipeline, pass, variant, or draw. + +`RasterProgram` composes that canonical shader with resources, application variants, pipeline/material state, and a draw +compiler. A custom gradient program normally calls `shader.fragment.evaluate(context)` and modifies the resolved output. Replacing +the complete technique shader is a low-level escape hatch, not the expected customization path; users should never need to +reimplement Slug merely to change final color. + +These are optional adapter-level seams declared by integration or shader packages, not core exports or requirements. A shared TypeGPU MTSDF program can create typed bind-group +layouts, GPU resources, and pipelines once for any host that exposes a compatible WebGPU device and lets the adapter encode +those pipelines in its render pass. TypeGPU can also unwrap pipelines and bindings to raw WebGPU handles, so the host does +not have to use TypeGPU for the rest of its renderer. + +```ts +const program = createTypeGpuMtsdfProgram(root); + +createWayfareTextTarget({ engine, program }); +createAnotherWebGpuTextTarget({ renderer, program }); +``` + +This does not make the two engine targets identical. They still differ in lifecycle hooks, transforms, visibility, +culling, pass ordering, command ownership, and retirement. If an engine does not expose compatible WebGPU device/pass +interop, the TypeGPU program cannot be inserted merely because the engine itself runs on WebGPU. + +The exact TypeGPU shader, program, variant codec, direct engine, pass-encoding, and ownership declarations live in the +[TypeGPU raster programs and text engine](typegpu-api.md) specification. + +TSL is a Three.js node-graph API and produces Three materials, so a completed TSL program remains Three-only. +`@typegpu/three@0.11.0` can inject a resolved zero-argument TypeGPU WGSL closure through Three's WebGPU node builder, while +`fromTSL()` exposes supported Three-owned data nodes inside that closure. Inspection of that release found no forced-WebGL2 +path and no demonstrated sampleable-resource bridge for dependent Slug loads; it is not a general TypeGPU-to-native-TSL +translation: + +```ts +const coverageNode = t3.toTSL(() => { + 'use gpu'; + return mtsdfCoverage(readMtsdfContextFromTsl()); +}); +createThreeMtsdfTarget({ renderer, program: createThreeMtsdfTslProgram({ coverageNode }) }); +``` + +The sample can admit limited pure-WebGPU math inside an optional Three program. It does not establish a complete second +Bitmap/MTSDF/Slug implementation. If a future exact-version gate proves the full resource and stage contract, TypeGPU could +become authoritative for that supported path while the Three adapter still owns material construction, accessors, +blending, depth, pipeline state, and lifecycle. + +The bridge is currently WebGPU-only according to the official `@typegpu/three` documentation. It cannot replace the native +TSL path while the Three integration promises WebGL2. The TypeGPU-authored path remains an experiment until the +implementation proof: + +- compiles against the repository-pinned Three.js version and the selected `@typegpu/three` version; +- proves the real texture/resource ABI, dependent Slug loads, vertex-stage Bitmap snapping and Slug dilation, and returned + value shapes rather than only a constant-color fragment; +- either supplies the required forced-WebGL2 path or remains an explicitly WebGPU-only optional package; +- inspects the emitted WebGPU shader and proves Bitmap and Slug output parity against the native TSL path; +- measures tree-shaken raw, gzip, and Brotli transfer cost plus graph construction and shader compilation cost; +- distinguishes `typegpu`, `@typegpu/three`, transform metadata, and optional build-plugin cost; +- keeps the dependency in an optional external shader/integration package so the default Three path and portable technique + do not pay for it. + +The npm package's unpacked size is not application bundle evidence. If the proof makes TypeGPU the authoritative source, +the measured generated program—not package metadata—owns the cost decision. + +## Package the boundaries independently + +The dependency direction is one-way: + +```ts +RasterBaker + -> RasterTechnique + -> TypeGpuShaderLogic? + -> TypeGpuRasterProgram -> EngineTarget + -> @typegpu/three toTSL -> ThreeTslRasterProgram -> ThreeTarget + -> NativeThreeTslRasterProgram? -> ThreeTarget +``` + +Do not publish one monolithic “raster plugin” that imports an engine at its portable entry point. A technique package may +offer several subpaths, but importing its baker or portable runtime must not load Three.js, TypeGPU, Wayfare, or another +engine. + +The implementation proof must demonstrate: + +```ts +expect(mtsdfBaker).not.toImportAnyRenderer(); +expect(mtsdfTechnique).not.toImportAnyRenderer(); + +expect(threeMtsdfTarget.technique).toBe(mtsdfTechnique); +expect(typeGpuMtsdfProgram.technique).toBe(mtsdfTechnique); +expect(typeGpuThreeMtsdfProgram.technique).toBe(mtsdfTechnique); + +expect(threeRevision.glyphRuns).toEqual(typeGpuRevision.glyphRuns); +expect(threeRevision.storageBytes).toEqual(typeGpuRevision.storageBytes); +expect(await render(typeGpuThreeMtsdfProgram)).toMatchRaster(await render(nativeThreeMtsdfProgram)); +``` + +Visual equivalence remains a renderer proof. Shared artifacts and CPU bytes prove that an engine adapter is consuming the +same technique contract; they do not by themselves prove shader output, blending, color space, transforms, or ordering. diff --git a/docs/planning/renderer-capabilities.md b/docs/planning/renderer-capabilities.md index 481cd163..dab7c1ec 100644 --- a/docs/planning/renderer-capabilities.md +++ b/docs/planning/renderer-capabilities.md @@ -1,7 +1,7 @@ --- type: Reference title: Renderer capability matrix -description: Compares evidence-backed V1 raster roles and explicitly planned later capabilities across bitmap, MSDF, and Slug. +description: Compares evidence-backed merged v0 raster roles and explicitly planned target v1 or later capabilities across bitmap, MSDF, and Slug. tags: [rendering, bitmap, msdf, mtsdf, slug, games] sources: - id: 'citation-1-1' @@ -40,21 +40,21 @@ sources: generated: by: 'openai-codex/gpt-5.6' - at: '2026-08-03T15:29:54Z' + at: '2026-08-07T01:16:02Z' --- # Renderer capability matrix -This matrix separates the evidence-backed V1 raster roles from explicitly planned later capabilities. The public MSDF +This matrix separates the evidence-backed merged v0 raster roles from explicitly planned target v1 or later capabilities. The public MSDF raster uses one MTSDF RGBA atlas; MTSDF is its encoding, not another selectable engine. The recommendation and current -scale/effect boundaries below are release claims. Rows describing later color, paging, mixed-raster, or expanded-effect -work remain intended capabilities rather than claims about the V1 implementation. +scale/effect boundaries below are implementation evidence, not release claims. Rows describing later color, paging, mixed-raster, or expanded-effect +work remain intended capabilities rather than claims about the merged v0 implementation. | Symbol | Meaning | | :----: | ------------------------------------------------------------------------- | | ✅ | Natural, fully intended capability | | ⚠️ | Supported with a bounded range, extra pass/data, or documented constraint | -| 🟡 | Planned additive capability; not implemented in V1 | +| 🟡 | Planned additive capability; not implemented in merged v0 | | ❌ | Not represented by this technique; choose another raster | ## Styling and effects @@ -98,7 +98,7 @@ Notes: Notes: -1. V1 bakes monochrome OpenType outlines only. The color and standalone-SVG rows are additive plans, not accepted input +1. Merged v0 bakes monochrome OpenType outlines only. The color and standalone-SVG rows are additive plans, not accepted input paths in the current baker or renderer. 2. A future Bitmap color path can flatten supported source artwork to RGBA strikes, but loses vector palette behavior. 3. Arbitrary SVG paint and embedded images are not distance fields. Any future MSDF admission must define a supported @@ -137,17 +137,17 @@ All rasters consume the same result for: - bidi ordering, wrapping, alignment, clipping, and ellipsis; - font-scoped glyph identity and, after its roadmap milestone, mixed-font fallback. -Switching raster must never reshape text or change line breaks. V1 selects one raster per font slot. The additive color-emoji/SVG lane may later assign a raster per glyph by combining each artifact's `page = 0xffff` availability sentinel with an explicit raster-priority policy and passing the resulting glyph mask through the required `stageBatch` transaction; that mechanism is not part of the first release contract. +Switching raster must never reshape text or change line breaks. Merged v0 selects one raster per font slot. The additive color-emoji/SVG lane may later assign a raster per glyph by combining each artifact's `page = 0xffff` availability sentinel with an explicit raster-priority policy and passing the resulting glyph mask through the required `stageBatch` transaction; that mechanism is not part of the merged v0 contract. ## Recommendation -- Use **MSDF** for ordinary scalable game and UI text and inexpensive runtime outlines/effects. Its V1 MTSDF encoding +- Use **MSDF** for ordinary scalable game and UI text and inexpensive runtime outlines/effects. Its merged v0 MTSDF encoding passed the shared workload, DPR, transform, effects, source-outline error, atlas, and dual-backend gates recorded in the [benchmark evidence](../packages/benchmarks.md). - Use **bitmap strikes** for tiny, known-density, or intentionally pixel-authored text. The exact DPR-1/DPR-2 strike and CPU/GPU frame oracles establish this role; bitmap does not silently approximate unsupported outline or shadow effects. - Use **Slug** for large or deeply zoomed fill text and intricate monochrome outlines. The 36-cell dual-backend/DPR - release-role matrix covers large size, 1,024-ppem magnification, complex scripts, clipping, affine transforms, and + raster-role matrix covers large size, 1,024-ppem magnification, complex scripts, clipping, affine transforms, and projection zoom against source outlines. Slug V0 deliberately rejects outline, shadow, and color-layer paint. - Keep the choice explicit. `pmndrs/text` may expose recommendations and capabilities, but it does not silently switch engines. diff --git a/docs/planning/slug-audit.md b/docs/planning/slug-audit.md index 8e0055d4..b96f330c 100644 --- a/docs/planning/slug-audit.md +++ b/docs/planning/slug-audit.md @@ -15,8 +15,8 @@ sources: title: 'Research bibliography' generated: - by: 'openai-codex/gpt-5' - at: '2026-07-25T01:24:00Z' + by: openai-codex/gpt-5.6 + at: '2026-08-07T01:16:02Z' --- # Three Flatland Slug audit @@ -166,7 +166,7 @@ Disposition: - preserve the algorithm and tests as the initial Slug generator candidate; - store chosen band counts/limits in raster metadata where necessary; -- benchmark font corpus distributions before fixing V1 limits; +- benchmark font corpus distributions before fixing target v1 limits; - reject or adapt glyphs that exceed shader/runtime capacity instead of truncating. ### Slug texture packing: port format intent, revisit constraints diff --git a/docs/planning/text-effect-composition.md b/docs/planning/text-effect-composition.md index 92c89012..bbb48f86 100644 --- a/docs/planning/text-effect-composition.md +++ b/docs/planning/text-effect-composition.md @@ -1,9 +1,10 @@ --- -type: Research Note -title: Composable text effects over TSL -description: Proposes a raster-independent node-composition seam for GPU text effects without baking product-specific shaders into core. -tags: [rendering, effects, tsl, webgpu, webgl2, research] -status: draft +type: API Specification +title: Three.js text effect composition +description: Optional TSL convenience for composing parameterized effects after canonical raster technique shaders while core carries only opaque render variants. +documentation_type: reference +tags: [rendering, effects, tsl, threejs, webgpu, variants] +status: stable sources: - id: raster-contract resource: ../../packages/text/src/raster.ts @@ -17,65 +18,131 @@ sources: - id: tsl-skill resource: ../../.agents/skills/tsl/SKILL.md title: Repository TSL implementation guidance + - id: core-api + resource: core-api.md + title: Core render variants and glyph runs + - id: three-api + resource: three-api.md + title: Three.js text API + - id: typegpu-api + resource: typegpu-api.md + title: TypeGPU raster programs and text engine generated: by: openai-codex/gpt-5.6 - at: '2026-07-27T12:07:13Z' + at: '2026-08-07T03:25:58Z' --- -# Composable text effects over TSL +# Three.js text effect composition -This note records a research direction, not an accepted public API. The immediate Paint & Effects benchmark updates retained `Text` instances through their synchronous paint-only path: React does not drive the animation, shaping and layout remain unchanged, and the raster batch rewrites only owned instance paint attributes. Moving an effect such as a per-word hue phase entirely onto the GPU first requires a general composition seam. A rainbow-specific branch in core or in the shared MTSDF material would be the wrong abstraction. - -## Proposed boundary - -An effect should compose over a raster's resolved fragment result rather than replace its sampling implementation. The following sketch communicates ownership and chaining; exact names and types remain evidence-gated: +`TextEffect` is optional Three/TSL program authoring sugar. It is not a core concept. Core carries an integration-defined +`renderVariant` from batch, paragraph, and span state into ordered glyph runs; the selected Three program interprets it. ```ts -const chromaticPaint = defineTextEffect({ - key: 'chromatic-paint-v1', - uniforms: { phase: 0 }, - compose({ color, opacity, paintIndex, uniforms }) { +const chromatic = defineTextEffect(slugShader, { + parameters: { phase: 'f32' }, + compose(base, parameters, context) { return { - color: chromaticColor(color, paintIndex, uniforms.phase), - opacity, + ...base, + color: chromaticColor(base.color, context.paintIndex, parameters.phase), }; }, }); const text = new Text({ - effects: [chromaticPaint], + font, + text: 'Spectrum', + renderVariant: { + effects: [chromatic.bind({ phase: phaseUniform })], + }, }); ``` -The effect list composes in declaration order. Each stage receives the previous stage's color and opacity plus a deliberately small semantic context. Bitmap, MTSDF, and Slug retain ownership of atlas or curve sampling, coverage reconstruction, clipping, outline limits, shadows, and technique-specific validation. An effect cannot reach into those private graphs or mutate a shared material. +## Complete helper surface -## Required invariants +```ts +type ThreeEffectParameterSchema = Readonly>; +type ThreeNodeFor = Type extends 'f32' + ? ReturnType + : Type extends 'vec2f' + ? ReturnType + : Type extends 'vec3f' + ? ReturnType + : ReturnType; +type ThreeEffectParametersOf = { + readonly [Key in keyof Schema]: ThreeNodeFor; +}; + +interface ThreeTextEffectDefinition { + readonly shader: Shader; + readonly parameters: Schema; + compose( + base: ThreeRasterFragmentOutputOf, + parameters: ThreeEffectParametersOf, + context: ThreeRasterFragmentContextOf, + ): ThreeRasterFragmentOutputOf; + bind(parameters: ThreeEffectParametersOf): ThreeTextEffectBinding; +} + +interface ThreeTextEffectBinding { + readonly effect: ThreeTextEffectDefinition; + readonly parameters: ThreeEffectParametersOf; +} + +declare function defineTextEffect( + shader: Shader, + definition: Omit, 'shader' | 'bind'>, +): ThreeTextEffectDefinition; + +interface ThreeRenderVariant { + readonly effects?: readonly ThreeTextEffectBinding[]; +} +``` -- **Graph identity:** every effect supplies a deterministic key for its graph shape. Material variants cache by raster identity plus the ordered effect-key list; uniform values are never part of that key. -- **Object-local uniforms:** changing `phase.value` updates one retained `Text` without rebuilding a node graph, replacing geometry, touching React, or changing another text object that shares the same graph variant. -- **Technique-independent inputs:** core exposes only reviewed semantic nodes such as resolved color, opacity, paint/span index, glyph index, and normalized local coordinates. A new input is added only when all intended raster adapters can define it precisely. -- **Compositional output:** a stage returns color and opacity nodes for the next stage. Coverage stays raster-owned unless a separately reviewed effect class explicitly requires geometry or coverage expansion. -- **Shared-material safety:** ordinary unmodified text continues to share the raster's canonical material. Effects use a cached variant and per-object bindings; no caller mutates the singleton atlas material. -- **Backend parity:** the same public TSL graph must compile through the installed Three.js `WebGPURenderer` for asserted WebGPU and forced WebGL2. Backend-specific shader strings are not part of the public contract. -- **Failure and disposal:** unsupported semantic inputs fail before publication. Effect-owned uniforms, buffers, and material variants have explicit owners and deterministic disposal. -- **Performance accounting:** measurements separate graph construction/compilation, first pipeline creation, uniform updates, instance uploads avoided, CPU submission, and GPU frame time. A GPU path is adopted only if it materially improves the complete workload rather than moving unmeasured work. +The shader argument is what makes the callback contextual: `base`, `context`, and the return type come from that exact +shader, while the literal schema maps every parameter key to its TSL node type. No type parameter is expected to infer only +from a callback parameter position. The heterogeneous binding list is erased only after construction; the standard program +narrows it by retained effect-definition identity before composing or writing parameters. -## Paint identity and word phases +## Composition boundary -The current paragraph model already resolves span paint into per-glyph paint indices. A GPU hue effect should consume a stable semantic paint/span index rather than infer words from glyph IDs, clusters, positions, or display text. The host may assign authored word phases once when it constructs spans; the RAF then changes only an object-local phase uniform. That preserves complex-script shaping and keeps word segmentation outside the shader. +Every effect composes after the program's canonical technique shader: -This semantic attribute needs an explicit batching contract. Reusing a palette index is safe only if its identity remains stable across repainting and the renderer does not deduplicate distinct authored phases merely because their current colors match. Otherwise the raster batch needs a separate compact effect index. The choice requires layout, batching, byte-size, and cross-raster evidence before it becomes API. +```ts +let output = slugShader.fragment(context); // canonical curve traversal and coverage +for (const binding of variant.effects ?? []) { + output = composeKnownEffect(output, binding, context); +} +return output; +``` + +Bitmap, MTSDF, and Slug retain atlas/curve sampling, coverage reconstruction, clipping, outline constraints, and +technique-specific validation. An effect changes resolved output; it does not replace the hard raster algorithm. A custom +`ThreeRasterProgram` may bypass this helper and define its own variant contract while still calling the same exported +technique shader. -## Admission gate +## Batching contract -Do not add `effects` to public `Text` until a prototype proves all of the following on the repository's exact Three.js and TypeScript pins: +Effect-definition identity and declaration order determine graph compatibility. Parameter values do not. The standard +program may therefore place bindings for many texts and spans into indexed sidecar storage and draw them together through +one material. A different ordered definition list requires another material/pipeline variant and may split the draw plan. -1. two effects chain in a deterministic order without broad type erasure; -2. two text objects share graph structure while retaining independent uniforms; -3. Bitmap and MTSDF produce the intended effect without exposing private sampling nodes; -4. toggling or disposing an effect leaves no stale material, uniform, listener, or GPU resource; -5. WebGPU and forced WebGL2 execute the real graph with causal pixel evidence and negative controls; -6. a live workload shows a material CPU or upload improvement over the retained paint-only batch update; and -7. initial browser-core size and untouched-text pipeline counts remain within their existing budgets. +Core only preserves variant boundaries and text order. It neither assigns TSL material keys nor forces one draw per effect. +Changing a paragraph/span binding rebuilds core glyph runs without reshaping. Updating a stable uniform or sidecar binding +may require no core call and no instance-buffer rewrite. + +## Required invariants -Until that gate closes, product demonstrations should use the existing direct `Text.setProperties` paint-only update path and describe its measured CPU/upload cost honestly. +- effects compose in declaration order over the previous resolved output; +- graph identity is definition identity plus ordered composition, never current parameter values; +- parameters remain text/span-local even when materials and pipelines are shared; +- semantic context is explicit and small: resolved output, paint/span index, glyph index, and normalized local coordinates; +- unsupported semantic inputs fail while staging and do not replace the live target revision; +- effect bindings and material variants have deterministic leases and disposal; +- TypeGPU-authored pure WebGPU math may adapt only within capabilities proven for the pinned `toTSL()` bridge, while native + TSL effects remain Three-specific; and +- proof measures graph construction, first pipeline creation, parameter updates, upload changes, CPU submit, GPU time, and + untouched-text bundle/pipeline cost. + +The API is complete only after two chained effects, shared graph/independent parameters, Bitmap and Slug composition, +disposal, pinned WebGPURenderer output, and single-draw multi-variant batching have causal tests. This is an integration +feature layered on the accepted core variant contract; failure of the convenience helper cannot remove core customization. diff --git a/docs/planning/three-api.md b/docs/planning/three-api.md new file mode 100644 index 00000000..2e285a84 --- /dev/null +++ b/docs/planning/three-api.md @@ -0,0 +1,1203 @@ +--- +type: API Specification +title: Three.js text API +description: Target v1 API for an external Three.js integration package that loads fonts, declares scene-local text batches, retains transform-bearing Text objects, and synchronizes hidden core work inside the Three.js render lifecycle. +documentation_type: reference +tags: [api, threejs, fonts, text, batching, lifecycle, rendering] +status: stable +sources: + - id: core-api + resource: core-api.md + title: Core text API + - id: engine-contract + resource: engine-integration-contract.md + title: Engine integration contract + - id: effect-composition + resource: text-effect-composition.md + title: Optional Three.js effect composition + - id: typegpu-api + resource: typegpu-api.md + title: TypeGPU raster programs and text engine + - id: current-loader + resource: ../../packages/text/src/loader.ts + title: Current font loader + - id: current-text + resource: ../../packages/text/src/text.ts + title: Current Three.js Text lifecycle + - id: three-object3d + resource: https://threejs.org/docs/pages/Object3D.html + title: Three.js Object3D + - id: three-loader + resource: https://threejs.org/docs/pages/Loader.html + title: Three.js Loader + - id: three-group + resource: https://threejs.org/docs/pages/Group.html + title: Three.js Group + - id: three-buffer-attribute + resource: https://threejs.org/docs/pages/BufferAttribute.html + title: Three.js BufferAttribute +generated: + by: openai-codex/gpt-5.6 + at: '2026-08-07T03:25:58Z' +--- + +# Three.js text API + +`@pmndrs/text-three` is an engine integration over public `@pmndrs/text` contracts. It may be maintained in this monorepo or +an external repository; core never imports Three.js, and consumers do not need an `@pmndrs/text/three` subpath. + +Three.js owns the core API internally. A Three.js application never creates a `TextRuntime`, +`ParagraphBatch`, `Paragraph`, prepared revision, or glyph run. + +```ts +FontLoader + -> LoadedFont[] + -> TextGroup // explicit batch for one scene render phase + -> Text[] // transform-bearing Three.js objects + -> renderer.render(scene, camera) // membership, shaping, packing, and uploads synchronize here +``` + +## The complete public surface + +```ts +import * as THREE from 'three/webgpu'; +import * as TSL from 'three/tsl'; +import type { + FontSelection, + FormattedText, + GlyphBatchKey, + GlyphRange, + ParagraphBatchTargetError, + PreparedGlyphBatch, + PreparedGlyphRun, + RasterBindingOf, + TextInput, + TextPreparationError, +} from '@pmndrs/text'; + +type TextError = TextPreparationError | ParagraphBatchTargetError; + +interface ThreeRenderVariant { + readonly effects?: readonly ThreeTextEffectBinding[]; +} + +type ThreeEffectParameterType = 'f32' | 'vec2f' | 'vec3f' | 'vec4f'; +type ThreeEffectParameterSchema = Readonly>; +type ThreeEffectParametersOf = { + readonly [Key in keyof Schema]: Schema[Key] extends 'f32' + ? ReturnType + : Schema[Key] extends 'vec2f' + ? ReturnType + : Schema[Key] extends 'vec3f' + ? ReturnType + : ReturnType; +}; + +interface ThreeTextEffectDefinition, Schema extends ThreeEffectParameterSchema> { + readonly shader: Shader; + readonly parameters: Schema; + compose( + base: ThreeRasterFragmentOutputOf, + parameters: ThreeEffectParametersOf, + context: ThreeRasterFragmentContextOf, + ): ThreeRasterFragmentOutputOf; + bind(parameters: ThreeEffectParametersOf): ThreeTextEffectBinding; +} + +interface ThreeTextEffectBinding< + Shader extends AnyThreeRasterShader = AnyThreeRasterShader, + Schema extends ThreeEffectParameterSchema = ThreeEffectParameterSchema, +> { + readonly effect: ThreeTextEffectDefinition; + readonly parameters: ThreeEffectParametersOf; +} + +declare function defineTextEffect< + Shader extends AnyThreeRasterShader, + const Schema extends ThreeEffectParameterSchema, +>( + shader: Shader, + definition: Omit, 'shader' | 'bind'>, +): ThreeTextEffectDefinition; + +type ThreeProgramVariantKey = PropertyKey | object; + +declare const threeRasterShaderTypes: unique symbol; +interface ThreeRasterShaderTypeMap { + readonly vertexContext: VertexContext; + readonly vertexOutput: VertexOutput; + readonly fragmentContext: FragmentContext; + readonly fragmentOutput: FragmentOutput; +} + +interface AnyThreeRasterShader { + readonly technique: Technique; + readonly [threeRasterShaderTypes]?: ThreeRasterShaderTypeMap; +} + +interface ThreeRasterShader + extends AnyThreeRasterShader { + readonly [threeRasterShaderTypes]?: ThreeRasterShaderTypeMap; + vertex(context: VertexContext): VertexOutput; + fragment(context: FragmentContext): FragmentOutput; +} + +type ThreeRasterShaderTypesOf> = NonNullable< + Shader[typeof threeRasterShaderTypes] +>; +type ThreeRasterFragmentContextOf> = + ThreeRasterShaderTypesOf['fragmentContext']; +type ThreeRasterFragmentOutputOf> = + ThreeRasterShaderTypesOf['fragmentOutput']; + +interface ThreeMtsdfVertexContext { + readonly localPosition: ReturnType; + readonly glyphIndex: ReturnType; + readonly viewport: ReturnType; + readonly modelViewProjection: THREE.Node; + readonly instance: ThreeMtsdfInstanceNodes; + readonly resources: ThreeMtsdfResourceNodes; +} + +interface ThreeMtsdfInstanceNodes { + readonly origin: ReturnType; + readonly fontSize: ReturnType; + readonly glyphRecord: ReturnType; + readonly paintIndex: ReturnType; +} + +interface ThreeMtsdfResourceNodes { + readonly atlas: THREE.Node; + readonly emSize: ReturnType; + readonly pixelRange: ReturnType; +} + +interface ThreeDerivativeNodes { + fwidth(value: THREE.Node): THREE.Node; +} + +interface ThreeRasterVertexOutput { + readonly position: ReturnType; + readonly techniqueVaryings: Readonly>; +} + +interface ThreeRasterFragmentOutput { + readonly color: ReturnType; + readonly coverage: ReturnType; +} + +interface ThreeMtsdfFragmentContext { + readonly localPosition: ReturnType; + readonly glyphIndex: ReturnType; + readonly paintIndex: ReturnType; + readonly screenScale: ReturnType; + readonly derivatives: ThreeDerivativeNodes; + readonly instance: ThreeMtsdfInstanceNodes; + readonly resources: ThreeMtsdfResourceNodes; +} + +interface ThreeProgramMaterialContext> { + readonly renderer: THREE.WebGPURenderer; + readonly shader: Shader; + readonly font: LoadedFont; + readonly binding: RasterBindingOf; + readonly pipelineVariant: number; +} + +interface ThreeProgramVariantWriteContext { + readonly runs: readonly PreparedGlyphRun[]; + readonly ranges: readonly GlyphRange[]; +} + +interface ThreeProgramRunContext { + readonly glyphBatches: readonly PreparedGlyphBatch[]; + readonly glyphRuns: readonly PreparedGlyphRun[]; +} + +interface ThreeProgramDraw { + readonly object: THREE.Object3D; + readonly batch: GlyphBatchKey; + readonly start: number; + readonly count: number; +} + +interface ThreeRasterProgram< + Technique extends AnyRasterTechnique, + Variant, + Shader extends AnyThreeRasterShader = AnyThreeRasterShader, +> { + readonly technique: Technique; + readonly shader: Shader; + readonly cacheLimits: { + readonly pipelines: number; + readonly materializedVariants: number; + }; + supportsVariant(value: unknown): value is Variant; + variantKey(value: Variant | undefined): ThreeProgramVariantKey; + createMaterial(context: ThreeProgramMaterialContext): THREE.NodeMaterial; + writeVariants(context: ThreeProgramVariantWriteContext): void; + compileRuns(context: ThreeProgramRunContext): readonly ThreeProgramDraw[]; + dispose(): void; +} + +declare function defineThreeRasterProgram< + Technique extends AnyRasterTechnique, + Variant, + Shader extends AnyThreeRasterShader, +>(program: ThreeRasterProgram): ThreeRasterProgram; + +interface FontLoaderOptions { + readonly runtimeBake?: RuntimeFontBake; + readonly createWorker?: () => TextPreparationWorker; +} + +declare class FontLoader extends THREE.Loader, LoadedFontRequest> { + constructor(manager?: THREE.LoadingManager, options?: FontLoaderOptions); + + load( + request: LoadedFontRequest, + onLoad: (font: LoadedFont) => void, + onProgress?: (event: ProgressEvent) => void, + onError?: (error: unknown) => void, + ): void; + + loadAsync( + request: LoadedFontRequest, + onProgress?: (event: ProgressEvent) => void, + ): Promise>; + + dispose(): void; +} + +declare class TextGroup extends THREE.Object3D { + constructor(options: TextGroupOptions); + + readonly technique: Technique; + readonly capacity: GlyphBufferCapacity; + readonly program: ThreeRasterProgram; + readonly textCount: number; + readonly disposed: boolean; + readonly error: TextError | undefined; + onError: ((error: TextError) => void) | undefined; + renderVariant: Variant | undefined; + + add( + ...children: CompatibleTextChildren + ): this; + setCapacity(capacity: GlyphBufferCapacity): void; + retry(): void; + clone(recursive?: boolean): never; + copy(source: THREE.Object3D, recursive?: boolean): never; + dispose(): void; +} + +declare class Text extends THREE.Object3D { + constructor(properties: StandaloneTextProperties); + + readonly textGroup: TextGroup | undefined; + readonly bound: boolean; + readonly disposed: boolean; + readonly layout: ParagraphLayout | undefined; + readonly error: TextError | undefined; + onError: ((error: TextError) => void) | undefined; + + font: FontSelection; + get text(): string; + set text(value: TextInput); + spans: readonly TextSpan[]; + contentBox: ParagraphContentBox; + style: ParagraphStyle; + paint: GlyphPaintInput; + rasterPixelRatio: number; + renderVariant: Variant | undefined; + + set(properties: TextUpdate): void; + setSpan(index: number, span: TextSpan): void; + removeSpan(index: number): void; + + snapshotGlyphs(): GlyphSnapshot; + setGlyphOrigins(update: GlyphOriginUpdate): void; + clearGlyphOriginOverrides(): void; + + setCapacity(capacity: GlyphBufferCapacity): void; + retry(): void; + dispose(): void; +} + +type SameType = [Left] extends [Right] ? ([Right] extends [Left] ? true : false) : false; + +type CompatibleTextChildren< + Technique extends AnyRasterTechnique, + Variant, + Children extends readonly THREE.Object3D[], +> = { + readonly [Index in keyof Children]: Children[Index] extends Text + ? SameType extends true + ? SameType extends true + ? Children[Index] + : never + : never + : Children[Index]; +}; + +export { txt, span } from '@pmndrs/text'; +export { defineTextEffect, defineThreeRasterProgram }; +export type { + FormattedText, + GlyphBufferCapacity, + SpanFormat, + SpanStyle, + SpanTag, + TextPreparationError, + UnboundSpanTag, +} from '@pmndrs/text'; +export type { ThreeRasterProgram, ThreeRasterShader, ThreeRenderVariant, ThreeTextEffectBinding }; +``` + +There is deliberately no universal four-field raster context. Each first-party shader exports its exact resource, +instance, vertex-context/output, and fragment-context/output types. Bitmap includes viewport/device-pixel snapping inputs; +MTSDF includes atlas access, `emSize`, `pixelRange`, derivatives, and screen scale; Slug includes curve/header/reference +resources, band bases, dilation inputs, and dependent-load accessors. The associated type map carries those exact types into +`createMaterial()` and `defineTextEffect()`. Adding a technique means defining those semantics, not widening a shared +context with optional fields. + +First-party programs keep bounded material/pipeline and materialized-variant caches. Factory options declare the limits, +eviction retires resources through renderer-safe disposal, and `program.dispose()` releases every remaining entry. A fresh +object-valued variant each frame therefore cannot grow the cache without bound. Custom programs own and document the same +policy. + +## Load fonts with the Three.js loader + +```ts +import { createFontStack } from '@pmndrs/text'; +import { FontLoader } from '@pmndrs/text-three'; +import { mtsdf } from '@pmndrs/text/raster/mtsdf'; + +const loader = new FontLoader(); + +const [inter, noto, iconFont] = await Promise.all([ + loader.loadAsync({ + input: { baked: '/fonts/Inter.font.glb' }, + raster: { technique: mtsdf }, + }), + loader.loadAsync({ + input: { baked: '/fonts/NotoSans.font.glb' }, + raster: { technique: mtsdf }, + }), + loader.loadAsync({ + input: { baked: '/fonts/Icons.font.glb' }, + raster: { technique: mtsdf }, + }), +]); + +const uiFont = createFontStack(inter, noto); +``` + +The first load in a Three font-cache domain lazily creates the single core text runtime and shaping engine. Concurrent loads +share that initialization Promise, and later loaders in the same domain reuse the resolved runtime and shaper. The cache +domain is integration-owned; Three users do not construct a core registry or runtime. `loadAsync()` does not resolve until +the font, selected technique data, and synchronous shaper are ready. Loading remains an explicit application wait; shaping +ordinary warm edits does not become a readiness Promise. + +The callback `load()` and Promise-returning `loadAsync()` follow the standard Three.js loader pattern and participate in the +provided `LoadingManager`. The loaded font is a Three-surface handle; it does not expose the hidden core runtime or core font +handle. + +Constructing a `Text` acquires a lease on every concrete font in its `Font` or `FontStack`, even while the object is +detached. Changing `text.font` acquires the complete replacement selection before releasing the old leases. +`LoadedFont.dispose()` fails while a live `Text` lease remains, so disposing a group or moving text can never silently drop +fallback data or replace a glyph with missing-glyph output. A `FontStack` value alone owns no lease; using a stack with a +successfully disposed member for a new `Text` is rejected. + +## Create an explicit batch with `TextGroup` + +```ts +import { TextGroup } from '@pmndrs/text-three'; + +const worldText = new TextGroup({ + technique: mtsdf, +}); + +scene.add(worldText); +``` + +```ts +interface TextGroupOptions { + readonly technique: Technique; + readonly program?: ThreeRasterProgram; + readonly capacity?: GlyphBufferCapacity; + readonly renderOrder?: number; + readonly renderVariant?: Variant; +} + +interface GlyphBufferCapacity { + readonly size: number; + readonly policy: 'grow' | 'chunk' | 'fixed'; +} +``` + +## Select a program and render variant + +`technique` remains construction-only because it fixes decoded resources and canonical glyph-buffer layout. `program` is +also construction-only because it fixes the accepted variant type, technique shader, Three attributes, node material, +pipeline compatibility, and final draw compiler. Fonts remain per `Text` and are never declared on the group. + +```ts +const gradientSlug = createThreeSlugProgram({ + fragment({ shader, context }) { + const base = shader.fragment(context); + return { ...base, color: gradient(base.color, context.localPosition) }; + }, +}); + +const labels = new TextGroup({ + technique: slug, + program: gradientSlug, + renderVariant: { gradient: 'ui-default' }, +}); + +const label = new Text({ + font: uiFont, + text: 'Warning', + renderVariant: { gradient: 'warning' }, +}); +labels.add(label); +``` + +The first-party default program is selected when `program` is omitted. A group, text, and manual span may each set a +variant; inheritance is group → text → span. The hidden core batch carries those exact values through ordered glyph runs. +The Three program decides whether adjacent variants use one material/draw with indexed sidecar parameters or require +separate draw proxies. A variant is not automatically a material and is not automatically a draw boundary. + +The standard programs accept `ThreeRenderVariant`, whose optional `effects` list is produced by the effect helpers: + +```ts +const chromatic = defineTextEffect(slugShader, { + parameters: { phase: 'f32' }, + compose(base, parameters, context) { + return { ...base, color: chromaticColor(base.color, context.paintIndex, parameters.phase) }; + }, +}); + +const animated = chromatic.bind({ phase: phaseUniform }); +const effectLabels = new TextGroup({ technique: slug }); // standard ThreeRenderVariant program +const effectLabel = new Text({ + font: uiFont, + text: 'Warning', + renderVariant: { effects: [animated] }, +}); +effectLabels.add(effectLabel); + +effectLabel.setSpan(0, { + start: 0, + end: 7, + renderVariant: { effects: [animated] }, +}); +``` + +Effects compose after the canonical Bitmap, MTSDF, or Slug shader has resolved coverage and base output. Definitions with +the same ordered graph identity share a material program; binding values are stored per text/span and do not create a new +pipeline. This is an optional TSL authoring convenience, not a core API and not a requirement for custom programs. A custom +program may define a completely different `Variant` type while still reusing the exported canonical technique shader. + +React Three Fiber expresses the same span variant through nested text: + +```tsx + + Normal animated + +``` + +An optional TypeGPU-authored pure-WebGPU function may enter a Three program only through capabilities proven for a pinned +`@typegpu/three` version. At the reviewed 0.11.0 bridge, `toTSL()` injects a nullary WGSL closure through Three's WebGPU +builder, has no WebGL2 path, and has not carried the real Slug resources. Three still owns accessors, material, blend/depth +state, render-list integration, draw compilation, and lifecycle. Native TSL remains the only specified complete Three +program; any adapted TypeGPU shader is an experimental program implementation, not a different core technique. + +### Use the default or preallocate explicitly + +An explicit `TextGroup` defaults to `{ size: 4_096, policy: 'chunk' }`. Storage is allocated lazily for each physical +font-resource buffer, so an empty group allocates no glyph arrays or GPU buffer. Text objects and their metadata are not +capacity-limited. + +```ts +const denseText = new TextGroup({ + technique: mtsdf, + capacity: { size: 20_000, policy: 'chunk' }, +}); +``` + +`size` counts glyph-instance slots per physical buffer, not texts and not total glyphs across the `TextGroup`. `chunk` +allocates another buffer without replacing published storage, `grow` transactionally replaces the full buffer with a +buffer whose capacity doubles until the pending glyphs fit, and `fixed` makes `size` a hard per-buffer limit. The readonly +`capacity` property exposes the normalized explicit or default value. + +`add()` validates text lifetime, font lifetime, and technique compatibility. It does not shape, so it cannot know whether a +fixed physical buffer will overflow. That check occurs during the owning group's pre-render synchronization, after fallback, +shaping, and layout reveal exact per-resource glyph counts. + +Resize explicitly when a fixed group needs a larger allocation: + +```ts +const overflow = labels.error; +if (overflow?.kind !== 'capacity-exceeded') throw new Error('No fixed-capacity overflow to resize'); + +labels.setCapacity({ size: overflow.required, policy: 'fixed' }); +``` + +`setCapacity()` preserves the public `TextGroup`, every nested `Text`, and every bound core `Paragraph`. It forwards the +normalized capacity to the existing hidden `ParagraphBatch`, clears an unchanged capacity-overflow latch, and schedules a +transactional canonical-storage and target-storage replacement for the next synchronization. The previous complete draw +objects remain live until the replacement commits; renderer fences then retire them normally. No scene reparenting, +listener transfer, ref replacement, or cleanup is required. + +`fixed` prevents automatic growth; it does not make the configured size permanently immutable. `setCapacity()` may also +switch policies or shrink deliberately. Passing the current normalized capacity is a no-op. A shrink that cannot hold the +desired generation reports the ordinary typed overflow while preserving the prior complete draw. + +`TextGroup.clone()` and `TextGroup.copy()` are unsupported and throw. A group owns identity-bearing text membership, +subscriptions, attachment state, and renderer resources that cannot follow ordinary recursive `Object3D` copy semantics +safely. Construct a separate group and add intentionally distinct `Text` objects when a second independently renderable +tree is required. + +A `TextGroup` is one author-declared text render phase and one hidden core paragraph batch. Its technique fixes the +canonical instance layout and shader family before any text is attached. Every `Text` owns its font selection, which must +use that technique. Core may produce several physical resource batches and ordered variant-bearing glyph runs beneath one +`TextGroup`; the selected program compiles those runs into Three draw objects. + +The `add()` override preserves normal `Object3D` children while conditionally rejecting any directly supplied +`Text` tuple member. Runtime ancestry validation remains mandatory for JavaScript, React reconciliation, +and text nested below arbitrary containers. + +`TextGroup` deliberately extends `THREE.Object3D`, not `THREE.Group`. Three carries the nearest real ancestor Group's +`renderOrder` through non-Group descendants as `groupOrder`; another Group would replace it, including with its default +value of `0`. The integration does not insert a hidden Group. + +`TextGroup.renderOrder` is the secondary render-order base for the batch. The integration maps the program's ordered +physical draws to consecutive native Three render orders beginning at that base. `Text.renderOrder` remains the paragraph +sorting value inside core; it cannot create a Three render-list boundary inside one GPU batch. + +```ts +parent.renderOrder = 100; +parent.add(textGroup); // physical draws use groupOrder 100 + +textGroup.renderOrder = 10; // physical draws begin at secondary order 10 +``` + +A nested `TextGroup` starts a new batch and render-order domain; it never joins its nearest outer `TextGroup`. + +Create separate `TextGroup` instances when text belongs to different scenes, render phases, or renderer lifetimes: + +```ts +const mainSceneText = new TextGroup(worldOptions); +const minimapSceneText = new TextGroup(minimapOptions); + +mainScene.add(mainSceneText); +minimapScene.add(minimapSceneText); +``` + +One Three object can have only one parent, so one `TextGroup` cannot be present in two scenes simultaneously. The group binds +to the first renderer that draws it. Rendering it through a different renderer fails before drawing; create a separate +`TextGroup` so attributes, materials, upload ranges, fences, and retirement remain renderer-owned. Standalone implicit +batches follow the same rule. + +## Add and remove text through the scene graph + +```ts +import { Text } from '@pmndrs/text-three'; + +const label = new Text({ + font: inter, + text: 'Player 1', +}); + +worldText.add(label); + +label.position.set(0, 2, 0); +label.rotation.y = Math.PI / 4; +label.scale.setScalar(2); +``` + +There is no `TextGroup.allocate()` shortcut. Construction creates one retained, late-bound `Text`; inherited +`Object3D.add()` and `Object3D.remove()` are the only membership operations. Adding binds the object to the batch before +the next synchronization. Removing releases its internal paragraph membership without disposing the public object, so it +can be added elsewhere. + +A `Text` joins its nearest `TextGroup` ancestor. Ordinary `Object3D` containers may appear between them. A nested +`TextGroup` stops membership discovery: + +```ts +worldText.add(container); +container.add(label); // label still belongs to worldText + +worldText.add(overlayText); +overlayText.add(icon); // icon belongs to overlayText, never worldText +``` + +## A standalone `Text` is a batch of one + +```ts +const title = new Text({ + font: uiFont, + text: 'Standalone title', +}); + +scene.add(title); +``` + +```ts +type StandaloneTextProperties = TextProperties< + Technique, + Variant +> & + Readonly<{ + capacity?: GlyphBufferCapacity; + }>; +``` + +When a render-attached `Text` has no `TextGroup` ancestor, it owns an implicit paragraph batch containing only itself. Its +required font selection supplies that implicit batch's technique. Adding that same object to a `TextGroup` retires the +implicit batch, validates its font selection against the explicit group technique, and creates new paragraph membership in +the group. + +An unattached or detached `Text` remains unbound and owns no implicit batch. `textGroup.remove(text)` therefore leaves only +the reusable public object and desired state. Adding it directly to a scene later creates its implicit batch before that +scene's first shaping and render; adding it to another `TextGroup` creates membership there instead. The public `Text`, +transform, desired properties, and glyph overrides remain the same object throughout. + +The standalone `capacity` value configures only that implicit batch and defaults to `{ size: 256, policy: 'grow' }` to +avoid reserving a full explicit-group chunk for every isolated label. While the object is inside a `TextGroup`, the parent +group's technique and capacity policy are authoritative; the `Text` always retains its own font selection. +`text.setCapacity()` changes the retained implicit-batch capacity without replacing the `Text`; while grouped, that setting +is retained but inactive until the text becomes standalone again. + +## Bind late; render on the first frame + +Constructing an unattached `Text` stores desired state only. It creates no core paragraph, performs no shaping, allocates no +glyph slots, and creates no GPU object. + +```ts +const score = new Text({ font: inter, text: '0' }); + +score.text = '1'; +score.text = '2'; +score.text = '3'; + +hudText.add(score); +renderer.render(scene, camera); // shapes and renders only "3" +``` + +Membership is resolved before the first shaping call. `Object3D` `added`, `removed`, `childadded`, and `childremoved` +events mark scene membership dirty synchronously. Because those events do not bubble through every arbitrary ancestor +change, `Text` and `TextGroup` perform a final ancestry reconciliation at the start of `updateMatrixWorld()`. + +The integration uses `updateMatrixWorld()` as its automatic synchronization hook. `TextGroup` owns a private +`ThreeTextBatchBinding`; this is the object that holds the core `ParagraphBatch`, its `ParagraphBatchAttachment`, the +`ThreeParagraphBatchTarget`, the internal draw meshes, and the map from each child `Text` to its core `Paragraph`. +It is implementation machinery, not another public API. + +The implementation sequence is: + +```ts +class TextGroup extends THREE.Object3D { + readonly #binding: ThreeTextBatchBinding; + + override updateMatrixWorld(force?: boolean): void { + this.#binding.reconcileMembership(this); + this.#binding.applyPendingMembership(); + + // Runtime-wide: shape, lay out, sort, partition, allocate, pack, and publish. + // Returns runtime.current without allocating when no desired state is dirty. + this.#binding.runtime.update(); + + // Stage only this observed renderer target from the latest core publication. + this.#binding.prepareCurrentRevision(); + + // Commit this target revision. This installs + // the exact internal meshes needed by the program-compiled draw sequence. + this.#binding.commitPreparedRevision(); + + // Three computes this group, every child Text, and every newly installed mesh. + super.updateMatrixWorld(force); + + // Core glyph origins are paragraph-local. Compose them with the now-current + // Text transforms, copy only changed transform slots, and mark those attribute + // ranges for WebGPURenderer. + this.#binding.writeGlyphTransforms(); + } +} +``` + +`applyPendingMembership()` is where scene membership becomes core membership. For each newly bound object it calls +`paragraphBatch.add(text.desiredState)` and records the returned `Paragraph`; for each departure it calls +`paragraph.dispose()`. It applies desired-state setters to already bound paragraphs before `runtime.update()`. No shaping +happens in `Text.text`, `Text.set()`, `Text.setSpan()`, or the scene-graph event handlers. + +`runtime.update()` publishes one atomic `TextRuntimeRevision` and updates the attachment's latest source revision. It never +calls a target or allocates renderer resources. The currently traversed binding then calls: + +```ts +attachment.prepare(); +// coordinator calls threeTarget.stage(attachment.current, attachment.source) +``` + +`ThreeParagraphBatchTarget.stage()` performs the engine-layout work: it creates or reuses the required Three +`BufferAttribute` storage, selects `PreparedGlyphBatch.dirtyRanges` when its committed target revision is the immediate +predecessor, otherwise selects every live range for that batch from the prepared glyph runs, copies those ranges, sets the +corresponding Three update ranges and `needsUpdate`, and asks the selected `ThreeRasterProgram` to compile ordered +compatible runs into internal meshes or draw proxies. It does not shape, sort paragraph source order, repartition physical +storage, or upload directly to a GPU queue. A ready +stage is still unpublished until `commitPreparedRevision()` calls `attachment.commit()` at this render boundary and swaps +the binding's live internal draw objects. + +The standard Three target is intentionally synchronous: `stage()` must return `{ status: 'ready', stage }` before +`prepareCurrentRevision()` returns. Font bytes, raster pages, and optional program modules are loaded explicitly before a +`Text` can bind; Three `NodeMaterial` and buffer objects are created synchronously, while WebGPURenderer performs physical +pipeline compilation/upload later in its normal render path. A custom target that returns `pending` remains valid under the +core attachment contract, but cannot provide this integration's same-observing-frame guarantee and is not accepted by the +standard `TextGroup` binding. + +With Three's default `scene.matrixWorldAutoUpdate = true`, the complete WebGPURenderer 0.185.1 call chain is: + +```ts +renderer.render(scene, camera) + -> Renderer._renderScene(scene, camera) + -> scene.updateMatrixWorld() + -> TextGroup.updateMatrixWorld(force) + -> binding.reconcileMembership(textGroup) + -> binding.applyPendingMembership() + -> ParagraphBatch.add(...) / Paragraph.dispose() / Paragraph setters + -> TextRuntime.update() + -> publish TextRuntimeRevision + -> attachment records latest source revision + -> binding.prepareCurrentRevision() + -> ParagraphBatchAttachment.prepare() + -> ThreeParagraphBatchTarget.stage(previous, preparedBatch) // must be ready + -> binding.commitPreparedRevision() + -> ParagraphBatchAttachment.commit() + -> install the staged internal draw meshes + -> Object3D.updateMatrixWorld(force) + -> Text.updateMatrixWorld(force) // transform only when grouped + -> drawMesh.updateMatrixWorld(force) + -> binding.writeGlyphTransforms() + -> BufferAttribute.addUpdateRange(...) + -> BufferAttribute.needsUpdate = true + -> Renderer._projectObject(...) // build and sort the render list + -> drawMesh.onBeforeRender(...) + -> Renderer._renderObjectDirect(...) + -> Geometries.updateForRender(...) + -> Attributes.update(...) + -> WebGPUBackend.updateAttribute(...) // actual dirty-range GPU write + -> backend.draw(...) // one program-compiled draw +``` + +Names beginning with `Renderer._` are shown to locate the integration in Three.js 0.185.1's implementation; they are not +APIs the package calls or overrides. The supported hook is the public `Object3D.updateMatrixWorld()` override. The internal +draw meshes use ordinary Three render-list and buffer-update behavior. + +If an application sets `scene.matrixWorldAutoUpdate = false`, Three deliberately skips `scene.updateMatrixWorld()` and +therefore skips this automatic text synchronization. That application has opted into manual scene updates and must call +`scene.updateMatrixWorld()` before `renderer.render(scene, camera)`; it does not call a text-specific update method. + +Publishing the target revision before `super.updateMatrixWorld()` ensures newly installed meshes receive a world matrix in +the same traversal. Writing transform attributes after it ensures they read completed `Text.matrixWorld` values. Both +happen before `_projectObject()` builds the render list, so resident text added immediately before `renderer.render()` is +present, transformed, uploaded, and drawn in that call; no preparatory frame is required. + +`super.updateMatrixWorld()` visits every child `Text` exactly once. A grouped `Text` still performs that normal transform +update but skips its standalone preparation branch because its nearest `TextGroup` owns the paragraph membership and draw +objects. Joining or leaving a group never changes `matrixAutoUpdate` or `matrixWorldAutoUpdate`; caller-authored Three matrix +policy survives unchanged. A detached text owns no preparation, while a directly rendered standalone text resumes its own +implicit-batch branch. That branch uses the same method order through a private one-paragraph `ThreeTextBatchBinding`. + +## Moving between batches is remove plus add + +```ts +overlayText.add(label); +``` + +Three.js removes `label` from its old parent before adding it to `overlayText`. The integration responds by staging two core +operations: + +```ts +oldParagraph.dispose(); +const nextParagraph = overlayParagraphBatch.add(label.desiredState); +``` + +It does not move a core paragraph handle between batches. Pending removal and allocation publish in the same pre-render +synchronization, so the old batch cannot leave ghost glyphs while the new batch renders the object. Cached shaping and +layout may be reused when their inputs are unchanged, but the destination receives new batch slots. + +The old paragraph slot and glyph instances belong to the old batch, not to `label`. Removal makes those slots reusable and +updates the old batch's logical counts and glyph runs. It does not dispose or shrink a shared buffer merely because one +text left. The old `TextGroup` retains that capacity until a later transactional replacement or `TextGroup.dispose()`. +The destination group owns any new physical storage it needs. Moving from a standalone implicit batch also retires that +text-owned target storage according to the renderer's in-flight-frame rules. + +That standalone-to-group transition is transactional. The integration validates and stages destination membership first, +publishes the new complete group revision, then retires the previous implicit target only after no in-flight frame can use +it. It never destroys the old target first and risks a missing frame or unrecoverable destination failure. + +Removal marks old membership dirty synchronously. Slot recycling and the updated glyph-run list publish at the old +group's next render synchronization. If the old group remains visible, that synchronization occurs before Three builds the +next render list. If the entire group is removed and will never render again, the application disposes the group rather +than waiting for another synchronization. + +Changing parents during an active Three.js traversal is unsupported, matching Three.js scene-graph expectations. Scene +membership changes must complete before `renderer.render()` enters world-matrix traversal. + +## Dispose a group; retain its text + +`TextGroup.dispose()` is terminal for the group, not recursive destruction of its scene children: + +```ts +groupA.add(label); +renderer.render(scene, camera); + +groupA.dispose(); + +groupA.disposed; // true +label.disposed; // false +label.bound; // false +label.textGroup; // undefined, even while label.parent is still groupA + +groupB.add(label); // Three reparents the same object +renderer.render(scene, camera); // new paragraph membership renders in groupB +``` + +Disposal synchronously invalidates `groupA` as a text-batch boundary, unbinds every direct or nested member `Text`, cancels +the group's unpublished preparation, and begins retirement of its core paragraph batch and renderer targets. Existing +children keep their transforms, desired state, glyph-origin override state, and font leases. The group contributes no +further text draws and rejects new text membership, but disposal does not mutate Three parent/child relationships. +While a live `Text` remains below the disposed group in the scene graph, that disposed group stays a terminal non-rendering +batch boundary: ancestry reconciliation must not fall through to an outer `TextGroup` or create an implicit standalone +batch. The caller moves the text explicitly when it should render elsewhere. + +`groupB.add(label)` validates the live text, every leased font, and technique compatibility before calling Three's +reparenting operation. Failure leaves `label` unchanged and unbound; success creates a new core paragraph handle and group-B +slots before its first render. No group-A paragraph handle or GPU allocation transfers to group B, and group-A resources +retire independently according to their renderer fences. + +## Three.js owns synchronization + +```ts +renderer.setAnimationLoop(() => { + renderer.render(scene, camera); +}); +``` + +Applications do not call a core update and then copy the result into Three. The integration coalesces desired-state and +membership changes, invokes the shared runtime's `update()` from each encountered standalone `Text` or `TextGroup`, then +prepares only that encountered owner's attachment and continues normal matrix traversal. Core specifies that a no-op +`update()` returns the current revision without allocation or notification. The first call after a mutation therefore +prepares every dirty paragraph across every paragraph batch; later calls in the same frame, scene, or render pass are cheap +revision checks unless their own membership reconciliation introduced new dirty work. Publication alone never stages, +cancels, aborts, allocates, uploads, or commits another scene's or renderer's target. That attachment reconciles its stale +candidate and prepares the latest source only when its owner is actually traversed. + +`WebGPURenderer.render(scene, camera)` updates and projects only the supplied scene or object root. Three does not first +update every scene known to the application. When an application renders several scenes, each scene traversal naturally +encounters its own text owners and calls the same shared runtime. No application-level text update is required: the first +encounter after a mutation performs the work, and every later encounter observes the published revision. Warm edits are +current in the render call that observes them. Loading and raster-page misses remain explicit readiness work owned by +`FontLoader` and the loaded font handle rather than being silently started as ordinary shaping. + +## Change retained text at runtime + +```ts +label.text = 'First value'; +label.text = 'Second value'; +label.text = 'Player 2'; + +label.contentBox = { + width: { mode: 'at-most', size: 360 }, + wrap: 'word', +}; +``` + +Those writes update desired state only. The parent batch shapes the final values once during its next render-loop +synchronization. Nested records are immutable replacement values; direct deep mutation is unsupported. + +```ts +interface TextBaseProperties { + readonly font: FontSelection; + readonly contentBox?: ParagraphContentBox; + readonly style?: ParagraphStyle; + readonly paint?: GlyphPaintInput; + readonly rasterPixelRatio?: number; + readonly renderVariant?: Variant; +} + +type TextContentProperties = + | Readonly<{ + text: string; + spans?: readonly TextSpan[]; + }> + | Readonly<{ + text: FormattedText; + spans?: never; + }>; + +type TextProperties = TextBaseProperties< + Technique, + Variant +> & + TextContentProperties; + +type TextUpdate = + | (Partial> & + Readonly<{ + text?: string; + spans?: readonly TextSpan[]; + }>) + | (Partial> & + Readonly<{ + text: FormattedText; + spans?: never; + }>); + +interface TextSpan { + readonly start: number; + readonly end: number; + readonly font?: FontSelection; + readonly style?: ParagraphStyle; + readonly paint?: GlyphPaintInput; + readonly renderVariant?: Variant; +} +``` + +`font` and every span font must match the effective batch technique. Changing `font` to another same-technique `Font` or +`FontStack` is a retained update. Assigning an incompatible selection throws without changing current desired or rendered +state. + +## Compose typed spans + +The Three entry point re-exports core's renderer-neutral `txt` and `span` tags. It does not add formatting methods to the +`Text` class or parse a markup language. + +```ts +import { Text, span, txt } from '@pmndrs/text-three'; + +const emphasis = span(noto, { color: '#ffddff' }); + +const label = new Text({ + font: uiFont, + text: txt`Fast ${emphasis`accurate`} text`, +}); + +label.text = 'Plain text'; +label.text = txt`Player ${span(noto)`Two`}`; +``` + +`txt` returns one immutable typed literal containing the flattened string and computed UTF-16 spans. `span()` accepts a +style by itself, or a `Font` / `FontStack` followed by styles and same-technique font overrides, merging left to right into +a reusable typed tag. TypeScript validates fonts, style and paint fields, property names, and technique. Assignment of a +plain string clears spans, while assignment of a literal replaces text and spans atomically. Explicit `spans`, `setSpan()`, +and `removeSpan()` remain the lower-level imperative form. + +React Three Fiber uses the same composer internally: + +```tsx + + Fast accurate text + +``` + +The nested React form and `txt` literal above must produce the same source string and span ranges. A nested React `` +is inline paragraph data; `label.add(new Text(...))` remains an ordinary spatial Three child and a separate paragraph. + +Three-native state remains Three-native: + +```ts +label.position.x += 1; +label.visible = false; +label.layers.set(2); +label.renderOrder = 10; +``` + +Transforms never reshape. `Text.renderOrder` maps to the paragraph ordering value inside the effective batch. Visibility, +layers, and transform changes update instance visibility/transform storage without changing shaping. `TextGroup.renderOrder` +sets the secondary Three render-order base for the batch's ordered physical draws. The nearest real Three Group owns +their primary `groupOrder`. + +## Structural, rebuilding, and hot changes + +### Construction-only batch identity + +Technique defines compatibility and has no setter: + +```ts +new TextGroup({ + technique, // canonical instance layout and shader family + program, // accepted variant type, attributes, material/pipeline, and draw compiler + capacity, // initial physical glyph-buffer size and overflow policy +}); +``` + +Changing technique or program requires a new `TextGroup`. Capacity is deliberately mutable through `setCapacity()` because storage +replacement must preserve the group, its text identities, and its core paragraph handles. + +For standalone `Text`, `setCapacity()` changes its implicit batch without changing the public object. Its font selection is +mutable; changing technique rebuilds the implicit batch. Inside an explicit `TextGroup`, changing to a different technique +is rejected and requires moving the retained `Text` to a compatible group. +The renderer identity becomes fixed on first draw and is also structural. `renderOrder` remains mutable. + +### Retained changes that rebuild internal storage + +These operations retain public objects but may allocate new internal glyph slots, chunks, attributes, or materials: + +```ts +destination.add(text); // remove old paragraph allocation, add new allocation +textGroup.setCapacity(nextCapacity); // preserve handles; replace canonical and target storage transactionally +text.font = anotherFont; // reshape and possibly change physical resource batch +text.spans = nextSpans; // reshape and possibly change raster-resource glyph runs +text.rasterPixelRatio = next; // select resources and rebuild affected target storage +text.renderVariant = nextVariant; // rebuild run/draw compatibility without reshaping +``` + +Glyph overflow follows the owning group's `grow`, `chunk`, or `fixed` policy. All fallible replacement work stages before +publication; failure preserves the last complete revision. + +### Text errors do not escape rendering + +A synchronous core preparation failure is caught by the Three adapter before it can escape `renderer.render()`. An +asynchronous failure enters the same adapter state. The owner is the effective `TextGroup`, or the standalone `Text` for an +implicit batch: + +```ts +labels.onError = (error) => { + if (error.kind === 'capacity-exceeded') { + console.error(`Text needs ${error.required} glyph slots; the fixed limit is ${error.capacity}.`); + } +}; + +renderer.render(scene, camera); +labels.error; // typed preparation or target failure, or undefined after a successful revision +``` + +The integration sets `error` during synchronization and defers `onError` until after the active Three traversal. Core +preparation failure or retained `attachment.error` preserves the last complete target revision; a first-render failure +submits nothing for that owner. The failed desired generation stays retained, but Three does not retry an identical failure +every frame. A relevant text, font, content-box, membership, or explicit `setCapacity()` change schedules new core work; +`retry()` requests one explicit attempt against unchanged state. Successful publication clears `error`. Capacity recovery +means resizing explicitly, reducing demand, or removing or moving text. No failure can partially publish or escape the +render call. + +While a `Text` is grouped, the group is the synchronization owner: read `text.textGroup.error` and use the group's callback. +The `Text` properties report and observe only its implicit standalone batch and are inactive while grouped. One failed +generation schedules one deferred callback, not one callback per render frame. `text.retry()` delegates to that effective +group while grouped and to the retained implicit attachment while standalone; `group.retry()` retries only that group's +attachment. + +### Hot retained changes + +These never recreate the `Text` or `TextGroup`: + +```ts +text.text = nextText; +text.contentBox = nextContentBox; +text.style = nextStyle; +text.paint = nextPaint; +text.renderOrder = nextOrder; +text.position.copy(nextPosition); +text.visible = nextVisible; +text.setGlyphOrigins(nextOrigins); +``` + +Dirty channels determine whether the hidden update shapes, reflows, rewrites paint/origins/transforms, or only rebuilds the +glyph-run plan. + +## Manual glyph motion + +```ts +const snapshot = label.snapshotGlyphs(); +const x = snapshot.displayedX.slice(); +const y = snapshot.displayedY.slice(); + +simulateGlyphs(x, y, delta); + +label.setGlyphOrigins({ + topology: snapshot.topology, + start: 0, + x, + y, +}); +``` + +Clear overrides to return to shaped positions: + +```ts +label.clearGlyphOriginOverrides(); +``` + +The next Three render-loop synchronization writes the changed origins without reshaping. Later content changes may reshape +the authoritative targets; the application can snapshot again and interpolate from its current displayed values. + +## Dispose ownership explicitly + +```ts +label.dispose(); +worldText.dispose(); +inter.dispose(); +noto.dispose(); +loader.dispose(); +``` + +`remove()` changes membership; `dispose()` ends ownership. Use the explicit destroy sequence when a text will never be +reused: + +```ts +label.removeFromParent(); +label.dispose(); +``` + +`Text.dispose()` is idempotent and permanent. It releases the current core paragraph membership, renderer-neutral cached +state, and any implicit standalone batch and target. It does not dispose explicit-group buffers or loaded fonts, and it +does not mutate the caller-owned scene graph; a disposed object still parented in Three is skipped but remains referenced +until the caller removes it. When grouped, disposal stages the same old-membership cleanup as `remove()` and the group +publishes that cleanup before its next render. When already detached and unbound, disposal still cancels pending work, +clears retained shaping/layout state and font references, marks the object permanently disposed, and prevents future +attachment. Mutating or adding a disposed `Text` throws. + +`TextGroup` owns its hidden paragraph batch, canonical batch storage, renderer-specific targets, materials, attributes, +and subscriptions. Removing a child only frees/recycles logical slots inside those shared resources. `TextGroup.dispose()` +permanently releases the group-owned resources, but does not dispose or remove child `Text` objects; callers may remove +those retained children and add them to a live compatible group. A disposed group rejects text attachment and cannot be +reactivated. + +`LoadedFont.dispose()` fails while any live `Text` lease remains. After the final text is disposed or changes font, font +disposal releases that loaded-font ownership. `FontStack` itself owns no lifecycle and cannot keep a disposed concrete font +valid. `FontLoader.dispose()` releases its cache-domain ownership; loaded fonts and their shared shaping state remain valid +until their own final owners are gone. + +Renderer-specific GPU resources retire according to the renderer target's in-flight-frame rules. Disposal is idempotent. + +## Required conformance cases + +The implementation is not complete until tests prove: + +- an unattached `Text` performs no shaping or GPU allocation; +- direct `scene.add(text)` renders through an implicit batch of one on its first render; +- `TextGroup` exposes no duplicate creation or allocation shortcut; `new Text()` plus ordinary `add()` is the only explicit-group path; +- direct and nested descendants join the nearest `TextGroup`, while nested `TextGroup` boundaries do not merge; +- a detached `Text` owns desired state but no paragraph batch or GPU resources, and direct scene attachment creates its implicit batch before first render; +- add/remove/reparent events plus pre-render ancestry reconciliation cannot leave stale or duplicate membership; +- moving a `Text` performs an atomic old allocation removal and new allocation creation without ghost glyphs; +- removing one text recycles its slots without shrinking or disposing shared group buffers; +- disposing a populated group unbinds but does not dispose its direct or nested text, and each retained text can bind to a live compatible group; +- text left parented below a disposed group remains unbound and cannot fall through to an outer group or implicit standalone batch; +- disposed text rejects mutation and attachment, text disposal does not dispose group/font resources, and group disposal does not dispose child text/fonts; +- font disposal fails while paragraph or text leases remain, and group disposal or reparenting cannot create missing glyphs by releasing font data; +- fixed capacity is checked after shaping rather than by `add()`, preserves the last complete revision, never throws from Three traversal, reports once, and retries only after a relevant change; +- `setCapacity()` preserves the group, every public `Text`, every core paragraph handle, and existing target attachments while replacing canonical and GPU storage transactionally; +- `TextGroup.clone()` and `copy()` are rejected rather than silently duplicating identity-bearing text, listener, and renderer state; +- simultaneous scene placements use separate groups, ordinary reparenting can move one group between scenes, and attempting + to draw one group through a second renderer fails before encoding; +- construction-only incompatibilities fail without mutating the current group; +- runtime setters coalesce and select the narrowest dirty work; +- automatic synchronous preparation renders warm edits in the observing frame; +- a same-technique `FontStack` produces the core-authored minimum physical batches and exact ordered glyph runs; +- mixed-technique group additions and font stacks fail before shaping without replacing live text; +- font-bound, font-stack-bound, style-only, reusable-tag, and readonly-tuple `span()` forms normalize identically, while mixed-technique format lists fail; +- `txt`/`span`, explicit spans, and nested React `` produce the same UTF-16 source/span snapshot; +- WebGPU and forced WebGL2 execute the same Bitmap, MTSDF, and Slug behavior on Three.js 0.185.1. diff --git a/docs/planning/typegpu-api.md b/docs/planning/typegpu-api.md new file mode 100644 index 00000000..bbcb77b6 --- /dev/null +++ b/docs/planning/typegpu-api.md @@ -0,0 +1,546 @@ +--- +type: API Specification +title: TypeGPU raster programs and text engine +description: Target v1 API for an external TypeGPU integration package containing reusable technique shaders, variant-aware raster programs, and a direct WebGPU text engine that consumes public core paragraph batches without Three.js. +documentation_type: reference +tags: [api, typegpu, webgpu, shaders, raster, engine, batching, variants] +status: draft +sources: + - id: core-api + resource: core-api.md + title: Core text API + - id: engine-contract + resource: engine-integration-contract.md + title: Engine integration contract + - id: raster-technique + resource: raster-technique-api.md + title: Raster technique and engine resource API + - id: typegpu-roots + resource: https://docs.swmansion.com/TypeGPU/apis/roots/ + title: TypeGPU roots and device ownership + - id: typegpu-functions + resource: https://docs.swmansion.com/TypeGPU/apis/functions/ + title: TypeGPU typed GPU functions + - id: typegpu-buffers + resource: https://docs.swmansion.com/TypeGPU/apis/buffers/ + title: TypeGPU buffers and range writes + - id: typegpu-bind-groups + resource: https://docs.swmansion.com/TypeGPU/apis/bind-groups/ + title: TypeGPU bind groups + - id: typegpu-pipelines + resource: https://docs.swmansion.com/TypeGPU/apis/pipelines/ + title: TypeGPU render pipelines and raw WebGPU interop + - id: typegpu-interop + resource: https://docs.swmansion.com/TypeGPU/integration/webgpu-interoperability/ + title: TypeGPU WebGPU interoperability + - id: typegpu-three + resource: https://docs.swmansion.com/TypeGPU/ecosystem/typegpu-three/ + title: TypeGPU and TSL interoperability +generated: + by: openai-codex/gpt-5.6 + at: '2026-08-07T03:25:58Z' +--- + +# TypeGPU raster programs and text engine + +This is an engine-integration package, not part of core: + +```ts +import { createTextRuntime, type ParagraphBatchTarget } from '@pmndrs/text'; +import { createTypeGpuTextEngine } from '@pmndrs/text-typegpu'; +``` + +`@pmndrs/text-typegpu` may live in this monorepo or an independent repository. It consumes only public `@pmndrs/text` and +raster-technique exports. Core never imports TypeGPU, and the integration does not require an `@pmndrs/text/typegpu` +subpath or access to package internals. + +The TypeGPU surface has two independent jobs: + +```ts +RasterTechnique // portable decoded CPU data and canonical glyph storage + -> TypeGpuRasterShader // canonical Bitmap, MTSDF, or Slug GPU algorithm + -> TypeGpuRasterProgram // resources, variants, pipelines, and draw compilation + -> TypeGpuTextEngine // retained core synchronization and pass encoding +``` + +The shader and program can be reused by Wayfare or another WebGPU host. The direct engine is the smallest complete renderer +for applications that already own a `GPUDevice` and render pass. It owns neither a canvas nor a scene graph nor a frame +loop. + +## Create an engine from an existing TypeGPU root + +```ts +import tgpu from 'typegpu'; +import { createTypeGpuTextEngine, createTypeGpuSlugProgram } from '@pmndrs/text-typegpu'; +import { slug } from '@pmndrs/text/raster/slug'; + +const root = tgpu.initFromDevice({ device }); +const program = createTypeGpuSlugProgram(root); + +const text = await createTypeGpuTextEngine({ + root, + colorFormat: navigator.gpu.getPreferredCanvasFormat(), + depthStencil: { format: 'depth24plus', depthWriteEnabled: true, depthCompare: 'less' }, + sampleCount: 4, +}); + +const font = await text.loadFont({ + input: { baked: '/fonts/Inter.font.glb' }, + raster: { technique: slug }, +}); + +const labels = text.createParagraphBatch({ technique: slug, program }); +const score = labels.add({ font, text: 'Score 0' }); + +score.text = 'Score 1'; +text.update(); + +const pass = commandEncoder.beginRenderPass(renderPassDescriptor); +labels.encode(pass, { viewProjection, viewport: [width, height], pixelRatio: devicePixelRatio }); +pass.end(); +``` + +`createTypeGpuTextEngine()` accepts a caller-owned root. It never requests an adapter/device and never destroys the root. +TypeGPU itself preserves that ownership when a root is initialized from an existing device. The application owns device +loss, canvas configuration, command encoders, pass descriptors, queue submission, and frame fences. + +## Proposed public surface (compile gate required) + +```ts +import type { TgpuRoot } from 'typegpu'; + +interface TypeGpuTextEngine { + readonly root: TgpuRoot; + readonly current: TextRuntimeRevision; + readonly disposed: boolean; + + loadFont( + request: LoadedFontRequest, + options?: { readonly signal?: AbortSignal }, + ): Promise>; + + createParagraphBatch>( + options: TypeGpuParagraphBatchOptions, + ): TypeGpuParagraphBatch, Program>; + + update(): TextRuntimeRevision; + updateAsync(options?: AsyncTextUpdateOptions): Promise; + updateAsync(callback: TextUpdateCallback): void; + updateAsync(options: AsyncTextUpdateOptions, callback: TextUpdateCallback): void; + dispose(): void; +} + +interface TypeGpuTextEngineOptions { + readonly root: TgpuRoot; + readonly colorFormat: GPUTextureFormat; + readonly depthStencil?: GPUDepthStencilState; + readonly sampleCount?: number; + readonly runtime?: TextRuntimeOptions; +} + +declare function createTypeGpuTextEngine(options: TypeGpuTextEngineOptions): Promise; +``` + +The engine privately owns one core `TextRuntime`. Its font, paragraph, synchronization, failure, capacity, and disposal +semantics are exactly the core semantics. Unlike Three, TypeGPU has no scene traversal hook, so synchronization is explicit: +mutate any paragraph handles, call `update()` or `updateAsync()` at the application's chosen frame boundary, then encode +the last committed revision into as many passes as needed. Calling `encode()` never shapes and never publishes pending +desired state. + +## Retain a renderable paragraph batch + +```ts +interface TypeGpuParagraphBatchOptions< + Technique extends AnyRasterTechnique, + Program extends AnyTypeGpuRasterProgram, +> { + readonly technique: Technique; + readonly program: Program; + readonly capacity?: GlyphBufferCapacity; + readonly rasterPixelRatio?: number; + readonly renderVariant?: TypeGpuVariantOf; +} + +interface TypeGpuParagraphBatch< + Technique extends AnyRasterTechnique, + Variant, + Program extends AnyTypeGpuRasterProgram, +> { + readonly technique: Technique; + readonly program: Program; + readonly current: TypeGpuParagraphBatchTargetRevision> | undefined; + readonly error: TextPreparationError | ParagraphBatchTargetError | undefined; + readonly disposed: boolean; + + rasterPixelRatio: number; + renderVariant: Variant | undefined; + add(properties: TypeGpuParagraphProperties): TypeGpuParagraph; + has(paragraph: TypeGpuParagraph): boolean; + setCapacity(capacity: GlyphBufferCapacity): void; + retry(): void; + encode(pass: GPURenderPassEncoder, frame: TypeGpuFrame): void; + dispose(): void; +} + +interface TypeGpuFrame { + readonly viewProjection: Float32Array; + readonly viewport: readonly [width: number, height: number]; + readonly pixelRatio: number; +} +``` + +One TypeGPU paragraph batch wraps one core `ParagraphBatch` and one attached target. `encode()` calls the hidden +attachment's `prepare()`, commits its ready candidate, binds program resources, and encodes the target revision's compiled +draws into the supplied pass. The standard target stages synchronously; an alternative target that needs asynchronous +pipeline work uses the public attachment contract directly and resolves it before encoding. The caller +may encode the same live batch in several compatible passes or omit it for a frame. A batch is fixed to the root/device and +render-target compatibility declared at engine construction. + +## Transform paragraphs without reshaping + +```ts +interface TypeGpuParagraphProperties extends ParagraphProperties< + Technique, + Variant +> { + readonly transform?: ArrayLike; + readonly visible?: boolean; +} + +interface TypeGpuParagraph { + readonly id: ParagraphId; + readonly disposed: boolean; + readonly layout: ParagraphLayout | undefined; + + font: FontSelection; + text: TextInput; + spans: readonly ParagraphSpan[]; + contentBox: ParagraphContentBox; + style: ParagraphStyle; + paint: GlyphPaintInput; + rasterPixelRatio: number; + order: number; + renderVariant: Variant | undefined; + visible: boolean; + + set(properties: TypeGpuParagraphUpdate): void; + setSpan(index: number, span: ParagraphSpan): void; + removeSpan(index: number): void; + setTransform(columnMajorMatrix4: ArrayLike): void; + snapshotGlyphs(): GlyphSnapshot; + setGlyphOrigins(update: GlyphOriginUpdate): void; + clearGlyphOriginOverrides(): void; + snapshotProperties(): TypeGpuParagraphSnapshot; + dispose(): void; +} + +type TypeGpuParagraphUpdate = ParagraphUpdate & + Readonly<{ + transform?: ArrayLike; + visible?: boolean; + }>; + +interface TypeGpuParagraphSnapshot extends ParagraphSnapshot< + Technique, + Variant +> { + readonly transform: Float32Array; + readonly visible: boolean; +} +``` + +`setTransform()` copies exactly 16 finite column-major values into retained engine state. Transform and visibility changes +dirty only the target's transform/visibility storage; they do not call core shaping. The program may repeat matrices per +glyph, index a transform table, use indirect draws, or cull complete paragraphs. This choice never changes paragraph-local +core layout. `encode()` flushes those target-owned dirty ranges before encoding the first draw that observes them. + +`rasterPixelRatio` participates in core resource selection and must be assigned before `update*()`. `TypeGpuFrame.pixelRatio` +is later frame state used by vertex snapping and screen-space evaluation during `encode()`; it cannot retroactively select a +Bitmap strike. An integration normally keeps the two equal and updates the batch density before synchronization when the +render target density changes. + +## Define a canonical TypeGPU technique shader + +```ts +interface TypeGpuRasterStage { + readonly input: InputSchema; + readonly output: OutputSchema; + readonly evaluate: Evaluate; +} + +interface TypeGpuRasterShader { + readonly technique: Technique; + readonly vertex: Vertex; + readonly fragment: Fragment; + readonly resources: ResourceSchema; +} + +declare function defineTypeGpuRasterShader( + shader: TypeGpuRasterShader, +): TypeGpuRasterShader; +``` + +Each concrete TypeGPU stage supplies the actual validation. For example, `fragment.evaluate` is the exact value returned by +`tgpu.fn([SlugFragmentInput], SlugFragmentOutput)(implementation)`, while `vertex.evaluate` owns Slug dilation and its +varying output. The helper infers and retains both functions, their input/output schemas, and the complete resource schema; +no associated type widens to `any`. JavaScript-authored functions require TypeGPU's build transform, while WGSL-shell +functions remain a supported package implementation choice. + +First-party shaders export their typed function as a public customization seam: + +```ts +const base = slugShader.fragment.evaluate(context); +const output = { ...base, color: gradient(base.color, context.localPosition) }; +``` + +`context` contains only technique and semantic instance inputs. `base` is the canonical resolved Slug fragment result. +The custom program keeps curve traversal, coverage, clipping, and technique validation instead of rewriting them. + +## Define variants and compile runs + +```ts +interface TypeGpuVariantCodec { + readonly schema: Schema; + key(variant: Variant | undefined): Key; + value(variant: Variant | undefined): Value; +} + +declare const typeGpuRasterProgramTypes: unique symbol; + +interface TypeGpuRasterProgramTypeMap { + readonly variant: Variant; + readonly shader: Shader; + readonly codec: Codec; + readonly fontResources: FontResources; + readonly pipeline: Pipeline; + readonly draw: Draw; +} + +interface AnyTypeGpuRasterProgram { + readonly technique: Technique; + readonly [typeGpuRasterProgramTypes]?: TypeGpuRasterProgramTypeMap< + unknown, + unknown, + unknown, + unknown, + unknown, + unknown + >; +} + +type TypeGpuProgramTypesOf> = NonNullable< + Program[typeof typeGpuRasterProgramTypes] +>; +type TypeGpuVariantOf> = + TypeGpuProgramTypesOf['variant']; +type TypeGpuDrawOf> = + TypeGpuProgramTypesOf['draw']; + +interface TypeGpuRasterProgram< + Technique extends AnyRasterTechnique, + Variant, + Shader extends TypeGpuRasterShader, + Vertex, + Fragment, + ResourceSchema, + VariantKey, + VariantSchema, + VariantValue, + FontResources, + Pipeline, + Draw, +> extends AnyTypeGpuRasterProgram { + readonly [typeGpuRasterProgramTypes]?: TypeGpuRasterProgramTypeMap< + Variant, + Shader, + TypeGpuVariantCodec, + FontResources, + Pipeline, + Draw + >; + readonly shader: Shader; + readonly variant: TypeGpuVariantCodec; + readonly cacheLimits: { + readonly pipelines: number; + readonly materializedVariants: number; + }; + + createFontResources(root: TgpuRoot, font: LoadedFont, binding: RasterBindingOf): FontResources; + createPipeline(root: TgpuRoot, key: VariantKey, pipelineVariant: number): Pipeline; + compileRuns( + context: TypeGpuProgramRunContext, + ): readonly Draw[]; + encode(pass: GPURenderPassEncoder, draw: Draw, frame: TypeGpuFrame): void; + disposeFontResources(resources: FontResources): void; + disposePipeline(pipeline: Pipeline): void; + dispose(): void; +} + +declare function defineTypeGpuRasterProgram< + Technique extends AnyRasterTechnique, + const Program extends AnyTypeGpuRasterProgram, +>(program: Program): Program; + +interface TypeGpuProgramRunContext { + readonly glyphBatches: readonly PreparedGlyphBatch[]; + readonly glyphRuns: readonly PreparedGlyphRun[]; + readonly fontResources: ReadonlyMap; + pipeline(key: VariantKey, pipelineVariant: number): Pipeline; +} +``` + +`AnyTypeGpuRasterProgram` contains only common identity plus an associated-type witness. A concrete program returned by +`defineTypeGpuRasterProgram()` preserves the exact shader input/output/function, variant key/schema/value, font-resource, +pipeline, and draw types. A heterogeneous registry exposes associated values as `unknown` and must narrow before +program-specific work. No public default uses `any` as an inference placeholder. + +The reusable program does not own paragraph-batch instance buffers or a live target revision. A target created from that +program owns those per-batch values: + +```ts +interface TypeGpuParagraphBatchTargetRevision extends ParagraphBatchTargetRevision { + readonly draws: readonly Draw[]; +} + +interface TypeGpuParagraphBatchTarget< + Technique extends AnyRasterTechnique, + Variant, + Program extends AnyTypeGpuRasterProgram, +> extends ParagraphBatchTarget>> { + readonly root: TgpuRoot; + readonly program: Program; + encode( + pass: GPURenderPassEncoder, + revision: TypeGpuParagraphBatchTargetRevision>, + frame: TypeGpuFrame, + ): void; +} + +declare function createTypeGpuParagraphBatchTarget< + Technique extends AnyRasterTechnique, + Program extends AnyTypeGpuRasterProgram, +>(options: { + readonly root: TgpuRoot; + readonly technique: Technique; + readonly program: Program; + readonly colorFormat: GPUTextureFormat; + readonly depthStencil?: GPUDepthStencilState; + readonly sampleCount?: number; +}): TypeGpuParagraphBatchTarget, Program>; +``` + +`TypeGpuTextEngine.createParagraphBatch()` constructs this target, attaches it to the hidden core batch, and exposes the +retained convenience shown earlier. Another engine may call the factory directly only after proving compatible WebGPU +device/pass interop, attach it to its own public core batch, and decide when to prepare, commit, and encode. Wayfare remains +an unverified candidate rather than a claimed consumer. Several targets and batches can lease one program without sharing their +instance, transform, draw-revision, or fence state. + +`variant.key()` describes pipeline/material compatibility, not authored identity. Two different parameter bindings may +return the same key and occupy one draw when the program writes their values to indexed sidecar storage. Conversely, a +variant that changes shader graph, bind-group layout, blend mode, depth policy, or another pipeline constraint returns a +different key. The program compiles adjacent core runs by physical glyph batch plus variant key and may split further for +engine limits. It does not reorder non-equivalent runs. + +Changing a core variant rebuilds the run plan but does not reshape. Mutating values inside a stable program-owned binding +may update only its TypeGPU sidecar buffer and need no core update at all. Programs should use immutable variant snapshots +or stable binding objects so equality and lifetime remain explicit. + +First-party program caches are bounded. Their factory options declare maximum pipeline and materialized-variant entries; +least-recently-used entries retire through the same GPU-safe path as explicit program disposal. A new object-valued variant +each frame cannot create an unbounded cache. Custom programs own and document equivalent bounds. + +## Compose effects without replacing the technique + +The convenience helper's exact declaration is intentionally not claimed yet. TypeGPU is not installed in this repository, +and the earlier five-parameter sketch could infer `Parameters`, `Context`, and `Output` as `unknown`. The accepted shape is +constrained instead: + +- the helper takes the exact technique shader as an inference anchor; +- parameter values are derived from the declared TypeGPU schema through the installed TypeGPU type utilities; +- fragment context and output are derived from that shader's exact fragment stage; and +- the returned binding accepts only the derived parameter value. + +Milestone 11's compile fixture must name the actual TypeGPU schema-value utility and prove contextual callback types before +this helper becomes public. If TypeGPU cannot expose that relation, the package exports only already-typed `tgpu.fn()` +composition values and does not manufacture a weaker wrapper. + +```ts +const gradient = defineTypeGpuTextEffect(slugShader, { + parameters: GradientParameters, + compose(base, parameters, context) { + return { ...base, color: applyGradient(base.color, context.localPosition, parameters) }; + }, +}); + +const gradientSlug = createTypeGpuSlugProgram(root, { + effects: [gradient], +}); +``` + +This is target syntax for the gated helper, not current compile evidence. + +The effect helper is optional program authoring sugar. It produces a typed program variant and composes after the canonical +technique shader. Applications may instead define their own variant and complete program. Core never imports or interprets +the effect. Effect-definition identity contributes to the program key; parameter values live in sidecar storage and do not +create a pipeline per text instance. + +## Adapt the same shader to Three + +`@typegpu/three@0.11.0` can inject a resolved zero-argument TypeGPU WGSL closure through Three's WebGPU node builder: + +```ts +const slugNode = t3.toTSL(() => { + 'use gpu'; + return slugShader.fragment.evaluate(readSlugContextFromTsl()); +}); +const threeProgram = createThreeSlugProgram({ shader: slugNode }); +``` + +`fromTSL()` can expose supported Three-owned data nodes where the closure needs them. The reviewed bridge has not carried +the real Slug sampleable resources, Bitmap texture sampling, returned structures, or both techniques' vertex work; those +are executable gates, not accepted capabilities. Three still +owns its node material, render-list integration, pipeline state, lifecycle, and final variant/draw compiler. Only a passed +complete-technique gate permits saying the direct TypeGPU and Three programs share the hard algorithm; they remain different +engine targets in every case. + +This bridge remains WebGPU-only in the currently documented `@typegpu/three` release and experimental until generated +shader inspection, Bitmap and Slug pixel parity, repository-pinned Three compatibility, and measured tree-shaken +transfer/graph-build/compile cost pass. Native TSL remains the authoritative Three path for WebGPU plus WebGL2 unless the +bridge proves both backends. + +## Ownership and failure + +```ts +TypeGpuTextEngine owns: hidden TextRuntime, batch attachments, target CPU/GPU storage, program leases +TypeGpuRasterProgram owns: bind-group layouts, pipelines, program caches, variant sidecar schemas +TypeGpuParagraphBatch owns: core ParagraphBatch, target revision, instance/transform buffers, draw plan +TypeGpuParagraph owns: desired paragraph state, font leases, transform, visibility +Application owns: TgpuRoot, GPUDevice, canvas, passes, queue submission, RAF, frame fences +``` + +Core preparation failures follow the core sync/async contract. Program staging failures remain retained on the batch and +do not replace the live target revision. `encode()` does not throw a previously retained preparation or staging failure; +it draws the last committed revision or nothing. Invalid pass/root compatibility and use-after-dispose are immediate API +errors. Device loss belongs to the application and invalidates every engine using that root. + +Dispose paragraphs before their batch when individually finished, batches before the engine, and the engine before +releasing its program leases. `engine.dispose()` cascades through its batches and hidden runtime but not the caller-owned +root/device. A program shared with another engine remains live until its final lease is released. + +## Conformance + +The surface is not implemented until the proof demonstrates: + +- Bitmap, MTSDF, and Slug consume the same portable artifacts, glyph batches, glyph runs, and canonical storage as Three; +- adjacent updates write only dirty byte ranges through TypeGPU buffers, while first/gapped attachment initializes live + ranges referenced by the current glyph runs; +- one program batches several parameterized variants in one draw and another deliberately splits incompatible variants; +- paragraph and span variants preserve fallback-font order without forcing shaping boundaries; +- transform, visibility, and effect-parameter animation cause no shaping; +- sync and Worker updates publish atomically and encode never exposes a partial revision; +- the engine encodes into caller-owned passes and never creates a canvas, RAF, adapter, device, or queue submission; +- raw `root.unwrap()` interop works for a host that does not otherwise use TypeGPU; +- Wayfare renders through the same programs while retaining its own entities, passes, transforms, and lifecycle; +- `toTSL()` renders canonical Bitmap and Slug output through Three with inspected generated shaders and measured cost; and +- disposal, fixed-capacity recovery, target staging failure, and device loss leave no stale buffers, bind groups, pipelines, + listeners, or hidden work. diff --git a/docs/planning/typegpu-first-shader-authority.md b/docs/planning/typegpu-first-shader-authority.md new file mode 100644 index 00000000..c1f91a02 --- /dev/null +++ b/docs/planning/typegpu-first-shader-authority.md @@ -0,0 +1,454 @@ +--- +type: Research Plan +title: TypeGPU-first shader authority +description: Exploratory architecture for authoring canonical text raster programs in TypeGPU and adapting them to direct WebGPU hosts, Three.js, and gpucat without coupling the renderer-neutral core to a GPU framework. +documentation_type: explanation +tags: [research, typegpu, three, gpucat, shaders, raster, webgpu, webgl] +status: draft +sources: + - id: core-api + resource: core-api.md + title: Core text API + - id: engine-contract + resource: engine-integration-contract.md + title: Engine integration contract + - id: raster-technique + resource: raster-technique-api.md + title: Raster technique and engine resource API + - id: typegpu-api + resource: typegpu-api.md + title: TypeGPU raster programs and text engine + - id: three-api + resource: three-api.md + title: Three.js text API + - id: gpucat-plan + resource: gpucat-integration.md + title: External gpucat integration fitness plan + - id: typegpu-three + resource: https://docs.swmansion.com/TypeGPU/ecosystem/typegpu-three/ + title: Official TypeGPU and TSL integration documentation + - id: typegpu-functions + resource: https://docs.swmansion.com/TypeGPU/apis/functions/ + title: Official TypeGPU shader-function documentation + - id: typegpu-philosophy + resource: https://docs.swmansion.com/TypeGPU/why-typegpu/ + title: Official TypeGPU architecture and WebGPU scope + - id: bitmap-v0 + resource: ../../packages/text/src/raster/bitmap.ts + title: Merged v0 Bitmap TSL implementation + - id: slug-v0 + resource: ../../packages/text/src/raster/slug.ts + title: Merged v0 Slug TSL implementation + - id: slug-texture-v0 + resource: ../../packages/text/src/internal/slug-shaders/slug-texture.ts + title: Merged v0 Slug texture access + - id: gpucat + resource: https://github.com/isaac-mason/gpucat/tree/11cf91b5172cc5143f68ff6ebf01c5e815de4e94 + title: gpucat at the reviewed revision +generated: + by: openai-codex/gpt-5.6 + at: '2026-08-07T03:25:58Z' +--- + +# TypeGPU-first shader authority + +## The question + +Could TypeGPU become the authoritative implementation of Bitmap, MTSDF, and Slug GPU logic while the same programs feed: + +```ts +TypeGPU shader source + -> direct TypeGPU/WebGPU text engine + -> Wayfare or another WebGPU host + -> @typegpu/three -> Three.js WebGPURenderer + -> generated WGSL -> gpucat WebGPU +``` + +This is an exploratory answer, not an accepted replacement for the native TSL implementation. The strongest form is worth +testing because one authoritative shader implementation would reduce drift and give custom programs the real Slug, +MTSDF, and Bitmap logic without copying it. The boundary must still survive if only part of that bridge works. + +## Reviewed-version baseline + +TypeGPU is not installed in this repository, so no TypeGPU declaration in this plan is yet compile evidence. The external +review inspected `three@0.185.1`, `typegpu@0.11.9`, and `@typegpu/three@0.11.0` exactly. At those versions: + +- `toTSL()` accepts a nullary closure and resolves it to WGSL text parsed by Three's `WGSLNodeBuilder`; it is not a native + TSL graph conversion; +- `fromTSL()` carries WGSL data values, not a demonstrated sampleable-resource handle for Slug's dependent loads; +- the package deep-imports Three WebGPU internals and has no forced-WebGL2 path; and +- an argument-taking `tgpu.fn(...)` cannot be passed directly to `toTSL()`. + +Those are current falsifiers for outcome A, not permanent claims about future releases. The experiment pins all three exact +versions, reruns the capability fixture on every upgrade, and constrains released peer ranges to combinations that passed. +The broad upstream peer range is not compatibility evidence because the bridge deep-imports renderer internals. + +## Preserve the accepted core + +TypeGPU does not enter shaping, layout, paragraph ownership, physical glyph partitioning, canonical CPU storage, variants, +or target synchronization: + +```ts +import { + createTextRuntime, + type ParagraphBatchTarget, + type PreparedGlyphBatch, + type PreparedGlyphRun, +} from '@pmndrs/text'; +``` + +The core prepares renderer-neutral revisions. A TypeGPU, Three, or gpucat integration consumes those same public values. +The experiment may correct an incomplete public datum—such as stable `GlyphBatchKey` identity or the pre-update +`rasterPixelRatio` input—but it must not add `TgpuRoot`, TSL nodes, gpucat nodes, GPU handles, materials, or pipeline types +to core. + +That separation is the fitness criterion: + +```ts +expect(core).not.toImport('typegpu'); +expect(core).not.toImport('@typegpu/three'); +expect(core).not.toImport('three'); +expect(core).not.toImport('gpucat'); +``` + +## Strongest TypeGPU-first package shape + +The reusable TypeGPU package does not need to be a complete scene engine. Its primary product can be typed raster programs: + +```txt +@pmndrs/text + core loading, shaping, layout, batches, storage, runs, target protocol + +@pmndrs/text-raster-{bitmap,mtsdf,slug} + baker + portable decoder + resource selection + canonical storage schema + +@pmndrs/text-typegpu + TypeGPU vertex/fragment functions + resource ABI + program factories + optional direct pass encoder; no scene graph, canvas, RAF, or adapter request + +@pmndrs/text-three + Three objects, loader, target, ordering, materials, native TSL programs + +@pmndrs/text-three-typegpu // experiment + @typegpu/three bridge into Three-owned NodeMaterials; WebGPU-only today + +@pmndrs/text-gpucat // external fitness package + gpucat objects, target, resource wrappers, draws, and shader adaptation +``` + +All engine packages may live outside this repository. They depend only on packed public packages; no internal subpath is a +privileged integration API. + +## Author a complete raster kernel, not only fragment coverage + +The existing `RasterShader.evaluate()` sketch is too narrow if it implies one fragment function. The merged v0 proves that +the hard technique contract includes both stages: + +```ts +interface TypeGpuRasterKernel { + readonly technique: Technique; + readonly vertex: TypeGpuFn; + readonly fragment: TypeGpuFn; + readonly resources: TypeGpuRasterResourceSchema; +} +``` + +- Bitmap expands the glyph quad, samples an R8 strike, and snaps projected vertex edges to physical framebuffer pixels. +- MTSDF expands the quad, samples its atlas, evaluates screen derivatives, reconstructs distance, and applies fill, + outline, and shadow coverage. +- Slug dilates geometry for antialiasing, passes a render coordinate, follows header/reference indirection, performs + dependent curve-texture loads inside bounded dynamic loops, and computes analytic coverage. + +An implementation that shares only the final coverage function is not authoritative for the technique. + +## Direct TypeGPU host + +The cleanest success path consumes the kernel without translation: + +```ts +const program = createTypeGpuSlugProgram(root, { kernel: slugKernel }); +const target = createTypeGpuParagraphBatchTarget({ + root, + technique: slug, + program, + colorFormat, +}); + +const attachment = paragraphs.attach(target); + +runtime.update(); +attachment.prepare(); +attachment.commit(); +target.encode(pass, attachment.current, frame); +``` + +The host owns the `TgpuRoot`, device, render pass, command submission, frame loop, transforms, and composition. The program +owns typed layouts, GPU font-resource caches, bounded pipeline/variant caches, shader functions, and draw compilation. The +target owns one batch's instance/transform buffers and committed draw revision. + +Wayfare is only a candidate consumer if it exposes compatible WebGPU device and render-pass interop. Its source has not yet +been inspected in this research pass, so reuse is a Gate 3 proof obligation rather than a claim. If compatible, it need not +adopt the direct text engine or surrender its own entity lifecycle. + +## Bridge to Three.js + +The optimistic adapter captures Three nodes inside a zero-argument `toTSL()` closure: + +```ts +const colorNode = t3.toTSL(() => { + 'use gpu'; + + return slugKernel.fragment({ + coordinate: t3.fromTSL(renderCoordinate, d.vec2f).$, + color: t3.fromTSL(instanceColor, d.vec4f).$, + resources: readSlugResourcesFromThree(), + }); +}); + +material.colorNode = colorNode.rgb; +material.opacityNode = colorNode.a; +``` + +Three still owns `MeshBasicNodeMaterial`, attributes/accessors, texture objects, render state, hidden meshes, scene +ordering, renderer isolation, resource retirement, and custom TSL composition. TypeGPU supplies only the kernel embedded in +that Three program. + +This does not work for the real techniques at the reviewed versions. Official `@typegpu/three` documentation states that +the bridge works only on WebGPU-enabled devices. Its examples capture supported TSL values inside a nullary closure; they do not prove that +Three textures can enter TypeGPU as sampleable resources, that Slug's dependent texture loads and dynamic loops survive, +or that a structured vertex/fragment ABI returns usable TSL nodes. + +The reviewed implementation confirms WGSL injection, no WebGL2 route, and no demonstrated way to carry the required +sampleable Three resources. Therefore the native TSL program remains the flagship implementation. Retiring it is permitted only after the bridge +passes the complete Bitmap, MTSDF, and Slug proof on every backend promised by `@pmndrs/text-three`. If TypeGPU remains +WebGPU-only, it is an optional package rather than a silent implementation detail of the default Three integration. + +## Bridge to gpucat + +The WebGPU hypothesis is: + +```ts +const source = resolveTypeGpuKernel(slugKernel); +const evaluateSlug = wgslFn(source.wgsl, { + output: SlugOutput, + params: SlugParameters, + glsl: source.glsl, +}); +``` + +Gpucat can consume the same core batches and dirty ranges, but its instance ABI is not Three's. Reviewed gpucat meshes +expect per-instance data in data textures indexed by `instanceIndex`; the Three implementation currently uses instanced +attributes. The shared kernel must consume semantic inputs supplied by an engine wrapper rather than directly naming +either layout. + +Gpucat's raw `wgslFn()` escape hatch also requires a GLSL companion on its WebGL backend. TypeGPU intentionally targets +WebGPU, so a TypeGPU-generated WGSL function alone cannot be authoritative for gpucat's two backends. The experiment must +choose explicitly: + +```ts +type GpucatShaderSupport = + | { backend: 'webgpu'; wgsl: string } + | { backend: 'webgpu+webgl'; wgsl: string; glsl: string }; +``` + +If WebGL is required, a native GLSL companion verified against the same semantic vectors is valid duplication. If the +package is WebGPU-only, its name and documentation must say so. + +## Where authority can actually live + +There are three viable outcomes: + +### A. TypeGPU is the complete shader authority + +```ts +TypeGPU vertex + fragment kernels + -> direct WebGPU programs + -> Three through @typegpu/three + -> gpucat through resolved WGSL +``` + +This is the ideal and the least proven. It requires full resource, loop, derivative, stage, and customization bridges. + +### B. TypeGPU is the WebGPU authority + +```ts +TypeGPU kernels -> direct WebGPU + Wayfare + gpucat WebGPU +native TSL -> Three WebGPU + WebGL2 +native GLSL -> gpucat WebGL when supported +``` + +This still gives WebGPU hosts one implementation while retaining engine-native fallbacks. It is the most plausible +TypeGPU-first result today. + +### C. The semantic raster specification is authoritative + +```ts +resource ABI + stage semantics + CPU reference evaluator + golden vectors + -> TypeGPU implementation + -> native TSL implementation + -> gpucat WGSL/GLSL implementation +``` + +If compiler bridges cannot carry Slug or vertex work, the shared source of truth becomes behavior rather than one shader +language. This is not hand-wavy prose: the specification must name exact record layouts, resource addressing, coordinate +spaces, bounded loops, sampling modes, derivatives, compositing, and stage outputs, with executable CPU vectors and image +gates. Users still receive exported first-party shader implementations and never have to rewrite Slug for a gradient. + +Outcome C is the fallback, not a core API change. + +The semantic resource ABI names logical records and addressing, not one GPU storage class. Slug may realize the same +header/reference/curve records as storage buffers on WebGPU and integer textures on WebGL2. Each backend wrapper must prove +that its accessor implements the same bounds, indices, texel/word decoding, and coordinate convention before it calls the +shared math. This separates the algorithm without pretending that a WebGPU bind-group layout is portable to WebGL2. + +## Preserve customization and batching + +Core `renderVariant` remains opaque and resolves batch → paragraph → span intent onto ordered runs: + +```ts +label.renderVariant = gradient.bind({ from: pink, to: blue }); +``` + +The program chooses how variants affect draws: + +```ts +program.compileRuns({ glyphBatches, glyphRuns }) + -> one draw when effect parameters fit indexed sidecar storage + -> several ordered draws when graph, blend, depth, or binding compatibility differs +``` + +For indexed sidecar batching, the program makes the opaque-to-slot step explicit: + +```ts +for (const run of glyphRuns) { + const compatibility = variantCodec.key(run.renderVariant); + const variantSlot = variantTable.intern(variantCodec.value(run.renderVariant)); + draws.appendOrMerge({ run, compatibility, variantSlot }); +} +``` + +The sidecar table belongs to the program revision, is bounded with the pipeline/material caches, and writes its slot index +into target-owned instance data while staging. Core neither assigns the slot nor splits physical storage by variant. + +A custom program imports the canonical kernel and replaces final composition, not the technique: + +```ts +const base = slugKernel.fragment(context); +return { ...base, color: gradient(base.color, context.localPosition, parameters) }; +``` + +The proof must inspect the generated shader and show one Slug traversal, not one traversal per chained effect. Pipeline and +material caches must be bounded or explicitly disposed; fresh variant object identity each frame cannot leak forever. + +## Proof ladder + +Run the cheapest falsifier first. + +### Gate 0 — bridge capability + +Using repository-pinned Three plus pinned `typegpu` and `@typegpu/three`: + +1. capture typed scalar/vector TSL accessors inside `toTSL()` and consume the result; +2. sample the real Bitmap and MTSDF Three textures; +3. perform Slug dependent texture loads inside its bounded dynamic loop; +4. express Bitmap pixel snapping and Slug vertex dilation; +5. return the structured values the Three program must compose; +6. run the bridge on forced WebGPU and confirm forced WebGL2 fails or passes explicitly. + +Failure narrows the bridge immediately; it does not trigger a core redesign. + +### Gate 1 — exact types and isolation + +- compile concrete interface-shaped glyph storage through `defineRasterTechnique()`; +- infer every shader, resource, variant, pipeline, and draw associated type without `any`; +- install packed public packages in isolated Three and gpucat fixtures; +- reject deep imports and prove portable/core graphs load no GPU framework. + +### Gate 2 — complete techniques + +- compare Bitmap WebGPU output byte-for-byte with the existing deterministic reference; +- compare MTSDF and Slug against their accepted error envelopes and visual corpora; +- inspect generated stages for pixel snapping, dilation, dependent loads, bounded loops, and one canonical traversal; +- prove fallback-font order across several physical batches and several engine draws. + +### Gate 3 — integration behavior + +- direct TypeGPU/Wayfare, Three, and gpucat consume identical core revisions and canonical bytes; +- adjacent revisions upload dirty ranges; skipped revisions upload all live ranges; +- first render observes late-bound text without an intentional frame delay; +- fixed overflow, resize, attachment retry, font disposal, and GPU retirement preserve old complete revisions; +- scene/render ordering limitations are documented rather than hidden behind claimed atomic batches. + +### Gate 4 — effects and cost + +- one gradient effect and two chained effects reuse the canonical technique in one compatible draw; +- an incompatible variant deliberately creates ordered additional draws; +- measure tree-shaken raw/gzip/Brotli transfer, graph construction, first pipeline compilation, and steady state; +- prove an application using only native Three TSL pays no TypeGPU dependency cost. + +## Decision rule + +Do not retire native TSL merely because a constant-color `toTSL()` sample compiles. Choose outcome A only if every complete +technique passes all promised Three and gpucat backends. Choose B if TypeGPU proves a strong WebGPU authority but engine +fallbacks remain native. Choose C if compiler/resource bridges prevent one shader source from expressing the complete +pipeline. + +The core API is sound for all three outcomes when it publishes stable keys, explicit pre-update raster density, exact typed +storage, complete bindings, ordered runs, canonical dirty/live ranges, and the stage/commit target protocol. Shader +authority is an integration-package decision layered above that boundary. + +## Current disposition + +Outcome A is an attractive hypothesis, not the plan of record. Current primary-source evidence supports TypeGPU as modular +WebGPU building blocks and confirms a WebGPU-only Three bridge; it does not yet prove the real text resource and stage ABI. +Implement Gate 0 before building a TypeGPU engine. Until then: + +- native TSL remains the flagship Three implementation; +- `@pmndrs/text-typegpu` is specified as an independent WebGPU shader/program package with an optional direct encoder; +- `@pmndrs/text-three-typegpu` is an isolated experiment; +- gpucat remains an external public-API fitness test; +- no TypeGPU, Three, or gpucat type enters core. + +## External review disposition + +This ledger covers the complete retained Claude Opus report, not only its top findings. “Gate” means the prose claim was +narrowed and cannot become accepted architecture until that executable evidence exists. + +| Finding | Disposition in the canonical docs | +| --- | --- | +| B1 raster density had no core path | Corrected: batch and paragraph density are pre-update core inputs; spans do not override target density. | +| B2 target font leases did not exist | Corrected by removing the lease claim: targets synchronously copy required CPU font data during staging and own the result. | +| B3 interface storage failed `Record` | Corrected with the self-mapped storage constraint; the focused TypeScript probe passed. | +| B4 key identity/IDs were undefined | Corrected with branded IDs and interned frozen key identity through a physical allocation generation. | +| B5 `toTSL` capability claims | Falsified at `three@0.185.1` / `typegpu@0.11.9` / `@typegpu/three@0.11.0`; exact-version Gate 0 replaces the claim. | +| B6 WebGL2 omitted from the gate | Corrected: forced WebGPU and forced WebGL2 are explicit acceptance cases. | +| H1 reusable shader omitted vertex work | Corrected: the reusable algorithm includes typed vertex and fragment stages; Bitmap snap and Slug dilation are named requirements. | +| H2 Three context could not express techniques | Corrected: each technique exports exact resource, instance, vertex, fragment, derivative, and screen-scale context types. | +| H3 effect generics inferred `unknown` | Three helper corrected by binding schema inference to an exact shader. TypeGPU helper remains gated on an installed compile fixture. | +| H4 program inference helper undeclared | Corrected: `defineTypeGpuRasterProgram()` is declared, but remains unverified until the TypeGPU fixture exists. | +| H5 per-group hook staged unrelated targets | Corrected: core publication only records attachment source; the observed engine calls `attachment.prepare()`. | +| H6 Three silently assumed ready staging | Corrected: the standard Three target must synchronously return `ready`; pending custom targets cannot claim same-frame publication. | +| H7 unbounded variant/pipeline caches | Corrected: first-party programs require configurable bounds and GPU-safe eviction; custom programs must document equivalents. | +| H8 gpucat order interval could interleave | Corrected as a limitation: no interval is claimed reserved; strict adjacency requires one aggregate object or host reservation support. | +| H9 gpucat WebGL needed GLSL | Corrected: WebGL support requires an explicit GLSL companion and parity gate; WGSL alone is WebGPU-only. | +| H10 gpucat instance ABI differs | Corrected: semantic canonical SoA is shared; Three attributes and gpucat data textures are target-owned accessors. | +| M1 duplicate `LoadedFont` | Removed; integration docs import the core declaration. | +| M2 resize/chunk identity was undefined | Corrected: every real capacity change creates a complete new physical allocation generation and retires old keys. | +| M3 adjacent revision rule was ambiguous | Corrected: only successful publication increments runtime/batch revisions. | +| M4 no instance-to-paragraph mapping | Corrected: the contract defines the complete derivation by scanning disjoint ordered runs. | +| M5 duplicate run `order` | Removed; array position is authoritative. | +| M6 duplicate batch `chunk` | Removed from `PreparedGlyphBatch`; `GlyphBatchKey.chunk` is authoritative. | +| M7 “complete API” used undefined types | Corrected for core-owned public values; external `TgpuRoot` remains an imported TypeGPU type and TypeGPU declarations remain a draft gate. | +| M8 topology semantics were undefined | Corrected: the exact invalidation/preservation rules and stale-write behavior are specified. | +| M9 duplicate decision IDs | Corrected by assigning D-146 through D-151 to the duplicate rows. | +| M10 broad peers hid deep-import drift | Corrected: the experiment pins exact versions and reruns the compatibility fixture per upgrade. | +| L1 `msdf`/`mtsdf` naming mismatch | Documented as an intentional target-v1 rename from the merged v0 export. | +| L2 preparing/pending/failure flags | Corrected: active async work, eligible dirty work, and latched failure are distinct states. | +| L3 gpucat failing test attribution | Rechecked: 256/260 passed; one failure proves process-global symbol instability and three are stale flip-Y golden snapshots, none text evidence. | +| U1 newer bridge versions may differ | Kept open through an exact-version rerun gate, never a floating peer-range assumption. | +| U2 “technique compositing order” undefined | Corrected to visual run-array order plus adjacent program-expanded per-run passes. | +| U3 vertex ownership unclear | Corrected: canonical semantics/specification are shared; each engine program owns its executable vertex stage. | +| U4 Slug buffer-vs-texture split | Corrected with a semantic resource ABI and backend-specific storage accessors. | +| U5 variant-to-sidecar mapping absent | Corrected with an explicit program-owned codec/intern/write step and bounded revision lifetime. | +| U6 Wayfare was not inspected | Claim withdrawn; Wayfare reuse is an explicit source-inspection and execution gate. | +| U7 async variant mapping under supersession | Corrected: candidate input/span tables are immutable and generation-tagged; stale Worker results never map against current state. | diff --git a/docs/planning/vertical-writing.md b/docs/planning/vertical-writing.md index 311a4bb2..41658676 100644 --- a/docs/planning/vertical-writing.md +++ b/docs/planning/vertical-writing.md @@ -1,7 +1,7 @@ --- type: Design Research title: Vertical writing for CJK and mixed scripts -description: Defines the retained data, layout stages, renderer work, and acceptance gates for a post-V1 vertical-writing milestone. +description: Defines the retained data, layout stages, renderer work, and acceptance gates for a post-v1 vertical-writing milestone. tags: [layout, shaping, cjk, vertical-writing, typography] sources: - id: unicode-vertical-orientation @@ -18,12 +18,12 @@ sources: title: Canonical implementation roadmap generated: by: openai-codex/gpt-5.6 - at: '2026-07-27T15:05:00Z' + at: '2026-08-07T01:16:02Z' --- # Vertical writing for CJK and mixed scripts -Status: accepted post-V1 direction; implementation is scheduled after complete CJK paging +Status: accepted post-v1 direction; implementation is scheduled after complete CJK paging ## Recommendation diff --git a/docs/roadmap/roadmap.md b/docs/roadmap/roadmap.md index c12ba9e9..fa908076 100644 --- a/docs/roadmap/roadmap.md +++ b/docs/roadmap/roadmap.md @@ -10,23 +10,31 @@ sources: - id: 'citation-2' resource: '../planning/conformance-plan.md' title: 'Conformance plan' - - id: 'citation-3' - resource: '../../README.md#benchmark-harness-wireframe' - title: 'Repository benchmark-harness wireframe' - id: 'benchmark-workload-catalog' resource: '../../apps/benchmarks/src/workloads/catalog.ts' title: 'Typed live-workload catalog' + - id: 'core-api' + resource: '../planning/core-api.md' + title: 'Core text API' + - id: 'engine-integration-contract' + resource: '../planning/engine-integration-contract.md' + title: 'Engine integration contract' + - id: 'engine-integration-plan' + resource: '../planning/engine-integration-boundary.md' + title: 'Renderer-neutral extraction plan' generated: by: openai-codex/gpt-5.6 - at: '2026-08-03T15:34:13Z' + at: '2026-08-07T01:16:02Z' --- # Canonical implementation roadmap This is the only active execution order. -In this roadmap, **integration slice** means the internal bitmap proof in milestones 0–7. **V1** means the first shippable release at milestone 10, after bitmap, MSDF, and Slug have all passed their gates. The MSDF engine uses MTSDF atlas encoding. +In this roadmap, **integration slice** means the internal bitmap proof in milestones 0–7. **v0** is the merged, unreleased +implementation completed through milestone 10. **Target v1** is milestone 11's renderer-neutral core and integration work; +**v1** becomes the first public release only after that API and its integrations pass their gates. The MSDF engine uses MTSDF atlas encoding. Effort estimates are relative: **S** is one focused change, **M** is a multi-part change normally completed in one or two pull requests, **L** spans several coordinated pull requests, and **XL** is an epic that must be split before implementation. @@ -36,7 +44,8 @@ One pinned OpenType font must travel through Node pre-baking and automatic Worke The architecture supports multiple one-face fonts and independently packaged rasters from the beginning, but the first slice proves one font and one raster. -This slice is an internal integration proof, not a release candidate. The first shippable release additionally requires production-ready MSDF and Slug generators, payloads, runtime modules, visual fixtures, and performance evidence. +This slice is an internal integration proof, not a release candidate. MTSDF and Slug subsequently completed the merged v0 +renderer baseline. Their completion did not publish a release or freeze the public API; milestone 11 owns that target v1 gate. > **First executable artifact:** build the shared interactive/headless benchmark harness before the baker, loader, shaper, paragraph engine, or raster. Each implementation milestone adds adapters and scenarios to that existing harness. The first rendered bitmap frame MUST appear there; the roadmap does not authorize a separate throwaway rendering demo that is benchmarked later. @@ -44,19 +53,20 @@ This slice is an internal integration proof, not a release candidate. The first Status key: ✅ complete · 🟡 in progress · ⬜ not started · ⛔ blocked -| Order | Status | Milestone | Effort | Depends on | Exit result | -| ----: | :----: | --------------------------------------------------------------------- | ------ | ------------------- | ----------------------------------------------------------------------------------------------------------- | -| 0 | ✅ | Accept contracts, type fixtures, and versions | S | documentation audit | Public inference and identity, ownership, package, and version decisions cannot force a redesign. | -| 1 | ✅ | Build benchmark harness and pin fixtures | L | 0 | The first executable product surface runs shared interactive/headless smoke scenarios over pinned fixtures. | -| 2 | ✅ | Build font bake core, bitmap baker package, and Node host | L | 1 | Node composes a valid core GLB and one package-owned bitmap artifact without advanced compiler work. | -| 3 | ✅ | Build baked-first loader and Worker fallback | L | 2 | Baked hits stay small; misses dynamically load the Worker path and reproduce canonical bytes. | -| 4 | ✅ | Integrate HarfRust Wasm shaping | L | 2–3 | Coarse batch calls match pinned HarfRust fixtures and expose clusters, positions, and flags. | -| 5 | ✅ | Implement paragraph reflow and validate universal shaping assumptions | L | 4 | Allocation-light layout passes Latin, bidi/complex-script, and focused CJK source/reduced-font evidence. | -| 6 | ✅ | Prove rendering with bitmap inside the benchmark harness | L | 3, 5 | The harness produces the first real font frame on WebGPU and WebGL2 with direct bulk upload. | -| 7 | ✅ | Harden the integration proof | L | 1–6 | Identity, cancellation, limits, invalid data, package separation, and baselines pass review. | -| 8 | ✅ | Implement and validate MSDF | XL | 7 | The MTSDF-backed general-purpose raster passes visual, payload, and GPU performance gates. | -| 9 | ✅ | Port/rewrite and validate Slug | XL | 7 | Outline-accurate text passes correctness, packing, visual, and GPU performance gates. | -| 10 | ✅ | Harden the first shippable release | L | 8–9 | Bitmap, MSDF, and Slug ship as independent modules over one shaping/layout result. | +| Order | Status | Milestone | Effort | Depends on | Exit result | +| ----: | :----: | --------------------------------------------------------------------- | ------ | ------------------- | ------------------------------------------------------------------------------------------------------------ | +| 0 | ✅ | Accept contracts, type fixtures, and versions | S | documentation audit | Public inference and identity, ownership, package, and version decisions cannot force a redesign. | +| 1 | ✅ | Build benchmark harness and pin fixtures | L | 0 | The first executable product surface runs shared interactive/headless smoke scenarios over pinned fixtures. | +| 2 | ✅ | Build font bake core, bitmap baker package, and Node host | L | 1 | Node composes a valid core GLB and one package-owned bitmap artifact without advanced compiler work. | +| 3 | ✅ | Build baked-first loader and Worker fallback | L | 2 | Baked hits stay small; misses dynamically load the Worker path and reproduce canonical bytes. | +| 4 | ✅ | Integrate HarfRust Wasm shaping | L | 2–3 | Coarse batch calls match pinned HarfRust fixtures and expose clusters, positions, and flags. | +| 5 | ✅ | Implement paragraph reflow and validate universal shaping assumptions | L | 4 | Allocation-light layout passes Latin, bidi/complex-script, and focused CJK source/reduced-font evidence. | +| 6 | ✅ | Prove rendering with bitmap inside the benchmark harness | L | 3, 5 | The harness produces the first real font frame on WebGPU and WebGL2 with direct bulk upload. | +| 7 | ✅ | Harden the integration proof | L | 1–6 | Identity, cancellation, limits, invalid data, package separation, and baselines pass review. | +| 8 | ✅ | Implement and validate MSDF | XL | 7 | The MTSDF-backed general-purpose raster passes visual, payload, and GPU performance gates. | +| 9 | ✅ | Port/rewrite and validate Slug | XL | 7 | Outline-accurate text passes correctness, packing, visual, and GPU performance gates. | +| 10 | ✅ | Harden the merged v0 renderer baseline | L | 8–9 | Bitmap, MSDF, and Slug merge as independent modules over one shaping/layout result; no release is published. | +| 11 | ⬜ | Extract the renderer-neutral batched core and engine target contract | XL | 10 | One explicit batch renders through Three.js and Wayfare without renderer dependencies in portable core. | Milestones 0–10 are closed. Milestone 11 is the next additive workstream. @@ -77,56 +87,68 @@ flowchart LR M7 --> M9["9 Slug"] M8 --> M10["10 Shippable raster set"] M9 --> M10 + M10 --> M11["11 Renderer-neutral batched core
+ engine targets"] ``` ## Issue-sized implementation sequence These rows replace the former separate backlog. Each is intended to become one focused issue or a short, explicitly linked PR sequence. -| ID | Status | Work | Size | Depends on | -| ---- | :----: | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :--: | ---------- | -| 0.1 | ✅ | Accept public core/React APIs, typed raster capabilities, URL resolution, and ESM-only exports. | S | — | -| 0.2 | ✅ | Make the initial `@pmndrs/text` contract shim preserve font/raster literals and pass positive/negative composition fixtures. | S | 0.1 | -| 0.3 | ✅ | Accept identity, GLB, Worker, and version contracts. | S | 0.2 | -| 1.1 | ✅ | Build shared benchmark target/scenario/result contracts and a deterministic synthetic smoke target. | M | 0.3 | -| 1.2 | ✅ | Add the interactive lab, headless runner, raw result export, and package-size lane over the same registry. | M | 1.1 | -| 1.3 | ✅ | Pin the source font, HarfRust/HarfBuzz shaping oracles, and browser HTML/CSS visual reference as harness fixtures. | M | 1.2 | -| 2.1 | ✅ | Implement static `defineFont` discovery, literal raster extraction, and conservative local source resolution. | M | 1.3 | -| 2.2 | ✅ | Implement the host-independent font bake request/result core. | M | 2.1 | -| 2.3 | ✅ | Emit/validate the core font and declared package-owned bitmap strikes. | M | 2.2 | -| 2.4 | ✅ | Add the Node API, CLI, deterministic bytes, and report. | M | 2.3 | -| 3.1 | ✅ | Implement baked probing, validation, and registration. | M | 2.4 | -| 3.2 | ✅ | Add the dynamically imported Worker bake path. | M | 3.1 | -| 3.3 | ✅ | Prove Node/Worker parity, cancellation, and import isolation. | M | 3.2 | -| 4.1 | ✅ | Register fonts and cache HarfRust data/plans in Wasm. | M | 2.2 | -| 4.2 | ✅ | Implement batched shape/reshape ABI and conformance fixtures. | M | 4.1 | -| 5.1 | ✅ | Build paragraph analysis, measured clusters, greedy breaks, and allocation-light `measure`. | M | 4.2 | -| 5.2 | ✅ | Add final positioned `layout`, reflow caches, and batched boundary reshaping. | M | 5.1 | -| 5.3 | ✅ | Add alignment, clipping, max-lines, ellipsis, bidi, and current-uikit adapter fixtures. | M | 5.2 | -| 5.4 | ✅ | Pin one redistributable pan-CJK face and prove source/reduced HarfRust, HarfBuzz, horizontal paragraph layout, fuzz, and Node/Chromium/Vitexec evidence without renderer or paging work. | L | 5.3 | -| 6.0 | ✅ | Establish the current-repository TSL compiler, shader, and live WebGPU/WebGL2 baseline without broad type erasure. | S | 3.3, 5.4 | -| 6.1 | ✅ | Upload/render bitmap records and textures as the harness's first real raster target on WebGPU/WebGL2. | M | 6.0 | -| 6.2 | ✅ | Implement the Three.js `Text` object over the bitmap proof. | M | 6.1 | -| 6.3 | ✅ | Implement `@pmndrs/text/react` as a thin reconciliation layer. | M | 6.2 | -| 6.4 | ✅ | Rework the harness into a benchmark-first human control plane with a separate visual conformance mode. | M | 6.1–6.3 | -| 7.1 | ✅ | Harden lifecycle, invalid input, limits, and package graphs. | M | 1–6 | -| 7.2 | ✅ | Ship the advanced-shaping showcase and record end-to-end conformance/performance baselines. | M | 7.1 | -| 8.1 | ✅ | Implement the repository-owned deterministic `no_std` Rust MTSDF core and pass panic, scalar/SIMD, Wasm, size, fuzz, and native-msdfgen quality gates. | L | 7.2 | -| 8.2 | ✅ | Implement the fixed MTSDF baker, canonical 20-byte records, linear RGBA8 KTX2 payload, and embedded/external parity. | XL | 8.1 | -| 8.3 | ✅ | Implement the optional MSDF runtime module, strict validation, one resource/batch family, paint effects, and disposal. | L | 8.2 | -| 8.4 | ✅ | Implement one version-matched TSL MTSDF graph for WebGPU and WebGL2 with resize, transform, base-level minification, and effects scenes. | L | 8.3 | -| 8.5 | ✅ | Record visual-error, atlas, upload, memory, bundle-isolation, and steady-state rendering evidence. | XL | 8.4 | -| 8.6 | ✅ | Add configurable MTSDF quality, bounded runtime-atlas options, compiler-derived Wasm ABI layouts, and measured baker performance hardening before closing Milestone 8. | XL | 8.5 | -| 9.1 | ✅ | Port Slug outline conversion, exact normalization/bands, compact packing, deterministic baker, validator, and embedded/external resources. | XL | 7.2 | -| 9.2 | ✅ | Copy and adapt the version-matched analytic TSL fill runtime, batching, lifecycle, fail-closed paint boundary, and public `Text` integration. | XL | 9.1 | -| 9.3 | ✅ | Integrate Slug into the shared benchmark/conformance product, release-role scenes, source-outline matrix, and complete two-axis icon-font grid. | XL | 9.2 | -| 9.4 | ✅ | Reproduce the applicable prior-fork performance baseline, evaluate retained challengers, and close payload, residency, frame-time, and bundle-isolation gates. | XL | 9.3 | -| 10.1 | ✅ | Replace the optional Three-shaped plugin seam with one required renderer-neutral transactional raster lifecycle and retain Three.js as an adapter. | L | 8.6, 9.4 | -| 10.2 | ✅ | Publish warm shaping, layout, paint planning, and raster staging through the Three.js object-update lifecycle without consumer `ready` waits. | L | 10.1 | -| 10.3 | ✅ | Add bounded glyph-capacity slack, complete in-place field replacement, authoritative shrink counts, overflow replacement, and coalesced dirty uploads to all three rasters. | XL | 10.2 | -| 10.4 | ✅ | Prove the public extension boundary with a private workspace raster/baker package that owns a new kind, artifact, adapter, retained updates, overflow, abort, and disposal. | L | 10.1, 10.3 | -| 10.5 | ✅ | Remove benchmark recycling workarounds and prove Icon Grid plus every Presentation workload through sequential, timed, allocation, cadence, dual-backend, and React Doctor gates. | XL | 10.2–10.4 | -| 10.6 | ✅ | Complete raster switching, release conformance, public API review, recommendations, plugin authoring guidance, package-size evidence, and signed stacked delivery. | L | 10.5 | +| ID | Status | Work | Size | Depends on | +| ----- | :----: | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | :--: | ---------- | +| 0.1 | ✅ | Accept public core/React APIs, typed raster capabilities, URL resolution, and ESM-only exports. | S | — | +| 0.2 | ✅ | Make the initial `@pmndrs/text` contract shim preserve font/raster literals and pass positive/negative composition fixtures. | S | 0.1 | +| 0.3 | ✅ | Accept identity, GLB, Worker, and version contracts. | S | 0.2 | +| 1.1 | ✅ | Build shared benchmark target/scenario/result contracts and a deterministic synthetic smoke target. | M | 0.3 | +| 1.2 | ✅ | Add the interactive lab, headless runner, raw result export, and package-size lane over the same registry. | M | 1.1 | +| 1.3 | ✅ | Pin the source font, HarfRust/HarfBuzz shaping oracles, and browser HTML/CSS visual reference as harness fixtures. | M | 1.2 | +| 2.1 | ✅ | Implement static `defineFont` discovery, literal raster extraction, and conservative local source resolution. | M | 1.3 | +| 2.2 | ✅ | Implement the host-independent font bake request/result core. | M | 2.1 | +| 2.3 | ✅ | Emit/validate the core font and declared package-owned bitmap strikes. | M | 2.2 | +| 2.4 | ✅ | Add the Node API, CLI, deterministic bytes, and report. | M | 2.3 | +| 3.1 | ✅ | Implement baked probing, validation, and registration. | M | 2.4 | +| 3.2 | ✅ | Add the dynamically imported Worker bake path. | M | 3.1 | +| 3.3 | ✅ | Prove Node/Worker parity, cancellation, and import isolation. | M | 3.2 | +| 4.1 | ✅ | Register fonts and cache HarfRust data/plans in Wasm. | M | 2.2 | +| 4.2 | ✅ | Implement batched shape/reshape ABI and conformance fixtures. | M | 4.1 | +| 5.1 | ✅ | Build paragraph analysis, measured clusters, greedy breaks, and allocation-light `measure`. | M | 4.2 | +| 5.2 | ✅ | Add final positioned `layout`, reflow caches, and batched boundary reshaping. | M | 5.1 | +| 5.3 | ✅ | Add alignment, clipping, max-lines, ellipsis, bidi, and current-uikit adapter fixtures. | M | 5.2 | +| 5.4 | ✅ | Pin one redistributable pan-CJK face and prove source/reduced HarfRust, HarfBuzz, horizontal paragraph layout, fuzz, and Node/Chromium/Vitexec evidence without renderer or paging work. | L | 5.3 | +| 6.0 | ✅ | Establish the current-repository TSL compiler, shader, and live WebGPU/WebGL2 baseline without broad type erasure. | S | 3.3, 5.4 | +| 6.1 | ✅ | Upload/render bitmap records and textures as the harness's first real raster target on WebGPU/WebGL2. | M | 6.0 | +| 6.2 | ✅ | Implement the Three.js `Text` object over the bitmap proof. | M | 6.1 | +| 6.3 | ✅ | Implement `@pmndrs/text/react` as a thin reconciliation layer. | M | 6.2 | +| 6.4 | ✅ | Rework the harness into a benchmark-first human control plane with a separate visual conformance mode. | M | 6.1–6.3 | +| 7.1 | ✅ | Harden lifecycle, invalid input, limits, and package graphs. | M | 1–6 | +| 7.2 | ✅ | Ship the advanced-shaping showcase and record end-to-end conformance/performance baselines. | M | 7.1 | +| 8.1 | ✅ | Implement the repository-owned deterministic `no_std` Rust MTSDF core and pass panic, scalar/SIMD, Wasm, size, fuzz, and native-msdfgen quality gates. | L | 7.2 | +| 8.2 | ✅ | Implement the fixed MTSDF baker, canonical 20-byte records, linear RGBA8 KTX2 payload, and embedded/external parity. | XL | 8.1 | +| 8.3 | ✅ | Implement the optional MSDF runtime module, strict validation, one resource/batch family, paint effects, and disposal. | L | 8.2 | +| 8.4 | ✅ | Implement one version-matched TSL MTSDF graph for WebGPU and WebGL2 with resize, transform, base-level minification, and effects scenes. | L | 8.3 | +| 8.5 | ✅ | Record visual-error, atlas, upload, memory, bundle-isolation, and steady-state rendering evidence. | XL | 8.4 | +| 8.6 | ✅ | Add configurable MTSDF quality, bounded runtime-atlas options, compiler-derived Wasm ABI layouts, and measured baker performance hardening before closing Milestone 8. | XL | 8.5 | +| 9.1 | ✅ | Port Slug outline conversion, exact normalization/bands, compact packing, deterministic baker, validator, and embedded/external resources. | XL | 7.2 | +| 9.2 | ✅ | Copy and adapt the version-matched analytic TSL fill runtime, batching, lifecycle, fail-closed paint boundary, and public `Text` integration. | XL | 9.1 | +| 9.3 | ✅ | Integrate Slug into the shared benchmark/conformance product, raster-role scenes, source-outline matrix, and complete two-axis icon-font grid. | XL | 9.2 | +| 9.4 | ✅ | Reproduce the applicable prior-fork performance baseline, evaluate retained challengers, and close payload, residency, frame-time, and bundle-isolation gates. | XL | 9.3 | +| 10.1 | ✅ | Replace the optional Three-shaped plugin seam with one required renderer-neutral transactional raster lifecycle and retain Three.js as an adapter. | L | 8.6, 9.4 | +| 10.2 | ✅ | Publish warm shaping, layout, paint planning, and raster staging through the Three.js object-update lifecycle without consumer `ready` waits. | L | 10.1 | +| 10.3 | ✅ | Add bounded glyph-capacity slack, complete in-place field replacement, authoritative shrink counts, overflow replacement, and coalesced dirty uploads to all three rasters. | XL | 10.2 | +| 10.4 | ✅ | Prove the public extension boundary with a private workspace raster/baker package that owns a new kind, artifact, adapter, retained updates, overflow, abort, and disposal. | L | 10.1, 10.3 | +| 10.5 | ✅ | Remove benchmark recycling workarounds and prove Icon Grid plus every Presentation workload through sequential, timed, allocation, cadence, dual-backend, and React Doctor gates. | XL | 10.2–10.4 | +| 10.6 | ✅ | Complete raster switching, v0 conformance, public API review, recommendations, plugin authoring guidance, package-size evidence, and signed stacked merge. | L | 10.5 | +| 11.1 | ⬜ | Freeze the accepted README/API fixtures and capture current Three.js behavior, package graphs, rendering, allocation, and shaping baselines. | M | 10.6 | +| 11.2 | ⬜ | Split portable raster decoding/bindings/packing from GPU realization; export reusable backend `RasterShader` algorithms and exact-typed programs, retaining native TSL and reusable TypeGPU paths. | L | 11.1 | +| 11.3 | ⬜ | Implement `TextRuntime`, same-technique `FontStack`, batch-owned `Paragraph` handles, desired snapshots/font leases, typed `txt`/`span`, opaque batch/paragraph/span render variants, capacity, and origin overrides. | XL | 11.2 | +| 11.4 | ⬜ | Implement dirty-channel coalescing plus per-call `update()` and Promise/callback `updateAsync()` synchronization with cross-batch atomic publication, cancellation, and supersession. | XL | 11.3 | +| 11.5 | ⬜ | Move raster-resource partitioning, typed bindings, stable slots, overflow chunks, canonical CPU storage, dirty/live ranges, attachments, resolved variants, and ordered `PreparedGlyphRun` values into core. | XL | 11.3–11.4 | +| 11.6 | ⬜ | Rebuild Bitmap, MTSDF, and Slug behind `FontLoader` → `TextGroup` → `Text`, including program-selected variants, reusable canonical shaders, optional TSL effects, late binding, native ordering, and renderer isolation. | XL | 11.5 | +| 11.7 | ⬜ | Rebuild React Three Fiber over the same retained `TextGroup`/`Text` lifecycle, letting Three synchronize once per batch during render while preserving nested spans. | L | 11.6 | +| 11.8 | ⬜ | Run the TypeGPU-first capability gate, then implement reusable complete-stage TypeGPU raster programs and only the minimal direct pass encoder needed to prove the same public core batches/runs through TypeGPU and Wayfare. | XL | 11.5 | +| 11.9 | ⬜ | Prove TypeGPU-authored Bitmap/MTSDF/Slug through pinned `@typegpu/three`, including real textures, dependent loads, loops, vertex work, generated shaders, forced WebGPU/WebGL2 capability, pixels, and isolated cost; retain native TSL unless every promised backend passes. | L | 11.6, 11.8 | +| 11.10 | ⬜ | Prove an external gpucat package against public core and technique exports, including ordering limits, partial uploads, lifetime, TypeGPU/WGSL reuse, and an explicit GLSL companion or WebGPU-only scope, without a core change or private import. | L | 11.5, 11.8 | +| 11.11 | ⬜ | Reconcile implementation against the authoritative README and engine contract, remove the merged v0 surface, update package concepts/digests, and close package, browser, GPU, size, and OKF gates before declaring v1. | L | 11.6–11.10 | ## Milestone 0 — accept contracts and versions @@ -260,7 +282,7 @@ Item 2.2 is closed. Item 2.3 adds the core/raster validators plus the package-ow - [x] Pinned Khronos glTF Validator 2.0.0-dev.3.10 runs offline and retains its report; only exact reviewed unsupported-extension and extension-owned-buffer informational messages are admitted. - [x] Ajv 6.15.0 evaluates the canonical Draft-04 `PMNDRS_font` schema against the vendored Khronos revision, with byte-identity and required-field/union mutation fixtures. - [x] Core semantic and payload validation covers buffer containment/non-overlap, versions, reciprocal raster identity, closed SFNT/checksums/metrics, dense extents, zero padding, and shaping identity. -- [x] The canonical Inter product path uses the shipped validator, while the baker-only entry remains import-isolated from Ajv and `gltf-validator`. +- [x] The canonical Inter product path uses the merged validator, while the baker-only entry remains import-isolated from Ajv and `gltf-validator`. - [x] Fixed-seed Rust-input and TypeScript artifact-mutation fuzz smoke tests run in the ordinary suite; longer mutation drivers plus pinned cargo-fuzz/libFuzzer coverage promote minimized findings into permanent malformed fixtures. - [x] The bitmap-owned module canonicalizes static strike tuples and derives the RFC 8785 raster key without a parallel core descriptor union. - [x] The bitmap baker emits deterministic unhinted grayscale strikes, dense 20-byte records, lossless R8 KTX2 pages, reports, and embedded/external packaging. @@ -305,7 +327,7 @@ Explicitly exclude subsetting, shaping closure, dense remapping, compiled layout - [x] String, `URL`, `{ source }`, `{ source, baked }`, and `{ baked }` inputs normalize against one base, remove fragments, preserve queries, detect baked-only GLBs, and derive exact case-insensitive TTF/OTF/WOFF/WOFF2 or extensionless siblings. - [x] Concurrent equivalent requests share one versioned promise key; validated shaping identity deduplicates registration within a registry while separate registries and post-disposal generations remain isolated. -- [x] Baked hits run the shipped GLB/Khronos/schema/semantic/payload validator before registration, distinguish missing, invalid, incompatible-version, fetch, and resource-limit failures, and cannot reach the runtime baker, bitmap baker, Node host, or bake Wasm in the initial graph. +- [x] Baked hits run the merged GLB/Khronos/schema/semantic/payload validator before registration, distinguish missing, invalid, incompatible-version, fetch, and resource-limit failures, and cannot reach the runtime baker, bitmap baker, Node host, or bake Wasm in the initial graph. - [x] Registration owns caller bytes, extracts the exact reduced SFNT, glyph extents, availability bits, metrics, Unicode/source provenance, and raster directory needed by later shaping without reparsing the source font. - [x] Loader fixtures compare every extracted shaping byte to the independently validated GLB views; milestone 4 consumes these same retained views for bit-for-bit corpus shaping rather than creating a second extraction path. - [x] Embedded/external delivery variants for one raster key merge without changing identity; generic attachment checks GLB framing, Khronos output, buffer ranges, reciprocal font/raster identity, artifact hash, and immutable copied views while package semantics remain module-owned. @@ -455,7 +477,7 @@ Deliver: Exit only when the complete horizontal corpus is byte-exact through the source and reduced-font HarfRust paths, the independent HarfBuzz oracle agrees under the documented normalization policy, and Node/Chromium/Vitexec paragraph outputs are deterministic. Any genuine mismatch in the retained SFNT profile, UTF-16 clustering, language selection, variation handling, glyph-width assumptions, or line layout blocks rendering work. -Large-coverage raster paging remains in Milestone 13. Vertical-form source tables must survive baking when present, but vertical shaping/layout remains deferred. +Large-coverage raster paging remains in Milestone 14. Vertical-form source tables must survive baking when present, but vertical shaping/layout remains deferred. ### 5.4 closure checklist @@ -541,7 +563,7 @@ Deliver: - accepted ADRs and updated extension schemas; - an autoresearch baseline with optimization campaigns still disabled. -Milestone 7 authorizes implementation of the release rasters; it does not authorize a package release. +Milestone 7 authorizes implementation of the remaining v0 rasters; it does not authorize a package release. ### 7.1 closure checklist @@ -566,7 +588,7 @@ Milestone 7 authorizes implementation of the release rasters; it does not author - [x] Reuse the exact showcase definitions through headless conformance and the admitted Vitexec product probe, then record reviewed exact conformance evidence. - [x] Record a separate environment-labeled live performance observation; conformance execution duration is never presented as renderer cost. -## Milestone 8 — MSDF release raster +## Milestone 8 — MTSDF raster Deliver: @@ -592,15 +614,15 @@ Exit only when MSDF is credible as the general-purpose recommendation across the - [x] Add deterministic unit, structured integration, malformed-input, and coverage-guided fuzz evidence for the owned core. - [x] Record raw/optimized/gzip/Brotli candidate-core Wasm size through a reproducible freshness-checked package script. - [x] Record cold/warm full-font generation cost after the owned generator and Fontations provider are integrated; Inter produces 2,915 glyphs with an identical checksum and current scalar observations of 45.38 seconds cold and 48.13 seconds warm. -- [x] Ship the Binaryen-optimized generator and generated ABI as package resources, with a zero-import admission kernel and one generated progress callback on the full artifact baker; validate the complete nested contract in TypeScript, copy borrowed RGBA8 before request release, and pass all seven native-oracle identities plus forged ownership, malformed host input, stale allocation, and cleanup cases through that host. +- [x] Package the Binaryen-optimized generator and generated ABI as repository resources, with a zero-import admission kernel and one generated progress callback on the full artifact baker; validate the complete nested contract in TypeScript, copy borrowed RGBA8 before request release, and pass all seven native-oracle identities plus forged ownership, malformed host input, stale allocation, and cleanup cases through that host. - [x] Measure the generator host and Wasm independently under reviewed size ceilings, and provide a host-labeled cold/warm seven-case benchmark command whose hashes must pass before timings publish. -- [x] Compare scalar, auto-vectorized, and explicit `simd128` kernels over exact quality hashes, the complete Inter pass, representative browser calls, allocation counts, and raw/optimized/gzip/Brotli size; retain one default implementation and no public toggle. Scalar remains the sole bounded-runtime kernel because it is fastest on the current Node and Chromium seven-case corpus. Explicit SIMD's 5.7% complete-Inter stress win remains checked item 8.6 evidence rather than a second published artifact. +- [x] Compare scalar, auto-vectorized, and explicit `simd128` kernels over exact quality hashes, the complete Inter pass, representative browser calls, allocation counts, and raw/optimized/gzip/Brotli size; retain one default implementation and no public toggle. Scalar remains the sole bounded-runtime kernel because it is fastest on the current Node and Chromium seven-case corpus. Explicit SIMD's 5.7% complete-Inter stress win remains checked item 8.6 evidence rather than a second packaged artifact. The [MTSDF generator admission](../planning/mtsdf-generator-admission.md) records why no published candidate is accepted unchanged. The implementation is repository-owned; native Chlumsky `msdfgen` is the independent quality oracle and does not ship in browser packages. Item 8.1 is closed with the scalar production boundary as the single default. The internal `simd128-experiment` Cargo feature remains non-shipping evidence and is not a JavaScript option, alternate package artifact, or runtime branch. ### 8.2 fixed-baker checklist -- [x] Compose the admitted scalar kernel with the shared Fontations provider, fallible atlas/record writer, GLB framing, content hashing, and generated direct-memory ABI in one published Wasm with one declared synchronous progress import for Worker-hosted long bakes. +- [x] Compose the admitted scalar kernel with the shared Fontations provider, fallible atlas/record writer, GLB framing, content hashing, and generated direct-memory ABI in one packaged Wasm with one declared synchronous progress import for Worker-hosted long bakes. - [x] Fix one descriptor and one lossless linear RGBA8 MTSDF representation with exact page, padding, range, and plane-unit constants. - [x] Prove canonical Inter record/page identities, embedded/external byte parity, native/Wasm parity, deterministic output, and isolated baker/host size. - [x] Keep baker Wasm and generation dependencies outside shaping and rendering module graphs. @@ -638,7 +660,7 @@ Item 8.3 is closed. Item 8.4 executes the same instanced TSL graph through WebGP ### 8.6 selective runtime bake, ABI, and performance checklist -Runtime baking is a supported delivery path, not merely a missing-asset recovery mechanism. Callers may deliberately generate Bitmap or MTSDF atlases in the module Worker, but interactive use must not require rasterizing an entire large face when the application knows its bounded coverage. Coverage selection reduces raster work only: the complete shaping font, font-local glyph IDs, and HarfRust behavior remain unchanged until Milestone 17 proves true source subsetting and shaping closure. +Runtime baking is a supported delivery path, not merely a missing-asset recovery mechanism. Callers may deliberately generate Bitmap or MTSDF atlases in the module Worker, but interactive use must not require rasterizing an entire large face when the application knows its bounded coverage. Coverage selection reduces raster work only: the complete shaping font, font-local glyph IDs, and HarfRust behavior remain unchanged until Milestone 18 proves true source subsetting and shaping closure. - [x] Expose authenticated integer MTSDF `emSize` and full `pixelRange` controls with bounds `1..=1022` and `1..=1020`, `planeUnitsPerEm = emSize`, and `ceil(pixelRange / 2)` field padding. Omitted or partial values resolve against 64/8, explicit 64/8 retains the legacy descriptor/key, and non-default descriptors carry both effective values. Real 155-glyph subset bakes at 32/4 and 32/6 pass artifact validation; choosing a new default remains quality/payload benchmark work. - [x] Add one typed runtime-bake options contract shared by explicit runtime delivery and automatic fallback. It carries the selected raster descriptor plus bounded coverage seeds, rejects duplicates/out-of-range values deterministically, and reports missing raster coverage explicitly before batch publication. @@ -648,13 +670,13 @@ Runtime baking is a supported delivery path, not merely a missing-asset recovery - [x] Keep Wasm direct-memory values little-endian because WebAssembly linear memory is normatively little-endian, while retaining explicit format-mandated byte order in GLB, KTX2, SFNT, and other portable serialized artifacts. Sparse raster coverage uses the same explicit little-endian bit numbering and zero terminal padding. - [x] Regenerate every affected ABI JSON file, optimized Wasm resource, baked fixture, identity, size record, and package digest; run the complete Rust, TypeScript, Node/Worker parity, artifact-validation, renderer, conformance, and live-product regression sweep before accepting the new boundary. - [x] Instrument the baker by phase and publish small, medium, and complete-face results for glyph selection, outline extraction, MTSDF texel generation, packing, texture-payload encoding, container serialization, Wasm-to-Worker copying, peak memory, and output bytes. Reports include glyphs, generated texels, edges visited, and throughput rather than one opaque wall-clock duration; direct Wasm and the real serial Worker retain exact artifact identity. -- [x] Optimize the measured dominant phase without weakening native-msdfgen quality or deterministic artifact gates. Texel generation dominates, so an equivalent four-texel scalar tile and an adjacent-texel SIMD line-distance kernel were compared against the unchanged scalar quadratic/cubic lane fallback. Every exact oracle and complete-Inter identity remains unchanged. Adjacent SIMD improves the bounded Node and Chromium corpora by 2.4% and 0.9%, but is indistinguishable from scalar over complete Inter warm execution while adding 20.7% optimized and 11.4% Brotli bytes. Scalar tile improves bounded Node by 10.1% but regresses Chromium by 1.5% and complete Inter warm by 1.4% while adding 20.0% optimized and 10.9% Brotli bytes. Machine-checked structured observations retain those tradeoffs. Both candidates are rejected as universal runtime defaults, remain non-shipping experiment features, and scalar Wasm remains the single shipped kernel. TypeGPU/WebGPU compute remains research until identical-work evidence can justify its device and readback complexity. +- [x] Optimize the measured dominant phase without weakening native-msdfgen quality or deterministic artifact gates. Texel generation dominates, so an equivalent four-texel scalar tile and an adjacent-texel SIMD line-distance kernel were compared against the unchanged scalar quadratic/cubic lane fallback. Every exact oracle and complete-Inter identity remains unchanged. Adjacent SIMD improves the bounded Node and Chromium corpora by 2.4% and 0.9%, but is indistinguishable from scalar over complete Inter warm execution while adding 20.7% optimized and 11.4% Brotli bytes. Scalar tile improves bounded Node by 10.1% but regresses Chromium by 1.5% and complete Inter warm by 1.4% while adding 20.0% optimized and 10.9% Brotli bytes. Machine-checked structured observations retain those tradeoffs. Both candidates are rejected as universal runtime defaults, remain experiment features, and scalar Wasm remains the single merged v0 kernel. TypeGPU/WebGPU compute remains research until identical-work evidence can justify its device and readback complexity. - [x] Select pinned dynamic Talc from the complete optimized Wasm corpus: byte-identical behavior retains the existing ownership/error/reused-Worker tests while saving 46,610 raw, 15,121 gzip, and 12,121 Brotli bytes versus `dlmalloc`. Reject a 128 MiB global arena because it raises initial memory to about 129 MiB for no meaningful transfer saving; keep request-local scratch arenas as profiling-led future work only. - [x] Complete the final adversarial Milestone 6/8 review with no unresolved actionable findings. Item 8.6 and Milestone 8 are closed. The combined closure review retained complete traces under the ignored repository review cache, every actionable finding was independently reproduced and remediated, the exact package-size identity and fail-closed provenance are current, and the complete package, benchmark-unit, headless conformance, packed-consumer, and documentation gates pass on the recorded host. -## Milestone 9 — Slug release renderer +## Milestone 9 — Slug renderer Deliver: @@ -676,18 +698,18 @@ Exit only when Slug satisfies its outline-accurate large/zoomed-text role withou - [x] Emit and validate native RGBA16F curve pages, R32UI headers, and exact R16UI references in embedded or independently authenticated external packaging, with byte-identical resources between forms. - [x] Load a freshly baked external core GLB, external Slug companion, and external curve/header/reference resources through public `FontLoader`; public `Text` renders byte-identically to the embedded fixture on WebGPU and forced WebGL2 while exact fetch counts prove every URL-aware path was exercised. - [x] Copy and adapt the reviewed Three Flatland coverage graph to the installed Three.js/TSL version, preserving bounded dynamic traversal, stable quadratic solving, loop-invariant hoists, direct integer addressing, page runs, transactional GPU ownership, and non-Slug import isolation. -- [x] Retain the fill-only material and reject outline/shadow paint before allocation or mutation. The copied dynamic exact-distance outline was proven correct but measured at `2.44×–4.33×` fill-only GPU time, then removed rather than shipped. The [outline research record](../planning/slug-outline-research.md) preserves the rejected architecture and bounded-approximation gate. -- [x] Retain 36 dual-backend/DPR release-role cells for large text, 1,024-ppem magnification, Arabic, Devanagari, CJK, clipping, affine transform, and projection zoom, with source outlines as the quality authority and historical prior-art transforms as labeled invariants only. +- [x] Retain the fill-only material and reject outline/shadow paint before allocation or mutation. The copied dynamic exact-distance outline was proven correct but measured at `2.44×–4.33×` fill-only GPU time, then removed rather than merged. The [outline research record](../planning/slug-outline-research.md) preserves the rejected architecture and bounded-approximation gate. +- [x] Retain 36 dual-backend/DPR raster-role cells for large text, 1,024-ppem magnification, Arabic, Devanagari, CJK, clipping, affine transform, and projection zoom, with source outlines as the quality authority and historical prior-art transforms as labeled invariants only. - [x] Retain the seven-source, 28-cell source-quality matrix and the complete 1,402-icon Font Awesome grid through Bitmap, MTSDF, and Slug with two-axis panning, overscan virtualization, fixed labels, logarithmic scaling, and zero missing glyphs through the final catalog entry. - [x] Reproduce the applicable prior-fork baseline improvements: dynamic curve loops, generated-shader hoisting, compact exact band storage, complete band-list deduplication, and exact quadratic bounds. Measure structural root branching instead of assuming it; retain and reject the candidate when the existing generated control flow wins overall. -- [x] Retain the initial fixed-32 calibration, then evaluate adaptive `{16,32,64}`, capped `{16,32}`, packed-hull, and per-root challengers through precommitted staged gates. A candidate rejected by an authenticated artifact or residency gate makes no pixel or GPU claim; every candidate that reaches product measurement retains exact quality and a complete dual-backend decision. Rejected candidates remain in auditable evidence/commits and do not add shipping format or shader branches. +- [x] Retain the initial fixed-32 calibration, then evaluate adaptive `{16,32,64}`, capped `{16,32}`, packed-hull, and per-root challengers through precommitted staged gates. A candidate rejected by an authenticated artifact or residency gate makes no pixel or GPU claim; every candidate that reaches product measurement retains exact quality and a complete dual-backend decision. Rejected candidates remain in auditable evidence/commits and do not add merged format or shader branches. - [x] Publish Slug payload, upload-frame, first-draw, steady CPU/GPU, exact curve/header/reference residency, isolated runtime/baker sizes, and seven-source performance matrices; assert Bitmap- and MTSDF-only graphs exclude Slug runtime, shaders, workers, and baker code. Milestone 9 is closed. Additional Slug optimization hypotheses are future measured research and do not reopen the accepted V0 renderer unless they change a checked contract. -## Milestone 10 — harden the first shippable release +## Milestone 10 — harden the merged v0 baseline -Milestone 10 is closed. Item 10.1 established the required renderer-neutral transaction and Three.js adapter parity, item 10.2 moved resident shaping, layout, paint planning, raster staging, and atomic publication into the Three.js object-update lifecycle without warm consumer readiness waits, item 10.3 added bounded retained instance capacity to all three first-party rasters, item 10.4 proved the published extension boundary with a private external consumer package, item 10.5 removed benchmark workarounds and retained complete dual-backend Presentation evidence, and item 10.6 completed release review and signed stacked delivery. The release pass makes every live workload a readable consumer example with one typed default/control/font/surface policy, isolates low-level conformance targets, and records any concrete package escape hatch in the API fixture before proposing implementation. Every example now owns `workloads//{definition,scene}.ts`: definitions carry exact Main/Presentation defaults and route policy, scenes show the public `@pmndrs/text` and Three.js usage, consumers import exact files rather than barrels, and the root catalog only preserves order and lookup. The workload audit records all nine live examples individually: each uses published `Text`, loader, registry, raster, and runtime-bake surfaces, while direct Wasm remains an explicit ABI target. No missing common package API is currently proven. Live Bitmap, MTSDF, and Slug adapters and metadata are isolated under `techniques`; generic renderer ownership and finite target machinery no longer depend on one another. Non-target retained comparison and isolated preview modules now live with conformance surfaces and probes, while true targets and reusable low-level references keep distinct hierarchies. Main/Presentation composition dispatches through named benchmark surfaces while preserving one shared `Harness` identity, canvas, renderer, and telemetry history across route changes. All seven retained comparison definitions own app-private construction, layout, animation, and retained-configuration hooks. Icon Grid also owns its per-mount active pool assignment, recycling, pan, frame smoothing, refresh suspension, visibility, and metrics state behind a narrow host lifecycle adapter. Benchmark Ipsum and Advanced Shaping authored state live beside the other workloads; explicit Advanced Shaping font selection now reaches the authored scene, and retained comparison font transactions keep layout measurement suspended until every replacement `Text` generation is ready. The complete live lane passed every sequential Bitmap/MTSDF/Slug workload, both renderer backends, Presentation exclusivity, the timed demo, and React Doctor at 100/100. A delayed-peer probe confirmed that the paired MSDF / Slug scene needs comparison-local target coordination; retaining the last complete target pair, publishing both objects in one task, and rolling back failures resolves it without a grouped public transaction. The public API is accepted for V1, external raster and baker authoring is documented, and renderer-wide batching across separate `Text` objects remains explicitly outside this milestone. +Milestone 10 is closed as the merged v0 baseline. Item 10.1 established the required renderer-neutral transaction and Three.js adapter parity, item 10.2 moved resident shaping, layout, paint planning, raster staging, and atomic publication into the Three.js object-update lifecycle without warm consumer readiness waits, item 10.3 added bounded retained instance capacity to all three first-party rasters, item 10.4 proved the public extension boundary with a private external consumer package, item 10.5 removed benchmark workarounds and retained complete dual-backend Presentation evidence, and item 10.6 completed the v0 review and signed stacked merge. Nothing in this milestone was published as a release or declared v1. Its evidence is the migration baseline for milestone 11, which may replace the v0 API while preserving proven shaping, raster, lifecycle, and rendering behavior. ### 10.1–10.6 closure checklist @@ -695,7 +717,7 @@ Milestone 10 is closed. Item 10.1 established the required renderer-neutral tran - [x] Staging never mutates committed state, commit is synchronous and infallible, failure or abort preserves the live generation, and batch/stage cleanup is idempotent across Bitmap, MTSDF, Slug, and the external proof. - [x] Warm resident updates publish before Three.js child traversal without consumer `await text.ready`; cold font, shaper, and raster-page preparation remains asynchronous and Suspense-owned. - [x] Same-capacity replacement updates every glyph identity and parallel instance field in place; shrink, exact-capacity growth, fragmented dirty ranges, overflow replacement, topology changes, and stale generations have deterministic tests. -- [x] The private external package bakes, packages, loads, renders, updates, overflows, aborts, and disposes through published entry points without a core kind switch or undocumented import. +- [x] The private external package bakes, packages, loads, renders, updates, overflows, aborts, and disposes through public package entry points without a core kind switch or undocumented import. - [x] Icon Grid reuses its Text pool across all 1,402 glyphs and all three techniques without blank recycling, missing glyphs, warnings, unhandled rejections, or avoidable GPU-object churn. - [x] WebGPU and forced WebGL complete every Presentation workload sequentially and in timed demo mode with visible text, one renderer, retained canvas/graph identity, no warm loader flash, no overlapping jobs, and recovery after success, failure, abort, technique/font changes, and navigation. - [x] Allocation/GC traces, approximately-60-Hz cadence sweeps, React Doctor 100/100, screenshots, complete repository checks, package-size evidence, OKF validation, signed stack history, accurate PR bodies, and green CI are retained. @@ -710,32 +732,73 @@ Deliver: exact MTSDF padded base texture-array allocation; - an editable realtime MSDF / Slug comparison that renders equal offscreen targets and a GPU-only signed-delta heatmap, plus interactive comparison scenarios for all three techniques with correctness/visual gates and downloadable raw results; - second-font registration and raster-binding smoke fixtures; -- release-level conformance, browser, GPU, memory, package-size, and malformed-input suites; +- v0 conformance, browser, GPU, memory, package-size, and malformed-input suites; - reviewed public API, external raster and baker authoring guidance, and versioned extension schemas; - matching Three.js and React examples with no React-only font behavior; - renderer-neutral prepared raster batches and resource ownership extracted only after Slug proves the shared requirements, with the existing Three.js implementation retained as an adapter over the direct integration boundary. -Milestone 10 passes this gate. Additive renderer and layout work begins with Milestone 11 and must not reopen the accepted -V1 contracts without new evidence. +Milestone 10 passes the merged v0 evidence gate. Milestone 11 may replace its API where the target v1 contracts require it, +while behavioral regressions still require new evidence. -## Additive work after the release renderer set +## Path from merged v0 to v1 and later work -The order below preserves lanes without pretending the work is part of V1: +Milestone 11 earns the v1 API and integration boundary. Later milestones remain post-v1 work unless maintainers explicitly +move them into the release gate: | Order | Workstream | Effort | Why next | | ----: | --------------------------------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| 11 | Editorial flow regions and mixed-raster composition | XL | Add responsive columns and exclusions, then prove bitmap, MTSDF, and Slug over one positioned layout in a live editorial benchmark. | -| 12 | Mixed-font spans and explicit font fallback | XL | Extend the multi-font identity smoke proof into paragraph behavior. | -| 13 | Large-coverage CJK raster paging and icons | XL | Add content-aware paging, independently resident resources, and paired CJK/icon correctness and payload gates without reopening item 5.4 shaping semantics. | -| 14 | Color emoji | XL | Extend Slug vector paint/layers and bitmap color resources without changing shaping or layout. | -| 15 | Raster effects and expanded recommendations | L | Test the bounded shared-traversal Slug outline approximation, then extend accepted outlines, colorization, shadows, and projected-size guidance with measurements. | -| 16 | Measured optimization campaigns | ongoing | Activate autoresearch only with strict correctness and visual gates. | -| 17 | Advanced font compiler units | XL each | Add general subsetting, remapping, normalized lookups, or SIMD only from evidence. | -| 18 | Vertical writing | XL | Add Japanese top-to-bottom shaping, orientation, column layout, interaction geometry, and three-renderer evidence after complete CJK paging. | +| 11 | Renderer-neutral batched core and engine targets | XL | Extract the accepted many-item API, preserve Three.js, and prove Bitmap/MTSDF/Slug through Wayfare and raw TypeGPU. | +| 12 | Editorial flow regions and mixed-raster composition | XL | Add responsive columns and exclusions, then prove bitmap, MTSDF, and Slug over one positioned layout in a live editorial benchmark. | +| 13 | Mixed-font spans and explicit font fallback | XL | Extend the multi-font identity smoke proof into paragraph behavior. | +| 14 | Large-coverage CJK raster paging and icons | XL | Add content-aware paging, independently resident resources, and paired CJK/icon correctness and payload gates without reopening item 5.4 shaping semantics. | +| 15 | Color emoji | XL | Extend Slug vector paint/layers and bitmap color resources without changing shaping or layout. | +| 16 | Raster effects and expanded recommendations | L | Test the bounded shared-traversal Slug outline approximation, then extend accepted outlines, colorization, shadows, and projected-size guidance with measurements. | +| 17 | Measured optimization campaigns | ongoing | Activate autoresearch only with strict correctness and visual gates. | +| 18 | Advanced font compiler units | XL each | Add general subsetting, remapping, normalized lookups, or SIMD only from evidence. | +| 19 | Vertical writing | XL | Add Japanese top-to-bottom shaping, orientation, column layout, interaction geometry, and three-renderer evidence after complete CJK paging. | + +### Milestone 11 — renderer-neutral batched core and engine targets + +This milestone implements the authoritative [README](../../README.md), [core text API](../planning/core-api.md), +and [engine integration contract](../planning/engine-integration-contract.md). It removes Three.js from portable entry +points while preserving the accepted shaping, paragraph, artifact, raster, and visible-generation behavior. -### Milestone 11 — editorial flow regions and mixed-raster composition +Deliver: -This post-V1 milestone adds an ordered flow-region planner without weakening the rectangular paragraph fast path. Each line band resolves one or more usable horizontal slots after explicit drop-cap, image, callout, or known-geometry exclusions are subtracted. Shaped clusters fill those slots using existing safe-break and batched boundary-reshape machinery. +- explicit runtime and font loading, same-technique ordered font stacks, technique-declared paragraph batches, and per-update synchronous or asynchronous preparation; +- desired-state paragraph handles covering multiline text, labels, and font-backed icons without separate public lifecycles; +- batch-owned core handles, immutable desired-state recreation snapshots, retained font leases, and terminal non-recursive disposal rules; +- explicit paragraph batches as application-owned render-phase boundaries with default or explicit per-buffer glyph capacity and deterministic order; +- `fixed` capacity overflow detected after shaping, rejected before publication, and reported by render-loop adapters without escaping rendering or retrying unchanged failures; +- explicit capacity changes that preserve core batch, paragraph, Three group, and text identities while replacing canonical and target storage transactionally; +- core-owned raster-resource partitioning, typed technique bindings, stable instance slots, overflow chunks, technique packing, dirty ranges, resolved variants, and ordered glyph runs; +- canonical technique-defined CPU instance storage with adjacent dirty ranges and live glyph-run ranges for engine-owned buffer synchronization; +- atomic runtime revisions spanning every paragraph batch touched at one synchronization point; +- owned glyph snapshots and topology-guarded displayed-origin writes without animation or physics policy in core; +- one target staging contract whose fallible work preserves the live revision and whose commit is synchronous + at an engine-owned safe frame boundary; +- Three.js/TSL and React Three Fiber rebuilt as adapters with reusable canonical technique shaders, custom programs, + optional effects, program-owned draw compilation, WebGPU/WebGL2 parity, and actual many-item batching; +- reusable Three `Text` objects that survive group disposal, bind fresh core handles elsewhere, and never inherit or transfer stale batch resources; +- a complete direct TypeGPU engine rendering Bitmap, MTSDF, and Slug into caller-owned passes without Three.js or TSL in + portable graphs, plus a Wayfare target reusing its programs; +- Three.js, React Three Fiber, TypeGPU, Wayfare, and gpucat integrations that can live as independent packages consuming + public core and technique exports without privileged subpaths; +- a pinned gpucat proof covering public buffer/texture realization, partial dirty-range uploads, instanced draw ordering, + transforms, lifecycle, and reusable Slug shader access without changing core; +- shared TypeGPU raster programs across compatible WebGPU hosts without moving scene or pass lifecycle into the technique; +- a Three.js + TypeGPU proof that GPU-authoring choice does not own text or scene lifecycle; +- exact package-graph, deterministic, Worker, lifecycle, browser, GPU, allocation, size, documentation, and OKF evidence. + +Only after these gates pass may maintainers declare and publish v1. + +The [renderer-neutral extraction plan](../planning/engine-integration-boundary.md) owns the issue sequence and proof matrix. +Engine transforms, scene composition, pass placement, command encoding, GPU synchronization, and device lifecycle remain +adapter-owned. Core owns physical glyph grouping and ordered variant-bearing text runs; programs own compatible final draws. + +### Milestone 12 — editorial flow regions and mixed-raster composition + +This post-v1 milestone adds an ordered flow-region planner without weakening the rectangular paragraph fast path. Each line band resolves one or more usable horizontal slots after explicit drop-cap, image, callout, or known-geometry exclusions are subtracted. Shaped clusters fill those slots using existing safe-break and batched boundary-reshape machinery. Deliver: @@ -748,9 +811,9 @@ Deliver: Contour-tight glyph-ink wrapping, arbitrary rendered-pixel occlusion, balanced columns, automatic hyphenation, vertical flow, and a frozen public flow API remain deferred until the initial integration produces evidence. The [editorial flow research concept](../planning/editorial-flow-layout.md) defines the proposed internal model, benchmark composition, comparison rules, and acceptance gates. -### Milestone 13 — large-coverage CJK raster paging and icons +### Milestone 14 — large-coverage CJK raster paging and icons -This milestone begins only after the Latin-first V1 renderer gate. Item 5.4 has already proven horizontal CJK shaping and paragraph semantics; this later milestone scales raster coverage, paging, residency, and icon delivery without changing those results. CJK and icons share the page-scale implementation while retaining separate semantic fixtures. +This milestone begins only after the Latin-first v1 renderer gate. Item 5.4 has already proven horizontal CJK shaping and paragraph semantics; this later milestone scales raster coverage, paging, residency, and icon delivery without changing those results. CJK and icons share the page-scale implementation while retaining separate semantic fixtures. Deliver: @@ -767,9 +830,9 @@ Deliver: Vertical writing remains deferred. The milestone retains vertical-form source data and tests that it survives baking, but does not add vertical paragraph layout. -### Milestone 17 — advanced font compiler units +### Milestone 18 — advanced font compiler units -This milestone turns declarative language/script/text coverage into compiler-produced shaping units only after Milestone 13 proves the lookup and residency model without changing glyph identity. It computes transitive shaping closure, preserves required locale-sensitive behavior, optionally remaps glyph IDs within each unit, and emits authenticated family-directory coverage metadata. Units keep independent font handles and never masquerade as one glyph namespace. +This milestone turns declarative language/script/text coverage into compiler-produced shaping units only after Milestone 14 proves the lookup and residency model without changing glyph identity. It computes transitive shaping closure, preserves required locale-sensitive behavior, optionally remaps glyph IDs within each unit, and emits authenticated family-directory coverage metadata. Units keep independent font handles and never masquerade as one glyph namespace. Deliver: @@ -779,9 +842,9 @@ Deliver: - normalized family-directory lookup with locale preference, explicit fallback, missing-coverage diagnostics, and mixed-unit paragraph tests; - complete source-versus-unit shaping, layout, transport, decoded-memory, page, and GPU-residency evidence. -### Milestone 18 — vertical writing +### Milestone 19 — vertical writing -This post-V1 milestone adds Japanese top-to-bottom text with right-to-left +This post-v1 milestone adds Japanese top-to-bottom text with right-to-left column progression after large-coverage CJK paging makes the result usable with a complete font. It interprets the vertical tables already preserved by the baker, proves HarfRust against HarfBuzz with vertical direction and font