From 32f4ff8572050a9aa7a8715101ed94f29572eec3 Mon Sep 17 00:00:00 2001 From: Justin Walsh Date: Sat, 1 Aug 2026 16:36:28 -0400 Subject: [PATCH 1/2] feat(text): retain bounded raster capacity --- .../src/benchmark/package-sizes.test.ts | 44 +- .../generated/autoresearch-baseline-v0.json | 2 +- .../src/generated/package-sizes.json | 30 +- docs/log.md | 1 + docs/packages/benchmarks.md | 6 +- docs/packages/text.md | 6 +- docs/planning/api-shapes.md | 4 +- docs/planning/decision-register.md | 3 +- docs/roadmap/roadmap.md | 6 +- .../src/internal/raster-instance-capacity.ts | 109 +++++ packages/text/src/raster/bitmap.ts | 361 ++++++++------ packages/text/src/raster/msdf.ts | 405 ++++++++-------- packages/text/src/raster/slug.ts | 440 +++++++++++++----- .../bitmap-retained-capacity.test.mjs | 254 ++++++++++ .../mtsdf-retained-capacity.test.mjs | 267 +++++++++++ .../slug-retained-capacity.test.mjs | 242 ++++++++++ .../tests/integration/text-object.test.mjs | 2 +- 17 files changed, 1699 insertions(+), 483 deletions(-) create mode 100644 packages/text/src/internal/raster-instance-capacity.ts create mode 100644 packages/text/tests/integration/bitmap-retained-capacity.test.mjs create mode 100644 packages/text/tests/integration/mtsdf-retained-capacity.test.mjs create mode 100644 packages/text/tests/integration/slug-retained-capacity.test.mjs diff --git a/apps/benchmarks/src/benchmark/package-sizes.test.ts b/apps/benchmarks/src/benchmark/package-sizes.test.ts index 3b56b844..93a426f2 100644 --- a/apps/benchmarks/src/benchmark/package-sizes.test.ts +++ b/apps/benchmarks/src/benchmark/package-sizes.test.ts @@ -65,7 +65,7 @@ describe('independent package-size report', () => { } }); - it('bounds the accepted coverage-capability growth from its pre-coverage baseline', () => { + it('bounds accumulated renderer growth from the pre-coverage baseline', () => { const coverageGrowth = { 'browser-core': { rawBytes: { baseline: 324_269, maximumGrowth: 16_000 }, @@ -86,10 +86,10 @@ describe('independent package-size report', () => { brotliBytes: { baseline: 173_552, maximumGrowth: 7_000 }, }, 'bitmap-runtime-js': { - rawBytes: { baseline: 361_809, maximumGrowth: 20_500 }, - minifiedBytes: { baseline: 271_005, maximumGrowth: 13_000 }, - gzipBytes: { baseline: 78_673, maximumGrowth: 2_800 }, - brotliBytes: { baseline: 60_857, maximumGrowth: 2_350 }, + 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 }, }, '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: 19_750 }, - minifiedBytes: { baseline: 275_271, maximumGrowth: 12_500 }, - gzipBytes: { baseline: 79_993, maximumGrowth: 2_800 }, - brotliBytes: { baseline: 62_081, maximumGrowth: 2_300 }, + 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 }, }, } as const; const fields = ['rawBytes', 'minifiedBytes', 'gzipBytes', 'brotliBytes'] as const; @@ -122,6 +122,32 @@ describe('independent package-size report', () => { } }); + it('bounds retained-capacity growth from the warm-publication baseline', () => { + 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 }, + }, + '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 }, + }, + '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 }, + }, + } as const; + const fields = ['rawBytes', 'minifiedBytes', 'gzipBytes', 'brotliBytes'] as const; + for (const [id, expectation] of Object.entries(retainedCapacityGrowth)) { + const entry = report.entries.find((candidate) => candidate.id === id); + expect(entry?.status).toBe('measured'); + if (entry?.status !== 'measured') throw new Error(`Missing measured size entry: ${id}`); + for (const field of fields) { + expect(entry[field] - expectation.baseline[field]).toBeLessThanOrEqual(expectation.maximumGrowth[field]); + } + } + }); + it('keeps the lazy validator out of the initial browser-core measurement', () => { const core = report.entries.find((candidate) => candidate.id === 'browser-core'); const validator = report.entries.find((candidate) => candidate.id === 'font-validator-js'); diff --git a/apps/benchmarks/src/generated/autoresearch-baseline-v0.json b/apps/benchmarks/src/generated/autoresearch-baseline-v0.json index b621ada8..c65546ec 100644 --- a/apps/benchmarks/src/generated/autoresearch-baseline-v0.json +++ b/apps/benchmarks/src/generated/autoresearch-baseline-v0.json @@ -14,7 +14,7 @@ { "id": "package-sizes", "path": "apps/benchmarks/src/generated/package-sizes.json", - "sha256": "2e7af35a43c9723209db882d67991a257a06133b3908369b12a8c9692554eb8b", + "sha256": "03038d8c419a7732e80b5ac092eab31362ac05322a207ffe5336f7d9db8bda3b", "bytes": 6825 }, { diff --git a/apps/benchmarks/src/generated/package-sizes.json b/apps/benchmarks/src/generated/package-sizes.json index da1b056d..ec40b434 100644 --- a/apps/benchmarks/src/generated/package-sizes.json +++ b/apps/benchmarks/src/generated/package-sizes.json @@ -76,33 +76,33 @@ "label": "Bitmap runtime JS graph", "status": "measured", "format": "javascript", - "sha256": "557bdc43359c60abf24af30464e07185d0c291d1e2ff3fd3e8ec131c85e5364b", - "rawBytes": 382060, - "minifiedBytes": 283898, - "gzipBytes": 81435, - "brotliBytes": 63146 + "sha256": "4298138fcc2ff76e028a7e00489c32c62704706d5d769127c70105ed56e20049", + "rawBytes": 387215, + "minifiedBytes": 286677, + "gzipBytes": 82062, + "brotliBytes": 63744 }, { "id": "mtsdf-runtime-js", "label": "MTSDF runtime JS graph", "status": "measured", "format": "javascript", - "sha256": "0bf8890daaf4e203ea8bee82a278a504055738525c1b7be26280c91e77caf131", - "rawBytes": 389761, - "minifiedBytes": 287629, - "gzipBytes": 82721, - "brotliBytes": 64286 + "sha256": "1b978b5594e963e8fae7323ea8bcbfe3d1fab7b2fd108b4e6e83ef44cb46483b", + "rawBytes": 395799, + "minifiedBytes": 290777, + "gzipBytes": 83499, + "brotliBytes": 65048 }, { "id": "slug-runtime-js", "label": "Slug runtime JS graph", "status": "measured", "format": "javascript", - "sha256": "6fd4b4c4350ad3085c1d29546de66c3961bb460a9d77bbe854155b6e1f559cd3", - "rawBytes": 390276, - "minifiedBytes": 286600, - "gzipBytes": 82730, - "brotliBytes": 64271 + "sha256": "8ecdc71de5ff5db1e864e12c460ca9836dc39ae90a8e0a6bd9c5ddfc58a95b0f", + "rawBytes": 399585, + "minifiedBytes": 291576, + "gzipBytes": 83968, + "brotliBytes": 65475 }, { "id": "bitmap-baker-wasm", diff --git a/docs/log.md b/docs/log.md index 8f1b56a5..f9e6a52b 100644 --- a/docs/log.md +++ b/docs/log.md @@ -2,6 +2,7 @@ ## 2026-08-01 +- **Bounded retained raster capacity** — Completed roadmap item 10.3 by giving Bitmap, MTSDF, and Slug deterministic 25% instance slack capped at 256 glyphs, separate logical draw counts, complete in-place parallel-field replacement, transactional overflow/topology replacement, and shared 32-instance dirty buckets with an eight-range full-upload fallback. Unconsumed Three.js update ranges carry into later stages. Focused tests prove arbitrary glyph replacement, shrink, exact-capacity growth, retained object/buffer identity, abort preservation, overflow disposal, Bitmap/Slug page-run changes, and color-only preservation; existing Text and Slug lifecycle suites remain green. Browser core, every baker host, and every Wasm artifact remain byte-identical. Optional Bitmap, MTSDF, and Slug closures grow by 5,155/2,779/627/598, 6,038/3,148/778/762, and 9,309/4,976/1,238/1,204 raw/minified/gzip/Brotli bytes, respectively, without changing existing absolute budgets. - **Warm Presentation publication and continuity** — Published resident `Text` replacements from the Three.js matrix-update lifecycle and removed Icon Grid's hide-and-await recycling path: pooled tiles retain their old complete glyph until one coherent warm assignment is staged and published. Ordinary Text Ladder playback keeps visible content while the timed demo alone owns its authored off-screen exit. The persistent host now ignores identical resize requests and keeps its provider-owned canvas attached across replacement-effect handoffs; benchmark DPR no longer keys a subtree remount. Split the DPR-2 evidence lanes so headless pixel sampling cannot flash a visible GPU canvas while the headful timed demo runs without screenshots. Bitmap, MTSDF, and Slug each complete all seven sequential workloads with visible pixels and one renderer; the headful timed sequence completes at 60.02 Icon Grid FPS, returns to Off-axis / 3D, and emits no warnings. A focused regression hides the zero-vertex screen grid during the temporary 1×1 pre-layout surface instead of submitting an empty WebGPU draw. - **Lifecycle-owned warm publication** — Added synchronous cache peeks for registered font loads, initialized shapers, and current decoded raster resources. Resident `Text` updates now shape, lay out, plan paint, and stage without crossing a Promise boundary, retain the previous generation until Three.js object traversal, and publish before raster children. React Suspense continues to own genuinely cold preparation; the React integration performs no consumer `ready` wait and explicitly invalidates its R3F root after core updates. Asynchronous plugin preparation is carried forward under one abort controller rather than probed and restarted. Adversarial findings led to shared staging logic, paint-only layout/page reuse, abort propagation across multi-font preparation, valid replacement preservation after superseded-font disposal, both Three matrix-update entry points, and defensive sibling traversal after a plugin violates the infallible-commit contract. The complete browser conformance lane proves 3/3 exact R3F reconciliations through explicit renderer traversal and 3/3 exact 68-frame Advanced Shaping timelines with five cold observations, 63 warm lifecycle publications, and zero warm readiness waits per sample; the packed consumer remains exact. Against the preceding transaction layer, browser core grows by 8,070 raw / 5,005 minified / 966 gzip / 795 Brotli bytes; Bitmap, MTSDF, and Slug runtime closures grow by 8,064/4,998/1,055/755, 8,064/4,998/1,065/808, and 8,064/4,997/973/657 bytes respectively. Baker hosts and Wasm remain byte-identical. - **Renderer-neutral raster transaction** — Started Milestone 10 by replacing the optional Bitmap-only retained-update seam plus separate build/repaint methods with one required `stageBatch` lifecycle for every raster. The portable batch contract no longer imports Three.js; the Three-backed `Text` adapter validates attachment separately. Bitmap, MTSDF, and Slug now share stage/commit/abort ownership, and focused evidence proves success, injected failure, stale abort, idempotent stage transitions, and preservation of the live scene. Against the closed Milestone 6/8 head, browser core grows by 1,747 raw / 1,053 minified / 207 gzip / 144 Brotli bytes; Bitmap, MTSDF, and Slug runtime closures grow by 2,100/1,253/261/253, 2,783/1,524/345/272, and 2,102/1,256/274/274 bytes respectively. Baker hosts and Wasm remain byte-identical. Only the browser-core raw/minified absolute ceilings and the pre-coverage caps for closures containing the new lifecycle advance; compressed absolute ceilings remain unchanged. diff --git a/docs/packages/benchmarks.md b/docs/packages/benchmarks.md index 5f108020..477af47d 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:e7c99c8d10158636b5eb15a053e1d34ff2f16fa19470e06396a1b46f61897332' +source_digest: 'sha256:87815fb10ee113959affd493fa5f766e82e9693adb9faa04b92cdae54d81f19c' tags: [package, benchmarks, react, vite, product-e2e] sources: - id: manifest @@ -64,7 +64,7 @@ sources: title: Realtime comparison product probe generated: by: openai-codex/gpt-5.6 - at: '2026-08-01T15:28:00Z' + at: '2026-08-01T20:19:48Z' --- # Package reference: `@pmndrs/text-benchmarks` @@ -187,7 +187,7 @@ The browser product also carries the React 19 subpath proofs. A shared registry The initial deterministic browser probe is admitted with a checked-in record: 100 executions across 10 fresh GPU-friendly Chromium/Vite lifecycles, zero retries/failures, unique causal completion identities, and wrong-expectation plus withheld-completion negative controls. Probe exit status and every parsed lifecycle/environment field are validated before publication. Browser scripts navigate only through DOM readiness and then wait on the product's own completion promise or visible state; they do not use network-idle heuristics. Exact contract comparison rejects non-finite numbers, exotic objects, key-order differences, and missing or additional fields without JSON coercion. The current live probe executes the exact TSL graph on asserted WebGPU and forced WebGL2 backends before paragraph measurement, positioned-layout, bidi/policy, CJK, and mobile Playwright flows. This proves a real GPU shader workload while reserving the rendered-font claim for item 6.1. -The final lifecycle remediation revises the reviewed browser-core raw ceiling from 330,000 to 331,000 bytes. Independent pre-coverage caps advance only for browser core and the Bitmap/MTSDF runtime closures that contain it, retaining 13–120 bytes of headroom over the current values. Every absolute minified/compressed budget and every baker-host and Wasm ceiling remains unchanged. +Roadmap item 10.3 leaves browser core, every baker host, and every Wasm artifact byte-identical. Relative to the warm-publication baseline, bounded retained capacity adds 5,155 raw / 2,779 minified / 627 gzip / 598 Brotli bytes to the optional Bitmap closure, 6,038 / 3,148 / 778 / 762 to MTSDF, and 9,309 / 4,976 / 1,238 / 1,204 to Slug. A dedicated regression bounds those increments independently from the accumulated pre-coverage baseline. All three remain below the existing absolute 425,000 raw / 325,000 minified / 95,000 gzip / 75,000 Brotli renderer ceilings, so no absolute runtime, baker-host, or Wasm budget changes. ## Package scripts diff --git a/docs/packages/text.md b/docs/packages/text.md index df65c3a5..4a154ac0 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:623f7e44f9e9b08ecf6f6188ae46f55ebddc9fb8efc500fd01bc70388c00dc88' +source_digest: 'sha256:053fb16ebac9880b1e5c64c499692e0c034ba30a955a3d2de588d3618d0195ab' tags: [package, public-api, typescript, contracts] sources: - id: manifest @@ -142,7 +142,7 @@ sources: title: Unicode analysis implementation generated: by: openai-codex/gpt-5.6 - at: '2026-08-01T15:28:00Z' + at: '2026-08-01T20:19:48Z' --- # Package reference: `@pmndrs/text` @@ -153,7 +153,7 @@ Slug V0 renders fill and opacity and rejects outline or shadow paint before batc This package owns the accepted public core and React contract types. Its fixtures prove literal font and raster inference, capability composition, source/baked input rules, paragraph constraints, React prop derivation, lazy raster and `useFont` inference, and invalid combinations at compile time. React and React Three Fiber remain optional peer capabilities and are not reachable from the core entry point. Three.js-facing runtime values and types resolve through the public `three/webgpu` and `three/tsl` subpaths rather than the legacy root or internal source exports, matching the renderer boundary used by first-party raster work; package lint rejects those forbidden imports. Public raster-baker descriptors are constrained to `JsonValue` while preserving their exact inferred shape. Plugin-produced values are still revalidated during their unavoidable RFC 8785 canonicalization pass: exotic prototypes, cycles, excessive nesting, non-finite numbers, invalid Unicode, and non-JSON values cannot collide with a valid raster identity, while repeated non-cyclic references remain legal. Project plans resolve each descriptor and `rasterKey` once, then carry that same pair through ordering, packaging, and baking so a stateful plugin cannot make identity drift within one bake. -Every `Text` generation now uses one required renderer-neutral raster transaction. `stageBatch` receives the prior compatible batch when one exists and returns an unpublished target plus synchronous `commit` and idempotent `abort`; there is no separate build, optional retained-update, or repaint-mutation contract. A candidate publishes only after every participating raster stages successfully. Failure, cancellation, or stale completion aborts staging without touching the live generation, while commit transfers exact batch ownership atomically. The portable batch surface owns only idempotent disposal and imports no Three.js type; the Three-backed `Text` adapter separately validates and attaches each target object. Bitmap retains the prior target only for the same resource, strike, ordered glyph sequence, and atlas-page run topology. Authoritative reshaping and paragraph layout still produce the new origins and quad sizes; retained dynamic attributes upload only changed origin, size, or color ranges while UVs, textures, materials, geometry, and draw objects remain stable. Strike or topology changes stage a correctness-preserving replacement.[^bitmap-identity] +Every `Text` generation now uses one required renderer-neutral raster transaction. `stageBatch` receives the prior compatible batch when one exists and returns an unpublished target plus synchronous `commit` and idempotent `abort`; there is no separate build, optional retained-update, or repaint-mutation contract. A candidate publishes only after every participating raster stages successfully. Failure, cancellation, or stale completion aborts staging without touching the live generation, while commit transfers exact batch ownership atomically. The portable batch surface owns only idempotent disposal and imports no Three.js type; the Three-backed `Text` adapter separately validates and attaches each target object. Bitmap, MTSDF, and Slug allocate deterministic 25% instance slack capped at 256 glyphs, track logical draw count independently, and retain geometry, material, texture, attribute, and backing-array identity while arbitrary replacement glyphs fit. Bitmap updates origin, size, UV, and color fields; MTSDF updates its complete 28-float instance record; Slug updates both its 17-float and six-integer records. Shrinks and exact-capacity growth update authoritative `instanceCount`, while overflow, strike changes, or incompatible ordered Bitmap/Slug page-run topology stage a correctness-preserving replacement. Dirty instances coalesce through 32-instance buckets and fall back to one logical full-range upload above eight disjoint ranges; unconsumed Three.js ranges carry into the next stage so a later commit cannot lose an earlier GPU upload.[^bitmap-identity][^mtsdf-contract][^slug-runtime] Milestone 9 introduces the fixed Slug V0 identity and standalone artifact-validation boundary.[^slug-contract][^slug-validator] The validator layers the pinned Khronos and byte-identical extension schemas over exact 40-byte dense records, exclusive buffer-view ownership, lossless native RGBA16F KTX2 curve pages, R32UI header grids, R16UI reference grids, checked page-relative addressing, authenticated external resources, and bounded GPU residency. Malformed identity, record, address, padding, KTX2 descriptor, integer-grid tail, external hash, and residency cases are named negative controls. That boundary feeds the package-owned Slug baker, registered-raster loader, analytic runtime, and framework-neutral public `Text` path described below. The retained all-external public-loader framebuffer gate and performance-review packets close Milestone 9; additional Slug tuning remains future measured research rather than unfinished renderer integration. diff --git a/docs/planning/api-shapes.md b/docs/planning/api-shapes.md index a1a5c677..9cbc7a91 100644 --- a/docs/planning/api-shapes.md +++ b/docs/planning/api-shapes.md @@ -19,7 +19,7 @@ sources: generated: by: openai-codex/gpt-5.6 - at: '2026-08-01T05:44:56Z' + at: '2026-08-01T20:19:48Z' --- # Runtime and bake API fixture V0 @@ -1164,6 +1164,8 @@ The raster module does not statically import its baker. Its optional `runtimeBak Core resolves root/span paint into a palette and a per-glyph `paintIndices` array by mapping shaped clusters back to source spans. Paint never enters paragraph measurement. Core invokes required `stageBatch` once for each `(fontSlot, raster resource)` represented in the paragraph and supplies both the previous compatible batch, when present, and the complete next `GlyphPaint`. A module MUST emit only glyphs whose `glyphFontSlots` equal the supplied slot. It performs every fallible validation and allocation while staging without mutating the previous batch. Only after every participant stages successfully does core call the synchronous infallible commits and transfer target ownership; failure, cancellation, or stale completion aborts every stage and preserves the live generation. The target may be the previous batch for a retained update or a replacement batch for overflow or incompatible topology. Optional synchronous `validatePaint` remains an early public-input rejection seam, not a second mutation path. This makes span fonts and future fallback fonts compatible with one non-generic `ParagraphLayout`, including paragraphs whose slots select different raster modules; raster code never interprets another font's local glyph IDs. Bitmap V0 accepts fill and opacity but rejects outline and shadow; Milestone 8's MTSDF module owns those distance-based effects. +First-party adapters allocate bounded deterministic instance slack and keep logical glyph count separate from capacity. A retained stage may replace every glyph identity and parallel field without replacing its batch, geometry, material, texture, attribute, or backing array when the compatible ordered page-run topology fits. Shrink and growth publish an authoritative draw count; overflow and incompatible Bitmap or Slug page-run topology return a replacement stage. Dirty instances are bucketed into bounded upload ranges, with fragmented updates falling back to one logical full-range upload. Pending Three.js upload ranges are part of the live adapter state and must be carried into a later stage until the renderer consumes them. This is per-batch storage reuse, not renderer-wide batching across independent `Text` objects. + The Node host receives explicit `RasterBakePlan` values and imports no unselected baker. Matching literal kinds make incorrect pairings visible to TypeScript without merging runtime and Node dependency graphs. External page packaging produces one companion index artifact plus deterministic `raster-page` artifacts whose IDs become relative URIs in the page directory; runtime fallback may request embedded pages while using the same generator and records. Raster-specific descriptor fields do not appear in core. Shader systems also do not appear here: first-party packages use TSL internally, while external packages may use TypeGPU or another implementation. ## Type-contract fixtures diff --git a/docs/planning/decision-register.md b/docs/planning/decision-register.md index f315097e..7c084483 100644 --- a/docs/planning/decision-register.md +++ b/docs/planning/decision-register.md @@ -31,7 +31,7 @@ sources: generated: by: openai-codex/gpt-5.6 - at: '2026-07-30T20:09:20Z' + at: '2026-08-01T20:19:48Z' --- # Decision register @@ -178,6 +178,7 @@ The [architecture](architecture.md) owns loading behavior and dependency rules. | D-117 | Each benchmark route owns one persistent render host per backend generation. The host owns the canvas, renderer, animation loop, GPU timing, telemetry history, viewport, and serialized scene/job lifecycle. React Suspense owns cold asset readiness; scene, technique, delivery, and font selections preload and commit with React transitions so the last complete scene remains visible until an atomic replacement is ready. Compatible font changes retain the active `Text` objects and registry. | Accepted | | D-118 | Milestone 10 replaces `buildBatches`, optional retained updates, and separate repaint mutation with one required renderer-neutral `stageBatch(previous, layout, resource, fontSlot, paint, rasterPixelRatio)` transaction. A stage owns one unpublished target batch, may retain or replace the previous batch, cannot mutate committed state before publication, commits synchronously and infallibly, and aborts idempotently; committed batch disposal is also idempotent. The portable contract exposes no Three.js, TSL, WebGPU, WebGL, or first-party raster-kind union. Three.js object attachment is an adapter requirement enforced by `Text`, not part of `RasterDrawBatch`. | Accepted | | D-119 | Once a font, shared shaper, decoded raster resource, and layout-required raster pages are resident, `Text.setProperties` shapes, lays out, plans paint, and stages synchronously while retaining the previous complete generation. The Three.js adapter publishes that candidate at the start of `updateMatrixWorld` or `updateWorldMatrix`, before child traversal; the React adapter explicitly invalidates its R3F root after a core-property update. `ready` remains an observation channel for cold work and queued publication, not a consumer coordination requirement for warm React updates. Synchronous validation, shaping, preparation, or staging faults throw from `setProperties` without cancelling an earlier candidate or live generation; asynchronous preparation and defensive commit-contract faults reject `ready`. A raster `prepare` implementation returns `void` when its requirement is resident and one shared idempotent Promise only for genuinely cold work. | Accepted | +| D-120 | First-party raster batches allocate deterministic 25% glyph-instance slack capped at 256 instances and track logical count separately from capacity. Bitmap, MTSDF, and Slug retain their complete parallel instance records when compatible content fits; shrinks and exact-capacity growth publish authoritative draw counts, while overflow and incompatible ordered Bitmap/Slug page-run topology replace transactionally. Dirty uploads use 32-instance buckets, at most eight disjoint ranges, and a logical full-range fallback; pending renderer ranges carry forward until consumed. This reuse is per batch and does not introduce automatic batching across independent `Text` objects. | Accepted | 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/roadmap/roadmap.md b/docs/roadmap/roadmap.md index cf9c2805..94e5b766 100644 --- a/docs/roadmap/roadmap.md +++ b/docs/roadmap/roadmap.md @@ -120,8 +120,8 @@ These rows replace the former separate backlog. Each is intended to become one f | 9.4 | ✅ | Reproduce the applicable prior-fork performance baseline, evaluate retained challengers, and close payload, residency, frame-time, and bundle-isolation gates. | XL | 9.3 | | 10.1 | ✅ | Replace the optional Three-shaped plugin seam with one required renderer-neutral transactional raster lifecycle and retain Three.js as an adapter. | L | 8.6, 9.4 | | 10.2 | ✅ | Publish warm shaping, layout, paint planning, and raster staging through the Three.js object-update lifecycle without consumer `ready` waits. | L | 10.1 | -| 10.3 | 🟡 | Add bounded glyph-capacity slack, complete in-place field replacement, authoritative shrink counts, overflow replacement, and coalesced dirty uploads to all three rasters. | XL | 10.2 | -| 10.4 | ⬜ | Prove the public extension boundary with a private workspace raster/baker package that owns a new kind, artifact, adapter, retained updates, overflow, abort, and disposal. | L | 10.1, 10.3 | +| 10.3 | ✅ | Add bounded glyph-capacity slack, complete in-place field replacement, authoritative shrink counts, overflow replacement, and coalesced dirty uploads to all three rasters. | XL | 10.2 | +| 10.4 | 🟡 | Prove the public extension boundary with a private workspace raster/baker package that owns a new kind, artifact, adapter, retained updates, overflow, abort, and disposal. | L | 10.1, 10.3 | | 10.5 | ⬜ | Remove benchmark recycling workarounds and prove Icon Grid plus every Presentation workload through sequential, timed, allocation, cadence, dual-backend, and React Doctor gates. | XL | 10.2–10.4 | | 10.6 | ⬜ | Complete raster switching, release conformance, public API review, recommendations, migration material, package-size evidence, and signed stacked delivery. | L | 10.5 | @@ -684,7 +684,7 @@ Milestone 9 is closed. Additional Slug optimization hypotheses are future measur ## Milestone 10 — harden the first shippable release -Item 10.3 is active. Item 10.1 established the required renderer-neutral transaction and Three.js adapter parity, and item 10.2 moved resident shaping, layout, paint planning, raster staging, and atomic publication into the Three.js object-update lifecycle without warm consumer readiness waits. The remaining accepted order is bounded retained capacity, the external raster/baker proof, benchmark cleanup and hardware evidence, then release review. Each layer must remain independently green; renderer-wide batching across separate `Text` objects is explicitly not part of this milestone. +Item 10.4 is active. Item 10.1 established the required renderer-neutral transaction and Three.js adapter parity, item 10.2 moved resident shaping, layout, paint planning, raster staging, and atomic publication into the Three.js object-update lifecycle without warm consumer readiness waits, and item 10.3 added bounded retained instance capacity to all three first-party rasters. The remaining accepted order is the external raster/baker proof, benchmark cleanup and hardware evidence, then release review. Each layer must remain independently green; renderer-wide batching across separate `Text` objects is explicitly not part of this milestone. ### 10.1–10.6 closure checklist diff --git a/packages/text/src/internal/raster-instance-capacity.ts b/packages/text/src/internal/raster-instance-capacity.ts new file mode 100644 index 00000000..63c7a713 --- /dev/null +++ b/packages/text/src/internal/raster-instance-capacity.ts @@ -0,0 +1,109 @@ +const DIRTY_BUCKET_SIZE = 32; +const MAX_DIRTY_RANGES = 8; + +export interface RasterComponentRange { + readonly start: number; + readonly count: number; +} + +/** Allocate bounded headroom for a non-empty retained instance buffer. */ +export function rasterInstanceCapacity(required: number): number { + assertCount(required, 'required instance count'); + if (required === 0) return 0; + const capacity = required + Math.min(Math.max(1, Math.ceil(required / 4)), 256); + if (!Number.isSafeInteger(capacity)) throw new RangeError('retained instance capacity exceeds safe integer range'); + return capacity; +} + +/** Convert dirty logical instances into bounded component upload ranges. */ +export function coalesceRasterInstanceRanges( + dirtyInstances: readonly number[], + logicalCount: number, + componentStride: number, +): readonly RasterComponentRange[] { + assertCount(logicalCount, 'logical instance count'); + if (!Number.isSafeInteger(componentStride) || componentStride < 1) { + throw new RangeError('instance component stride must be a positive safe integer'); + } + if (logicalCount === 0 || dirtyInstances.length === 0) return []; + + const bucketCount = Math.ceil(logicalCount / DIRTY_BUCKET_SIZE); + const dirtyBuckets = new Uint8Array(bucketCount); + for (const instance of dirtyInstances) { + if (!Number.isSafeInteger(instance) || instance < 0 || instance >= logicalCount) { + throw new RangeError('dirty instance lies outside the logical instance range'); + } + dirtyBuckets[Math.floor(instance / DIRTY_BUCKET_SIZE)] = 1; + } + + const ranges: RasterComponentRange[] = []; + for (let bucket = 0; bucket < bucketCount; bucket += 1) { + if (dirtyBuckets[bucket] === 0) continue; + const firstBucket = bucket; + while (bucket + 1 < bucketCount && dirtyBuckets[bucket + 1] !== 0) bucket += 1; + const firstInstance = firstBucket * DIRTY_BUCKET_SIZE; + const lastInstance = Math.min((bucket + 1) * DIRTY_BUCKET_SIZE, logicalCount); + ranges.push({ + start: firstInstance * componentStride, + count: (lastInstance - firstInstance) * componentStride, + }); + } + + if (ranges.length > MAX_DIRTY_RANGES) { + return [{ start: 0, count: logicalCount * componentStride }]; + } + return ranges; +} + +/** Recover logical instances whose prior upload ranges have not yet reached the GPU. */ +export function pendingRasterDirtyInstances( + ranges: readonly RasterComponentRange[], + logicalCount: number, + componentStride: number, +): number[] { + assertCount(logicalCount, 'logical instance count'); + if (!Number.isSafeInteger(componentStride) || componentStride < 1) { + throw new RangeError('instance component stride must be a positive safe integer'); + } + const dirty: number[] = []; + for (const range of ranges) { + const first = Math.floor(range.start / componentStride); + const last = Math.min(logicalCount, Math.ceil((range.start + range.count) / componentStride)); + for (let instance = Math.max(0, first); instance < last; instance += 1) dirty.push(instance); + } + return dirty; +} + +/** Plan retained instance uploads without mutating the committed backing allocation. */ +export function rasterInstanceUpdateRanges( + liveValues: ArrayLike, + stagedValues: ArrayLike, + pendingRanges: readonly RasterComponentRange[], + previousLogicalCount: number, + logicalCount: number, + componentStride: number, +): readonly RasterComponentRange[] { + assertCount(previousLogicalCount, 'previous logical instance count'); + assertCount(logicalCount, 'logical instance count'); + if (!Number.isSafeInteger(componentStride) || componentStride < 1) { + throw new RangeError('instance component stride must be a positive safe integer'); + } + const logicalComponents = logicalCount * componentStride; + if (liveValues.length < logicalComponents || stagedValues.length !== logicalComponents) { + throw new RangeError('retained instance values do not match the logical instance range'); + } + const dirtyInstances = pendingRasterDirtyInstances(pendingRanges, logicalCount, componentStride); + for (let instance = 0; instance < logicalCount; instance += 1) { + const start = instance * componentStride; + let changed = instance >= previousLogicalCount; + for (let component = 0; component < componentStride && !changed; component += 1) { + changed = stagedValues[start + component] !== liveValues[start + component]; + } + if (changed) dirtyInstances.push(instance); + } + return coalesceRasterInstanceRanges(dirtyInstances, logicalCount, componentStride); +} + +function assertCount(value: number, label: string): void { + if (!Number.isSafeInteger(value) || value < 0) throw new RangeError(`${label} must be a non-negative safe integer`); +} diff --git a/packages/text/src/raster/bitmap.ts b/packages/text/src/raster/bitmap.ts index 3d6a9e13..2bec2747 100644 --- a/packages/text/src/raster/bitmap.ts +++ b/packages/text/src/raster/bitmap.ts @@ -21,6 +21,7 @@ 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 { rasterInstanceCapacity, rasterInstanceUpdateRanges } from '../internal/raster-instance-capacity.js'; import { ABSENT_GLYPH_PAGE, DENSE_GLYPH_RECORD_STRIDE, @@ -98,9 +99,13 @@ export interface BitmapResource { } interface BitmapBatchRun { - readonly glyphIndices: Uint32Array; + readonly capacity: number; + glyphIndices: Uint32Array; + logicalCount: number; readonly originAttribute: THREE.InstancedBufferAttribute; readonly sizeAttribute: THREE.InstancedBufferAttribute; + readonly uvOriginAttribute: THREE.InstancedBufferAttribute; + readonly uvSizeAttribute: THREE.InstancedBufferAttribute; targetOrigins?: Float32Array; readonly colorAttribute: THREE.InstancedBufferAttribute; readonly geometry: THREE.InstancedBufferGeometry; @@ -211,7 +216,7 @@ export function bitmap( export function captureBitmapGlyphPositions(object: THREE.Object3D): BitmapGlyphPositionSnapshot { const batch = presentableBitmapBatch(object); const identities = bitmapGlyphIdentities(batch.layout); - const glyphCount = batch.runs.reduce((count, run) => count + run.glyphIndices.length, 0); + const glyphCount = batch.runs.reduce((count, run) => count + run.logicalCount, 0); const fontHandles = new Uint32Array(glyphCount); const glyphIds = new Uint16Array(glyphCount); const clusters = new Uint32Array(glyphCount); @@ -221,7 +226,7 @@ export function captureBitmapGlyphPositions(object: THREE.Object3D): BitmapGlyph let outputIndex = 0; for (const run of batch.runs) { const displayedOrigins = run.originAttribute.array as Float32Array; - for (let instance = 0; instance < run.glyphIndices.length; instance += 1) { + for (let instance = 0; instance < run.logicalCount; instance += 1) { const glyphIndex = run.glyphIndices[instance]!; fontHandles[outputIndex] = identities.fontHandles[glyphIndex]!; glyphIds[outputIndex] = batch.layout.glyphIds[glyphIndex]!; @@ -260,10 +265,10 @@ export function createBitmapGlyphPositionTransition( let targetGlyphs = 0; for (const run of batch.runs) { const displayedOrigins = run.originAttribute.array as Float32Array; - const targetOrigins = run.targetOrigins ?? displayedOrigins.slice(); + const targetOrigins = run.targetOrigins ?? displayedOrigins.slice(0, run.logicalCount * 2); run.targetOrigins = targetOrigins; const fromOrigins = targetOrigins.slice(); - for (let instance = 0; instance < run.glyphIndices.length; instance += 1) { + for (let instance = 0; instance < run.logicalCount; instance += 1) { const glyphIndex = run.glyphIndices[instance]!; const key = bitmapGlyphIdentityKey( identities.fontHandles[glyphIndex]!, @@ -300,10 +305,12 @@ export function createBitmapGlyphPositionTransition( const fromOrigins = fromOriginsByRun[runIndex]!; const targetOrigins = targetOriginsByRun[runIndex]!; const displayedOrigins = run.originAttribute.array as Float32Array; - for (let offset = 0; offset < displayedOrigins.length; offset += 1) { + for (let offset = 0; offset < run.logicalCount * 2; offset += 1) { const start = fromOrigins[offset]!; displayedOrigins[offset] = start + (targetOrigins[offset]! - start) * nextProgress; } + run.originAttribute.clearUpdateRanges(); + run.originAttribute.addUpdateRange(0, run.logicalCount * 2); run.originAttribute.needsUpdate = true; } progress = nextProgress; @@ -522,40 +529,12 @@ function buildBitmapBatches( assertParallelRasterLayout(layout, paint); assertRasterCoverage(layout, fontSlot, resource.coverage, BITMAP_KIND); const strike = selectBitmapStrike(resource.strikes, layout, fontSlot, rasterPixelRatio); - const records = new DataView(strike.records.buffer, strike.records.byteOffset, strike.records.byteLength); const group = new THREE.Group(); - const runs: BitmapBatchRun[] = []; - let glyphCount = 0; - let pendingPage = -1; - let pendingGlyphs: number[] = []; - - const finishRun = (): void => { - if (pendingGlyphs.length === 0) return; - const page = strike.pages[pendingPage]; - if (page === undefined) throw new TypeError('bitmap batch references a missing page'); - const run = createBitmapRun(layout, strike, page, pendingGlyphs, paint); - runs.push(run); + const runs = collectBitmapRunPlans(layout, strike, fontSlot).map(({ page, glyphIndices }) => { + const run = createBitmapRun(layout, strike, page, glyphIndices, paint); group.add(run.mesh); - glyphCount += pendingGlyphs.length; - pendingGlyphs = []; - }; - - for (let glyphIndex = 0; glyphIndex < layout.glyphIds.length; glyphIndex += 1) { - if (layout.glyphFontSlots[glyphIndex] !== fontSlot) continue; - const glyphId = layout.glyphIds[glyphIndex]; - if (glyphId === undefined) continue; - if (glyphId >= strike.records.byteLength / RECORD_STRIDE) { - throw new TypeError('paragraph layout references a bitmap glyph outside the registered font'); - } - const pageIndex = records.getUint16(glyphId * RECORD_STRIDE + 16, true); - if (pageIndex === ABSENT_PAGE) continue; - if (pendingPage !== pageIndex) { - finishRun(); - pendingPage = pageIndex; - } - pendingGlyphs.push(glyphIndex); - } - finishRun(); + return run; + }); const presentation: PresentableBitmapBatch = { layout, @@ -570,8 +549,12 @@ function buildBitmapBatches( let disposed = false; return { object: group, - glyphCount, - drawCount: runs.length, + get glyphCount() { + return runs.reduce((count, run) => count + run.logicalCount, 0); + }, + get drawCount() { + return runs.reduce((count, run) => count + (run.logicalCount === 0 ? 0 : 1), 0); + }, strikePpem: strike.ppem, dispose() { if (disposed) return; @@ -604,69 +587,186 @@ function stageBitmapBatchUpdate( const strike = selectBitmapStrike(resource.strikes, layout, fontSlot, rasterPixelRatio); if (strike !== presentation.strike) return undefined; if (layout === presentation.layout) return stageBitmapPaintUpdate(presentation.runs, paint); + const plans = collectBitmapRunPlans(layout, strike, fontSlot); + if ( + plans.length !== presentation.runs.length || + plans.some(({ page, glyphIndices }, index) => { + const run = presentation.runs[index]; + return run === undefined || run.page !== page || glyphIndices.length > run.capacity; + }) + ) { + return undefined; + } + const staged = plans.map(({ glyphIndices, page }, index) => { + const run = presentation.runs[index]!; + return stageBitmapRunUpdate(run, glyphIndices, bitmapRunValues(layout, strike, page, glyphIndices, paint)); + }); + let disposed = false; + return { + commit() { + if (disposed) return; + disposed = true; + for (const update of staged) update.commit(); + presentation.layout = layout; + presentation.revision += 1; + }, + dispose() { + disposed = true; + for (const update of staged) update.dispose(); + }, + }; +} + +interface BitmapRunPlan { + readonly page: BitmapPageResource; + readonly glyphIndices: Uint32Array; +} + +interface BitmapRunValues { + readonly origins: Float32Array; + readonly sizes: Float32Array; + readonly uvOrigins: Float32Array; + readonly uvSizes: Float32Array; + readonly colors: Float32Array; +} + +interface BitmapAttributeUpdate { + commit(): void; + dispose(): void; +} + +function collectBitmapRunPlans( + layout: ParagraphLayout, + strike: BitmapStrikeResource, + fontSlot: number, +): readonly BitmapRunPlan[] { const records = new DataView(strike.records.buffer, strike.records.byteOffset, strike.records.byteLength); - let runIndex = 0; - let instance = 0; + const runs: BitmapRunPlan[] = []; + let pendingPage = -1; + let pendingGlyphs: number[] = []; + const finishRun = (): void => { + if (pendingGlyphs.length === 0) return; + const page = strike.pages[pendingPage]; + if (page === undefined) throw new TypeError('bitmap batch references a missing page'); + runs.push({ page, glyphIndices: Uint32Array.from(pendingGlyphs) }); + pendingGlyphs = []; + }; for (let glyphIndex = 0; glyphIndex < layout.glyphIds.length; glyphIndex += 1) { if (layout.glyphFontSlots[glyphIndex] !== fontSlot) continue; const glyphId = layout.glyphIds[glyphIndex]; - if (glyphId === undefined || glyphId >= strike.records.byteLength / RECORD_STRIDE) return undefined; + if (glyphId === undefined || glyphId >= strike.records.byteLength / RECORD_STRIDE) { + throw new TypeError('paragraph layout references a bitmap glyph outside the registered font'); + } const pageIndex = records.getUint16(glyphId * RECORD_STRIDE + 16, true); if (pageIndex === ABSENT_PAGE) continue; - const run = presentation.runs[runIndex]; - if ( - run === undefined || - run.glyphIndices[instance] !== glyphIndex || - run.page !== strike.pages[pageIndex] || - presentation.layout.glyphIds[glyphIndex] !== glyphId - ) { - return undefined; - } - instance += 1; - if (instance === run.glyphIndices.length) { - runIndex += 1; - instance = 0; + if (strike.pages[pageIndex] === undefined) throw new TypeError('bitmap batch references a missing page'); + if (pendingPage !== pageIndex) { + finishRun(); + pendingPage = pageIndex; } + pendingGlyphs.push(glyphIndex); } - if (runIndex !== presentation.runs.length || instance !== 0) return undefined; - - const staged = presentation.runs.map((run) => { - const origins = new Float32Array(run.glyphIndices.length * 2); - const sizes = new Float32Array(run.glyphIndices.length * 2); - const colors = new Float32Array(run.glyphIndices.length * 4); - for (let index = 0; index < run.glyphIndices.length; index += 1) { - const glyphIndex = run.glyphIndices[index]!; - const glyphId = layout.glyphIds[glyphIndex]!; - const record = glyphId * RECORD_STRIDE; - const scale = layout.glyphFontSizes[glyphIndex]! / strike.planeUnitsPerEm; - const planeLeft = records.getInt16(record, true); - const planeBottom = records.getInt16(record + 2, true); - const planeRight = records.getInt16(record + 4, true); - const planeTop = records.getInt16(record + 6, true); - origins.set([layout.x[glyphIndex]! + planeLeft * scale, -layout.y[glyphIndex]! + planeBottom * scale], index * 2); - sizes.set([(planeRight - planeLeft) * scale, (planeTop - planeBottom) * scale], index * 2); - colors.set(resolvedGlyphColor(paint, glyphIndex), index * 4); - } - return { - run, - origins: sameFloatValues(origins, run.originAttribute.array as Float32Array) ? undefined : origins, - sizes: sameFloatValues(sizes, run.sizeAttribute.array as Float32Array) ? undefined : sizes, - colors: sameFloatValues(colors, run.colorAttribute.array as Float32Array) ? undefined : colors, - }; - }); + finishRun(); + return runs; +} + +function bitmapRunValues( + layout: ParagraphLayout, + strike: BitmapStrikeResource, + page: BitmapPageResource, + glyphIndices: Uint32Array, + paint: GlyphPaint, +): BitmapRunValues { + const origins = new Float32Array(glyphIndices.length * 2); + const sizes = new Float32Array(glyphIndices.length * 2); + const uvOrigins = new Float32Array(glyphIndices.length * 2); + const uvSizes = new Float32Array(glyphIndices.length * 2); + const colors = new Float32Array(glyphIndices.length * 4); + const records = new DataView(strike.records.buffer, strike.records.byteOffset, strike.records.byteLength); + for (let instance = 0; instance < glyphIndices.length; instance += 1) { + const glyphIndex = glyphIndices[instance]!; + const glyphId = layout.glyphIds[glyphIndex]!; + const record = glyphId * RECORD_STRIDE; + const scale = layout.glyphFontSizes[glyphIndex]! / strike.planeUnitsPerEm; + const planeLeft = records.getInt16(record, true); + const planeBottom = records.getInt16(record + 2, true); + const planeRight = records.getInt16(record + 4, true); + const planeTop = records.getInt16(record + 6, true); + const atlasLeft = records.getUint16(record + 8, true); + const atlasTop = records.getUint16(record + 10, true); + const atlasRight = records.getUint16(record + 12, true); + const atlasBottom = records.getUint16(record + 14, true); + origins.set( + [layout.x[glyphIndex]! + planeLeft * scale, -layout.y[glyphIndex]! + planeBottom * scale], + instance * 2, + ); + sizes.set([(planeRight - planeLeft) * scale, (planeTop - planeBottom) * scale], instance * 2); + uvOrigins.set([atlasLeft / page.width, 1 - atlasBottom / page.height], instance * 2); + uvSizes.set([(atlasRight - atlasLeft) / page.width, (atlasBottom - atlasTop) / page.height], instance * 2); + colors.set(resolvedGlyphColor(paint, glyphIndex), instance * 4); + } + return { origins, sizes, uvOrigins, uvSizes, colors }; +} + +function stageBitmapRunUpdate( + run: BitmapBatchRun, + glyphIndices: Uint32Array, + values: BitmapRunValues, +): BitmapBatchUpdate { + const logicalCount = glyphIndices.length; + const attributeUpdates = [ + stageBitmapAttribute(run.originAttribute, values.origins, run.logicalCount, 2, logicalCount), + stageBitmapAttribute(run.sizeAttribute, values.sizes, run.logicalCount, 2, logicalCount), + stageBitmapAttribute(run.uvOriginAttribute, values.uvOrigins, run.logicalCount, 2, logicalCount), + stageBitmapAttribute(run.uvSizeAttribute, values.uvSizes, run.logicalCount, 2, logicalCount), + stageBitmapAttribute(run.colorAttribute, values.colors, run.logicalCount, 4, logicalCount), + ]; let disposed = false; return { commit() { if (disposed) return; disposed = true; - for (const update of staged) { - commitAttribute(update.run.originAttribute, update.origins); - commitAttribute(update.run.sizeAttribute, update.sizes); - commitAttribute(update.run.colorAttribute, update.colors); - delete update.run.targetOrigins; - } - presentation.layout = layout; - presentation.revision += 1; + for (const update of attributeUpdates) update.commit(); + run.glyphIndices.set(glyphIndices); + run.logicalCount = logicalCount; + run.geometry.instanceCount = logicalCount; + run.mesh.renderOrder = glyphIndices[0] ?? 0; + delete run.targetOrigins; + }, + dispose() { + if (disposed) return; + disposed = true; + for (const update of attributeUpdates) update.dispose(); + }, + }; +} + +function stageBitmapAttribute( + bufferAttribute: THREE.InstancedBufferAttribute, + values: Float32Array, + previousLogicalCount: number, + componentStride: number, + logicalCount: number, +): BitmapAttributeUpdate { + const liveValues = bufferAttribute.array as Float32Array; + const ranges = rasterInstanceUpdateRanges( + liveValues, + values, + bufferAttribute.updateRanges, + previousLogicalCount, + logicalCount, + componentStride, + ); + let disposed = false; + return { + commit() { + if (disposed) return; + disposed = true; + liveValues.set(values); + if (ranges.length === 0) return; + bufferAttribute.clearUpdateRanges(); + for (const range of ranges) bufferAttribute.addUpdateRange(range.start, range.count); + bufferAttribute.needsUpdate = true; }, dispose() { disposed = true; @@ -676,40 +776,27 @@ function stageBitmapBatchUpdate( function stageBitmapPaintUpdate(runs: readonly BitmapBatchRun[], paint: GlyphPaint): BitmapBatchUpdate { const staged = runs.map((run) => { - const colors = new Float32Array(run.glyphIndices.length * 4); - for (let instance = 0; instance < run.glyphIndices.length; instance += 1) { + const colors = new Float32Array(run.logicalCount * 4); + for (let instance = 0; instance < run.logicalCount; instance += 1) { colors.set(resolvedGlyphColor(paint, run.glyphIndices[instance]!), instance * 4); } - return { - attribute: run.colorAttribute, - colors: sameFloatValues(colors, run.colorAttribute.array as Float32Array) ? undefined : colors, - }; + return stageBitmapAttribute(run.colorAttribute, colors, run.logicalCount, 4, run.logicalCount); }); let disposed = false; return { commit() { if (disposed) return; disposed = true; - for (const update of staged) commitAttribute(update.attribute, update.colors); + for (const update of staged) update.commit(); }, dispose() { + if (disposed) return; disposed = true; + for (const update of staged) update.dispose(); }, }; } -function sameFloatValues(left: Float32Array, right: Float32Array): boolean { - return left.length === right.length && left.every((value, index) => value === right[index]); -} - -function commitAttribute(bufferAttribute: THREE.InstancedBufferAttribute, values: Float32Array | undefined): void { - if (values === undefined) return; - (bufferAttribute.array as Float32Array).set(values); - bufferAttribute.clearUpdateRanges(); - bufferAttribute.addUpdateRange(0, values.length); - bufferAttribute.needsUpdate = true; -} - function selectBitmapStrike( strikes: readonly BitmapStrikeResource[], layout: ParagraphLayout, @@ -729,38 +816,22 @@ function createBitmapRun( layout: ParagraphLayout, strike: BitmapStrikeResource, page: BitmapPageResource, - glyphIndices: readonly number[], + glyphIndices: Uint32Array, paint: GlyphPaint, ): BitmapBatchRun { const count = glyphIndices.length; - const origins = new Float32Array(count * 2); - const sizes = new Float32Array(count * 2); - const uvOrigins = new Float32Array(count * 2); - const uvSizes = new Float32Array(count * 2); - const colors = new Float32Array(count * 4); - const records = new DataView(strike.records.buffer, strike.records.byteOffset, strike.records.byteLength); - for (let instance = 0; instance < count; instance += 1) { - const glyphIndex = glyphIndices[instance]!; - const glyphId = layout.glyphIds[glyphIndex]!; - const record = glyphId * RECORD_STRIDE; - const scale = layout.glyphFontSizes[glyphIndex]! / strike.planeUnitsPerEm; - const planeLeft = records.getInt16(record, true); - const planeBottom = records.getInt16(record + 2, true); - const planeRight = records.getInt16(record + 4, true); - const planeTop = records.getInt16(record + 6, true); - const atlasLeft = records.getUint16(record + 8, true); - const atlasTop = records.getUint16(record + 10, true); - const atlasRight = records.getUint16(record + 12, true); - const atlasBottom = records.getUint16(record + 14, true); - origins.set( - [layout.x[glyphIndex]! + planeLeft * scale, -layout.y[glyphIndex]! + planeBottom * scale], - instance * 2, - ); - sizes.set([(planeRight - planeLeft) * scale, (planeTop - planeBottom) * scale], instance * 2); - uvOrigins.set([atlasLeft / page.width, 1 - atlasBottom / page.height], instance * 2); - uvSizes.set([(atlasRight - atlasLeft) / page.width, (atlasBottom - atlasTop) / page.height], instance * 2); - colors.set(resolvedGlyphColor(paint, glyphIndex), instance * 4); - } + const capacity = rasterInstanceCapacity(count); + const origins = new Float32Array(capacity * 2); + const sizes = new Float32Array(capacity * 2); + const uvOrigins = new Float32Array(capacity * 2); + const uvSizes = new Float32Array(capacity * 2); + const colors = new Float32Array(capacity * 4); + const values = bitmapRunValues(layout, strike, page, glyphIndices, paint); + origins.set(values.origins); + sizes.set(values.sizes); + uvOrigins.set(values.uvOrigins); + uvSizes.set(values.uvSizes); + colors.set(values.colors); const geometry = unitRasterQuadGeometry(); geometry.instanceCount = count; @@ -768,18 +839,26 @@ function createBitmapRun( geometry.setAttribute('bitmapOrigin', originAttribute); const sizeAttribute = new THREE.InstancedBufferAttribute(sizes, 2).setUsage(THREE.DynamicDrawUsage); geometry.setAttribute('bitmapSize', sizeAttribute); - geometry.setAttribute('bitmapUvOrigin', new THREE.InstancedBufferAttribute(uvOrigins, 2)); - geometry.setAttribute('bitmapUvSize', new THREE.InstancedBufferAttribute(uvSizes, 2)); + const uvOriginAttribute = new THREE.InstancedBufferAttribute(uvOrigins, 2).setUsage(THREE.DynamicDrawUsage); + geometry.setAttribute('bitmapUvOrigin', uvOriginAttribute); + const uvSizeAttribute = new THREE.InstancedBufferAttribute(uvSizes, 2).setUsage(THREE.DynamicDrawUsage); + geometry.setAttribute('bitmapUvSize', uvSizeAttribute); const colorAttribute = new THREE.InstancedBufferAttribute(colors, 4).setUsage(THREE.DynamicDrawUsage); geometry.setAttribute('bitmapColor', colorAttribute); const material = bitmapMaterial(page.texture); const mesh = new THREE.Mesh(geometry, material); mesh.frustumCulled = false; mesh.renderOrder = glyphIndices[0] ?? 0; + const retainedGlyphIndices = new Uint32Array(capacity); + retainedGlyphIndices.set(glyphIndices); return { - glyphIndices: Uint32Array.from(glyphIndices), + capacity, + glyphIndices: retainedGlyphIndices, + logicalCount: count, originAttribute, sizeAttribute, + uvOriginAttribute, + uvSizeAttribute, colorAttribute, geometry, mesh, diff --git a/packages/text/src/raster/msdf.ts b/packages/text/src/raster/msdf.ts index a09b3be1..54fdffa7 100644 --- a/packages/text/src/raster/msdf.ts +++ b/packages/text/src/raster/msdf.ts @@ -51,6 +51,7 @@ import { assertParallelRasterPaint, unitRasterQuadGeometry, } from '../internal/raster-batch.js'; +import { rasterInstanceCapacity, rasterInstanceUpdateRanges } from '../internal/raster-instance-capacity.js'; import type { ParagraphLayout } from '../layout.js'; import type { GlyphPaint, ResolvedPaint } from '../paint.js'; import { @@ -110,20 +111,11 @@ export interface MsdfResource { } interface MsdfBatchRun { - readonly glyphIndices: Uint32Array; + readonly capacity: number; + glyphIndices: Uint32Array; + logicalCount: number; readonly instanceData: THREE.InstancedInterleavedBuffer; readonly paintStructure: Float64Array; - readonly originAttribute: THREE.InterleavedBufferAttribute; - readonly sizeAttribute: THREE.InterleavedBufferAttribute; - readonly uvOriginAttribute: THREE.InterleavedBufferAttribute; - readonly uvSizeAttribute: THREE.InterleavedBufferAttribute; - readonly uvBoundsAttribute: THREE.InterleavedBufferAttribute; - readonly shadowOffsetAttribute: THREE.InterleavedBufferAttribute; - readonly fillColorAttribute: THREE.InterleavedBufferAttribute; - readonly outlineColorAttribute: THREE.InterleavedBufferAttribute; - readonly outlineWidthAttribute: THREE.InterleavedBufferAttribute; - readonly shadowColorAttribute: THREE.InterleavedBufferAttribute; - readonly pageIndexAttribute: THREE.InterleavedBufferAttribute; readonly geometry: THREE.InstancedBufferGeometry; readonly mesh: THREE.Mesh; } @@ -140,15 +132,14 @@ interface MsdfMaterialState { } const materialByAtlasTexture = new WeakMap(); -const batchContext = new WeakMap< - MsdfDrawBatch, - { - readonly layout: ParagraphLayout; - readonly resource: MsdfResource; - readonly fontSlot: number; - readonly run: MsdfBatchRun | undefined; - } ->(); +interface MsdfBatchContext { + layout: ParagraphLayout; + readonly resource: MsdfResource; + readonly fontSlot: number; + readonly run: MsdfBatchRun | undefined; +} + +const batchContext = new WeakMap(); const INSTANCE_STRIDE = 28; const INSTANCE_OFFSETS = { @@ -164,6 +155,7 @@ const INSTANCE_OFFSETS = { shadowColor: 23, pageIndex: 27, } as const; +const PAINT_STRUCTURE_STRIDE = 3; const msdfModule: RasterModule = defineRaster({ kind: MSDF_KIND, @@ -183,18 +175,12 @@ const msdfModule: RasterModule commitMsdfBatchPaint(context.run, layout, resource, paint), - () => undefined, - ); + if (previous !== undefined && context?.resource === resource && context.fontSlot === fontSlot) { + const update = + context.layout === layout && sameMsdfPaintStructure(context.run, paint) + ? stageMsdfPaintUpdate(context.layout, context.run, paint) + : stageMsdfBatchUpdate(context, layout, resource, fontSlot, paint); + if (update !== undefined) return defineRasterBatchStage(previous, update.commit, update.dispose); } const batch = buildMsdfBatches(layout, resource, fontSlot, paint); return defineRasterBatchStage( @@ -377,32 +363,20 @@ function buildMsdfBatches( assertParallelRasterLayout(layout, paint); assertRasterCoverage(layout, fontSlot, resource.coverage, MSDF_KIND); assertMsdfPaint(paint); - const records = new DataView(resource.records.buffer, resource.records.byteOffset, resource.records.byteLength); const group = new THREE.Group(); - const glyphIndices: number[] = []; - - for (let glyphIndex = 0; glyphIndex < layout.glyphIds.length; glyphIndex += 1) { - if (layout.glyphFontSlots[glyphIndex] !== fontSlot) continue; - const glyphId = layout.glyphIds[glyphIndex]; - if (glyphId === undefined) continue; - if (glyphId >= resource.records.byteLength / RECORD_STRIDE) { - throw new TypeError('paragraph layout references an MTSDF glyph outside the registered font'); - } - const pageIndex = records.getUint16(glyphId * RECORD_STRIDE + 16, true); - if (pageIndex === ABSENT_PAGE) continue; - if (resource.pages[pageIndex] === undefined) { - throw new TypeError('MTSDF batch references a missing page'); - } - glyphIndices.push(glyphIndex); - } + const glyphIndices = collectMsdfGlyphIndices(layout, resource, fontSlot); const run = glyphIndices.length === 0 ? undefined : createMsdfRun(layout, resource, glyphIndices, paint); if (run !== undefined) group.add(run.mesh); let disposed = false; const batch: MsdfDrawBatch = { object: group, - glyphCount: glyphIndices.length, - drawCount: run === undefined ? 0 : 1, + get glyphCount() { + return run?.logicalCount ?? 0; + }, + get drawCount() { + return run === undefined || run.logicalCount === 0 ? 0 : 1; + }, dispose() { if (disposed) return; disposed = true; @@ -418,74 +392,44 @@ function buildMsdfBatches( function createMsdfRun( layout: ParagraphLayout, resource: MsdfResource, - glyphIndices: readonly number[], + glyphIndices: Uint32Array, paint: GlyphPaint, ): MsdfBatchRun { const count = glyphIndices.length; + const capacity = rasterInstanceCapacity(count); const geometry = unitRasterQuadGeometry(); geometry.instanceCount = count; const instanceData = new THREE.InstancedInterleavedBuffer( - new Float32Array(count * INSTANCE_STRIDE), + new Float32Array(capacity * INSTANCE_STRIDE), INSTANCE_STRIDE, 1, - ); - const originAttribute = instanceAttribute(geometry, instanceData, 'msdfOrigin', 2, INSTANCE_OFFSETS.origin); - const sizeAttribute = instanceAttribute(geometry, instanceData, 'msdfSize', 2, INSTANCE_OFFSETS.size); - const uvOriginAttribute = instanceAttribute(geometry, instanceData, 'msdfUvOrigin', 2, INSTANCE_OFFSETS.uvOrigin); - const uvSizeAttribute = instanceAttribute(geometry, instanceData, 'msdfUvSize', 2, INSTANCE_OFFSETS.uvSize); - const uvBoundsAttribute = instanceAttribute(geometry, instanceData, 'msdfUvBounds', 4, INSTANCE_OFFSETS.uvBounds); - const shadowOffsetAttribute = instanceAttribute( - geometry, - instanceData, - 'msdfShadowOffset', - 2, - INSTANCE_OFFSETS.shadowOffset, - ); - const fillColorAttribute = instanceAttribute(geometry, instanceData, 'msdfFillColor', 4, INSTANCE_OFFSETS.fillColor); - const outlineColorAttribute = instanceAttribute( - geometry, - instanceData, - 'msdfOutlineColor', - 4, - INSTANCE_OFFSETS.outlineColor, - ); - const outlineWidthAttribute = instanceAttribute( - geometry, - instanceData, - 'msdfOutlineWidth', - 1, - INSTANCE_OFFSETS.outlineWidth, - ); - const shadowColorAttribute = instanceAttribute( - geometry, - instanceData, - 'msdfShadowColor', - 4, - INSTANCE_OFFSETS.shadowColor, - ); - const pageIndexAttribute = instanceAttribute(geometry, instanceData, 'msdfPageIndex', 1, INSTANCE_OFFSETS.pageIndex); + ).setUsage(THREE.DynamicDrawUsage); + instanceAttribute(geometry, instanceData, 'msdfOrigin', 2, INSTANCE_OFFSETS.origin); + instanceAttribute(geometry, instanceData, 'msdfSize', 2, INSTANCE_OFFSETS.size); + instanceAttribute(geometry, instanceData, 'msdfUvOrigin', 2, INSTANCE_OFFSETS.uvOrigin); + instanceAttribute(geometry, instanceData, 'msdfUvSize', 2, INSTANCE_OFFSETS.uvSize); + instanceAttribute(geometry, instanceData, 'msdfUvBounds', 4, INSTANCE_OFFSETS.uvBounds); + instanceAttribute(geometry, instanceData, 'msdfShadowOffset', 2, INSTANCE_OFFSETS.shadowOffset); + instanceAttribute(geometry, instanceData, 'msdfFillColor', 4, INSTANCE_OFFSETS.fillColor); + instanceAttribute(geometry, instanceData, 'msdfOutlineColor', 4, INSTANCE_OFFSETS.outlineColor); + instanceAttribute(geometry, instanceData, 'msdfOutlineWidth', 1, INSTANCE_OFFSETS.outlineWidth); + instanceAttribute(geometry, instanceData, 'msdfShadowColor', 4, INSTANCE_OFFSETS.shadowColor); + instanceAttribute(geometry, instanceData, 'msdfPageIndex', 1, INSTANCE_OFFSETS.pageIndex); const mesh = new THREE.Mesh(geometry, msdfMaterial(resource.atlas, resource.pixelRange)); mesh.frustumCulled = false; mesh.renderOrder = glyphIndices[0] ?? 0; const run: MsdfBatchRun = { - glyphIndices: Uint32Array.from(glyphIndices), + capacity, + glyphIndices: new Uint32Array(capacity), + logicalCount: count, instanceData, - paintStructure: new Float64Array(count * PAINT_STRUCTURE_STRIDE), - originAttribute, - sizeAttribute, - uvOriginAttribute, - uvSizeAttribute, - uvBoundsAttribute, - shadowOffsetAttribute, - fillColorAttribute, - outlineColorAttribute, - outlineWidthAttribute, - shadowColorAttribute, - pageIndexAttribute, + paintStructure: new Float64Array(capacity * PAINT_STRUCTURE_STRIDE), geometry, mesh, }; - updateMsdfRun(layout, resource, run, paint); + run.glyphIndices.set(glyphIndices); + writeMsdfInstances(layout, resource, run.instanceData.array as Float32Array, glyphIndices, paint, run.paintStructure); + run.instanceData.needsUpdate = true; return run; } @@ -501,75 +445,179 @@ function instanceAttribute( return attribute; } -function updateMsdfRun(layout: ParagraphLayout, resource: MsdfResource, run: MsdfBatchRun, paint: GlyphPaint): void { +function writeMsdfInstances( + layout: ParagraphLayout, + resource: MsdfResource, + values: Float32Array, + glyphIndices: Uint32Array, + paint: GlyphPaint, + paintStructure?: Float64Array, +): void { const records = new DataView(resource.records.buffer, resource.records.byteOffset, resource.records.byteLength); - for (let instance = 0; instance < run.glyphIndices.length; instance += 1) { - const glyphIndex = run.glyphIndices[instance]!; + for (let instance = 0; instance < glyphIndices.length; instance += 1) { + const glyphIndex = glyphIndices[instance]!; const paintEntry = resolvedPaint(paint, glyphIndex); - writeMsdfInstance(layout, resource, run, records, instance, glyphIndex, paintEntry); + writeMsdfInstance(layout, resource, values, records, instance, glyphIndex, paintEntry, paintStructure); } - run.instanceData.needsUpdate = true; } -function assertMsdfBatchPaintUpdate( +function collectMsdfGlyphIndices(layout: ParagraphLayout, resource: MsdfResource, fontSlot: number): Uint32Array { + const records = new DataView(resource.records.buffer, resource.records.byteOffset, resource.records.byteLength); + let count = 0; + for (let glyphIndex = 0; glyphIndex < layout.glyphIds.length; glyphIndex += 1) { + if (layout.glyphFontSlots[glyphIndex] !== fontSlot) continue; + const glyphId = layout.glyphIds[glyphIndex]; + if (glyphId === undefined || glyphId >= resource.records.byteLength / RECORD_STRIDE) { + throw new TypeError('paragraph layout references an MTSDF glyph outside the registered font'); + } + const pageIndex = records.getUint16(glyphId * RECORD_STRIDE + 16, true); + if (pageIndex === ABSENT_PAGE) continue; + if (resource.pages[pageIndex] === undefined) throw new TypeError('MTSDF batch references a missing page'); + count += 1; + } + const glyphIndices = new Uint32Array(count); + let instance = 0; + for (let glyphIndex = 0; glyphIndex < layout.glyphIds.length; glyphIndex += 1) { + if (layout.glyphFontSlots[glyphIndex] !== fontSlot) continue; + const glyphId = layout.glyphIds[glyphIndex]!; + const pageIndex = records.getUint16(glyphId * RECORD_STRIDE + 16, true); + if (pageIndex !== ABSENT_PAGE) glyphIndices[instance++] = glyphIndex; + } + return glyphIndices; +} + +interface MsdfBatchUpdate { + commit(): void; + dispose(): void; +} + +function stageMsdfBatchUpdate( + context: MsdfBatchContext, layout: ParagraphLayout, resource: MsdfResource, + fontSlot: number, + paint: GlyphPaint, +): MsdfBatchUpdate | undefined { + assertParallelRasterLayout(layout, paint); + assertRasterCoverage(layout, fontSlot, resource.coverage, MSDF_KIND); + assertMsdfPaint(paint); + const glyphIndices = collectMsdfGlyphIndices(layout, resource, fontSlot); + const run = context.run; + if (run === undefined) { + if (glyphIndices.length !== 0) return undefined; + let disposed = false; + return { + commit() { + if (disposed) return; + disposed = true; + context.layout = layout; + }, + dispose() { + disposed = true; + }, + }; + } + if (glyphIndices.length > run.capacity) return undefined; + + const values = new Float32Array(glyphIndices.length * INSTANCE_STRIDE); + const paintStructure = new Float64Array(glyphIndices.length * PAINT_STRUCTURE_STRIDE); + writeMsdfInstances(layout, resource, values, glyphIndices, paint, paintStructure); + const liveValues = run.instanceData.array as Float32Array; + return stageMsdfRunCommit(run, liveValues, values, glyphIndices, () => { + run.paintStructure.set(paintStructure); + context.layout = layout; + }); +} + +function stageMsdfPaintUpdate( + layout: ParagraphLayout, run: MsdfBatchRun | undefined, paint: GlyphPaint, -): void { +): MsdfBatchUpdate { assertParallelRasterPaint(layout, paint); assertMsdfPaint(paint); - if (run === undefined) return; - for (const glyphIndex of run.glyphIndices) { + if (run === undefined) return noOpMsdfBatchUpdate; + const logicalLength = run.logicalCount * INSTANCE_STRIDE; + const values = new Float32Array((run.instanceData.array as Float32Array).subarray(0, logicalLength)); + for (let instance = 0; instance < run.logicalCount; instance += 1) { + const glyphIndex = run.glyphIndices[instance]!; const entry = resolvedPaint(paint, glyphIndex); - const fontSize = layout.glyphFontSizes[glyphIndex]!; - resolveMsdfOutlineAtlasPixels(resource, fontSize, entry.outline?.width ?? 0); + const offset = instance * INSTANCE_STRIDE; + values.set(entry.color, offset + INSTANCE_OFFSETS.fillColor); + values.set(entry.outline?.color ?? TRANSPARENT_LINEAR_RGBA, offset + INSTANCE_OFFSETS.outlineColor); + values.set(entry.shadow?.color ?? TRANSPARENT_LINEAR_RGBA, offset + INSTANCE_OFFSETS.shadowColor); } + return stageMsdfRunCommit( + run, + run.instanceData.array as Float32Array, + values, + run.glyphIndices.subarray(0, run.logicalCount), + () => undefined, + ); } -function commitMsdfBatchPaint( - run: MsdfBatchRun | undefined, - layout: ParagraphLayout, - resource: MsdfResource, - paint: GlyphPaint, -): void { - if (run === undefined) return; - if (sameMsdfPaintStructure(run, paint)) updateMsdfRunColors(run, paint); - else updateMsdfRun(layout, resource, run, paint); +function stageMsdfRunCommit( + run: MsdfBatchRun, + liveValues: Float32Array, + values: Float32Array, + glyphIndices: Uint32Array, + beforeCommit: () => void, +): MsdfBatchUpdate { + const logicalCount = glyphIndices.length; + const ranges = rasterInstanceUpdateRanges( + liveValues, + values, + run.instanceData.updateRanges, + run.logicalCount, + logicalCount, + INSTANCE_STRIDE, + ); + let disposed = false; + return { + commit() { + if (disposed) return; + disposed = true; + beforeCommit(); + liveValues.set(values); + run.glyphIndices.set(glyphIndices); + run.logicalCount = logicalCount; + run.geometry.instanceCount = logicalCount; + run.mesh.renderOrder = glyphIndices[0] ?? 0; + if (ranges.length === 0) return; + run.instanceData.clearUpdateRanges(); + for (const range of ranges) run.instanceData.addUpdateRange(range.start, range.count); + run.instanceData.needsUpdate = true; + }, + dispose() { + disposed = true; + }, + }; } -const PAINT_STRUCTURE_STRIDE = 3; +const noOpMsdfBatchUpdate: MsdfBatchUpdate = { commit: () => undefined, dispose: () => undefined }; -function sameMsdfPaintStructure(run: MsdfBatchRun, next: GlyphPaint): boolean { - for (let instance = 0; instance < run.glyphIndices.length; instance += 1) { +function sameMsdfPaintStructure(run: MsdfBatchRun | undefined, paint: GlyphPaint): boolean { + if (run === undefined) return true; + for (let instance = 0; instance < run.logicalCount; instance += 1) { const glyphIndex = run.glyphIndices[instance]!; - const nextPaint = resolvedPaint(next, glyphIndex); + const entry = resolvedPaint(paint, glyphIndex); const offset = instance * PAINT_STRUCTURE_STRIDE; - if (run.paintStructure[offset] !== (nextPaint.outline?.width ?? 0)) return false; - if (run.paintStructure[offset + 1] !== (nextPaint.shadow?.offset[0] ?? 0)) return false; - if (run.paintStructure[offset + 2] !== (nextPaint.shadow?.offset[1] ?? 0)) return false; + if (run.paintStructure[offset] !== (entry.outline?.width ?? 0)) return false; + if (run.paintStructure[offset + 1] !== (entry.shadow?.offset[0] ?? 0)) return false; + if (run.paintStructure[offset + 2] !== (entry.shadow?.offset[1] ?? 0)) return false; } return true; } -function updateMsdfRunColors(run: MsdfBatchRun, paint: GlyphPaint): void { - for (let instance = 0; instance < run.glyphIndices.length; instance += 1) { - const paintEntry = resolvedPaint(paint, run.glyphIndices[instance]!); - setAttribute4(run.fillColorAttribute, instance, ...paintEntry.color); - setAttribute4(run.outlineColorAttribute, instance, ...(paintEntry.outline?.color ?? TRANSPARENT_LINEAR_RGBA)); - setAttribute4(run.shadowColorAttribute, instance, ...(paintEntry.shadow?.color ?? TRANSPARENT_LINEAR_RGBA)); - } - run.instanceData.needsUpdate = true; -} - function writeMsdfInstance( layout: ParagraphLayout, resource: MsdfResource, - run: MsdfBatchRun, + values: Float32Array, records: DataView, instance: number, glyphIndex: number, paint: ResolvedPaint, + paintStructure: Float64Array | undefined, ): void { const glyphId = layout.glyphIds[glyphIndex]!; const record = glyphId * RECORD_STRIDE; @@ -604,21 +652,32 @@ function writeMsdfInstance( const uvOriginX = baseUvX + (originX - baseOriginX) * uvPerUnitX; const uvOriginY = baseUvY + (originY - baseOriginY) * uvPerUnitY; const outlineAtlasPixels = resolveMsdfOutlineAtlasPixels(resource, fontSize, paint.outline?.width ?? 0); - setAttribute2(run.originAttribute, instance, originX, originY); - setAttribute2(run.sizeAttribute, instance, width, height); - setAttribute2(run.uvOriginAttribute, instance, uvOriginX, uvOriginY); - setAttribute2(run.uvSizeAttribute, instance, width * uvPerUnitX, height * uvPerUnitY); - setAttribute4(run.uvBoundsAttribute, instance, baseUvX, baseUvY, baseUvX + baseUvWidth, baseUvY + baseUvHeight); - setAttribute2(run.shadowOffsetAttribute, instance, shadowX * uvPerUnitX, shadowY * uvPerUnitY); - setAttribute4(run.fillColorAttribute, instance, ...paint.color); - setAttribute4(run.outlineColorAttribute, instance, ...(paint.outline?.color ?? TRANSPARENT_LINEAR_RGBA)); - setAttribute1(run.outlineWidthAttribute, instance, outlineAtlasPixels / resource.pixelRange); - setAttribute4(run.shadowColorAttribute, instance, ...(paint.shadow?.color ?? TRANSPARENT_LINEAR_RGBA)); - setAttribute1(run.pageIndexAttribute, instance, pageIndex); - const paintStructureOffset = instance * PAINT_STRUCTURE_STRIDE; - run.paintStructure[paintStructureOffset] = paint.outline?.width ?? 0; - run.paintStructure[paintStructureOffset + 1] = shadowX; - run.paintStructure[paintStructureOffset + 2] = sourceShadowY; + const offset = instance * INSTANCE_STRIDE; + values[offset + INSTANCE_OFFSETS.origin] = originX; + values[offset + INSTANCE_OFFSETS.origin + 1] = originY; + values[offset + INSTANCE_OFFSETS.size] = width; + values[offset + INSTANCE_OFFSETS.size + 1] = height; + values[offset + INSTANCE_OFFSETS.uvOrigin] = uvOriginX; + values[offset + INSTANCE_OFFSETS.uvOrigin + 1] = uvOriginY; + values[offset + INSTANCE_OFFSETS.uvSize] = width * uvPerUnitX; + values[offset + INSTANCE_OFFSETS.uvSize + 1] = height * uvPerUnitY; + values[offset + INSTANCE_OFFSETS.uvBounds] = baseUvX; + values[offset + INSTANCE_OFFSETS.uvBounds + 1] = baseUvY; + values[offset + INSTANCE_OFFSETS.uvBounds + 2] = baseUvX + baseUvWidth; + values[offset + INSTANCE_OFFSETS.uvBounds + 3] = baseUvY + baseUvHeight; + values[offset + INSTANCE_OFFSETS.shadowOffset] = shadowX * uvPerUnitX; + values[offset + INSTANCE_OFFSETS.shadowOffset + 1] = shadowY * uvPerUnitY; + values.set(paint.color, offset + INSTANCE_OFFSETS.fillColor); + values.set(paint.outline?.color ?? TRANSPARENT_LINEAR_RGBA, offset + INSTANCE_OFFSETS.outlineColor); + values[offset + INSTANCE_OFFSETS.outlineWidth] = outlineAtlasPixels / resource.pixelRange; + values.set(paint.shadow?.color ?? TRANSPARENT_LINEAR_RGBA, offset + INSTANCE_OFFSETS.shadowColor); + values[offset + INSTANCE_OFFSETS.pageIndex] = pageIndex; + if (paintStructure !== undefined) { + const structureOffset = instance * PAINT_STRUCTURE_STRIDE; + paintStructure[structureOffset] = paint.outline?.width ?? 0; + paintStructure[structureOffset + 1] = shadowX; + paintStructure[structureOffset + 2] = sourceShadowY; + } } function resolveMsdfOutlineAtlasPixels(resource: MsdfResource, fontSize: number, outlineWidth: number): number { @@ -635,38 +694,6 @@ function resolveMsdfOutlineAtlasPixels(resource: MsdfResource, fontSize: number, const TRANSPARENT_LINEAR_RGBA = [0, 0, 0, 0] as const; -function attributeArrayOffset(attribute: THREE.InterleavedBufferAttribute, instance: number): number { - return instance * attribute.data.stride + attribute.offset; -} - -function setAttribute1(attribute: THREE.InterleavedBufferAttribute, instance: number, x: number): void { - const array = attribute.data.array as Float32Array; - array[attributeArrayOffset(attribute, instance)] = x; -} - -function setAttribute2(attribute: THREE.InterleavedBufferAttribute, instance: number, x: number, y: number): void { - const array = attribute.data.array as Float32Array; - const offset = attributeArrayOffset(attribute, instance); - array[offset] = x; - array[offset + 1] = y; -} - -function setAttribute4( - attribute: THREE.InterleavedBufferAttribute, - instance: number, - x: number, - y: number, - z: number, - w: number, -): void { - const array = attribute.data.array as Float32Array; - const offset = attributeArrayOffset(attribute, instance); - array[offset] = x; - array[offset + 1] = y; - array[offset + 2] = z; - array[offset + 3] = w; -} - function resolvedPaint(paint: GlyphPaint, glyphIndex: number): ResolvedPaint { const paintIndex = paint.paintIndices[glyphIndex]; const resolved = paintIndex === undefined ? undefined : paint.palette[paintIndex]; diff --git a/packages/text/src/raster/slug.ts b/packages/text/src/raster/slug.ts index 85330316..3f9a8a9a 100644 --- a/packages/text/src/raster/slug.ts +++ b/packages/text/src/raster/slug.ts @@ -11,11 +11,13 @@ 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, - assertParallelRasterPaint, - unitRasterQuadGeometry, -} from '../internal/raster-batch.js'; + coalesceRasterInstanceRanges, + pendingRasterDirtyInstances, + rasterInstanceCapacity, + rasterInstanceUpdateRanges, +} from '../internal/raster-instance-capacity.js'; import { jsonArray, jsonObject, nonnegativeSafeInteger, positiveSafeInteger } from '../internal/raster-atlas.js'; import { validateNativeKtx2 } from '../internal/raster-ktx.js'; import { @@ -102,11 +104,15 @@ export interface SlugResource { } interface SlugBatchRun { + readonly capacity: number; readonly glyphIndices: Uint32Array; + logicalCount: number; readonly floatData: THREE.InstancedInterleavedBuffer; - geometry: THREE.InstancedBufferGeometry; + readonly uintData: THREE.InstancedInterleavedBuffer; + readonly geometry: THREE.InstancedBufferGeometry; readonly fillMesh: THREE.Mesh; readonly materialState: SlugMaterialState; + readonly pageIndex: number; } export interface SlugDrawBatch { @@ -117,15 +123,14 @@ export interface SlugDrawBatch { } const materialStateByCurveTexture = new WeakMap(); -const batchContext = new WeakMap< - SlugDrawBatch, - { - readonly layout: ParagraphLayout; - readonly resource: SlugResource; - readonly fontSlot: number; - readonly runs: readonly SlugBatchRun[]; - } ->(); +interface SlugBatchContext { + layout: ParagraphLayout; + readonly resource: SlugResource; + readonly fontSlot: number; + readonly runs: readonly SlugBatchRun[]; +} + +const batchContext = new WeakMap(); const slugModule: RasterModule = defineRaster({ kind: SLUG_KIND, @@ -143,23 +148,12 @@ const slugModule: RasterModule = signal?.throwIfAborted(); }, stageBatch(previous, layout, resource, fontSlot, paint) { - const context = previous === undefined ? undefined : batchContext.get(previous); - if ( - previous !== undefined && - context?.layout === layout && - context.resource === resource && - context.fontSlot === fontSlot - ) { - assertParallelRasterPaint(layout, paint); - assertSlugPaint(paint); - for (const run of context.runs) assertSlugRunPaint(run.glyphIndices, paint); - return defineRasterBatchStage( - previous, - () => { - for (const run of context.runs) updateRunPaint(run, paint); - }, - () => undefined, - ); + assertParallelRasterLayout(layout, paint); + assertSlugPaint(paint); + assertSlugGlyphInputs(layout, resource, fontSlot, paint); + if (previous !== undefined) { + const update = stageSlugBatchUpdate(previous, layout, resource, fontSlot, paint); + if (update !== undefined) return defineRasterBatchStage(previous, update.commit, update.dispose); } const batch = buildSlugBatches(layout, resource, fontSlot, paint); return defineRasterBatchStage( @@ -483,32 +477,15 @@ function buildSlugBatches( assertParallelRasterLayout(layout, paint); assertSlugPaint(paint); assertSlugGlyphInputs(layout, resource, fontSlot, paint); - const records = recordView(resource); const group = new THREE.Group(); group.name = 'pmndrs.text.slug'; const runs: SlugBatchRun[] = []; - let pageIndex: number | undefined; - let glyphIndices: number[] = []; - - const finishRun = (): void => { - if (pageIndex === undefined || glyphIndices.length === 0) return; - const run = createSlugRun(layout, resource, pageIndex, glyphIndices, paint); - runs.push(run); - group.add(run.fillMesh); - glyphIndices = []; - }; - try { - for (let glyphIndex = 0; glyphIndex < layout.glyphIds.length; glyphIndex += 1) { - if (layout.glyphFontSlots[glyphIndex] !== fontSlot) continue; - const glyphId = layout.glyphIds[glyphIndex]!; - const nextPage = records.getUint16(glyphId * SLUG_GLYPH_RECORD_STRIDE + 8, true); - if (nextPage === ABSENT_PAGE) continue; - if (pageIndex !== undefined && pageIndex !== nextPage) finishRun(); - pageIndex = nextPage; - glyphIndices.push(glyphIndex); + for (const { pageIndex, glyphIndices } of collectSlugRunPlans(layout, resource, fontSlot)) { + const run = createSlugRun(layout, resource, pageIndex, glyphIndices, paint); + runs.push(run); + group.add(run.fillMesh); } - finishRun(); } catch (error) { group.clear(); for (const run of runs) run.geometry.dispose(); @@ -516,11 +493,14 @@ function buildSlugBatches( } let disposed = false; - const glyphCount = runs.reduce((count, run) => count + run.glyphIndices.length, 0); const batch: SlugDrawBatch = { object: group, - glyphCount, - drawCount: runs.length, + get glyphCount() { + return runs.reduce((count, run) => count + run.logicalCount, 0); + }, + get drawCount() { + return runs.reduce((count, run) => count + (run.logicalCount === 0 ? 0 : 1), 0); + }, dispose() { if (disposed) return; disposed = true; @@ -533,44 +513,97 @@ function buildSlugBatches( return batch; } -function createSlugRun( +interface SlugRunPlan { + readonly pageIndex: number; + readonly glyphIndices: Uint32Array; +} + +interface SlugRunValues { + readonly floats: Float32Array; + readonly uints: Uint32Array; +} + +interface SlugBatchUpdate { + commit(): void; + dispose(): void; +} + +function stageSlugBatchUpdate( + batch: SlugDrawBatch, layout: ParagraphLayout, resource: SlugResource, - pageIndex: number, - glyphIndices: readonly number[], + fontSlot: number, paint: GlyphPaint, -): SlugBatchRun { - const geometry = unitRasterQuadGeometry(); - try { - return populateSlugRun(geometry, layout, resource, pageIndex, glyphIndices, paint); - } catch (error) { - geometry.dispose(); - throw error; +): SlugBatchUpdate | undefined { + const context = batchContext.get(batch); + if (context === undefined || context.resource !== resource || context.fontSlot !== fontSlot) return undefined; + if (context.layout === layout) return stageSlugPaintUpdate(context.runs, paint); + const plans = collectSlugRunPlans(layout, resource, fontSlot); + if ( + plans.length !== context.runs.length || + plans.some(({ pageIndex, glyphIndices }, index) => { + const run = context.runs[index]; + return run === undefined || run.pageIndex !== pageIndex || glyphIndices.length > run.capacity; + }) + ) { + return undefined; } + const staged = plans.map(({ glyphIndices }, index) => + stageSlugRunUpdate(context.runs[index]!, glyphIndices, slugRunValues(layout, resource, glyphIndices, paint)), + ); + let disposed = false; + return { + commit() { + if (disposed) return; + disposed = true; + for (const update of staged) update.commit(); + context.layout = layout; + }, + dispose() { + if (disposed) return; + disposed = true; + for (const update of staged) update.dispose(); + }, + }; } -function populateSlugRun( - geometry: THREE.InstancedBufferGeometry, +function collectSlugRunPlans( layout: ParagraphLayout, resource: SlugResource, - pageIndex: number, - glyphIndices: readonly number[], + fontSlot: number, +): readonly SlugRunPlan[] { + const records = recordView(resource); + const plans: SlugRunPlan[] = []; + let pendingPage: number | undefined; + let pendingGlyphs: number[] = []; + const finishRun = (): void => { + if (pendingPage === undefined || pendingGlyphs.length === 0) return; + plans.push({ pageIndex: pendingPage, glyphIndices: Uint32Array.from(pendingGlyphs) }); + pendingGlyphs = []; + }; + for (let glyphIndex = 0; glyphIndex < layout.glyphIds.length; glyphIndex += 1) { + if (layout.glyphFontSlots[glyphIndex] !== fontSlot) continue; + const glyphId = layout.glyphIds[glyphIndex]!; + const pageIndex = records.getUint16(glyphId * SLUG_GLYPH_RECORD_STRIDE + 8, true); + if (pageIndex === ABSENT_PAGE) continue; + if (pendingPage !== undefined && pendingPage !== pageIndex) finishRun(); + pendingPage = pageIndex; + pendingGlyphs.push(glyphIndex); + } + finishRun(); + return plans; +} + +function slugRunValues( + layout: ParagraphLayout, + resource: SlugResource, + glyphIndices: Uint32Array, paint: GlyphPaint, -): SlugBatchRun { - const count = glyphIndices.length; - geometry.instanceCount = count; - const floatData = new THREE.InstancedInterleavedBuffer( - new Float32Array(count * SLUG_FLOAT_INSTANCE_STRIDE), - SLUG_FLOAT_INSTANCE_STRIDE, - 1, - ); - const uintData = new THREE.InstancedInterleavedBuffer( - new Uint32Array(count * SLUG_UINT_INSTANCE_STRIDE), - SLUG_UINT_INSTANCE_STRIDE, - 1, - ); +): SlugRunValues { + const floats = new Float32Array(glyphIndices.length * SLUG_FLOAT_INSTANCE_STRIDE); + const uints = new Uint32Array(glyphIndices.length * SLUG_UINT_INSTANCE_STRIDE); const records = recordView(resource); - for (let instance = 0; instance < count; instance += 1) { + for (let instance = 0; instance < glyphIndices.length; instance += 1) { const glyphIndex = glyphIndices[instance]!; const glyphId = layout.glyphIds[glyphIndex]!; const record = glyphId * SLUG_GLYPH_RECORD_STRIDE; @@ -589,40 +622,227 @@ function populateSlugRun( const normalizedBottom = bottom / resource.planeUnitsPerEm; const normalizedWidth = (right - left) / resource.planeUnitsPerEm; const normalizedHeight = (top - bottom) / resource.planeUnitsPerEm; - setInstanceValues(floatData, instance, SLUG_FLOAT_INSTANCE_OFFSETS.origin, [ + setSlugValues(floats, SLUG_FLOAT_INSTANCE_STRIDE, instance, SLUG_FLOAT_INSTANCE_OFFSETS.origin, [ layout.x[glyphIndex]! + left * scale, -layout.y[glyphIndex]! + bottom * scale, ]); - setInstanceValues(floatData, instance, SLUG_FLOAT_INSTANCE_OFFSETS.size, [ + setSlugValues(floats, SLUG_FLOAT_INSTANCE_STRIDE, instance, SLUG_FLOAT_INSTANCE_OFFSETS.size, [ (right - left) * scale, (top - bottom) * scale, ]); - setInstanceValues(floatData, instance, SLUG_FLOAT_INSTANCE_OFFSETS.emOrigin, [normalizedLeft, normalizedBottom]); - setInstanceValues(floatData, instance, SLUG_FLOAT_INSTANCE_OFFSETS.emSize, [normalizedWidth, normalizedHeight]); - setInstanceValues(floatData, instance, SLUG_FLOAT_INSTANCE_OFFSETS.inverseScale, [1 / fontSize]); + setSlugValues(floats, SLUG_FLOAT_INSTANCE_STRIDE, instance, SLUG_FLOAT_INSTANCE_OFFSETS.emOrigin, [ + normalizedLeft, + normalizedBottom, + ]); + setSlugValues(floats, SLUG_FLOAT_INSTANCE_STRIDE, instance, SLUG_FLOAT_INSTANCE_OFFSETS.emSize, [ + normalizedWidth, + normalizedHeight, + ]); + setSlugValues(floats, SLUG_FLOAT_INSTANCE_STRIDE, instance, SLUG_FLOAT_INSTANCE_OFFSETS.inverseScale, [ + 1 / fontSize, + ]); const bandScaleX = verticalBands / normalizedWidth; const bandScaleY = horizontalBands / normalizedHeight; - setInstanceValues(floatData, instance, SLUG_FLOAT_INSTANCE_OFFSETS.bandTransform, [ + setSlugValues(floats, SLUG_FLOAT_INSTANCE_STRIDE, instance, SLUG_FLOAT_INSTANCE_OFFSETS.bandTransform, [ bandScaleX, bandScaleY, -normalizedLeft * bandScaleX, -normalizedBottom * bandScaleY, ]); - const resolvedPaint = resolvedSlugPaint(paint, glyphIndex); - setInstanceValues(floatData, instance, SLUG_FLOAT_INSTANCE_OFFSETS.color, resolvedPaint.color); - setInstanceValues(uintData, instance, SLUG_UINT_INSTANCE_OFFSETS.curveBase, [records.getUint32(record + 16, true)]); - setInstanceValues(uintData, instance, SLUG_UINT_INSTANCE_OFFSETS.horizontalHeaderBase, [ + setSlugValues( + floats, + SLUG_FLOAT_INSTANCE_STRIDE, + instance, + SLUG_FLOAT_INSTANCE_OFFSETS.color, + resolvedSlugPaint(paint, glyphIndex).color, + ); + setSlugValues(uints, SLUG_UINT_INSTANCE_STRIDE, instance, SLUG_UINT_INSTANCE_OFFSETS.curveBase, [ + records.getUint32(record + 16, true), + ]); + setSlugValues(uints, SLUG_UINT_INSTANCE_STRIDE, instance, SLUG_UINT_INSTANCE_OFFSETS.horizontalHeaderBase, [ records.getUint32(record + 24, true), ]); - setInstanceValues(uintData, instance, SLUG_UINT_INSTANCE_OFFSETS.verticalHeaderBase, [ + setSlugValues(uints, SLUG_UINT_INSTANCE_STRIDE, instance, SLUG_UINT_INSTANCE_OFFSETS.verticalHeaderBase, [ records.getUint32(record + 28, true), ]); - setInstanceValues(uintData, instance, SLUG_UINT_INSTANCE_OFFSETS.referenceBase, [ + setSlugValues(uints, SLUG_UINT_INSTANCE_STRIDE, instance, SLUG_UINT_INSTANCE_OFFSETS.referenceBase, [ records.getUint32(record + 32, true), ]); - setInstanceValues(uintData, instance, SLUG_UINT_INSTANCE_OFFSETS.horizontalBandCount, [horizontalBands]); - setInstanceValues(uintData, instance, SLUG_UINT_INSTANCE_OFFSETS.verticalBandCount, [verticalBands]); + setSlugValues(uints, SLUG_UINT_INSTANCE_STRIDE, instance, SLUG_UINT_INSTANCE_OFFSETS.horizontalBandCount, [ + horizontalBands, + ]); + setSlugValues(uints, SLUG_UINT_INSTANCE_STRIDE, instance, SLUG_UINT_INSTANCE_OFFSETS.verticalBandCount, [ + verticalBands, + ]); + } + return { floats, uints }; +} + +function stageSlugRunUpdate(run: SlugBatchRun, glyphIndices: Uint32Array, values: SlugRunValues): 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); + let disposed = false; + return { + commit() { + if (disposed) return; + disposed = true; + floatUpdate.commit(); + uintUpdate.commit(); + run.glyphIndices.set(glyphIndices); + run.logicalCount = logicalCount; + run.geometry.instanceCount = logicalCount; + run.fillMesh.renderOrder = glyphIndices[0] ?? 0; + }, + dispose() { + if (disposed) return; + disposed = true; + floatUpdate.dispose(); + uintUpdate.dispose(); + }, + }; +} + +function stageSlugInterleavedData( + data: THREE.InstancedInterleavedBuffer, + values: Values, + previousLogicalCount: number, + logicalCount: number, +): SlugBatchUpdate { + const liveValues = data.array as Values; + const ranges = rasterInstanceUpdateRanges( + liveValues, + values, + data.updateRanges, + previousLogicalCount, + logicalCount, + data.stride, + ); + let disposed = false; + return { + commit() { + if (disposed) return; + disposed = true; + liveValues.set(values); + if (ranges.length === 0) return; + data.clearUpdateRanges(); + for (const range of ranges) data.addUpdateRange(range.start, range.count); + data.needsUpdate = true; + }, + dispose() { + disposed = true; + }, + }; +} + +function stageSlugPaintUpdate(runs: readonly SlugBatchRun[], paint: GlyphPaint): SlugBatchUpdate { + const staged = runs.map((run) => { + const colors = new Float32Array(run.logicalCount * 4); + const liveFloats = run.floatData.array as Float32Array; + const dirtyInstances = pendingRasterDirtyInstances( + run.floatData.updateRanges, + run.logicalCount, + SLUG_FLOAT_INSTANCE_STRIDE, + ); + for (let instance = 0; instance < run.logicalCount; instance += 1) { + const color = resolvedSlugPaint(paint, run.glyphIndices[instance]!).color; + colors.set(color, instance * 4); + const liveStart = instance * SLUG_FLOAT_INSTANCE_STRIDE + SLUG_FLOAT_INSTANCE_OFFSETS.color; + let changed = false; + for (let component = 0; component < 4 && !changed; component += 1) { + changed = color[component] !== liveFloats[liveStart + component]; + } + if (changed) dirtyInstances.push(instance); + } + const ranges = coalesceRasterInstanceRanges(dirtyInstances, run.logicalCount, SLUG_FLOAT_INSTANCE_STRIDE); + let disposed = false; + return { + commit() { + if (disposed) return; + disposed = true; + for (let instance = 0; instance < run.logicalCount; instance += 1) { + const colorStart = instance * 4; + const liveStart = instance * SLUG_FLOAT_INSTANCE_STRIDE + SLUG_FLOAT_INSTANCE_OFFSETS.color; + liveFloats[liveStart] = colors[colorStart]!; + liveFloats[liveStart + 1] = colors[colorStart + 1]!; + liveFloats[liveStart + 2] = colors[colorStart + 2]!; + liveFloats[liveStart + 3] = colors[colorStart + 3]!; + } + if (ranges.length === 0) return; + run.floatData.clearUpdateRanges(); + for (const range of ranges) run.floatData.addUpdateRange(range.start, range.count); + run.floatData.needsUpdate = true; + }, + dispose() { + disposed = true; + }, + } satisfies SlugBatchUpdate; + }); + let disposed = false; + return { + commit() { + if (disposed) return; + disposed = true; + for (const update of staged) update.commit(); + }, + dispose() { + if (disposed) return; + disposed = true; + for (const update of staged) update.dispose(); + }, + }; +} + +function setSlugValues( + data: Float32Array | Uint32Array, + stride: number, + instance: number, + offset: number, + values: readonly number[], +): void { + data.set(values, instance * stride + offset); +} + +function createSlugRun( + layout: ParagraphLayout, + resource: SlugResource, + pageIndex: number, + glyphIndices: Uint32Array, + paint: GlyphPaint, +): SlugBatchRun { + const geometry = unitRasterQuadGeometry(); + try { + return populateSlugRun(geometry, layout, resource, pageIndex, glyphIndices, paint); + } catch (error) { + geometry.dispose(); + throw error; } +} + +function populateSlugRun( + geometry: THREE.InstancedBufferGeometry, + layout: ParagraphLayout, + resource: SlugResource, + pageIndex: number, + glyphIndices: Uint32Array, + paint: GlyphPaint, +): SlugBatchRun { + const count = glyphIndices.length; + const capacity = rasterInstanceCapacity(count); + geometry.instanceCount = count; + const floatData = new THREE.InstancedInterleavedBuffer( + new Float32Array(capacity * SLUG_FLOAT_INSTANCE_STRIDE), + SLUG_FLOAT_INSTANCE_STRIDE, + 1, + ).setUsage(THREE.DynamicDrawUsage); + const uintData = new THREE.InstancedInterleavedBuffer( + new Uint32Array(capacity * SLUG_UINT_INSTANCE_STRIDE), + SLUG_UINT_INSTANCE_STRIDE, + 1, + ).setUsage(THREE.DynamicDrawUsage); + const values = slugRunValues(layout, resource, glyphIndices, paint); + (floatData.array as Float32Array).set(values.floats); + (uintData.array as Uint32Array).set(values.uints); instanceAttribute(geometry, floatData, 'slugOrigin', 2, SLUG_FLOAT_INSTANCE_OFFSETS.origin); instanceAttribute(geometry, floatData, 'slugSize', 2, SLUG_FLOAT_INSTANCE_OFFSETS.size); @@ -643,12 +863,18 @@ function populateSlugRun( const fillMesh = new THREE.Mesh(geometry, initialState.material); fillMesh.frustumCulled = false; fillMesh.renderOrder = glyphIndices[0] ?? 0; + const retainedGlyphIndices = new Uint32Array(capacity); + retainedGlyphIndices.set(glyphIndices); const run: SlugBatchRun = { - glyphIndices: Uint32Array.from(glyphIndices), + capacity, + glyphIndices: retainedGlyphIndices, + logicalCount: count, floatData, + uintData, geometry, fillMesh, materialState: initialState, + pageIndex, }; fillMesh.onBeforeRender = (renderer, _scene, camera): void => { renderer.getDrawingBufferSize(drawingBufferSize); @@ -670,15 +896,6 @@ function instanceAttribute( return bufferAttribute; } -function setInstanceValues( - data: THREE.InstancedInterleavedBuffer, - instance: number, - offset: number, - values: readonly number[], -): void { - (data.array as Float32Array | Uint32Array).set(values, instance * data.stride + offset); -} - function resolvedSlugPaint(paint: GlyphPaint, glyphIndex: number): ResolvedPaint { const paintIndex = paint.paintIndices[glyphIndex]; const resolved = paintIndex === undefined ? undefined : paint.palette[paintIndex]; @@ -802,15 +1019,6 @@ function updateMvpUniforms(state: SlugMaterialState, object: THREE.Object3D, cam state.mvpRow3.value.set(values[3]!, values[7]!, values[11]!, values[15]!); } -function updateRunPaint(run: SlugBatchRun, paint: GlyphPaint): void { - for (let instance = 0; instance < run.glyphIndices.length; instance += 1) { - const glyphIndex = run.glyphIndices[instance]!; - const resolvedPaint = resolvedSlugPaint(paint, glyphIndex); - setInstanceValues(run.floatData, instance, SLUG_FLOAT_INSTANCE_OFFSETS.color, resolvedPaint.color); - } - run.floatData.needsUpdate = true; -} - function assertSlugPaint(paint: GlyphPaint): void { for (const entry of paint.palette) { assertSlugColor(entry.color, 'Slug fill'); diff --git a/packages/text/tests/integration/bitmap-retained-capacity.test.mjs b/packages/text/tests/integration/bitmap-retained-capacity.test.mjs new file mode 100644 index 00000000..9a01969c --- /dev/null +++ b/packages/text/tests/integration/bitmap-retained-capacity.test.mjs @@ -0,0 +1,254 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import * as THREE from 'three/webgpu'; +import { bitmap } from '../../dist/raster/bitmap.js'; + +const bitmapModule = bitmap({ strikes: [16] }).module; + +test('Bitmap retains every instance field within capacity and replaces changed run topology or overflow', () => { + const resource = syntheticResource(); + const initialLayout = layout([0, 0, 0], 1, 2, 16); + let batch = committedBatch(initialLayout, resource, paint(3, [0.1, 0.2, 0.3, 0.4])); + try { + const mesh = batch.object.children[0]; + assert.ok(mesh); + const geometry = mesh.geometry; + const material = mesh.material; + const attributes = bitmapAttributes(geometry); + const arrays = Object.fromEntries(Object.entries(attributes).map(([name, attribute]) => [name, attribute.array])); + const initialValues = Object.fromEntries( + Object.entries(arrays).map(([name, values]) => [name, Array.from(values)]), + ); + assert.equal(batch.glyphCount, 3); + assert.equal(batch.drawCount, 1); + assert.equal(geometry.instanceCount, 3); + for (const attribute of Object.values(attributes)) assert.equal(attribute.usage, THREE.DynamicDrawUsage); + + const replacementLayout = layout([1, 1, 1], 9, 11, 32); + const replacement = bitmapModule.stageBatch( + batch, + replacementLayout, + resource, + 0, + paint(3, [0.6, 0.5, 0.4, 0.3]), + 1, + ); + assert.equal(replacement.batch, batch); + assert.equal(batch.glyphCount, 3, 'staging preserves the live logical count'); + for (const [name, values] of Object.entries(arrays)) { + assert.deepEqual(Array.from(values), initialValues[name], `staging preserves live ${name} data`); + } + replacement.commit(); + + assert.equal(batch.object.children[0], mesh); + assert.equal(mesh.geometry, geometry); + assert.equal(mesh.material, material); + assert.equal(batch.glyphCount, 3); + assert.equal(geometry.instanceCount, 3); + 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`); + assert.notDeepEqual(Array.from(attribute.array), initialValues[name], `updates ${name} values`); + assert.deepEqual(attribute.updateRanges, [{ start: 0, count: 3 * attribute.itemSize }]); + } + + const externalOrigin = attributes.origin; + externalOrigin.setXY(0, 123, 456); + const colorOnly = bitmapModule.stageBatch(batch, replacementLayout, resource, 0, paint(3, [0.2, 0.3, 0.4, 0.5]), 1); + colorOnly.commit(); + assert.deepEqual([externalOrigin.getX(0), externalOrigin.getY(0)], [123, 456]); + + const shrunk = bitmapModule.stageBatch( + batch, + layout([0, 1], 3, 5, 20), + resource, + 0, + paint(2, [0.4, 0.3, 0.2, 0.1]), + 1, + ); + assert.equal(shrunk.batch, batch); + shrunk.commit(); + assert.equal(batch.glyphCount, 2); + assert.equal(geometry.instanceCount, 2); + for (const [name, attribute] of Object.entries(bitmapAttributes(geometry))) { + assert.equal(attribute.array, arrays[name], `shrink retains ${name} allocation`); + } + + const exactCapacity = bitmapModule.stageBatch( + batch, + layout([1, 0, 1, 0], 4, 6, 24), + resource, + 0, + paint(4, [0.3, 0.4, 0.5, 0.6]), + 1, + ); + assert.equal(exactCapacity.batch, batch); + exactCapacity.commit(); + assert.equal(batch.glyphCount, 4); + assert.equal(geometry.instanceCount, 4); + for (const [name, attribute] of Object.entries(bitmapAttributes(geometry))) { + assert.equal(attribute.array, arrays[name], `exact-capacity growth retains ${name} allocation`); + } + + const liveValues = Object.fromEntries(Object.entries(arrays).map(([name, values]) => [name, Array.from(values)])); + const aborted = bitmapModule.stageBatch( + batch, + layout([0, 1, 0], 7, 8, 18), + resource, + 0, + paint(3, [0.7, 0.6, 0.5, 0.4]), + 1, + ); + assert.equal(aborted.batch, batch); + aborted.abort(); + assert.equal(batch.glyphCount, 4); + assert.equal(geometry.instanceCount, 4); + for (const [name, values] of Object.entries(arrays)) { + assert.deepEqual(Array.from(values), liveValues[name], `retained abort preserves ${name}`); + } + + const changedTopology = bitmapModule.stageBatch( + batch, + layout([0, 2, 0], 10, 12, 18), + resource, + 0, + paint(3, [0.2, 0.4, 0.6, 0.8]), + 1, + ); + assert.notEqual(changedTopology.batch, batch); + assert.equal(changedTopology.batch.drawCount, 3); + const topologyGeometries = changedTopology.batch.object.children.map(({ geometry: stagedGeometry }) => { + let disposed = false; + stagedGeometry.addEventListener('dispose', () => { + disposed = true; + }); + return () => disposed; + }); + changedTopology.abort(); + assert.equal(changedTopology.batch.object.children.length, 0); + assert.ok(topologyGeometries.every((wasDisposed) => wasDisposed())); + assert.equal(batch.glyphCount, 4); + + const overflow = bitmapModule.stageBatch( + batch, + layout([1, 0, 1, 0, 1], 12, 13, 18), + resource, + 0, + paint(5, [0.8, 0.6, 0.4, 0.2]), + 1, + ); + assert.notEqual(overflow.batch, batch); + overflow.commit(); + const previous = batch; + batch = overflow.batch; + const replacementMesh = batch.object.children[0]; + assert.ok(replacementMesh); + assert.notEqual(replacementMesh.geometry, geometry); + assert.equal(batch.glyphCount, 5); + assert.equal(replacementMesh.geometry.instanceCount, 5); + previous.dispose(); + } finally { + batch.dispose(); + bitmapModule.dispose(resource); + } +}); + +function committedBatch(layoutValue, resource, paintValue) { + const stage = bitmapModule.stageBatch(undefined, layoutValue, resource, 0, paintValue, 1); + stage.commit(); + return stage.batch; +} + +function bitmapAttributes(geometry) { + return { + origin: geometry.getAttribute('bitmapOrigin'), + size: geometry.getAttribute('bitmapSize'), + uvOrigin: geometry.getAttribute('bitmapUvOrigin'), + uvSize: geometry.getAttribute('bitmapUvSize'), + color: geometry.getAttribute('bitmapColor'), + }; +} + +function layout(glyphIds, x, y, fontSize) { + return { + glyphIds: Uint16Array.from(glyphIds), + glyphFontSlots: new Uint16Array(glyphIds.length), + glyphFontSizes: Float32Array.from({ length: glyphIds.length }, () => fontSize), + x: Float32Array.from({ length: glyphIds.length }, (_value, index) => x + index), + y: Float32Array.from({ length: glyphIds.length }, (_value, index) => y + index), + }; +} + +function paint(count, color) { + return { paintIndices: new Uint16Array(count), palette: [{ color }] }; +} + +function syntheticResource() { + const records = new Uint8Array(3 * 20); + writeRecord(records, 0, { + left: 0, + bottom: 0, + right: 8, + top: 12, + atlasLeft: 0, + atlasTop: 0, + atlasRight: 1, + atlasBottom: 1, + page: 0, + }); + writeRecord(records, 1, { + left: 4, + bottom: 5, + right: 28, + top: 37, + atlasLeft: 1, + atlasTop: 1, + atlasRight: 3, + atlasBottom: 4, + page: 0, + }); + writeRecord(records, 2, { + left: 2, + bottom: 3, + right: 14, + top: 19, + atlasLeft: 0, + atlasTop: 0, + atlasRight: 2, + atlasBottom: 2, + page: 1, + }); + return { + strikes: [ + { + ppem: 16, + planeUnitsPerEm: 16, + records, + pages: [bitmapPage(4, 8), bitmapPage(2, 2)], + }, + ], + }; +} + +function bitmapPage(width, height) { + return { + width, + height, + texture: new THREE.DataTexture(new Uint8Array(width * height), width, height, THREE.RedFormat), + }; +} + +function writeRecord(records, glyph, values) { + const view = new DataView(records.buffer, records.byteOffset, records.byteLength); + const offset = glyph * 20; + view.setInt16(offset, values.left, true); + view.setInt16(offset + 2, values.bottom, true); + view.setInt16(offset + 4, values.right, true); + view.setInt16(offset + 6, values.top, true); + view.setUint16(offset + 8, values.atlasLeft, true); + view.setUint16(offset + 10, values.atlasTop, true); + view.setUint16(offset + 12, values.atlasRight, true); + view.setUint16(offset + 14, values.atlasBottom, true); + view.setUint16(offset + 16, values.page, true); +} diff --git a/packages/text/tests/integration/mtsdf-retained-capacity.test.mjs b/packages/text/tests/integration/mtsdf-retained-capacity.test.mjs new file mode 100644 index 00000000..adcf0fa1 --- /dev/null +++ b/packages/text/tests/integration/mtsdf-retained-capacity.test.mjs @@ -0,0 +1,267 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import * as THREE from 'three/webgpu'; +import { + coalesceRasterInstanceRanges, + rasterInstanceCapacity, + rasterInstanceUpdateRanges, +} from '../../dist/internal/raster-instance-capacity.js'; +import { msdf } from '../../dist/raster/msdf.js'; + +const STRIDE = 28; + +test('MTSDF retained capacity plans bounded slack and coalesces dirty buckets', () => { + assert.equal(rasterInstanceCapacity(0), 0); + assert.equal(rasterInstanceCapacity(1), 2); + assert.equal(rasterInstanceCapacity(4), 5); + assert.equal(rasterInstanceCapacity(1024), 1280); + assert.equal(rasterInstanceCapacity(1025), 1281); + + assert.deepEqual(coalesceRasterInstanceRanges([0, 31, 32, 63, 96], 128, STRIDE), [ + { start: 0, count: 64 * STRIDE }, + { start: 96 * STRIDE, count: 32 * STRIDE }, + ]); + assert.deepEqual(coalesceRasterInstanceRanges([0, 64, 128, 192, 256, 320, 384, 448, 512], 576, STRIDE), [ + { start: 0, count: 576 * STRIDE }, + ]); + assert.deepEqual(rasterInstanceUpdateRanges([1, 2, 0, 0], [1, 3, 4, 5], [], 1, 2, 2), [{ start: 0, count: 4 }]); + assert.throws(() => rasterInstanceUpdateRanges([1, 2], [1], [], 1, 1, 2), /logical instance range/); +}); + +test('MTSDF retains capacity for arbitrary glyph replacement and replaces only on overflow', () => { + const resource = syntheticResource(); + const initialLayout = layout([0, 0, 0], 1, 2, 16); + const initialPaint = paint(3, [0.1, 0.2, 0.3, 0.4], [0.2, 0.3, 0.4, 0.5], [0.3, 0.4, 0.5, 0.6], 1, [-1, 2]); + let batch = committedBatch(initialLayout, resource, initialPaint); + try { + const mesh = batch.object.children[0]; + assert.ok(mesh); + const geometry = mesh.geometry; + const material = mesh.material; + const data = geometry.getAttribute('msdfOrigin').data; + const backingArray = data.array; + const initialValues = Array.from(backingArray.subarray(0, STRIDE)); + assert.equal(data.usage, THREE.DynamicDrawUsage); + assert.equal(batch.glyphCount, 3); + assert.equal(batch.drawCount, 1); + assert.equal(geometry.instanceCount, 3); + + 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]); + const replacement = msdf.stageBatch(batch, replacementLayout, resource, 0, replacementPaint, 1); + assert.equal(replacement.batch, batch); + assert.equal(batch.glyphCount, 3, 'staging preserves the live logical count'); + assert.equal(geometry.instanceCount, 3, 'staging preserves the live draw count'); + assert.deepEqual( + Array.from(backingArray.subarray(0, STRIDE)), + initialValues, + 'staging preserves live instance data', + ); + replacement.commit(); + + assert.equal(batch.object.children[0], mesh); + assert.equal(mesh.geometry, geometry); + assert.equal(mesh.material, material); + assert.equal(geometry.getAttribute('msdfOrigin').data, data); + assert.equal(data.array, backingArray); + assert.equal(batch.glyphCount, 3); + assert.equal(geometry.instanceCount, 3); + for (const [component, value] of Array.from(backingArray.subarray(0, STRIDE)).entries()) { + assert.notEqual(value, initialValues[component], `all-field replacement updates component ${component}`); + } + assert.deepEqual(data.updateRanges, [{ start: 0, count: 3 * STRIDE }]); + + const origin = geometry.getAttribute('msdfOrigin'); + origin.setXY(0, 123, 456); + const colorOnly = msdf.stageBatch( + batch, + replacementLayout, + resource, + 0, + paint(3, [0.2, 0.3, 0.4, 0.5], [0.3, 0.4, 0.5, 0.6], [0.4, 0.5, 0.6, 0.7], 3, [3, -4]), + 1, + ); + assert.equal(colorOnly.batch, batch); + colorOnly.commit(); + assert.equal(batch.glyphCount, 3, 'same-layout paint keeps the logical count'); + assert.equal(geometry.instanceCount, 3, 'same-layout paint keeps the authoritative draw count'); + assert.deepEqual([origin.getX(0), origin.getY(0)], [123, 456], 'color-only staging preserves structural values'); + + const shrunk = msdf.stageBatch( + batch, + layout([0, 1], 3, 5, 20), + resource, + 0, + paint(2, [0.6, 0.5, 0.4, 0.3], [0.7, 0.6, 0.5, 0.4], [0.8, 0.7, 0.6, 0.5], 2, [3, -4]), + 1, + ); + assert.equal(shrunk.batch, batch); + shrunk.commit(); + assert.equal(batch.glyphCount, 2); + assert.equal(batch.drawCount, 1); + assert.equal(geometry.instanceCount, 2); + assert.equal(data.array, backingArray, 'shrinking retains the backing allocation'); + + const exactCapacity = msdf.stageBatch( + batch, + layout([0, 1, 0, 1], 4, 6, 24), + resource, + 0, + paint(4, [0.1, 0.2, 0.3, 0.4], [0.2, 0.3, 0.4, 0.5], [0.3, 0.4, 0.5, 0.6], 1, [-1, 2]), + 1, + ); + assert.equal(exactCapacity.batch, batch); + exactCapacity.commit(); + assert.equal(batch.glyphCount, 4); + assert.equal(geometry.instanceCount, 4); + assert.equal(data.array, backingArray, 'growth through the allocated capacity retains the backing allocation'); + + const liveValues = Array.from(backingArray); + const liveRanges = data.updateRanges.map(({ start, count }) => ({ start, count })); + const aborted = msdf.stageBatch( + batch, + layout([1, 0, 1], 7, 8, 18), + resource, + 0, + 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], 2, [3, -4]), + 1, + ); + assert.equal(aborted.batch, batch); + aborted.abort(); + assert.equal(batch.glyphCount, 4); + assert.equal(geometry.instanceCount, 4); + assert.deepEqual(Array.from(backingArray), liveValues, 'aborting retained staging preserves live instance data'); + assert.deepEqual(data.updateRanges, liveRanges, 'aborting retained staging preserves live upload ranges'); + + const abortedOverflow = msdf.stageBatch( + batch, + layout([1, 0, 1, 0, 1], 10, 11, 18), + resource, + 0, + paint(5, [0.6, 0.5, 0.4, 0.3], [0.7, 0.6, 0.5, 0.4], [0.8, 0.7, 0.6, 0.5], 2, [3, -4]), + 1, + ); + assert.notEqual(abortedOverflow.batch, batch); + const abortedOverflowMesh = abortedOverflow.batch.object.children[0]; + assert.ok(abortedOverflowMesh); + let abortedOverflowGeometryDisposed = false; + abortedOverflowMesh.geometry.addEventListener('dispose', () => { + abortedOverflowGeometryDisposed = true; + }); + abortedOverflow.abort(); + assert.equal(abortedOverflowGeometryDisposed, true, 'aborting overflow disposes the staged geometry'); + assert.equal(abortedOverflow.batch.object.children.length, 0, 'aborting overflow clears staged draw objects'); + assert.equal(batch.glyphCount, 4); + assert.equal(geometry.instanceCount, 4); + assert.deepEqual(Array.from(backingArray), liveValues, 'aborting overflow preserves the live backing data'); + assert.deepEqual(data.updateRanges, liveRanges, 'aborting overflow preserves the live upload ranges'); + + const overflow = msdf.stageBatch( + batch, + layout([0, 1, 0, 1, 0], 12, 13, 18), + resource, + 0, + paint(5, [0.1, 0.2, 0.3, 0.4], [0.2, 0.3, 0.4, 0.5], [0.3, 0.4, 0.5, 0.6], 1, [-1, 2]), + 1, + ); + assert.notEqual(overflow.batch, batch); + assert.equal(batch.glyphCount, 4); + assert.equal(geometry.instanceCount, 4); + overflow.commit(); + const previous = batch; + batch = overflow.batch; + const replacementMesh = batch.object.children[0]; + assert.ok(replacementMesh); + assert.notEqual(replacementMesh.geometry, geometry); + assert.notEqual(replacementMesh.geometry.getAttribute('msdfOrigin').data.array, backingArray); + assert.equal(batch.glyphCount, 5); + assert.equal(replacementMesh.geometry.instanceCount, 5); + previous.dispose(); + } finally { + batch.dispose(); + msdf.dispose(resource); + } +}); + +function committedBatch(layoutValue, resource, paintValue) { + const stage = msdf.stageBatch(undefined, layoutValue, resource, 0, paintValue, 1); + stage.commit(); + return stage.batch; +} + +function layout(glyphIds, x, y, fontSize) { + return { + glyphIds: Uint16Array.from(glyphIds), + glyphFontSlots: new Uint16Array(glyphIds.length), + glyphFontSizes: Float32Array.from({ length: glyphIds.length }, () => fontSize), + x: Float32Array.from({ length: glyphIds.length }, (_value, index) => x + index), + y: Float32Array.from({ length: glyphIds.length }, (_value, index) => y + index), + }; +} + +function paint(count, color, outlineColor, shadowColor, outlineWidth, shadowOffset) { + return { + paintIndices: new Uint16Array(count), + palette: [ + { + color, + outline: { color: outlineColor, width: outlineWidth }, + shadow: { color: shadowColor, offset: shadowOffset }, + }, + ], + }; +} + +function syntheticResource() { + const records = new Uint8Array(2 * 20); + writeRecord(records, 0, { + left: 0, + bottom: 0, + right: 8, + top: 12, + atlasLeft: 0, + atlasTop: 0, + atlasRight: 1, + atlasBottom: 1, + page: 0, + }); + writeRecord(records, 1, { + left: 4, + bottom: 5, + right: 28, + top: 37, + atlasLeft: 1, + atlasTop: 1, + atlasRight: 2, + atlasBottom: 2, + page: 1, + }); + const texture = new THREE.DataArrayTexture(new Uint8Array(4 * 4 * 2 * 4), 4, 4, 2); + return { + emSize: 16, + pixelRange: 4, + planeUnitsPerEm: 16, + records, + pages: [ + { width: 4, height: 4 }, + { width: 4, height: 4 }, + ], + atlas: { width: 4, height: 4, layers: 2, texture }, + gpuBytes: 4 * 4 * 2 * 4, + }; +} + +function writeRecord(records, glyph, values) { + const view = new DataView(records.buffer, records.byteOffset, records.byteLength); + const offset = glyph * 20; + view.setInt16(offset, values.left, true); + view.setInt16(offset + 2, values.bottom, true); + view.setInt16(offset + 4, values.right, true); + view.setInt16(offset + 6, values.top, true); + view.setUint16(offset + 8, values.atlasLeft, true); + view.setUint16(offset + 10, values.atlasTop, true); + view.setUint16(offset + 12, values.atlasRight, true); + view.setUint16(offset + 14, values.atlasBottom, true); + view.setUint16(offset + 16, values.page, true); +} diff --git a/packages/text/tests/integration/slug-retained-capacity.test.mjs b/packages/text/tests/integration/slug-retained-capacity.test.mjs new file mode 100644 index 00000000..a2a962da --- /dev/null +++ b/packages/text/tests/integration/slug-retained-capacity.test.mjs @@ -0,0 +1,242 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import * as THREE from 'three/webgpu'; +import { slug } from '../../dist/raster/slug.js'; + +test('Slug retains both interleaved instance records and replaces changed page topology or overflow', () => { + const resource = syntheticResource(); + const initialLayout = layout([0, 0, 0], 1, 2, 16); + let batch = committedBatch(initialLayout, resource, paint(3, [0.1, 0.2, 0.3, 0.4])); + try { + const mesh = batch.object.children[0]; + assert.ok(mesh); + const geometry = mesh.geometry; + const material = mesh.material; + const floatData = geometry.getAttribute('slugOrigin').data; + const uintData = geometry.getAttribute('slugCurveBase').data; + const floatArray = floatData.array; + const uintArray = uintData.array; + const initialFloats = Array.from(floatArray); + const initialUints = Array.from(uintArray); + assert.equal(floatData.usage, THREE.DynamicDrawUsage); + assert.equal(uintData.usage, THREE.DynamicDrawUsage); + assert.equal(batch.glyphCount, 3); + assert.equal(batch.drawCount, 1); + assert.equal(geometry.instanceCount, 3); + + 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); + assert.equal(replacement.batch, batch); + assert.deepEqual(Array.from(floatArray), initialFloats, 'staging preserves live float data'); + assert.deepEqual(Array.from(uintArray), initialUints, 'staging preserves live integer data'); + replacement.commit(); + + assert.equal(batch.object.children[0], mesh); + assert.equal(mesh.geometry, geometry); + assert.equal(mesh.material, material); + assert.equal(geometry.getAttribute('slugOrigin').data, floatData); + assert.equal(geometry.getAttribute('slugCurveBase').data, uintData); + assert.equal(floatData.array, floatArray); + assert.equal(uintData.array, uintArray); + assert.notDeepEqual(Array.from(floatArray), initialFloats); + assert.notDeepEqual(Array.from(uintArray), initialUints); + assert.deepEqual(floatData.updateRanges, [{ start: 0, count: 3 * floatData.stride }]); + assert.deepEqual(uintData.updateRanges, [{ start: 0, count: 3 * uintData.stride }]); + + const origin = geometry.getAttribute('slugOrigin'); + origin.setXY(0, 123, 456); + const colorOnly = slug.stageBatch(batch, replacementLayout, resource, 0, paint(3, [0.2, 0.3, 0.4, 0.5]), 1); + colorOnly.commit(); + assert.deepEqual([origin.getX(0), origin.getY(0)], [123, 456], 'paint-only staging preserves structural data'); + + const shrunk = slug.stageBatch(batch, layout([0, 1], 3, 5, 20), resource, 0, paint(2, [0.4, 0.3, 0.2, 0.1]), 1); + assert.equal(shrunk.batch, batch); + shrunk.commit(); + assert.equal(batch.glyphCount, 2); + assert.equal(geometry.instanceCount, 2); + assert.equal(floatData.array, floatArray); + assert.equal(uintData.array, uintArray); + + const exactCapacity = slug.stageBatch( + batch, + layout([1, 0, 1, 0], 4, 6, 24), + resource, + 0, + paint(4, [0.3, 0.4, 0.5, 0.6]), + 1, + ); + assert.equal(exactCapacity.batch, batch); + exactCapacity.commit(); + assert.equal(batch.glyphCount, 4); + assert.equal(geometry.instanceCount, 4); + assert.equal(floatData.array, floatArray); + assert.equal(uintData.array, uintArray); + + const liveFloats = Array.from(floatArray); + const liveUints = Array.from(uintArray); + const liveFloatRanges = floatData.updateRanges.map(({ start, count }) => ({ start, count })); + const liveUintRanges = uintData.updateRanges.map(({ start, count }) => ({ start, count })); + const aborted = slug.stageBatch(batch, layout([0, 1, 0], 7, 8, 18), resource, 0, paint(3, [0.7, 0.6, 0.5, 0.4]), 1); + assert.equal(aborted.batch, batch); + aborted.abort(); + assert.equal(batch.glyphCount, 4); + assert.equal(geometry.instanceCount, 4); + assert.deepEqual(Array.from(floatArray), liveFloats); + assert.deepEqual(Array.from(uintArray), liveUints); + assert.deepEqual(floatData.updateRanges, liveFloatRanges); + assert.deepEqual(uintData.updateRanges, liveUintRanges); + + const changedTopology = slug.stageBatch( + batch, + layout([0, 2, 0], 10, 12, 18), + resource, + 0, + paint(3, [0.2, 0.4, 0.6, 0.8]), + 1, + ); + assert.notEqual(changedTopology.batch, batch); + assert.equal(changedTopology.batch.drawCount, 3); + const topologyGeometries = changedTopology.batch.object.children.map(({ geometry: stagedGeometry }) => { + let disposed = false; + stagedGeometry.addEventListener('dispose', () => { + disposed = true; + }); + return () => disposed; + }); + changedTopology.abort(); + assert.equal(changedTopology.batch.object.children.length, 0); + assert.ok(topologyGeometries.every((wasDisposed) => wasDisposed())); + assert.equal(batch.glyphCount, 4); + + const overflow = slug.stageBatch( + batch, + layout([1, 0, 1, 0, 1], 12, 13, 18), + resource, + 0, + paint(5, [0.8, 0.6, 0.4, 0.2]), + 1, + ); + assert.notEqual(overflow.batch, batch); + overflow.commit(); + const previous = batch; + batch = overflow.batch; + const replacementMesh = batch.object.children[0]; + assert.ok(replacementMesh); + assert.notEqual(replacementMesh.geometry, geometry); + assert.equal(batch.glyphCount, 5); + assert.equal(replacementMesh.geometry.instanceCount, 5); + previous.dispose(); + } finally { + batch.dispose(); + slug.dispose(resource); + } +}); + +function committedBatch(layoutValue, resource, paintValue) { + const stage = slug.stageBatch(undefined, layoutValue, resource, 0, paintValue, 1); + stage.commit(); + return stage.batch; +} + +function layout(glyphIds, x, y, fontSize) { + return { + glyphIds: Uint16Array.from(glyphIds), + glyphFontSlots: new Uint16Array(glyphIds.length), + glyphFontSizes: Float32Array.from({ length: glyphIds.length }, () => fontSize), + x: Float32Array.from({ length: glyphIds.length }, (_value, index) => x + index), + y: Float32Array.from({ length: glyphIds.length }, (_value, index) => y + index), + }; +} + +function paint(count, color) { + return { paintIndices: new Uint16Array(count), palette: [{ color }] }; +} + +function syntheticResource() { + const records = new Uint8Array(3 * 40); + writeRecord(records, 0, { + left: 0, + bottom: 0, + right: 1024, + top: 1536, + page: 0, + horizontalBands: 1, + verticalBands: 2, + curveBase: 0, + horizontalHeaderBase: 1, + verticalHeaderBase: 2, + referenceBase: 3, + }); + writeRecord(records, 1, { + left: 128, + bottom: 256, + right: 1792, + top: 1920, + page: 0, + horizontalBands: 3, + verticalBands: 4, + curveBase: 5, + horizontalHeaderBase: 6, + verticalHeaderBase: 7, + referenceBase: 8, + }); + writeRecord(records, 2, { + left: 64, + bottom: 96, + right: 960, + top: 1408, + page: 1, + horizontalBands: 2, + verticalBands: 3, + curveBase: 2, + horizontalHeaderBase: 3, + verticalHeaderBase: 4, + referenceBase: 5, + }); + return { + planeUnitsPerEm: 2048, + records, + pages: [slugPage(), slugPage()], + gpuBytes: 0, + }; +} + +function slugPage() { + return { + curveWidth: 4, + curveHeight: 4, + curveTexture: texture(new Uint16Array(4 * 4 * 4), 4, 4, THREE.RGBAFormat, THREE.HalfFloatType), + headerCount: 16, + headerWidth: 4, + headerHeight: 4, + headerTexture: texture(new Uint32Array(16), 4, 4, THREE.RedIntegerFormat, THREE.UnsignedIntType), + referenceCount: 16, + referenceWidth: 4, + referenceHeight: 4, + referenceTexture: texture(new Uint32Array(16), 4, 4, THREE.RedIntegerFormat, THREE.UnsignedIntType), + gpuBytes: 0, + }; +} + +function texture(data, width, height, format, type) { + return new THREE.DataTexture(data, width, height, format, type); +} + +function writeRecord(records, glyph, values) { + const view = new DataView(records.buffer, records.byteOffset, records.byteLength); + const offset = glyph * 40; + view.setInt16(offset, values.left, true); + view.setInt16(offset + 2, values.bottom, true); + view.setInt16(offset + 4, values.right, true); + view.setInt16(offset + 6, values.top, true); + view.setUint16(offset + 8, values.page, true); + view.setUint16(offset + 10, values.horizontalBands, true); + view.setUint16(offset + 12, values.verticalBands, true); + view.setUint32(offset + 16, values.curveBase, true); + view.setUint32(offset + 20, 1, true); + view.setUint32(offset + 24, values.horizontalHeaderBase, true); + view.setUint32(offset + 28, values.verticalHeaderBase, true); + view.setUint32(offset + 32, values.referenceBase, true); + view.setUint32(offset + 36, 1, true); +} diff --git a/packages/text/tests/integration/text-object.test.mjs b/packages/text/tests/integration/text-object.test.mjs index de7373a4..64a5c8ac 100644 --- a/packages/text/tests/integration/text-object.test.mjs +++ b/packages/text/tests/integration/text-object.test.mjs @@ -1196,7 +1196,7 @@ function bitmapOrigins(object) { for (const mesh of object.children) { const attribute = mesh.geometry?.getAttribute('bitmapOrigin'); assert.ok(attribute); - values.push(...attribute.array); + values.push(...attribute.array.subarray(0, mesh.geometry.instanceCount * attribute.itemSize)); } return Float32Array.from(values); } From 2245948c351861f1e8051ece0fd909747ec5bb99 Mon Sep 17 00:00:00 2001 From: Justin Walsh Date: Sat, 1 Aug 2026 20:26:38 -0400 Subject: [PATCH 2/2] fix(benchmarks): inspect submitted paint instances --- apps/benchmarks/src/renderer/react-text.ts | 11 +++++++++-- docs/packages/benchmarks.md | 2 +- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/apps/benchmarks/src/renderer/react-text.ts b/apps/benchmarks/src/renderer/react-text.ts index be199323..d9e69de7 100644 --- a/apps/benchmarks/src/renderer/react-text.ts +++ b/apps/benchmarks/src/renderer/react-text.ts @@ -132,7 +132,9 @@ async function runReconciliation(resources: ReactTextResources): Promise colors.count) { + throw new Error(`React Text submits ${instanceCount} instances from a ${colors.count}-entry paint buffer`); + } + for (let instance = 0; instance < instanceCount; instance += 1) { paints.add( [colors.getX(instance), colors.getY(instance), colors.getZ(instance), colors.getW(instance)].join(','), ); diff --git a/docs/packages/benchmarks.md b/docs/packages/benchmarks.md index 477af47d..d6ccd93d 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:87815fb10ee113959affd493fa5f766e82e9693adb9faa04b92cdae54d81f19c' +source_digest: 'sha256:de43027a0a1d5e34671a28cc375eee89d8889cab7b2924c970de5a411bf95a51' tags: [package, benchmarks, react, vite, product-e2e] sources: - id: manifest