diff --git a/README.md b/README.md index 8d027c79..64051974 100644 --- a/README.md +++ b/README.md @@ -1,23 +1,22 @@ -# pmndrs/text +# @pmndrs/text -Unicode-aware text for Three.js and React Three Fiber, with portable font baking and explicit Bitmap, MTSDF, and Slug -renderers. +Portable, Unicode-aware text for 3D and canvas rendering engines, with Three.js and React Three Fiber integrations today. > [!IMPORTANT] -> `pmndrs/text` is in active development toward a public v1 API. The implementation is substantially complete and usable +> `@pmndrs/text` is in active development toward a public v1 API. The implementation is substantially complete and usable > from this workspace, but the packages are still private and have not been published to npm. -The engine shapes text once with HarfRust, lays it out as a paragraph, and renders the same positioned glyphs through the -raster technique selected by the application. Font artifacts can be prepared ahead of time or generated in a Worker when a -baked asset is unavailable. +The public core loads fonts, shapes Unicode with HarfRust, lays out paragraphs, resolves paint, and exposes raster lifecycle +contracts for renderer integrations. A Three.js implementation with React Three Fiber support is available today. - Native ESM for modern JavaScript runtimes. -- Framework-neutral `THREE.Group` text objects and a thin React Three Fiber component. +- Portable shaping, layout, paint, artifact, and raster-technique foundations. - Unicode 17 bidi, line breaking, grapheme segmentation, complex-script shaping, and horizontal CJK layout. - Bitmap strikes, MTSDF atlases, and analytic Slug outlines over one shaping and layout result. - Baked-first delivery with authenticated runtime fallback. - Retained glyph storage for warm text, layout, paint, font, and raster updates. - Public raster and baker contracts that third-party packages can implement without importing core internals. +- A Three.js integration and thin React Three Fiber component. - WebGPU and WebGL2 product paths exercised by the benchmark and Presentation application. The [roadmap](docs/roadmap/roadmap.md) records exact milestone status. The v1 renderer and API milestone is closed in the @@ -38,7 +37,9 @@ pnpm dev `pnpm dev` starts the benchmark and Presentation app. Mise is the easiest way to install the exact tool versions, but the same pnpm commands work when compatible versions are already installed. -## Render text +## Render text today + +The implemented rendering path targets Three.js directly or through React Three Fiber. ### React Three Fiber @@ -74,7 +75,7 @@ await useFont.preload(uiFont); ### Three.js -The core `Text` class owns a normal Three.js lifecycle. Its asynchronous generation becomes renderable through ordinary +The Three.js `Text` class owns a normal engine lifecycle. Its asynchronous generation becomes renderable through ordinary matrix updates, and warm property changes retain the object while the replacement generation is prepared. ```ts @@ -143,14 +144,17 @@ Use `pnpm bake --help` for CLI options. The Node API is available from `@pmndrs/ ## How the pieces fit -```text -source font ──► font baker ──► authenticated core GLB ──► HarfRust shaping - │ │ - └────────► selected raster baker ──► raster GLB/pages ▼ - paragraph layout - │ - ▼ - Three.js Text / React Text +```mermaid +flowchart LR + Font["Font source or baked GLB"] --> Load["defineFont
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"] ``` The core artifact owns shaping data, font metrics, provenance, and the font-local glyph identity space. Raster artifacts own @@ -161,6 +165,23 @@ Third-party raster implementations use the same public contracts as the built-in [raster and baker plugin guide](docs/planning/raster-baker-plugin.md); the private [`@pmndrs/text-glyph-example-raster`](packages/glyph-example-raster) package is the executable external-package proof. +## Core and renderer integrations + +The public APIs below are available today; see the [API contract](docs/planning/api-shapes.md) for the complete surface. + +| API | Role | +| ----------------------------------------------- | -------------------------------------------------------------------------------- | +| `defineFont`, `FontLoader`, `FontRegistry` | Declare, authenticate, cache, and own font artifacts | +| `createRuntimeShaper`, `createParagraphEngine` | Produce synchronous measurements and positioned `ParagraphLayout` glyph data | +| `defineRaster`, `RasterRuntime`, `RasterModule` | Define, load, decode, prepare, and dispose a raster technique | +| `RasterBatchStage`, `RasterDrawBatch` | Stage complete renderer-owned batches, then commit or abort them transactionally | +| `Text`, `@pmndrs/text/react` | Use the current Three.js and React Three Fiber integration | + +A new renderer consumes `ParagraphLayout`, implements the generic raster resource and batch types, and owns its transforms, +GPU resources, ordering, publication, and device lifecycle. The [renderer-agnostic core plan](docs/planning/engine-integration-boundary.md) +tracks the WIP generation boundary, and the [raster plugin guide](docs/planning/raster-baker-plugin.md) shows a working external +technique. + ## Repository commands The contributor-facing command surface is intentionally small: @@ -197,8 +218,9 @@ The README is the short path into the project. Deeper documentation is organized - **Look up:** use the [workspace package catalog](docs/packages/index.md), [renderer capability matrix](docs/planning/renderer-capabilities.md), and [`PMNDRS_font` extension schemas](docs/planning/extensions/index.md). -- **Understand:** read the [architecture](docs/planning/architecture.md), [canonical roadmap](docs/roadmap/roadmap.md), and - [attributed research](RESEARCH.md). +- **Understand:** read the [architecture](docs/planning/architecture.md), + [renderer-agnostic core plan](docs/planning/engine-integration-boundary.md), [canonical roadmap](docs/roadmap/roadmap.md), + and [attributed research](RESEARCH.md). The documentation under [`docs/`](docs/index.md) is also an Open Knowledge Format v0.2 bundle with package-source freshness checks, provenance, and progressive-disclosure indexes. @@ -206,7 +228,6 @@ checks, provenance, and progressive-disclosure indexes. ## Current scope The workspace already implements the v1 shaping, horizontal paragraph, delivery, Three.js/React, and three-raster foundation. -The roadmap keeps post-v1 work explicit: editorial flow regions, mixed-font fallback, large-coverage CJK raster paging, color -emoji, expanded effects, and vertical writing. +The renderer-agnostic core and additional engine integrations remain WIP alongside the roadmap's later layout and raster work. -`pmndrs/text` is MIT licensed. Contributions are welcome while the public v1 surface is being stabilized. +`@pmndrs/text` is MIT licensed. Contributions are welcome while the public v1 surface is being stabilized. diff --git a/apps/benchmarks/scripts/build.mts b/apps/benchmarks/scripts/build.mts index 5d581b3d..d3157db5 100644 --- a/apps/benchmarks/scripts/build.mts +++ b/apps/benchmarks/scripts/build.mts @@ -2,7 +2,6 @@ import { buildRuntimePackages, isMainModule, runNodeScript } from './support/com export async function runBenchmarkBuild(options: { readonly runtimePackagesReady?: boolean } = {}): Promise { if (!options.runtimePackagesReady) await buildRuntimePackages(); - await runNodeScript('scripts/measure-package-sizes.mts'); await runNodeScript('node_modules/vite/bin/vite.js', ['build']); await runNodeScript('scripts/check-font-notices.mts'); } diff --git a/apps/benchmarks/src/benchmark/package-size-budgets.ts b/apps/benchmarks/src/benchmark/package-size-budgets.ts index 2693f644..e575cfad 100644 --- a/apps/benchmarks/src/benchmark/package-size-budgets.ts +++ b/apps/benchmarks/src/benchmark/package-size-budgets.ts @@ -1,7 +1,7 @@ export const packageSizeBudgets = { 'browser-core': { rawBytes: 341_000, - minifiedBytes: 258_000, + minifiedBytes: 258_500, gzipBytes: 75_000, brotliBytes: 57_500, }, diff --git a/apps/benchmarks/src/benchmark/package-sizes.test.ts b/apps/benchmarks/src/benchmark/package-sizes.test.ts index 93a426f2..b07b9a14 100644 --- a/apps/benchmarks/src/benchmark/package-sizes.test.ts +++ b/apps/benchmarks/src/benchmark/package-sizes.test.ts @@ -68,10 +68,10 @@ describe('independent package-size report', () => { it('bounds accumulated renderer growth from the pre-coverage baseline', () => { const coverageGrowth = { 'browser-core': { - rawBytes: { baseline: 324_269, maximumGrowth: 16_000 }, - minifiedBytes: { baseline: 247_205, maximumGrowth: 10_500 }, - gzipBytes: { baseline: 72_108, maximumGrowth: 2_250 }, - brotliBytes: { baseline: 55_251, maximumGrowth: 1_900 }, + rawBytes: { baseline: 324_269, maximumGrowth: 17_000 }, + minifiedBytes: { baseline: 247_205, maximumGrowth: 11_000 }, + gzipBytes: { baseline: 72_108, maximumGrowth: 2_500 }, + brotliBytes: { baseline: 55_251, maximumGrowth: 2_100 }, }, 'bitmap-baker-js': { rawBytes: { baseline: 17_478, maximumGrowth: 5_700 }, @@ -86,10 +86,10 @@ describe('independent package-size report', () => { brotliBytes: { baseline: 173_552, maximumGrowth: 7_000 }, }, 'bitmap-runtime-js': { - rawBytes: { baseline: 361_809, maximumGrowth: 25_500 }, - minifiedBytes: { baseline: 271_005, maximumGrowth: 15_750 }, - gzipBytes: { baseline: 78_673, maximumGrowth: 3_450 }, - brotliBytes: { baseline: 60_857, maximumGrowth: 2_950 }, + rawBytes: { baseline: 361_809, maximumGrowth: 27_000 }, + minifiedBytes: { baseline: 271_005, maximumGrowth: 16_500 }, + gzipBytes: { baseline: 78_673, maximumGrowth: 3_750 }, + brotliBytes: { baseline: 60_857, maximumGrowth: 3_200 }, }, 'mtsdf-baker-wasm': { rawBytes: { baseline: 534_709, maximumGrowth: 18_500 }, @@ -104,10 +104,10 @@ describe('independent package-size report', () => { brotliBytes: { baseline: 4_176, maximumGrowth: 800 }, }, 'mtsdf-runtime-js': { - rawBytes: { baseline: 370_255, maximumGrowth: 25_650 }, - minifiedBytes: { baseline: 275_271, maximumGrowth: 15_600 }, - gzipBytes: { baseline: 79_993, maximumGrowth: 3_600 }, - brotliBytes: { baseline: 62_081, maximumGrowth: 3_050 }, + rawBytes: { baseline: 370_255, maximumGrowth: 27_000 }, + minifiedBytes: { baseline: 275_271, maximumGrowth: 16_500 }, + gzipBytes: { baseline: 79_993, maximumGrowth: 3_800 }, + brotliBytes: { baseline: 62_081, maximumGrowth: 3_300 }, }, } as const; const fields = ['rawBytes', 'minifiedBytes', 'gzipBytes', 'brotliBytes'] as const; @@ -126,15 +126,15 @@ describe('independent package-size report', () => { const retainedCapacityGrowth = { 'bitmap-runtime-js': { baseline: { rawBytes: 382_060, minifiedBytes: 283_898, gzipBytes: 81_435, brotliBytes: 63_146 }, - maximumGrowth: { rawBytes: 5_200, minifiedBytes: 2_850, gzipBytes: 700, brotliBytes: 650 }, + maximumGrowth: { rawBytes: 6_500, minifiedBytes: 3_500, gzipBytes: 900, brotliBytes: 850 }, }, 'mtsdf-runtime-js': { baseline: { rawBytes: 389_761, minifiedBytes: 287_629, gzipBytes: 82_721, brotliBytes: 64_286 }, - maximumGrowth: { rawBytes: 6_100, minifiedBytes: 3_200, gzipBytes: 850, brotliBytes: 825 }, + maximumGrowth: { rawBytes: 7_500, minifiedBytes: 4_000, gzipBytes: 1_050, brotliBytes: 1_050 }, }, 'slug-runtime-js': { baseline: { rawBytes: 390_276, minifiedBytes: 286_600, gzipBytes: 82_730, brotliBytes: 64_271 }, - maximumGrowth: { rawBytes: 9_400, minifiedBytes: 5_050, gzipBytes: 1_300, brotliBytes: 1_275 }, + maximumGrowth: { rawBytes: 10_750, minifiedBytes: 5_750, gzipBytes: 1_500, brotliBytes: 1_450 }, }, } as const; const fields = ['rawBytes', 'minifiedBytes', 'gzipBytes', 'brotliBytes'] as const; diff --git a/apps/benchmarks/src/benchmark/scenarios.ts b/apps/benchmarks/src/benchmark/scenarios.ts index 429c7969..09a1ecdd 100644 --- a/apps/benchmarks/src/benchmark/scenarios.ts +++ b/apps/benchmarks/src/benchmark/scenarios.ts @@ -50,6 +50,7 @@ function externalRasterProofValidation(values: readonly import('./contracts').Be metrics.retainedObject !== 1 || metrics.retainedGeometry !== 1 || (metrics.litPixels ?? 0) < 100 || + (metrics.layeringPixels ?? 0) < 100 || (metrics.backendWebGpu ?? 0) + (metrics.backendWebGl2 ?? 0) !== 1 ) { throw new Error('External raster proof did not preserve its visible retained draw contract'); diff --git a/apps/benchmarks/src/benchmark/targets/product/external-raster-proof.ts b/apps/benchmarks/src/benchmark/targets/product/external-raster-proof.ts index ade2a8ac..fb747351 100644 --- a/apps/benchmarks/src/benchmark/targets/product/external-raster-proof.ts +++ b/apps/benchmarks/src/benchmark/targets/product/external-raster-proof.ts @@ -28,6 +28,8 @@ interface ExternalRasterResources { readonly camera: THREE.OrthographicCamera; readonly text: Text; readonly font: import('@pmndrs/text').RegisteredFont; + readonly orderingGeometry: THREE.PlaneGeometry; + readonly orderingMaterial: THREE.MeshBasicNodeMaterial; readonly retainedObject: THREE.Object3D; readonly retainedGeometry: THREE.BufferGeometry; readonly glyphCount: number; @@ -61,6 +63,8 @@ export function createExternalRasterProofTarget(backend: RendererBackend): Bench state = { kind: 'empty' }; resources.text.dispose(); resources.font.dispose(); + resources.orderingGeometry.dispose(); + resources.orderingMaterial.dispose(); resources.target.dispose(); if (resources.ownedRenderer !== undefined) await disposeConfiguredRenderer(resources.ownedRenderer); }, @@ -88,6 +92,8 @@ async function createResources( let target: THREE.RenderTarget | undefined; let text: Text | undefined; let font: import('@pmndrs/text').RegisteredFont | undefined; + let orderingGeometry: THREE.PlaneGeometry | undefined; + let orderingMaterial: THREE.MeshBasicNodeMaterial | undefined; try { const physicalWidth = Math.round(WIDTH * dpr); const physicalHeight = Math.round(HEIGHT * dpr); @@ -139,8 +145,31 @@ async function createResources( if (text.layout === undefined) throw new Error('warm external raster update did not publish during object traversal'); text.position.set(32, -36, 0); + text.renderOrder = 600; + text.updateMatrixWorld(); + if (Number(retainedMesh.renderOrder) !== 600) + throw new Error('warm external raster did not apply the Text render-order base'); + text.renderOrder = 0; + text.updateMatrixWorld(); + if (Number(retainedMesh.renderOrder) !== 0) + throw new Error('warm external raster did not resynchronize the Text render-order base'); const scene = new THREE.Scene(); - scene.add(text); + const coverGroup = new THREE.Group(); + coverGroup.renderOrder = 100; + orderingGeometry = new THREE.PlaneGeometry(WIDTH, HEIGHT); + orderingMaterial = new THREE.MeshBasicNodeMaterial({ + color: 0x7f1734, + depthTest: false, + depthWrite: false, + transparent: true, + }); + const cover = new THREE.Mesh(orderingGeometry, orderingMaterial); + cover.position.set(WIDTH / 2, -HEIGHT / 2, 0); + coverGroup.add(cover); + const textGroup = new THREE.Group(); + textGroup.renderOrder = 200; + textGroup.add(text); + scene.add(coverGroup, textGroup); const camera = new THREE.OrthographicCamera(0, WIDTH, 0, -HEIGHT, 0.1, 10); camera.position.z = 1; camera.updateProjectionMatrix(); @@ -154,6 +183,8 @@ async function createResources( camera, text, font, + orderingGeometry, + orderingMaterial, retainedObject, retainedGeometry, glyphCount: text.layout.glyphIds.length, @@ -161,6 +192,8 @@ async function createResources( } catch (error) { text?.dispose(); font?.dispose(); + orderingGeometry?.dispose(); + orderingMaterial?.dispose(); target?.dispose(); if (ownedRenderer !== undefined) await disposeConfiguredRenderer(ownedRenderer); throw error; @@ -169,28 +202,56 @@ async function createResources( async function renderResources(resources: ExternalRasterResources, signal?: AbortSignal): Promise { signal?.throwIfAborted(); - const bytes = await withRendererStateRestored(resources.renderer, async () => { + const { coverBytes, bytes } = await withRendererStateRestored(resources.renderer, async () => { const { renderer, target } = resources; const physicalWidth = Math.round(WIDTH * resources.dpr); const physicalHeight = Math.round(HEIGHT * resources.dpr); renderer.setRenderTarget(target); renderer.setClearColor(0x000000, 1); + resources.text.visible = false; + let coverFrame: Uint8Array; + try { + renderer.clear(); + renderer.render(resources.scene, resources.camera); + const baselinePixels = await renderer.readRenderTargetPixelsAsync(target, 0, 0, physicalWidth, physicalHeight); + coverFrame = compactRgba8Readback( + new Uint8Array(baselinePixels.buffer, baselinePixels.byteOffset, baselinePixels.byteLength), + physicalWidth, + physicalHeight, + resources.backend === 'webgl2' ? 'bottom-to-top' : 'top-to-bottom', + ); + } finally { + resources.text.visible = true; + } renderer.clear(); renderer.render(resources.scene, resources.camera); const pixels = await renderer.readRenderTargetPixelsAsync(target, 0, 0, physicalWidth, physicalHeight); - return compactRgba8Readback( - new Uint8Array(pixels.buffer, pixels.byteOffset, pixels.byteLength), - physicalWidth, - physicalHeight, - resources.backend === 'webgl2' ? 'bottom-to-top' : 'top-to-bottom', - ); + return { + coverBytes: coverFrame, + bytes: compactRgba8Readback( + new Uint8Array(pixels.buffer, pixels.byteOffset, pixels.byteLength), + physicalWidth, + physicalHeight, + resources.backend === 'webgl2' ? 'bottom-to-top' : 'top-to-bottom', + ), + }; }); signal?.throwIfAborted(); let litPixels = 0; + let layeringPixels = 0; for (let offset = 0; offset < bytes.byteLength; offset += 4) { if (bytes[offset] !== 0 || bytes[offset + 1] !== 0 || bytes[offset + 2] !== 0) litPixels += 1; + if ( + bytes[offset] !== coverBytes[offset] || + bytes[offset + 1] !== coverBytes[offset + 1] || + bytes[offset + 2] !== coverBytes[offset + 2] || + bytes[offset + 3] !== coverBytes[offset + 3] + ) { + layeringPixels += 1; + } } if (litPixels < 100) throw new Error('external raster proof produced no visible glyph frames'); + if (layeringPixels < 100) throw new Error('external raster proof did not honor its caller-owned parent Group order'); const liveObject = exactlyOne(resources.text.children, 'retained external raster draw object'); const liveMesh = exactlyOne(liveObject.children, 'retained external raster mesh'); if ( @@ -210,6 +271,7 @@ async function renderResources(resources: ExternalRasterResources, signal?: Abor glyphCount: resources.glyphCount, drawCount: 1, litPixels, + layeringPixels, retainedObject: 1, retainedGeometry: 1, renderTargetGpuBytes: bytes.byteLength, diff --git a/apps/benchmarks/src/generated/package-sizes.json b/apps/benchmarks/src/generated/package-sizes.json index 5f625102..3bfd6424 100644 --- a/apps/benchmarks/src/generated/package-sizes.json +++ b/apps/benchmarks/src/generated/package-sizes.json @@ -10,11 +10,11 @@ "label": "Browser core", "status": "measured", "format": "javascript", - "sha256": "312d7e895d738763bd9e757d3c91b732683ea7b9b0198d8fa88a48eebf7239b4", - "rawBytes": 340105, - "minifiedBytes": 257533, - "gzipBytes": 74305, - "brotliBytes": 57146 + "sha256": "9ca630720749bd6cfe05890e0c8f532ed2e229c2c36887a8af4d7ca469a70444", + "rawBytes": 340812, + "minifiedBytes": 258037, + "gzipBytes": 74457, + "brotliBytes": 57290 }, { "id": "font-validator-js", @@ -76,33 +76,33 @@ "label": "Bitmap runtime JS graph", "status": "measured", "format": "javascript", - "sha256": "31a90a5186ce493c878340f41568da707b944957393106f49269fe801478ad85", - "rawBytes": 387160, - "minifiedBytes": 286628, - "gzipBytes": 82047, - "brotliBytes": 63670 + "sha256": "db2272cc995d5561bf8829897f55668502295f80beae679088b09557e22c1ea3", + "rawBytes": 388324, + "minifiedBytes": 287345, + "gzipBytes": 82263, + "brotliBytes": 63964 }, { "id": "mtsdf-runtime-js", "label": "MTSDF runtime JS graph", "status": "measured", "format": "javascript", - "sha256": "87e4ff302ab05cef9154a56e16016c49f9106968c64c88fbebd6ec5dc978e05e", - "rawBytes": 395744, - "minifiedBytes": 290728, - "gzipBytes": 83485, - "brotliBytes": 65007 + "sha256": "3399c72d46bf210d813343950791b4d5114d37f0410f261d2c72fdd0aa7b2a9f", + "rawBytes": 397032, + "minifiedBytes": 291509, + "gzipBytes": 83717, + "brotliBytes": 65318 }, { "id": "slug-runtime-js", "label": "Slug runtime JS graph", "status": "measured", "format": "javascript", - "sha256": "3a199b5aa44a608e355dcb01ae29a8fa6e0114b02868672a6fc93fe0a6599c65", - "rawBytes": 399530, - "minifiedBytes": 291527, - "gzipBytes": 83955, - "brotliBytes": 65481 + "sha256": "f6ccace1a011dec2582d874dc7bcb3fd070f4ad04de45f5ae1c507c6e4156c77", + "rawBytes": 400767, + "minifiedBytes": 292301, + "gzipBytes": 84209, + "brotliBytes": 65667 }, { "id": "bitmap-baker-wasm", diff --git a/apps/benchmarks/vitexec/external-raster-proof.probe.ts b/apps/benchmarks/vitexec/external-raster-proof.probe.ts index 0a54c878..6f7b314a 100644 --- a/apps/benchmarks/vitexec/external-raster-proof.probe.ts +++ b/apps/benchmarks/vitexec/external-raster-proof.probe.ts @@ -68,7 +68,8 @@ for (const [targetId, backendMetric] of [ measurement.metrics.drawCount !== 1 || measurement.metrics.retainedObject !== 1 || measurement.metrics.retainedGeometry !== 1 || - (measurement.metrics.litPixels ?? 0) < 100, + (measurement.metrics.litPixels ?? 0) < 100 || + (measurement.metrics.layeringPixels ?? 0) < 100, ) ) { throw new Error(`${targetId} did not preserve the public external raster contract`); diff --git a/docs/log.md b/docs/log.md index f36e816b..f4fa1e44 100644 --- a/docs/log.md +++ b/docs/log.md @@ -1,7 +1,13 @@ # pmndrs/text documentation update log +## 2026-08-05 + +- **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 +- **Renderer-agnostic engine boundary planning** — Drafted the proposed next additive milestone around three independent axes: canvas/game-engine host, GPU-authoring layer, and application binding. The plan keeps Bitmap, MSDF, and Slug contracts plus lazy bakers portable; extracts the Three-owned text-generation state machine without accepting its final name; preserves Three.js + TSL as the dual-backend baseline; and requires separate Three.js + TypeGPU and non-Three engine proofs before stabilizing package exports or an adapter API. The canonical roadmap order remains unchanged until maintainer acceptance. +- **Text layering contract** — Made framework-neutral `Text` a composite `Object3D` so it honors caller-owned parent Group ordering, while `Text.renderOrder` becomes the base for each generated drawable's raster-local order. Bitmap, MTSDF, Slug, and the external raster proof implement the public base-order method and use neutral `Object3D` batch roots; the adapter rejects nested raster Groups, and focused tests cover cold publication, changes without reshaping, retained updates, React Object3D props, and multi-font spans. Against the parent stack layer, browser core grows by 707 raw / 504 minified / 152 gzip / 144 Brotli bytes; Bitmap, MTSDF, and Slug runtime closures grow by 1,164/717/216/294, 1,288/781/232/311, and 1,237/774/254/186 bytes respectively. The reviewed absolute and cumulative JavaScript ceilings advance only where those production paths grew. Ordinary builds consume the checked-in canonical size record instead of rewriting it with host-specific measurements; the explicit size-generation workflow remains its sole writer, while tests measure the current host read-only against the reviewed ceilings. - **Human-facing repository orientation** — Replaced the root README's stale planned-milestone narrative with a concise pre-release landing page for the implemented workspace. It now declares active development toward a public v1 API without implying npm availability, provides runnable local setup plus canonical React, Three.js, raster-selection, and bake examples, explains the shaping-to-rendering pipeline, and routes readers by learning, task, reference, and explanation needs. Corrected the font-baker Wasm URL example and moved its retired package commands to the source-indexed workflow surface. - **Contributor workflow cleanup** — Limited the root command surface to `bake`, `dev`, `build`, `test`, `check`, and `scripts`; library manifests now expose only build, test, and check, while the benchmark app additionally exposes dev. Replaced duplicated command-family routers with one source-metadata index that validates and describes specialized fixture, release-evidence, fuzz, profiling, capture, and hardware-browser workflows. Removed closed-milestone probes, rejected experiment runners, and implementation-shaped benchmark tests superseded by public package integration, headless product, sequential Presentation, timed-demo, and exclusive finite-job recovery gates. Agent guidance now requires `pnpm scripts list/show` before inventing a maintenance command. diff --git a/docs/packages/benchmarks.md b/docs/packages/benchmarks.md index 22b52157..69207216 100644 --- a/docs/packages/benchmarks.md +++ b/docs/packages/benchmarks.md @@ -5,7 +5,7 @@ description: Provides the shared interactive and automated benchmark product sur resource: ../../apps/benchmarks workspace_package: '@pmndrs/text-benchmarks' documentation_type: reference -source_digest: 'sha256:0f186806b091836dea9ff4bcaa7a59d8980a59f87af8ceb34ee46c04b188836c' +source_digest: 'sha256:37ba2404b7731d266f117f25353a23879d739b0ec3aa4f84f6d17d47a58a0297' tags: [package, benchmarks, react, vite, product-e2e] sources: - id: manifest @@ -178,7 +178,7 @@ sources: title: Realtime comparison product probe generated: by: openai-codex/gpt-5.6 - at: '2026-08-04T17:37:17Z' + at: '2026-08-04T20:03:01Z' --- # Package reference: `@pmndrs/text-benchmarks` @@ -202,7 +202,7 @@ glyphs or paints. Font delivery is an explicit benchmark axis. **Baked asset** exercises the normal sibling asset, while **Runtime bake** passes `{ source, baked: null }`, downloads the source font, builds the core font in the serial core-baker Worker, then builds the selected Bitmap or MSDF raster in its serial lazy Worker. The inspector distinguishes the always-loaded runtime/shaper graph from the conditional core and raster baker host, Worker, and Wasm graphs; it reports source download bytes, generated core/raster CPU bytes, bake durations, and atlas GPU memory. The runtime-fallback conformance workload renders both delivery paths through the same public pipeline and requires an exact RGBA frame match. Canonical Inter matched with zero differing bytes for Bitmap and MSDF on the admitted WebGPU product probe; the observed cold MSDF raster bake was roughly 114 seconds on this host and remains an observation, not a portability threshold. -The benchmark manifest exposes only `build`, `dev`, `test`, and `check`. Specialized maintenance files declare their own names, requirements, write behavior, arguments, and runner; the root `pnpm scripts` command validates and indexes that metadata. `benchmark:presentation` runs every sequential workload through Bitmap, MTSDF, and Slug on WebGPU and forced WebGL2; `benchmark:demo` runs the timed sequence; `benchmark:raster-comparison` owns finite-job recovery; and `benchmark:presentation-performance` records the current complete cadence sweep. Closed milestone experiments and technique-specific performance matrices are retained as results, not executable product gates. The authenticated HarfBuzz freshness gate remains separate from ordinary repository checks because Meson, Ninja, and GLib belong only to that workload. Install the scoped `apps/benchmarks/mise.toml` pins when needed, then run `pnpm scripts run fixture:harfbuzz:provision` and `pnpm scripts run fixture:japanese-showcase:check`. React Doctor remains a manual review tool rather than a package or CI script; when requested, run `mise exec -- pnpm --dir apps/benchmarks dlx react-doctor@0.7.2 . --scope full --blocking warning --verbose --no-supply-chain --no-color`.[^presentation-framerate-sweep] +The benchmark manifest exposes only `build`, `dev`, `test`, and `check`. Specialized maintenance files declare their own names, requirements, write behavior, arguments, and runner; the root `pnpm scripts` command validates and indexes that metadata. An ordinary build consumes the checked-in canonical package-size record without rewriting it for the current host. `release:size:generate` is the sole writer, while the test gate measures the current host read-only and enforces the reviewed absolute and cumulative ceilings. `benchmark:presentation` runs every sequential workload through Bitmap, MTSDF, and Slug on WebGPU and forced WebGL2; `benchmark:demo` runs the timed sequence; `benchmark:raster-comparison` owns finite-job recovery; and `benchmark:presentation-performance` records the current complete cadence sweep. Closed milestone experiments and technique-specific performance matrices are retained as results, not executable product gates. The authenticated HarfBuzz freshness gate remains separate from ordinary repository checks because Meson, Ninja, and GLib belong only to that workload. Install the scoped `apps/benchmarks/mise.toml` pins when needed, then run `pnpm scripts run fixture:harfbuzz:provision` and `pnpm scripts run fixture:japanese-showcase:check`. React Doctor remains a manual review tool rather than a package or CI script; when requested, run `mise exec -- pnpm --dir apps/benchmarks dlx react-doctor@0.7.2 . --scope full --blocking warning --verbose --no-supply-chain --no-color`.[^presentation-framerate-sweep] `pnpm scripts run benchmark:demo` exercises the complete 60-second timed sequence through a focused control on WebGPU and forced WebGL. Off-axis / 3D and Icon Grid each receive two seconds before Paint & Effects begins at second four; the more visual Zoom Text and returning Icon Grid scenes receive longer holds than Dynamic Layout. Advanced Shaping resets to CJK and reveals one complete five-case cycle at 180 grapheme units per second. Playing case transitions begin the next script at its first grapheme; a font-changing handoff deliberately blanks the live line until that generation commits instead of showing mismatched old-script state. Zoom Text pre-shapes all 16 fixed-Inter, language-tagged words during cold scene preparation and retains one node per word; animation performs only scale, opacity, and visibility changes, so it continues its normal word cycle without an animation-time readiness boundary and cuts after three complete default-speed drops. Text Ladder receives the derived 7.2 seconds required for its default-speed vertical travel and 1024 px marquee to pass completely through the left edge before the nine-second Icon Grid return. A final 8.016-second Off-axis / 3D scene supplies the closing frame. The probe requires window-capture Space handling, exact workload defaults after preload, advancing telemetry, a retained canvas, exactly one renderer, both Icon Grid entries, the configured backend throughout, and the final Off-axis / 3D scene. @@ -220,6 +220,10 @@ Every live benchmark identity resolves through one typed catalog under `apps/ben The root `app.tsx` owns only runner detection, shell Suspense, and URL route selection. Both route branches render the same `routes/harness-route.tsx` component type, preserving one runtime-world identity while `controllers/harness-controller.tsx` owns URL revisions, post-preload transitions, presentation playback, shortcuts, and execution state. Persistent renderer provisioning and exclusive conformance-action adaptation live in `surfaces/harness/persistent-layout.tsx`; Benchmark/Conformance scene composition lives in `surfaces/harness/scene.tsx`; Main and Presentation chrome remains in `components/harness-layout.tsx`; and runtime control binding remains in `components/runtime-controls.tsx`. The three persistent Bitmap, MTSDF, and Slug live-text viewport controllers live under `surfaces/benchmark`, keeping their host lease, warm update queue, loading state, telemetry, and probe contract beside the rendered surface. Their renderer imports remain literal dynamic boundaries: type-only references use `import type`, so the production build retains separate technique chunks rather than pulling renderer implementations into the route entry. Authored scenes load fixtures through `workloads/font-assets`: one discriminated adapter selects only the requested Bitmap, MTSDF, or Slug lane through literal dynamic imports, while each lane uses the public `FontLoader`, `FontRegistry`, raster request, and `@pmndrs/text/runtime-bake` entrypoint. The adapter owns source-font URLs, baked transport URLs, gzip and SHA-256 authentication, runtime progress and delivery metrics, and bounded default registries; renderer modules retain only live GPU lifecycle, configuration, statistics, and compatibility delegates. Direct font-baker imports and Wasm URLs remain prohibited from this workload-facing path. Conformance React composition lives under `surfaces/conformance`: both the retained comparison and finite captures can receive only the host-owned renderer, while executable low-level work lives below `benchmark/targets/conformance`, `benchmark/targets/product`, and `benchmark/targets/measurement`. The realtime MTSDF/Slug comparison, runtime-fallback capture, external raster proof, React reconciliation target, and finite Bitmap/MTSDF/Slug product lifecycles are owned by those explicit target trees rather than `renderer`. The finite product targets accept the runner's renderer and abort signal, lazily load their public `Text` scenes, render deterministic frames, and dispose only resources they own. Shared Bitmap line construction, exact CPU-reference composition, renderer-state restoration, and RGBA8 readback normalization live below `benchmark/low-level/raster`; MTSDF and Slug product scenes remain target-owned because they are executable benchmark examples. Pure CPU raster and source-outline oracles live in the same low-level tree, so renderer-adjacent finite capture code can share primitives without importing executable targets; a source-boundary regression prohibits renderer-to-target dependencies. Advanced Shaping lives in the conformance target hierarchy behind the registry's literal selected-target dynamic import. Bitmap, MTSDF, and Slug conformance dispatch now enters technique-owned target modules rather than live renderer files. MTSDF and Slug sampling plus source-outline targets implement the same warm session contract, preserving `load → capture → dispose` reuse and forwarding the borrowed renderer and abort signal unchanged; Bitmap's thin target wrapper reuses its neutral low-level finite scene. The target modules own CPU comparison, renderer-state restoration, standard visual captures, and Slug role/external-resource proofs; renderer modules retain only live persistent-scene and font/configuration infrastructure. The targets share the explicitly named `targets/shared/direct-wasm.ts` dependency adapter only after target selection. The public missing-sibling loader Worker is conformance because it proves authenticated Worker bytes and loader fallback behavior; it is not a rendering product target. Boundary tests reject workload imports back into renderer implementation, reject renderer imports of executable targets, authenticate literal selected-target imports, preserve selected-technique asset chunks, and reject direct font-baker or Wasm URL imports outside the shared adapter, preventing raw tooling from leaking into the normal Presentation module graph. +The external raster product proof renders a competing transparent cover and public `Text` under different parent Groups on +WebGPU and WebGL2. Framebuffer differences prove that the composite Text and neutral plugin batch preserve the caller-owned +primary group order through actual Three.js sorting. + Timed playback compares each frame with the latest requested location rather than the last committed scene, so an in-flight preload receives exactly one request and cannot be superseded by a duplicate transition that skips workload-default initialization. Presentation captures Space at the window capture boundary to start or stop timed playback even while a button, switch, slider, select, or combobox owns focus; matching key-up activation is suppressed, while inputs, textareas, and editable text retain ordinary space entry. Arrow navigation remains disabled on interactive controls. Icon Grid auto-pan integrates a bounded exponential average of observed frame deltas so a delayed display frame does not become a visually abrupt catch-up jump. This affects motion only: the virtual window still traverses the complete 1,402-glyph catalog, retains its overscanned pool, and requests content reassignment after crossing a complete cell pitch. Its second timed appearance, after Text Ladder, starts at a different catalog position and reverses both axes. Content changes use the generic `Text` update contract and retained raster capacity; catalog glyph/label strings, assignment epochs, recyclable-entry lists, visible-entry metrics, geometry deduplication, font totals, and bitmap atlas-page reports retain caller-owned storage instead of allocating on every frame or recycle. Pool growth remains genuinely cold and detached until ready, then publishes size, view position, and assignments in one continuation. A 20-second WebGPU/Bitmap trace traversed 660 recycled glyph assignments across 31 coherent windows at 60.05 average FPS, 18.60 ms p95, 18.64 ms maximum, and zero frames over 20 ms; 20 minor and five major GC events each remained below 2.6 ms and produced no visible cadence miss. Renderer-wide batching across separate `Text` objects remains outside this milestone. @@ -256,7 +260,7 @@ GitHub CI uses the Ubuntu runner's rolling system Chromium as a deliberate compa The independent package-size lane measures the initial public browser graph, lazy font validator, runtime Worker boundary, baker and shaper JavaScript/Wasm, and Unicode 17 analysis without zero-byte placeholders. Static entry closures and dynamic chunks are separated from Rollup metadata rather than conflated; the browser-core lane externalizes the package's declared `three`, React, and R3F peers, and package-owned Wasm URLs are externalized from JavaScript measurements regardless of their owning package. The report records its measurement platform and architecture plus the SHA-256 identity of each measured payload: minified bundle bytes for JavaScript and emitted module bytes for Wasm. Same-host regeneration is exact; every foreign-host raw/minified/gzip/Brotli result must satisfy the shared reviewed budget table because native Rust/Binaryen and Rolldown output has small cross-architecture byte variance. Coverage-capability growth is independently bounded against its pre-coverage baseline, and foreign-host failures report the measured payload, reviewed ceiling, and exceeded dimensions. The product inspector's selected-runtime total is the gzip transfer sum of the selected raster runtime graph and separately emitted shaper Wasm. The raster graph already contains the shared core and shaper JavaScript host, so adding the independent browser-core or text-shaper-JavaScript measurements would double-count code. Selected runtime and conditional runtime-bake totals are default-collapsed disclosures; their component rows remain available on demand without displacing the separate font-asset total. The font-asset card reports only one transport quantity: gzip bytes for compressed MTSDF artifacts and exact transferred bytes for uncompressed Bitmap/runtime-source assets. Decoded container, raster, and GPU allocation sizes never appear as children of that transfer total; GPU texture allocation remains isolated in the resource card. Each full row is the interaction target, while fixed label, status, and byte columns use a neutral centered chevron, a green check for loaded code, and a gray X for unloaded code. The total intentionally excludes external Three.js, React, and R3F peers plus font assets; those assets remain separate rows rather than being mislabeled as a complete application bundle. -The current Darwin arm64 record reports a 257,582 minified / 74,316 gzip / 57,070 Brotli peer-externalized browser graph and an independently measured 139,936 / 42,047 / 30,989 Unicode analysis graph. The validator, runtime host, runtime Worker JavaScript, portable baker JavaScript, baker Wasm, shaper JavaScript, and shaper Wasm report 584,479, 9,524, 8,936, 6,077, 422,538, 36,966, and 680,312 minified/raw bytes respectively. The configurable MTSDF baker host measures 19,117 minified / 5,530 gzip / 4,908 Brotli bytes and its coverage-capable full Wasm measures 552,025 raw bytes; the reviewed host ceiling includes authenticated quality and coverage policy. The exact-distance-outline removal experiment measured Slug's isolated runtime graph falling from 288,338 to 278,977 minified bytes, from 83,445 to 81,151 gzip bytes, and from 64,856 to 63,013 Brotli bytes on its original same-host comparison. The current lifecycle-published Slug runtime measures 286,600 minified / 82,730 gzip / 64,271 Brotli bytes. Its baker host measures 12,913 / 4,129 / 3,680 and its Wasm measures 465,046 raw / 186,683 gzip / 146,720 Brotli bytes. Bitmap-only and MTSDF-only size entries inspect their initial module closures and fail if Slug runtime, shader, baker, or runtime-baker modules enter either graph. Paragraph layout hashes and the policy composite hash share one implementation over the actual normalized layouts; the generator, benchmark target, unit tests, and Vitexec probes no longer maintain parallel digest logic. +The current Darwin arm64 record reports a 258,037 minified / 74,457 gzip / 57,290 Brotli peer-externalized browser graph and an independently measured 139,936 / 42,047 / 30,989 Unicode analysis graph. The validator, runtime host, runtime Worker JavaScript, portable baker JavaScript, baker Wasm, shaper JavaScript, and shaper Wasm report 584,479, 9,524, 8,936, 6,077, 422,538, 36,966, and 680,312 minified/raw bytes respectively. The configurable MTSDF baker host measures 19,117 minified / 5,530 gzip / 4,908 Brotli bytes and its coverage-capable full Wasm measures 552,025 raw bytes; the reviewed host ceiling includes authenticated quality and coverage policy. The exact-distance-outline removal experiment measured Slug's isolated runtime graph falling from 288,338 to 278,977 minified bytes, from 83,445 to 81,151 gzip bytes, and from 64,856 to 63,013 Brotli bytes on its original same-host comparison. The render-order-capable Bitmap, MTSDF, and Slug runtime closures now measure 287,345 / 82,263 / 63,964, 291,509 / 83,717 / 65,318, and 292,301 / 84,209 / 65,667 minified/gzip/Brotli bytes. Slug's baker host measures 12,913 / 4,129 / 3,680 and its Wasm measures 465,046 raw / 186,683 gzip / 146,720 Brotli bytes. Bitmap-only and MTSDF-only size entries inspect their initial module closures and fail if Slug runtime, shader, baker, or runtime-baker modules enter either graph. Paragraph layout hashes and the policy composite hash share one implementation over the actual normalized layouts; the generator, benchmark target, unit tests, and Vitexec probes no longer maintain parallel digest logic. The local Worker-queue Vitexec probe authenticates every output and reports observations rather than asserting machine-sensitive timing. Two Chromium runs measured a three-font queued burst at 30.8–32.0 ms and three separately initialized sequential Workers at 68.3–88.6 ms. The correctness suite separately proves one active post, FIFO completion, queued cancellation, and active-cancellation recovery without timers. The combined live lane runs its performance observation before interaction and conformance probes so accumulated renderer work cannot contaminate cold/steady telemetry. diff --git a/docs/packages/glyph-example-raster.md b/docs/packages/glyph-example-raster.md index cd539ebf..c0f3b330 100644 --- a/docs/packages/glyph-example-raster.md +++ b/docs/packages/glyph-example-raster.md @@ -5,7 +5,7 @@ description: Proves the published raster and baker extension boundary with a pri resource: ../../packages/glyph-example-raster workspace_package: '@pmndrs/text-glyph-example-raster' documentation_type: reference -source_digest: 'sha256:1b25dd5a8c679e241da5d73402e42c3441087587efffc9a25799d69dc5f229a0' +source_digest: 'sha256:e7d18c2c53b9b5090c4f81fc3e20ed9be3d7b048b9a84db104830d6ffd33c6fb' tags: [package, raster, extension-proof, threejs, tsl] sources: - id: manifest @@ -28,7 +28,7 @@ sources: title: Dual-backend product rendering probe generated: by: openai-codex/gpt-5.6 - at: '2026-08-04T17:42:34Z' + at: '2026-08-04T18:59:39Z' --- # Package reference: `@pmndrs/text-glyph-example-raster` @@ -39,8 +39,8 @@ This private workspace package is a consumer proof, not a fourth recommended pro `@pmndrs/text` entry points and its own pinned Three.js dependency. It owns the literal `glyphExample` kind, companion extension and descriptor, deterministic baker, standalone-valid GLB framing, embedded or authenticated external RGBA glyph records, decoder validation, runtime baker, TSL material, retained instance storage, dirty upload policy, overflow replacement, -abort behavior, and disposal. A source boundary test rejects imports from core internals or the three first-party raster and -baker subpaths. +paragraph/local-run render-order inheritance, abort behavior, and disposal. A source boundary test rejects imports from +core internals or the three first-party raster and baker subpaths. The technique makes the proof observable by assigning each source-local glyph ID a deterministic color and drawing a framed em-relative diagnostic cell at the position produced by core shaping and paragraph layout. Its visual output is deliberately @@ -64,11 +64,14 @@ clear, viewport, scissor, and scissor-test state, and never creates or disposes ## Boundary findings -The proof found and closed two public integration defects. First, portable `RasterDrawBatch` correctly promised only disposal +The proof found and closed three public integration defects. First, portable `RasterDrawBatch` correctly promised only disposal while Three-backed `Text` silently required an `Object3D`. Core now publishes renderer-neutral `RasterObjectDrawBatch` and the Three adapter publishes `ThreeRasterDrawBatch`; the portable contract still imports no renderer. Second, `RasterRuntime.load` accepted `resolveResource` but dropped it when constructing cache-owned load options; it now preserves the resolver and the package's authenticated external-record test fails without that forwarding. +Third, generated raster Groups replaced the ordering inherited from caller-owned parent Groups before draws reached Three.js +sorting. `Text` and the example batch now use neutral `Object3D` containers. The example implements the public base-order +method so its child mesh combines `Text.renderOrder` with glyph-run-local order across cold and in-place updates. The remaining friction is documented rather than hidden. Static discovery maps an imported factory export name to `package.json#pmndrs.text[exportName]` and requires the default baker's kind to equal that export name. A standalone companion diff --git a/docs/packages/text.md b/docs/packages/text.md index d6fb743f..0629e655 100644 --- a/docs/packages/text.md +++ b/docs/packages/text.md @@ -5,7 +5,7 @@ description: Implements public font loading, shaping, paragraph measurement, sta resource: ../../packages/text workspace_package: '@pmndrs/text' documentation_type: reference -source_digest: 'sha256:602ffb5cfb70317210c6cabe7be8b0eaa2e1fb0e09e4580b1ea506f4e471fccd' +source_digest: 'sha256:59cfc72dfcff42889b5e951c630c8778b058eac24f5b240662cc43cab0be8958' tags: [package, public-api, typescript, contracts] sources: - id: manifest @@ -142,18 +142,26 @@ sources: title: Unicode analysis implementation generated: by: openai-codex/gpt-5.6 - at: '2026-08-04T12:55:07Z' + at: '2026-08-04T19:04:36Z' --- # Package reference: `@pmndrs/text` Status: ✅ Milestone 9 Slug integration is complete +`Text` is a composite `Object3D`, not a `Group`, so it honors the primary `groupOrder` of any caller-owned parent Group. +Generated raster batches also use neutral `Object3D` roots rather than nested Groups. `Text.renderOrder` becomes the secondary +base applied to drawable meshes, which preserve their first-glyph/page-run-local offsets. Cold publication, warm retained +commits, base changes, and multi-font spans need no reshaping or per-frame descendant walk. Bitmap, MTSDF, Slug, and the +external proof package implement the required batch method, and the Three adapter rejects a plugin batch that would reset +inheritance with a nested Group. + Milestone 10.4 proves that the open contract is implementable outside this package. The private `@pmndrs/text-glyph-example-raster` consumer owns a new literal kind, companion GLB, embedded/external records, static and runtime bakers, decoder, retained Three.js/TSL adapter, dirty uploads, overflow, abort, and disposal without importing this package's internals or first-party raster modules. The proof made the Three adapter requirement explicit through public -`RasterObjectDrawBatch` and `ThreeRasterDrawBatch` types while keeping portable `RasterDrawBatch` renderer-neutral. It +`RasterObjectDrawBatch` and `ThreeRasterDrawBatch` types while keeping portable `RasterDrawBatch` renderer-neutral. Its +neutral Three.js root preserves renderer-local transparent-run order beneath the caller-owned parent Group and `Text` base. It also corrected `RasterRuntime.load` to retain the caller's `resolveResource` callback in cache-owned options; authenticated external records now traverse the same deduplicated load as their companion artifact. The public type additions erase at runtime. Compact forwarding of the complete public option bag makes browser core and every first-party runtime closure 55 raw diff --git a/docs/planning/api-shapes.md b/docs/planning/api-shapes.md index 0a837572..ee5d0044 100644 --- a/docs/planning/api-shapes.md +++ b/docs/planning/api-shapes.md @@ -532,6 +532,12 @@ interface RasterDrawBatch { dispose(): void; } +interface RasterObjectDrawBatch extends RasterDrawBatch { + readonly object: SceneObject; + /** Synchronous and infallible, including after retained commits. */ + setRenderOrderBase(base: number): void; +} + interface RasterBatchStage { readonly batch: DrawBatch; /** Synchronous and infallible; transfers target-batch ownership to the caller. */ @@ -550,7 +556,7 @@ declare class RasterRuntime { } ``` -Every raster module's draw-batch type extends the renderer-neutral `RasterDrawBatch` ownership surface. It does not expose a scene object, shader system, or backend resource. The Three.js `Text` adapter separately requires and validates a Three-backed target batch before attaching it to its group. `RasterRuntime` derives the request identity, reuses one decoded resource per font/module/key, evicts failed promises, detaches an aborted consumer without cancelling other consumers, and releases decoded resources when the registered font generation or runtime is disposed. Disposal increments the font generation and invalidates stale raster, shape, layout, and GPU-resource cache entries. +Every raster module's draw-batch type extends the renderer-neutral `RasterDrawBatch` ownership surface. It does not expose a scene object, shader system, or backend resource. The Three.js `Text` adapter separately requires and validates a Three-backed target batch before attaching it to its composite `Object3D`. That target uses a neutral `Object3D` root rather than a nested `Group`, so an enclosing caller-owned Group remains Three.js's primary `groupOrder`. `Text.renderOrder` is applied as the secondary base on each drawable while the raster preserves its run-local offset. `RasterRuntime` derives the request identity, reuses one decoded resource per font/module/key, evicts failed promises, detaches an aborted consumer without cancelling other consumers, and releases decoded resources when the registered font generation or runtime is disposed. Disposal increments the font generation and invalidates stale raster, shape, layout, and GPU-resource cache entries. ## Shared bake core @@ -1146,9 +1152,12 @@ The resource and draw-batch types are owned by their optional raster packages. ` `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 -Three.js for modules rendered through `Text`. The `Text` adapter validates the object at the untrusted plugin boundary before -publication. This makes the runtime requirement statically visible to external Three adapters without importing Three.js into -the renderer-neutral raster contract. +Three.js for modules rendered through `Text`. It also requires `setRenderOrderBase(base)`: the adapter calls it before cold +publication and when the retained `Text.renderOrder` changes. `Text` itself is a composite `Object3D`, so a caller-owned parent +Group remains the primary Three.js `groupOrder`; drawable children use `Text.renderOrder + raster-local order` as their +secondary key. A Three raster batch must use a non-`Group` `Object3D` root so it does not replace the inherited primary key. +The adapter rejects nested `Group` roots at the untrusted plugin boundary. This makes object attachment and layering +statically visible to external adapters without importing Three.js into the renderer-neutral raster contract. The private `@pmndrs/text-glyph-example-raster` workspace package is the accepted external proof. Its `glyphExample` factory, literal kind, `PMNDRS_text_glyph_example` extension, descriptor, baker, standalone companion GLB, embedded/external record diff --git a/docs/planning/decision-register.md b/docs/planning/decision-register.md index b81dad86..6c5130d7 100644 --- a/docs/planning/decision-register.md +++ b/docs/planning/decision-register.md @@ -179,7 +179,8 @@ The [architecture](architecture.md) owns loading behavior and dependency rules. | 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-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 | 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. diff --git a/docs/planning/engine-integration-boundary.md b/docs/planning/engine-integration-boundary.md new file mode 100644 index 00000000..8d5d95c6 --- /dev/null +++ b/docs/planning/engine-integration-boundary.md @@ -0,0 +1,306 @@ +--- +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] +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: 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 +generated: + by: openai-codex/gpt-5.6 + at: '2026-08-05T14:29:49Z' +--- + +# WIP: Renderer-agnostic core and engine integration boundary + +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. + +## Decision sought + +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"] +``` + +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 | + +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. + +## Proposed milestone slices + +### 11.1 — accept the boundary and proof matrix + +- 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. + +Exit: the maintainer accepts the terminology, dependency direction, proof hosts, and non-goals. No implementation name is accepted without evidence from a second host. + +### 11.2 — separate portable technique data from renderer resources + +- 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. + +Exit: all three techniques expose renderer-neutral authenticated data; offline/runtime bake identity and initial bundle size remain accounted for. + +### 11.3 — extract the portable text-generation state machine + +- 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. + +Exit: the complete lifecycle runs with no Three.js object and no GPU. The current Three.js behavior is not yet removed. + +### 11.4 — rebuild the existing Three.js + TSL product as an integration + +- 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. + +Exit: the shipped behavior is an adapter over the public or publishable portable boundary, with no unexplained regression. + +### 11.5 — prove Three.js + TypeGPU is an orthogonal GPU path + +- 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. + +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. + +### 11.6 — prove a non-Three engine host + +- 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. + +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. + +### 11.7 — stabilize exports, guidance, and release gates + +- 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. + +Exit: a third party can identify the correct extension seam without copying core orchestration or importing an unrelated engine. + +## Proof matrix + +| 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 | + +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. + +## Non-negotiable gates + +- `@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. + +## Explicit non-goals + +- 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. + +## Roadmap placement + +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. + +## Questions the proof must answer + +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? + +[^typegpu-scope]: TypeGPU documentation, “Why TypeGPU?” + +[^typegpu-three]: TypeGPU documentation, “@typegpu/three.” diff --git a/docs/planning/index.md b/docs/planning/index.md index c45f71c9..ae956d02 100644 --- a/docs/planning/index.md +++ b/docs/planning/index.md @@ -6,6 +6,7 @@ - [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. - [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. - [Canonical roadmap](../roadmap/roadmap.md) — authoritative implementation order and exit gates. - [uikit integration](uikit-integration.md) — third-party retained-layout integration boundary. diff --git a/docs/planning/raster-baker-plugin.md b/docs/planning/raster-baker-plugin.md index adf8fe64..50007c9d 100644 --- a/docs/planning/raster-baker-plugin.md +++ b/docs/planning/raster-baker-plugin.md @@ -248,8 +248,12 @@ infallible; make abort, batch disposal, and resource disposal idempotent. A reta and dirty-range coalescing privately. Those policies are not public API. For Three.js, publish `RasterObjectDrawBatch` so `Text` can attach the object while the portable -`RasterDrawBatch` contract remains renderer-neutral. Another adapter may use a different batch type without adding that -renderer to core. +`RasterDrawBatch` contract remains renderer-neutral. Implement `setRenderOrderBase(base)` by assigning +`base + rasterLocalOrder` to every drawable, including after retained commits. Use `new Object3D()` as a neutral batch +container, not `new Group()`. `Text` is also a composite `Object3D`, so a caller-owned parent Group remains Three.js's primary +`groupOrder`; `Text.renderOrder` and the raster-local offset form each drawable's secondary order. A nested batch Group would +replace the inherited group order with its own default zero and is rejected by the `Text` adapter. Another renderer adapter +may use a different batch type without adding that renderer to core. ## 6. Add optional runtime baking without growing the normal runtime diff --git a/packages/glyph-example-raster/src/raster.ts b/packages/glyph-example-raster/src/raster.ts index 86b4a0e7..1cbd703e 100644 --- a/packages/glyph-example-raster/src/raster.ts +++ b/packages/glyph-example-raster/src/raster.ts @@ -34,7 +34,7 @@ export interface GlyphExampleResource { readonly material: THREE.MeshBasicNodeMaterial; } -export interface GlyphExampleDrawBatch extends RasterObjectDrawBatch { +export interface GlyphExampleDrawBatch extends RasterObjectDrawBatch { readonly capacity: number; readonly glyphCount: number; } @@ -46,6 +46,8 @@ interface BatchContext { readonly instances?: THREE.InstancedInterleavedBuffer; readonly mesh?: THREE.Mesh; logicalCount: number; + localRenderOrder: number; + renderOrderBase: number; disposed: boolean; } @@ -193,7 +195,7 @@ function createBatch( values: Float32Array, ): GlyphExampleDrawBatch { const capacity = retainedCapacity(glyphIndices.length); - const object = new THREE.Group(); + const object = new THREE.Object3D(); object.name = 'pmndrs.text.glyph-example'; const geometry = capacity === 0 ? undefined : unitQuad(); const instances = @@ -212,6 +214,7 @@ function createBatch( instances.needsUpdate = true; mesh = new THREE.Mesh(geometry, resource.material); mesh.frustumCulled = false; + mesh.renderOrder = glyphIndices[0] ?? 0; object.add(mesh); } let batch!: GlyphExampleDrawBatch; @@ -221,6 +224,12 @@ function createBatch( get glyphCount() { return batchContexts.get(batch)?.logicalCount ?? 0; }, + setRenderOrderBase(base) { + const context = batchContexts.get(batch); + if (context === undefined) return; + context.renderOrderBase = base; + if (context.mesh !== undefined) context.mesh.renderOrder = base + context.localRenderOrder; + }, dispose() { const context = batchContexts.get(batch); if (context === undefined || context.disposed) return; @@ -237,6 +246,8 @@ function createBatch( ...(instances === undefined ? {} : { instances }), ...(mesh === undefined ? {} : { mesh }), logicalCount: glyphIndices.length, + localRenderOrder: glyphIndices[0] ?? 0, + renderOrderBase: 0, disposed: false, }); return batch; @@ -271,8 +282,12 @@ function stageRetained( const liveValues = instances.array as Float32Array; liveValues.set(values); context.logicalCount = glyphIndices.length; + context.localRenderOrder = glyphIndices[0] ?? 0; geometry.instanceCount = glyphIndices.length; - if (mesh !== undefined) mesh.visible = glyphIndices.length > 0; + if (mesh !== undefined) { + mesh.visible = glyphIndices.length > 0; + mesh.renderOrder = context.renderOrderBase + context.localRenderOrder; + } if (ranges.length === 0) return; instances.clearUpdateRanges(); for (const range of ranges) { diff --git a/packages/glyph-example-raster/tests/glyph-example.test.ts b/packages/glyph-example-raster/tests/glyph-example.test.ts index 366823fb..931952c6 100644 --- a/packages/glyph-example-raster/tests/glyph-example.test.ts +++ b/packages/glyph-example-raster/tests/glyph-example.test.ts @@ -116,9 +116,14 @@ describe('public external raster proof', () => { initialStage.commit(); const initial = initialStage.batch; const geometry = meshGeometry(initial.object); + const mesh = initial.object.children[0]; + expect(mesh).toBeDefined(); const initialCapacity = initial.capacity; expect(initial.glyphCount).toBe(2); expect(geometry.instanceCount).toBe(2); + expect(initial.object).not.toBeInstanceOf(THREE.Group); + initial.setRenderOrderBase(600); + expect(mesh?.renderOrder).toBe(600); const aborted = glyphExampleModule.stageBatch(initial, layout([3]), loaded.resource, 0, paint(1), 1); expect(aborted.batch).toBe(initial); @@ -131,6 +136,8 @@ describe('public external raster proof', () => { expect(shrink.batch).toBe(initial); expect(initial.glyphCount).toBe(1); expect(geometry.instanceCount).toBe(1); + expect(initial.object).not.toBeInstanceOf(THREE.Group); + expect(mesh?.renderOrder).toBe(600); expect(() => glyphExampleModule.stageBatch(initial, layout([font.glyphCount]), loaded.resource, 0, paint(1), 1), @@ -239,8 +246,8 @@ function paint(count: number): GlyphPaint { }; } -function meshGeometry(group: THREE.Group): THREE.InstancedBufferGeometry { - const mesh = group.children[0]; +function meshGeometry(object: THREE.Object3D): THREE.InstancedBufferGeometry { + const mesh = object.children[0]; assert.ok(mesh instanceof THREE.Mesh); assert.ok(mesh.geometry instanceof THREE.InstancedBufferGeometry); return mesh.geometry; diff --git a/packages/text/src/internal/raster-batch.ts b/packages/text/src/internal/raster-batch.ts index 1a10f43e..c2d5d044 100644 --- a/packages/text/src/internal/raster-batch.ts +++ b/packages/text/src/internal/raster-batch.ts @@ -19,6 +19,11 @@ export function resolvedGlyphColor(paint: GlyphPaint, glyphIndex: number): Linea return resolved.color; } +/** Compose one composite-object base with a raster run's first-glyph-local order. */ +export function rasterRenderOrder(base: number, glyphIndices: Uint32Array): number { + return base + (glyphIndices[0] ?? 0); +} + export function assertParallelRasterLayout(layout: ParagraphLayout, paint: GlyphPaint): void { const glyphCount = layout.glyphIds.length; for (const values of [layout.glyphFontSlots, layout.glyphFontSizes, layout.x, layout.y]) { diff --git a/packages/text/src/internal/text-properties.ts b/packages/text/src/internal/text-properties.ts index d4909d77..f2e8b6a1 100644 --- a/packages/text/src/internal/text-properties.ts +++ b/packages/text/src/internal/text-properties.ts @@ -308,13 +308,20 @@ function isRasterModule(value: unknown): value is AnyRasterModule { export type ThreeRasterDrawBatch = RasterObjectDrawBatch; function assertRasterBatch(value: unknown): asserts value is ThreeRasterDrawBatch { + const object = isObject(value) ? readProperty(value, 'object') : undefined; if ( !isObject(value) || - !(readProperty(value, 'object') instanceof THREE.Object3D) || + !(object instanceof THREE.Object3D) || + typeof readProperty(value, 'setRenderOrderBase') !== 'function' || typeof readProperty(value, 'dispose') !== 'function' ) { throw new TypeError('raster module returned an invalid draw batch'); } + if (object instanceof THREE.Group) { + throw new TypeError( + 'a Three.js raster batch must use a neutral Object3D root so parent group order remains inherited', + ); + } } export function assertRasterBatchStage(value: unknown): asserts value is RasterBatchStage { diff --git a/packages/text/src/raster.ts b/packages/text/src/raster.ts index 92782903..ab89cc53 100644 --- a/packages/text/src/raster.ts +++ b/packages/text/src/raster.ts @@ -193,6 +193,8 @@ export interface RasterDrawBatch { /** Renderer adapter batch that publishes one host-owned scene object. */ export interface RasterObjectDrawBatch extends RasterDrawBatch { readonly object: SceneObject; + /** Synchronously and infallibly apply the owning object's order while preserving draw-local ordering. */ + setRenderOrderBase(base: number): void; } /** diff --git a/packages/text/src/raster/bitmap.ts b/packages/text/src/raster/bitmap.ts index 2bec2747..0a3b9077 100644 --- a/packages/text/src/raster/bitmap.ts +++ b/packages/text/src/raster/bitmap.ts @@ -20,7 +20,12 @@ import { import type { RegisteredFont } from '../font.js'; import type { ParagraphLayout } from '../layout.js'; import type { GlyphPaint } from '../paint.js'; -import { assertParallelRasterLayout, resolvedGlyphColor, unitRasterQuadGeometry } from '../internal/raster-batch.js'; +import { + assertParallelRasterLayout, + rasterRenderOrder, + resolvedGlyphColor, + unitRasterQuadGeometry, +} from '../internal/raster-batch.js'; import { rasterInstanceCapacity, rasterInstanceUpdateRanges } from '../internal/raster-instance-capacity.js'; import { ABSENT_GLYPH_PAGE, @@ -37,6 +42,7 @@ import { defineRasterBatchStage, type JsonValue, type RasterModule, + type RasterObjectDrawBatch, type RasterRequest, type RegisteredRaster, } from '../raster.js'; @@ -113,8 +119,7 @@ interface BitmapBatchRun { readonly page: BitmapPageResource; } -export interface BitmapDrawBatch { - readonly object: THREE.Group; +export interface BitmapDrawBatch extends RasterObjectDrawBatch { readonly glyphCount: number; readonly drawCount: number; /** Selected baked strike in pixels per em. */ @@ -147,6 +152,7 @@ interface PresentableBitmapBatch { readonly fontSlot: number; readonly strike: BitmapStrikeResource; revision: number; + renderOrderBase: number; disposed: boolean; } @@ -529,7 +535,7 @@ function buildBitmapBatches( assertParallelRasterLayout(layout, paint); assertRasterCoverage(layout, fontSlot, resource.coverage, BITMAP_KIND); const strike = selectBitmapStrike(resource.strikes, layout, fontSlot, rasterPixelRatio); - const group = new THREE.Group(); + const group = new THREE.Object3D(); const runs = collectBitmapRunPlans(layout, strike, fontSlot).map(({ page, glyphIndices }) => { const run = createBitmapRun(layout, strike, page, glyphIndices, paint); group.add(run.mesh); @@ -543,6 +549,7 @@ function buildBitmapBatches( fontSlot, strike, revision: 0, + renderOrderBase: 0, disposed: false, }; presentableBatchByObject.set(group, presentation); @@ -556,6 +563,10 @@ function buildBitmapBatches( return runs.reduce((count, run) => count + (run.logicalCount === 0 ? 0 : 1), 0); }, strikePpem: strike.ppem, + setRenderOrderBase(base) { + presentation.renderOrderBase = base; + for (const run of runs) run.mesh.renderOrder = rasterRenderOrder(base, run.glyphIndices); + }, dispose() { if (disposed) return; disposed = true; @@ -599,7 +610,12 @@ function stageBitmapBatchUpdate( } const staged = plans.map(({ glyphIndices, page }, index) => { const run = presentation.runs[index]!; - return stageBitmapRunUpdate(run, glyphIndices, bitmapRunValues(layout, strike, page, glyphIndices, paint)); + return stageBitmapRunUpdate( + run, + glyphIndices, + bitmapRunValues(layout, strike, page, glyphIndices, paint), + presentation.renderOrderBase, + ); }); let disposed = false; return { @@ -712,6 +728,7 @@ function stageBitmapRunUpdate( run: BitmapBatchRun, glyphIndices: Uint32Array, values: BitmapRunValues, + renderOrderBase: number, ): BitmapBatchUpdate { const logicalCount = glyphIndices.length; const attributeUpdates = [ @@ -730,7 +747,7 @@ function stageBitmapRunUpdate( run.glyphIndices.set(glyphIndices); run.logicalCount = logicalCount; run.geometry.instanceCount = logicalCount; - run.mesh.renderOrder = glyphIndices[0] ?? 0; + run.mesh.renderOrder = rasterRenderOrder(renderOrderBase, glyphIndices); delete run.targetOrigins; }, dispose() { @@ -848,7 +865,7 @@ function createBitmapRun( const material = bitmapMaterial(page.texture); const mesh = new THREE.Mesh(geometry, material); mesh.frustumCulled = false; - mesh.renderOrder = glyphIndices[0] ?? 0; + mesh.renderOrder = rasterRenderOrder(0, glyphIndices); const retainedGlyphIndices = new Uint32Array(capacity); retainedGlyphIndices.set(glyphIndices); return { diff --git a/packages/text/src/raster/msdf.ts b/packages/text/src/raster/msdf.ts index 54fdffa7..f1f2610f 100644 --- a/packages/text/src/raster/msdf.ts +++ b/packages/text/src/raster/msdf.ts @@ -49,6 +49,7 @@ import { import { assertParallelRasterLayout, assertParallelRasterPaint, + rasterRenderOrder, unitRasterQuadGeometry, } from '../internal/raster-batch.js'; import { rasterInstanceCapacity, rasterInstanceUpdateRanges } from '../internal/raster-instance-capacity.js'; @@ -59,6 +60,7 @@ import { defineRasterBatchStage, type JsonValue, type RasterModule, + type RasterObjectDrawBatch, type RegisteredRaster, } from '../raster.js'; import { assertRasterCoverage, decodeRasterCoverage } from '../internal/raster-coverage-artifact.js'; @@ -120,8 +122,7 @@ interface MsdfBatchRun { readonly mesh: THREE.Mesh; } -export interface MsdfDrawBatch { - readonly object: THREE.Group; +export interface MsdfDrawBatch extends RasterObjectDrawBatch { readonly glyphCount: number; readonly drawCount: number; dispose(): void; @@ -137,6 +138,7 @@ interface MsdfBatchContext { readonly resource: MsdfResource; readonly fontSlot: number; readonly run: MsdfBatchRun | undefined; + renderOrderBase: number; } const batchContext = new WeakMap(); @@ -178,7 +180,7 @@ const msdfModule: RasterModule { + return stageMsdfRunCommit(run, liveValues, values, glyphIndices, context.renderOrderBase, () => { run.paintStructure.set(paintStructure); context.layout = layout; }); @@ -533,6 +541,7 @@ function stageMsdfPaintUpdate( layout: ParagraphLayout, run: MsdfBatchRun | undefined, paint: GlyphPaint, + renderOrderBase: number, ): MsdfBatchUpdate { assertParallelRasterPaint(layout, paint); assertMsdfPaint(paint); @@ -552,6 +561,7 @@ function stageMsdfPaintUpdate( run.instanceData.array as Float32Array, values, run.glyphIndices.subarray(0, run.logicalCount), + renderOrderBase, () => undefined, ); } @@ -561,6 +571,7 @@ function stageMsdfRunCommit( liveValues: Float32Array, values: Float32Array, glyphIndices: Uint32Array, + renderOrderBase: number, beforeCommit: () => void, ): MsdfBatchUpdate { const logicalCount = glyphIndices.length; @@ -582,7 +593,7 @@ function stageMsdfRunCommit( run.glyphIndices.set(glyphIndices); run.logicalCount = logicalCount; run.geometry.instanceCount = logicalCount; - run.mesh.renderOrder = glyphIndices[0] ?? 0; + run.mesh.renderOrder = rasterRenderOrder(renderOrderBase, glyphIndices); if (ranges.length === 0) return; run.instanceData.clearUpdateRanges(); for (const range of ranges) run.instanceData.addUpdateRange(range.start, range.count); diff --git a/packages/text/src/raster/slug.ts b/packages/text/src/raster/slug.ts index 3f9a8a9a..371a03c4 100644 --- a/packages/text/src/raster/slug.ts +++ b/packages/text/src/raster/slug.ts @@ -11,7 +11,7 @@ import { Fn, add, attribute, bool, mul, positionLocal, sub, uniform, varyingProp import type { RegisteredFont } from '../font.js'; import type { Sha256Hex } from '../identity.js'; -import { assertParallelRasterLayout, unitRasterQuadGeometry } from '../internal/raster-batch.js'; +import { assertParallelRasterLayout, rasterRenderOrder, unitRasterQuadGeometry } from '../internal/raster-batch.js'; import { coalesceRasterInstanceRanges, pendingRasterDirtyInstances, @@ -36,6 +36,7 @@ import { defineRasterBatchStage, type JsonValue, type RasterModule, + type RasterObjectDrawBatch, type RasterResourceSource, type RegisteredRaster, } from '../raster.js'; @@ -115,8 +116,7 @@ interface SlugBatchRun { readonly pageIndex: number; } -export interface SlugDrawBatch { - readonly object: THREE.Group; +export interface SlugDrawBatch extends RasterObjectDrawBatch { readonly glyphCount: number; readonly drawCount: number; dispose(): void; @@ -128,6 +128,7 @@ interface SlugBatchContext { readonly resource: SlugResource; readonly fontSlot: number; readonly runs: readonly SlugBatchRun[]; + renderOrderBase: number; } const batchContext = new WeakMap(); @@ -477,7 +478,7 @@ function buildSlugBatches( assertParallelRasterLayout(layout, paint); assertSlugPaint(paint); assertSlugGlyphInputs(layout, resource, fontSlot, paint); - const group = new THREE.Group(); + const group = new THREE.Object3D(); group.name = 'pmndrs.text.slug'; const runs: SlugBatchRun[] = []; try { @@ -501,6 +502,12 @@ function buildSlugBatches( get drawCount() { return runs.reduce((count, run) => count + (run.logicalCount === 0 ? 0 : 1), 0); }, + setRenderOrderBase(base) { + const context = batchContext.get(batch); + if (context === undefined) return; + context.renderOrderBase = base; + for (const run of runs) run.fillMesh.renderOrder = rasterRenderOrder(base, run.glyphIndices); + }, dispose() { if (disposed) return; disposed = true; @@ -509,7 +516,7 @@ function buildSlugBatches( for (const run of runs) run.geometry.dispose(); }, }; - batchContext.set(batch, { layout, resource, fontSlot, runs }); + batchContext.set(batch, { layout, resource, fontSlot, runs, renderOrderBase: 0 }); return batch; } @@ -549,7 +556,12 @@ function stageSlugBatchUpdate( return undefined; } const staged = plans.map(({ glyphIndices }, index) => - stageSlugRunUpdate(context.runs[index]!, glyphIndices, slugRunValues(layout, resource, glyphIndices, paint)), + stageSlugRunUpdate( + context.runs[index]!, + glyphIndices, + slugRunValues(layout, resource, glyphIndices, paint), + context.renderOrderBase, + ), ); let disposed = false; return { @@ -678,7 +690,12 @@ function slugRunValues( return { floats, uints }; } -function stageSlugRunUpdate(run: SlugBatchRun, glyphIndices: Uint32Array, values: SlugRunValues): SlugBatchUpdate { +function stageSlugRunUpdate( + run: SlugBatchRun, + glyphIndices: Uint32Array, + values: SlugRunValues, + renderOrderBase: number, +): SlugBatchUpdate { const logicalCount = glyphIndices.length; const floatUpdate = stageSlugInterleavedData(run.floatData, values.floats, run.logicalCount, logicalCount); const uintUpdate = stageSlugInterleavedData(run.uintData, values.uints, run.logicalCount, logicalCount); @@ -692,7 +709,7 @@ function stageSlugRunUpdate(run: SlugBatchRun, glyphIndices: Uint32Array, values run.glyphIndices.set(glyphIndices); run.logicalCount = logicalCount; run.geometry.instanceCount = logicalCount; - run.fillMesh.renderOrder = glyphIndices[0] ?? 0; + run.fillMesh.renderOrder = rasterRenderOrder(renderOrderBase, glyphIndices); }, dispose() { if (disposed) return; @@ -862,7 +879,7 @@ function populateSlugRun( const initialState = slugMaterialState(page); const fillMesh = new THREE.Mesh(geometry, initialState.material); fillMesh.frustumCulled = false; - fillMesh.renderOrder = glyphIndices[0] ?? 0; + fillMesh.renderOrder = rasterRenderOrder(0, glyphIndices); const retainedGlyphIndices = new Uint32Array(capacity); retainedGlyphIndices.set(glyphIndices); const run: SlugBatchRun = { diff --git a/packages/text/src/react.ts b/packages/text/src/react.ts index 1095b390..6758f70a 100644 --- a/packages/text/src/react.ts +++ b/packages/text/src/react.ts @@ -39,7 +39,7 @@ type ReactTextCoreProps = DistributiveOmit & { readonly children?: TextChild | readonly TextChild[]; }; -export type ReactTextProps = Omit & +export type ReactTextProps = Omit & ReactTextCoreProps & { readonly ref?: Ref }; export interface UseFont { diff --git a/packages/text/src/text.ts b/packages/text/src/text.ts index 0bba3c24..4f20c53d 100644 --- a/packages/text/src/text.ts +++ b/packages/text/src/text.ts @@ -207,13 +207,14 @@ interface PreparedFontRaster { } /** Framework-neutral Three.js text object with transactional generations. */ -export class Text extends THREE.Group { +export class Text extends THREE.Object3D { #state: TextState; #generation: TextGeneration | undefined; #pending: AbortController | undefined; #publication: PendingPublication | undefined; #invalidatedState: TextState | undefined; #revision = 0; + #renderOrderBase = Number.NaN; #ready: Promise = Promise.resolve(); #disposed = false; @@ -280,11 +281,13 @@ export class Text extends THREE.Group { override updateMatrixWorld(force?: boolean): void { this.#publishPending(); + this.#syncRenderOrderBase(); super.updateMatrixWorld(force); } override updateWorldMatrix(updateParents: boolean, updateChildren: boolean): void { this.#publishPending(); + this.#syncRenderOrderBase(); super.updateWorldMatrix(updateParents, updateChildren); } @@ -611,11 +614,19 @@ export class Text extends THREE.Group { } if (previous !== undefined && previous.paragraph !== generation.paragraph) previous.paragraph.dispose(); for (const owned of generation.batches) { + owned.batch.setRenderOrderBase(this.renderOrder); if (owned.batch.object.parent !== this) this.add(owned.batch.object); } + this.#renderOrderBase = this.renderOrder; if (previous?.layout !== generation.layout) this.#state.onLayout?.(generation.layout); } + #syncRenderOrderBase(): void { + if (this.#renderOrderBase === this.renderOrder) return; + for (const owned of this.#generation?.batches ?? []) owned.batch.setRenderOrderBase(this.renderOrder); + this.#renderOrderBase = this.renderOrder; + } + #publishPending(): void { const publication = this.#publication; if (publication === undefined) return; diff --git a/packages/text/tests/integration/bitmap-retained-capacity.test.mjs b/packages/text/tests/integration/bitmap-retained-capacity.test.mjs index 9a01969c..e866c60a 100644 --- a/packages/text/tests/integration/bitmap-retained-capacity.test.mjs +++ b/packages/text/tests/integration/bitmap-retained-capacity.test.mjs @@ -23,6 +23,9 @@ test('Bitmap retains every instance field within capacity and replaces changed r assert.equal(batch.glyphCount, 3); assert.equal(batch.drawCount, 1); assert.equal(geometry.instanceCount, 3); + assert.equal(batch.object.isGroup, undefined); + batch.setRenderOrderBase(600); + assert.equal(mesh.renderOrder, 600); for (const attribute of Object.values(attributes)) assert.equal(attribute.usage, THREE.DynamicDrawUsage); const replacementLayout = layout([1, 1, 1], 9, 11, 32); @@ -46,6 +49,8 @@ test('Bitmap retains every instance field within capacity and replaces changed r assert.equal(mesh.material, material); assert.equal(batch.glyphCount, 3); assert.equal(geometry.instanceCount, 3); + assert.equal(batch.object.isGroup, undefined, 'retained replacement keeps a neutral root'); + assert.equal(mesh.renderOrder, 600, 'retained replacement preserves the Text-local order'); for (const [name, attribute] of Object.entries(bitmapAttributes(geometry))) { assert.equal(attribute, attributes[name], `retains ${name} attribute identity`); assert.equal(attribute.array, arrays[name], `retains ${name} backing allocation`); @@ -118,6 +123,13 @@ test('Bitmap retains every instance field within capacity and replaces changed r ); assert.notEqual(changedTopology.batch, batch); assert.equal(changedTopology.batch.drawCount, 3); + changedTopology.batch.setRenderOrderBase(600); + assert.deepEqual( + changedTopology.batch.object.children.map(({ renderOrder }) => renderOrder), + [600, 601, 602], + 'page runs compose the Text-local base with first-glyph-local order', + ); + assert.equal(changedTopology.batch.object.isGroup, undefined); const topologyGeometries = changedTopology.batch.object.children.map(({ geometry: stagedGeometry }) => { let disposed = false; stagedGeometry.addEventListener('dispose', () => { diff --git a/packages/text/tests/integration/mtsdf-retained-capacity.test.mjs b/packages/text/tests/integration/mtsdf-retained-capacity.test.mjs index adcf0fa1..71b1cc52 100644 --- a/packages/text/tests/integration/mtsdf-retained-capacity.test.mjs +++ b/packages/text/tests/integration/mtsdf-retained-capacity.test.mjs @@ -46,6 +46,9 @@ test('MTSDF retains capacity for arbitrary glyph replacement and replaces only o assert.equal(batch.glyphCount, 3); assert.equal(batch.drawCount, 1); assert.equal(geometry.instanceCount, 3); + assert.equal(batch.object.isGroup, undefined); + batch.setRenderOrderBase(600); + assert.equal(mesh.renderOrder, 600); const replacementLayout = layout([1, 1, 1], 9, 11, 32); const replacementPaint = paint(3, [0.6, 0.5, 0.4, 0.3], [0.7, 0.6, 0.5, 0.4], [0.8, 0.7, 0.6, 0.5], 3, [3, -4]); @@ -67,6 +70,8 @@ test('MTSDF retains capacity for arbitrary glyph replacement and replaces only o assert.equal(data.array, backingArray); assert.equal(batch.glyphCount, 3); assert.equal(geometry.instanceCount, 3); + assert.equal(batch.object.isGroup, undefined, 'retained replacement keeps a neutral root'); + assert.equal(mesh.renderOrder, 600, 'retained replacement preserves the Text-local order'); for (const [component, value] of Array.from(backingArray.subarray(0, STRIDE)).entries()) { assert.notEqual(value, initialValues[component], `all-field replacement updates component ${component}`); } diff --git a/packages/text/tests/integration/react-text.test.mjs b/packages/text/tests/integration/react-text.test.mjs index 9451fbec..a82ba09e 100644 --- a/packages/text/tests/integration/react-text.test.mjs +++ b/packages/text/tests/integration/react-text.test.mjs @@ -15,13 +15,25 @@ after(restoreR3fEnvironment); const fixtureUrl = new URL('../../../../apps/benchmarks/fixtures/rendering/inter-bitmap-16.font.glb', import.meta.url); const shaperUrl = new URL('../../dist/text_shaper.wasm', import.meta.url); -test('React Text flattens spans, reconciles, forwards its ref, and disposes', async () => { +test('React Text flattens spans, retains its Object3D identity, forwards its ref, and disposes', async () => { const restoreFetch = installFileFetch(); const font = defineFont(fixtureUrl.href, bitmap({ strikes: [16] })); const loaded = await useFont.preload(font); const reference = createRef(); const layouts = []; - const render = (suffix, color, position = [0, 0, 0]) => + const render = ( + suffix, + color, + { + position = [0, 0, 0], + rotation = [0, 0, 0], + scale = [1, 1, 1], + name = 'initial headline', + visible = true, + frustumCulled = true, + renderOrder = 600, + } = {}, + ) => React.createElement( StrictMode, null, @@ -31,6 +43,12 @@ test('React Text flattens spans, reconciles, forwards its ref, and disposes', as font, fontSize: 16, position, + rotation, + scale, + name, + visible, + frustumCulled, + renderOrder, ref: reference, onLayout: (layout) => layouts.push(layout), }, @@ -48,20 +66,42 @@ test('React Text flattens spans, reconciles, forwards its ref, and disposes', as reference.current instanceof CoreText, `forwarded ref resolved to ${reference.current?.constructor?.name ?? String(reference.current)}`, ); + assert.equal(reference.current.isObject3D, true, 'Text remains a Three.js Object3D'); + assert.equal(reference.current.isGroup, undefined, 'Text does not introduce nested Group ordering'); reference.current.updateMatrixWorld(); assert.equal(reference.current.children.length, 1); assert.equal(reference.current.layout?.glyphIds.length, 11); + assert.equal(reference.current.children[0]?.children[0]?.renderOrder, 600); assert.equal(layouts.length, 1); const object = reference.current; const initialLayout = object.layout; - await renderer.update(render('office', '#00aaff', [2, 1, 0])); + const initialBatch = object.children[0]; + await renderer.update( + render('office', '#00aaff', { + position: [2, 1, 0], + rotation: [0, 0.5, 0], + scale: [2, 3, 4], + name: 'updated headline', + visible: false, + frustumCulled: false, + renderOrder: 700, + }), + ); object.updateMatrixWorld(); assert.equal(reference.current, object, 'React updates retain the core object identity'); assert.equal(object.layout, initialLayout, 'paint and transform changes do not reflow'); + assert.equal(object.children[0], initialBatch, 'Object3D changes retain the raster batch'); assert.deepEqual(object.position.toArray(), [2, 1, 0]); - - await renderer.update(render('accurate', '#00aaff', [2, 1, 0])); + assert.deepEqual(object.rotation.toArray(), [0, 0.5, 0, 'XYZ']); + assert.deepEqual(object.scale.toArray(), [2, 3, 4]); + assert.equal(object.name, 'updated headline'); + assert.equal(object.visible, false); + assert.equal(object.frustumCulled, false); + assert.equal(object.renderOrder, 700); + assert.equal(initialBatch.children[0]?.renderOrder, 700, 'the retained draw mesh receives the new Text order'); + + await renderer.update(render('accurate', '#00aaff', { position: [2, 1, 0] })); object.updateMatrixWorld(); assert.notEqual(object.layout, initialLayout, 'text changes replace the layout generation'); assert.equal(object.layout?.glyphIds.length, 13); @@ -69,13 +109,9 @@ test('React Text flattens spans, reconciles, forwards its ref, and disposes', as await assert.rejects( ReactThreeTestRenderer.create( - React.createElement( - Text, - { font }, - React.createElement(Text, { position: [1, 0, 0] }, 'invalid inline transform'), - ), + React.createElement(Text, { font }, React.createElement(Text, { renderOrder: 1 }, 'invalid inline order')), ), - /nested Text does not accept position/, + /nested Text does not accept renderOrder/, ); await renderer.unmount(); diff --git a/packages/text/tests/integration/slug-retained-capacity.test.mjs b/packages/text/tests/integration/slug-retained-capacity.test.mjs index a2a962da..7670f2a5 100644 --- a/packages/text/tests/integration/slug-retained-capacity.test.mjs +++ b/packages/text/tests/integration/slug-retained-capacity.test.mjs @@ -24,6 +24,9 @@ test('Slug retains both interleaved instance records and replaces changed page t assert.equal(batch.glyphCount, 3); assert.equal(batch.drawCount, 1); assert.equal(geometry.instanceCount, 3); + assert.equal(batch.object.isGroup, undefined); + batch.setRenderOrderBase(600); + assert.equal(mesh.renderOrder, 600); const replacementLayout = layout([1, 1, 1], 9, 11, 32); const replacement = slug.stageBatch(batch, replacementLayout, resource, 0, paint(3, [0.6, 0.5, 0.4, 0.3]), 1); @@ -39,6 +42,8 @@ test('Slug retains both interleaved instance records and replaces changed page t assert.equal(geometry.getAttribute('slugCurveBase').data, uintData); assert.equal(floatData.array, floatArray); assert.equal(uintData.array, uintArray); + assert.equal(batch.object.isGroup, undefined, 'retained replacement keeps a neutral root'); + assert.equal(mesh.renderOrder, 600, 'retained replacement preserves the Text-local order'); assert.notDeepEqual(Array.from(floatArray), initialFloats); assert.notDeepEqual(Array.from(uintArray), initialUints); assert.deepEqual(floatData.updateRanges, [{ start: 0, count: 3 * floatData.stride }]); @@ -97,6 +102,13 @@ test('Slug retains both interleaved instance records and replaces changed page t ); assert.notEqual(changedTopology.batch, batch); assert.equal(changedTopology.batch.drawCount, 3); + changedTopology.batch.setRenderOrderBase(600); + assert.deepEqual( + changedTopology.batch.object.children.map(({ renderOrder }) => renderOrder), + [600, 601, 602], + 'page runs compose the Text-local base with first-glyph-local order', + ); + assert.equal(changedTopology.batch.object.isGroup, undefined); const topologyGeometries = changedTopology.batch.object.children.map(({ geometry: stagedGeometry }) => { let disposed = false; stagedGeometry.addEventListener('dispose', () => { diff --git a/packages/text/tests/integration/text-object.test.mjs b/packages/text/tests/integration/text-object.test.mjs index 64a5c8ac..0232bb14 100644 --- a/packages/text/tests/integration/text-object.test.mjs +++ b/packages/text/tests/integration/text-object.test.mjs @@ -37,7 +37,12 @@ test('Text commits layout and draw generations atomically', async () => { fontSize: 16, onLayout: (layout) => layouts.push(layout), }); + text.renderOrder = 600; + const parent = new THREE.Group(); + parent.renderOrder = 500; + parent.add(text); try { + assert.equal(text.isGroup, undefined, 'Text does not replace its parent group order'); assert.equal(text.children.length, 0); await publishText(text); assert.equal(text.children.length, 1); @@ -46,10 +51,19 @@ test('Text commits layout and draw generations atomically', async () => { const initialLayout = text.layout; const initialBatch = text.children[0]; + assert.equal(initialBatch.isGroup, undefined, 'a raster root does not replace the parent group order'); + assert.equal(initialBatch.children[0]?.renderOrder, 600, 'the first raster run applies the Text-local order'); + text.renderOrder = 700; + text.updateMatrixWorld(); + assert.equal(text.renderOrder, 700, 'the caller controls the Text-local order directly'); + assert.equal(initialBatch.children[0]?.renderOrder, 700, 'matrix traversal updates the drawable order'); + assert.equal(parent.renderOrder, 500, 'Text-local ordering does not replace the parent group order'); + assert.equal(text.layout, initialLayout); text.setProperties({ opacity: 0.5 }); await publishText(text); assert.equal(text.layout, initialLayout, 'paint-only updates retain the committed layout'); assert.equal(text.children[0], initialBatch, 'paint-only updates retain the draw batch'); + assert.equal(initialBatch.children[0]?.renderOrder, 700, 'retained paint preserves the Text-local order'); text.setProperties({ text: 'office AVATAR', @@ -72,6 +86,7 @@ test('Text commits layout and draw generations atomically', async () => { assert.equal(childTraversalLayout, text.layout, 'warm publication precedes retained child traversal'); assert.equal(text.children.length, 1); assert.equal(text.children[0], initialBatch, 'compatible bitmap reflow retains the draw batch'); + assert.equal(initialBatch.children[0]?.renderOrder, 700, 'retained layout preserves the Text-local order'); const narrowLayout = text.layout; text.setProperties({ fontSize: 18 }); @@ -798,6 +813,46 @@ test('Text rejects a raster batch without the required Three.js lifecycle surfac } }); +test('Text rejects a nested raster Group that would replace its inherited paragraph order', async () => { + const restoreFetch = installFileFetch(); + const registry = new FontRegistry(); + const font = await registry.registerAsset(await readFile(fixtureUrl)); + const groupBatchModule = { + kind: 'bitmap', + extension: 'PMNDRS_font_bitmap', + version: 0, + descriptor() { + return { generatorVersion: '0.0.0', strikes: [16] }; + }, + async decode() { + return {}; + }, + async prepare() {}, + stageBatch() { + return { + batch: { object: new THREE.Group(), setRenderOrderBase() {}, dispose() {} }, + commit() {}, + abort() {}, + }; + }, + dispose() {}, + }; + const text = new Text({ + text: 'nested group', + font, + raster: { module: groupBatchModule }, + fontSize: 16, + }); + try { + await assert.rejects(text.ready, /neutral Object3D root/); + assert.equal(text.children.length, 0); + } finally { + text.dispose(); + font.dispose(); + restoreFetch(); + } +}); + test('Text resolves independent raster resources for two fonts in one paragraph', async () => { const restoreFetch = installFileFetch(); const registry = new FontRegistry(); @@ -852,10 +907,16 @@ test('Text resolves independent raster resources for two fonts in one paragraph' }, ], }); + text.renderOrder = 600; try { await publishText(text); assert.equal(text.layout?.fontHandles.length, 2); assert.equal(text.children.length, 2); + assert.deepEqual( + text.children.map((batch) => batch.children[0]?.renderOrder), + [600, 606], + 'font batches compose Text-local and absolute glyph-run order', + ); const liveBatches = [...text.children]; failPreparation = true; assert.throws(() => text.setProperties({ width: 200 }), /injected second-font preparation failure/); @@ -883,6 +944,11 @@ test('Text resolves independent raster resources for two fonts in one paragraph' await publishText(text); assert.equal(text.children[0], liveBatches[0], 'a successful transaction retains the first batch'); assert.equal(text.children[1], liveBatches[1], 'a successful transaction retains the second batch'); + assert.deepEqual( + text.children.map((batch) => batch.children[0]?.renderOrder), + [600, 606], + 'span paint commits preserve cross-font local ordering', + ); } finally { text.dispose(); inter.dispose(); diff --git a/packages/text/tests/types/public-api.test.ts b/packages/text/tests/types/public-api.test.ts index 25bf1f4e..a9bed1a7 100644 --- a/packages/text/tests/types/public-api.test.ts +++ b/packages/text/tests/types/public-api.test.ts @@ -37,6 +37,7 @@ import { type ParagraphMeasurement, type TextProperties, type TextUpdateProperties, + type ThreeRasterDrawBatch, } from '../../src/index.js'; import type { ReactElement } from 'react'; import type { Object3D } from 'three/webgpu'; @@ -113,6 +114,12 @@ interface MsdfBatch { } declare const rasterObject: Object3D; +const threeRasterBatch: ThreeRasterDrawBatch = { + object: rasterObject, + setRenderOrderBase() {}, + dispose() {}, +}; +void threeRasterBatch; const msdf = defineRaster({ kind: 'msdf', @@ -296,6 +303,12 @@ const reactTokenProps: ReactTextProps = { font: titleFont, fontSize: 0.24, position: [0, 1, 0], + rotation: [0, 0.25, 0], + scale: [1.5, 1.5, 1], + name: 'headline', + visible: true, + frustumCulled: false, + renderOrder: 600, children: ['Fast ', nestedText], }; const reactRawProps: ReactTextProps = {