feat: target-v1 core API, Three integration, and layout performance - #45
Open
thejustinwalsh wants to merge 73 commits into
Open
feat: target-v1 core API, Three integration, and layout performance#45thejustinwalsh wants to merge 73 commits into
thejustinwalsh wants to merge 73 commits into
Conversation
The target-v1 Three adapter dispatched batch targets through object-identity comparisons against the three first-party techniques and threw for anything else. That closed the public raster extension boundary proven in milestone 10 and made a wrapped technique unrenderable, so an application could not instrument a first-party runtime baker without losing its program. Resolve programs through a registry keyed by the technique's stable identifier and pre-register Bitmap, MTSDF, and Slug. An unregistered technique now fails at batch construction with a typed error naming the identifier. Rendering is unchanged: Bitmap, MTSDF, and Slug each still compile one draw with 1226, 1935, and 1510 lit pixels on both native WebGPU and forced WebGL2. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The benchmark reports selected strike ppem, rendered ppem, and scale ratio as density conformance evidence, but target-v1 exposed strike selection only as an internal call. Reimplementing nearest-strike selection in a consumer would let a reported strike diverge from the strike actually rendered, turning a display value into a silent correctness bug. Export selectBitmapStrikePpem from /raster/bitmap over the same nearestBitmapStrikeIndex the technique uses to pick a glyph's page. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Merged-v0 exposed gpuBytes on portable raster resources, but its own comment shows the number counted "packed reference pairs in R32UI" — a Three-specific WebGL workaround for a backend that mis-declares a sampler for UnsignedShortType. A renderer-neutral module cannot answer that question truthfully, and under another engine the figure would simply be wrong. Target-v1 correctly dropped the field when techniques stopped owning GPU resources. Restore the reporting where it is now true: each Three target tracks the shared atlas or page textures it creates plus the instance attribute buffers of its current revision, and Text and TextGroup expose the sum. A revision that transfers its resources to a successor reports zero so a warm commit cannot double-count. Bitmap reports 707,584 bytes, which is the 695,296-byte R8 page recorded in the package concept plus 12,288 bytes of instance attributes. Rendering is unchanged at 1226, 1935, and 1510 lit pixels on both backends. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…loor The target-v1 React binding drives the retained Text and TextGroup lifecycle correctly, but carried three defects that a real render exposes. Neither component wired onError, so a capacity overflow or preparation failure left a React application with a silently non-rendering group and no channel to observe it. Install a dispatcher at construction that forwards to a ref-held latest callback; assigning the prop directly to the retained object is rejected by the React compiler as mutation of a hook-returned value. TextGroup never called invalidate(), so under frameloop="demand" a change that touched only the group — renderVariant or capacity — mutated the retained object without scheduling a frame and rendered late or not at all. The React peer range admitted 19.0 and 19.1, which do not provide the useEffectEvent the binding imports, so both components would crash on mount. Raise the floor to 19.2. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Add the newest-first chronology entry for today's landing-stack work and correct a stale extraction-plan bullet requiring per-frame synchronous versus asynchronous selection in the React binding. That contradicted the settled Three API decision that the standard target is synchronous by construction, so a target returning pending cannot offer the same-observing-frame guarantee and is not accepted by the standard TextGroup binding. Refresh provenance on the concepts edited today rather than leaving them attributed to the previous producer. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…loader The core runtime already accepted both capabilities: TextRuntime.loadFont takes an AbortSignal and threads it through registered-font and technique loading, and createTextRuntime accepts a caller-owned FontRegistry. The Three FontLoader exposed neither, so the adapter silently withheld them. Cancellation is a regression against merged v0, where FontRegistry.registerAsset and the core FontLoader both took a signal. Several consumers abort mid-load, and without forwarding, a cancelled Three load ran to completion. Withholding the registry is why a consumer holding registry-scoped state — a retained fixture controller, an artifact-byte ceiling — could not adopt the v1 loader without also keeping the v0 one, which forced loading every asset twice. Rendering is unchanged at 1226, 1935, and 1510 lit pixels on both backends. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Each Three target built its node graph inline, so a third-party program registered through `registerThreeRasterProgram` could not change its final output without reimplementing Bitmap's atlas sampling, MTSDF's median decode and screen-space range, or Slug's band walk and quadratic solve. `/three` now exports `bitmapShader`, `mtsdfShader`, and `slugShader`. Each takes one glyph instance's resolved nodes plus that batch's bound GPU resources and returns a named readonly output carrying position, coverage, resolved colour, opacity, and the intermediate stages a composition needs. The first-party targets consume those exact functions. A separate copy kept for external use would be a shader nobody renders: it would drift from the built-ins the first time either side changed, and every claim made about it would silently stop being true. Because `ThreeBitmapTarget`, `ThreeMtsdfTarget`, and `ThreeSlugTarget` build their materials from the export, deleting it breaks rendering rather than an unused mirror, and any change to the canonical math necessarily moves both paths together. `registerThreeRasterProgram` also infers its technique, so a program may type its prepared batches, storage, and binding concretely. That replaces the three erasing casts the first-party registrations previously needed and keeps the same requirement off third parties; the registry holds the erased form after proving the pairing at the call. Rendering is unchanged: Bitmap, MTSDF, and Slug still compile one draw with 1,226, 1,935, and 1,510 lit pixels on native WebGPU and forced WebGL2 with retained draw and storage identity. A new browser proof renders one paragraph through the pre-registered Bitmap program and then through a third-party program that owns its own attributes, geometry, and material and composes only its final colour over `bitmapShader`; both light the same 1,243-pixel set while the composed pass emits no green channel. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every Bitmap, MTSDF, and Slug fixture now reaches the benchmark through one target-v1 `FontLoader` load instead of a merged-v0 `FontRegistry` registration. Baked delivery still authenticates its gzip artifact and only then publishes those bytes as a blob URL, because `LoadedFontInput` names URLs rather than bytes; runtime delivery passes the measured core baker as the request's `runtimeBake`. Both paths thread the caller's `AbortSignal`, which previously guarded only the merged-v0 fetch. Because the loader now accepts a caller-owned registry, `LoadedFont.font` is a `RegisteredFont` in the registry the surface already owns, so `BenchmarkFontAsset` keeps `font` as a projection of `loaded.font` rather than a second registration. The retained merged-v0 `raster` module resolves the raster key the load already attached, so no consumer bakes a second time; both carriers derive one identical key for all four fixture configurations. Delivery metrics move onto a clone of the technique's runtime baker, which still renders because the Three program registry resolves programs by stable technique ID rather than object identity. Loads that name no registry share one `THREE.LoadingManager` so their fonts share a text runtime as paragraph batching requires; each caller-supplied registry keeps its own manager, runtime, and loader, preserving the ownership isolation those surfaces already had. The headless conformance suite only runs baked delivery, so `benchmark:runtime-fallback` adds the missing lane: Bitmap, MTSDF, and Slug each report `1/1 exact` baked and runtime frames with zero mismatched bytes and zero changed pixels. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ement Every span resolver applies the last covering span, but the composer emitted nested spans before their enclosing span, so inner formatting was overridden by the formatting it was nested in. Emit the enclosing span first. A style-only span was given an empty paint object, which shadowed the paragraph paint through `span.paint ?? state.paint` and reset those glyphs to the default colour. Omit paint when the format states none, so the span inherits it the same way it already inherits the surrounding font. Replacement text carries its own formatting, so retaining the previous spans reinterpreted them against unrelated text: a plain string shorter than the literal it replaced failed preparation with a range error and, through a TextGroup, poisoned the whole group's synchronization while the stale paragraph stayed drawn. Clear the replaced spans when an update states text without spans. Integration coverage exercises each case against real shaped output: inherited font handles and glyph IDs, per-glyph font sizes and canonical linear colours, UTF-16 ranges across a surrogate pair, tuple-spread and direct span calls, and a formatted literal driven through the Three render lifecycle to its drawn per-run instance counts. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Resolving composed spans by nesting, inheritance, and replacement changed formatted-text and paragraph code that the browser-core graph actually measures, growing it 511 raw bytes from 364,766 to 365,277. Exactly one entry moved. No Wasm baker hash changed, which also confirms the divergence seen in fresh worktrees is a build-reproducibility problem rather than drift in this tree; that is tracked separately and must not be resolved by regenerating this record. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The MTSDF glyph record table is a generated wire contract, but its stride was only reachable internally, so consumers that walk the table copied the 20-byte offset by hand. Re-export it the way `@pmndrs/text/raster/slug` already re-exports `SLUG_GLYPH_RECORD_STRIDE`, keeping one owner for the constant. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Move the MTSDF technique lane off the merged-v0 raster APIs and onto the target-v1 surfaces the font-asset workload already loads: - consume `BenchmarkFontAsset.loaded` (`LoadedFont<typeof mtsdf>`) instead of the v0 `RegisteredFont`/`MsdfModule` projection, which drops the redundant second raster load and decode from the conformance capture; - build paragraphs with `Text` from `@pmndrs/text/three`, replacing the flat v0 properties with nested `contentBox`/`style`/`paint` and `await ready` with an explicit world-matrix pass plus an `error` check; - read the CPU reference from `MtsdfData` per-page texels in top-down page space rather than from the v0 flat, vertically flipped `DataArrayTexture`, and address records through the published technique stride; - resolve the MTSDF raster key through the renderer-neutral `@pmndrs/text/raster/mtsdf` module. The CPU sampler is the algebraic mirror of the v0 one, so the unit tests keep their pinned pixel rows; only the fabricated page bytes flip to page order. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Move the advanced-shaping conformance target and the React Text product target off the merged-v0 surface onto target-v1, preserving both pinned oracles byte for byte. Advanced shaping now loads its fixtures through the v1 Three FontLoader on a loading manager it owns, shapes inside a scene because a standalone Text only binds a batch while parented, and reads layout after the world-matrix update instead of an awaited readiness promise. Flat properties become the nested content-box and style groups. React Text moves to the `@pmndrs/text/r3f` binding: the v0 font token becomes a LoadedFontRequest resolved through `useFont`, batch failures are reported through the new `onError` prop, and the paint probe reads each draw's own window of the shared instance buffer. Two target-v1 behaviours needed explicit handling. An update merges into the state a Text already holds, so restoring a natural measurement states an unconstrained axis rather than dropping the content box. A paragraph style validates an unbounded OpenType feature as a non-empty UTF-16 range, so the empty opening frame of each timeline states no features. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
An unbounded feature covers whatever it is applied to, and resolveFeatures defaults its range to the containing one. On an empty paragraph that produced [0, 0), which the shared range check rejects as empty, so preparation failed before the paragraph had any text. That fails an ordinary feature-styled input field before its first character is typed, which merged v0 accepted. Treat an unbounded feature over an empty containing range as vacuous and drop it. An explicitly empty range stays a caller error and still fails preparation while preserving the prior revision. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…echnique `bitmapRasterKey` is the same contract function in both entry points, but importing it from `/raster/bitmap/v0` pulled the merged-v0 Three-bound raster module into a file that only reads baked atlas topology. Import it from `/raster/bitmap` so atlas metadata no longer depends on the merged-v0 draw path. The key is content-derived, so every consumer resolves the identical raster. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`slugShader` documents `emOrigin` as the glyph quad's upper-left em coordinate, and its position half maps `positionLocal.y` downward: the quad's `t = 0` edge is the glyph's top. `writeSlugStorage` instead published the lower-left corner and the shader advanced the em coordinate upward with `t`, so every glyph sampled its coverage integral vertically mirrored inside an otherwise correctly placed quad. Publish the documented upper-left corner and walk em space downward with the same `t` the position half already uses. Both halves now agree, and `slugDilate` keeps offsetting the texture coordinate by the world-space dilation because em y and world y once again point the same way. Inter's target-v1 Slug proof moves from 1510 to 1479 lit pixels. The old figure was a self-baseline of this defect: no pixel oracle covered the target-v1 Slug path, and both oracles that do cover it - the CPU band-walk reference and the browser-rasterized source outline - reject the old output and accept the new one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every Slug benchmark surface that owned a merged-v0 raster now consumes the `LoadedFont` the shared font-asset workload already produced. `slug.decode` and the `font.loadRaster` that fed it are gone from the app: the conformance capture, the product scene, and the CPU reference all read one `loaded.data`, so a page resource is fetched and decoded once per load instead of once per consumer. The external-parity probe's fetch contract records that change. `renderFlatSlugCpuReference` takes `SlugData` and reads the technique's own `SLUG_GLYPH_RECORD_STRIDE`. Target-v1 hands it page bytes rather than textures, so it binds one typed view per page instead of re-reading `DataTexture.image.data` inside every band, and it reads the 16-bit reference table directly rather than unpacking the Three-specific R32UI pairs. Its unit fixtures fabricate the same raw byte arrays and keep the pinned pixel rows unchanged. `Text` replaces `await line.ready` with a scene attachment, a forced `updateMatrixWorld`, and an `error` read, and flat properties become nested `contentBox`/`style`/`paint`. Widths stay exact so centre and end alignment keep measuring against a real box, and packed colours become `#rrggbb` because `ColorInput` rejects numeric hex while applying the same transfer. GPU bytes now come from `Text.gpuBytes`, which is the only honest source once the technique stopped owning textures. `SlugRasterConfiguration` reports decoded resource bytes under names that say so; the scenario contract drops its equality against those subtotals, which could now only restate the app's own arithmetic, and asserts instead that the renderer retains at least what the technique decoded. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both defects were masked by the same mistake: a rendered-pixel count taken from the program under test proves the program is stable, not correct. Slug's 1,510 and Bitmap's 1,226 were self-baselines of their own defects, and holding them constant actively suppressed the fixes. Note also that the concept prose the migration lanes changed was left with stale provenance and digests by design, so the lanes could run in parallel without conflicting on the same frontmatter. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…e shader Driving the finite Bitmap conformance oracle from target-v1 exposed two defects in the exported `bitmapShader`. Both moved ink without removing it, so the retained proof page's coverage threshold reported a healthy 1,226 lit pixels while the exact CPU atlas reference disagreed in 23,172 bytes. - `atlasUv` still applied merged-v0's vertical flip. That flip belongs to the merged renderer's `flipY`-enabled upload; the target-v1 pages upload in the atlas's own top-down row order, so every fragment sampled the mirrored row of its page. Address the page space directly, as MTSDF already does. - The graph had dropped the physical-pixel snap the strike's integer placement depends on. Bitmap coverage is authored at one atlas texel per device pixel, so an unsnapped quad resamples the strike instead of reproducing it. Publish the snap as `clipPosition` on the shader's output contract rather than applying it inside `ThreeBitmapTarget`, so a third-party program composing over the exported graph inherits it by construction: the output offers no other route to a vertex stage. MTSDF and Slug deliberately publish no clip position, since a distance field and an analytic outline integral are both correct at any subpixel placement. Migrate the finite Bitmap conformance lane onto target-v1 `Text` and `LoadedFont` raster data, which also drops the second raster load and decode the merged-v0 path performed. Both `bitmap-text-webgl2` and `source-outline-bitmap-webgl2` consume that scene, so both moved together. The migrated lane reproduces the benchmark's independent CPU compositor in zero mismatched bytes and returns merged-v0's pinned full-frame hash `a47930d3…e893` with the same 5,930 lit and 3,473 half-coverage pixels and `[68, 18, 313, 112]` ink bounds: the oracle changed renderer without changing what counts as correct. The full 20-case headless conformance suite passes. The composed proof now lights the same 2,616-pixel set as the canonical pass instead of diverging in glyph footprint; the retained Bitmap proof moves to 2,606 lit pixels while MTSDF and Slug stay at 1,935 and 1,510. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Accepting a paragraph-wide font feature on empty text added 92 raw bytes to core paragraph preparation, which propagates identically to the browser core graph and all three runtime raster graphs. No Wasm baker hash moved, which again distinguishes this legitimate source-driven delta from the cross-checkout build variance tracked separately. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…d quads The 1,000-byte brotli growth ceiling was reviewed before the Three Bitmap program carried device-pixel snapping, so it measured a graph missing a contract milestone 1 records as hard: the TSL graph snaps projected quad edges to physical pixels. Restoring that snapping is what makes this graph reproduce the pinned merged-v0 frame a47930d3…e893 with zero mismatched bytes against the CPU atlas compositor. Growth is 1,006 bytes. Raise the ceiling to 1,050 to cover the contract the baseline was taken without. Every other budget on every graph is unchanged and still passes, several with room to spare. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The Bitmap, MTSDF, and Slug live scenes now build a standalone target-v1 `Text` from the `LoadedFont` the font-asset lane already produced, commit it by parenting and forcing `updateMatrixWorld`, and read `error` and `layout` instead of awaiting a readiness promise. They stay off `TextGroup` so the implicit batch-of-one adapter path keeps being exercised and their draw counts stay comparable with merged v0. Merged v0 packaged glyph identity matching and interpolation together in `captureBitmapGlyphPositions` and `createBitmapGlyphPositionTransition`, for Bitmap only. Target-v1 core deliberately stops at owned glyph snapshots and topology-guarded displayed-origin writes, so the policy moves into one shared application helper that all three techniques use: it matches on the identity v0 matched on, interpolates toward the shaped origins, writes through `setGlyphOrigins`, clears the overrides once settled, and reports `matchedGlyphs` so the viewport telemetry keeps its meaning. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Move the technique-generic comparison workload layer off merged v0. Every
workload factory now takes the `LoadedFont` the shared target-v1 `FontLoader`
already produced and builds `Text` from `@pmndrs/text/three`, so no comparison
scene names or loads a raster module.
Type erasure happens once, at the font. `LoadedFont` is covariant in its
technique, so a concrete handle widens to `LoadedFont<AnyRasterTechnique>` and
every `Text`, `TextGroup`, and `TextUpdate` downstream is uniformly erased with
no cast. Erasing at the `Text` does not compile: `set` and the `font` accessor
make `Text` invariant in its technique.
Batching becomes a per-workload policy. The six multi-instance workloads mount
under one shared `TextGroup` so their paragraphs pack into a single batch owning
one set of GPU resources; Paragraph stress stays standalone because one Text
holding a large body is already a batch of one, which keeps both adapter paths
under test. The group takes a `grow` capacity so a chunk boundary cannot split a
paragraph's glyph run into extra draws.
Publication replaces readiness: a rebuild stages its batch root off-scene,
commits it with one `updateMatrixWorld`, positions from the committed layouts,
and only then swaps the live scene. The retained font-fixture swap collapses
from an async two-phase rollback to `set({ font })` plus one publication.
The presentation probe now also samples draw and glyph counts at the settled
workload mount, before the presentation timeline advances. Measured there, every
deterministic cell reports the same drawCount as merged v0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The migration lanes ran concurrently in separate worktrees and deliberately left provenance and digests alone so they would not conflict four ways on the same frontmatter. Regenerate both concepts once, now that every lane has landed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Maintainers intend an editorial piece as a v1 showcase, so the typography that composition depends on moves out of the post-v1 flow-region milestone and into this one as items 11.12 through 11.14. Two of those are scoped now because deferring them is expensive rather than merely late. Underline and strikeout metrics live in the source post and OS/2 tables and are absent from the baked artifact, so adding decoration after release would bump the artifact version and invalidate every font already baked; carrying the metrics costs a few bytes and no public API. A hyphen the line breaker inserts at a break has no source cluster, while every glyph today maps back to a UTF-16 cluster, so the contract question is settled before the API freezes even though patterns and break selection stay later work. The remaining typography — wordSpacing, first-line indent, paragraph spacing, and justification controls — is additive and sequenced after the current Three.js and span slice. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The probe typechecked under the app project but not tsconfig.scripts.json, where getImageData returns Uint8ClampedArray<ArrayBufferLike> and a role locator resolves to HTMLElement | SVGElement. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Glyph identity keyed on exact font size, so a resize failed to match a glyph with itself — the one change the transition exists to animate. Every glyph read as new, nothing interpolated, and the size case of the presentation rule was declared but dead. Font handle, glyph id, cluster, and occurrence still identify a glyph, and a uniform scale preserves visual order, so matching across a resize recovers exactly the glyph that moved. Measured on the live probe: matched glyphs go from 0/1350 to 1350/1350, and MSDF and Slug present eight distinct intermediate frames where they previously had nothing to interpolate. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Shaping is deterministic in its request, and the request carries no font size, line height, or letter spacing — those scale the shaped advances afterward. An animated resize therefore rebuilt an identical request and paid for HarfRust to return the same glyphs, which is why a size-only change cost more than a cold shape: it redid the work and rebuilt the fallback maps. Compare the rebuilt request against the retained one and reuse the shape when they match. A shape is plain owned typed arrays that nothing releases, so retaining one across preparations is safe, and any shaping-relevant difference falls through to a full shape exactly as before. Measured on a repeated-ipsum paragraph, size-only reshape median: 729 glyphs 6.93ms to 4.31ms, 2916 glyphs 19.72ms to 10.22ms, 5832 glyphs 36.20ms to 21.85ms, with p95 at 5832 falling from 48.42ms to 26.51ms. The 2916-glyph case now fits inside a 60fps frame. Layout is unchanged: the pinned contract hashes bb15bbcc, 4f111a3f, and e8c0e9d5 all reproduce, conformance stays 20/20, and bitmap-text-webgl2 holds a47930d3 with zero mismatched bytes. 5832 glyphs still exceeds the frame budget. Shaping is no longer the cost there; measurement and layout, which must re-run at a new size, are what remain. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
indexClusters allocated three fresh typed arrays every preparation. They now reuse the retained backing memory, growing to a high watermark that later preparations reuse and never shrinking, with a 512-element floor so ordinary paragraphs never pay a growth step on their first frames. The returned views carry the live length, which keeps the binary search in clusterRangeSum correct while the backing allocation outlives any single preparation. This is the smaller half of the problem. The dominant allocation is ownShape copying all eleven shaped arrays out of wasm memory on every shape, even though readResultViews already builds those views zero-copy over the shaper's own memory. Removing that copy needs the shaper to double buffer its result region in Rust, so a retained view cannot be overwritten by the next call. Layout is unchanged: bb15bbcc, 4f111a3f, and e8c0e9d5 all reproduce, conformance holds at 20/20, and bitmap-text-webgl2 keeps a47930d3 with zero mismatched bytes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…bpath Deleting the merged-v0 surface exposed that /three registered all three built-in programs at module scope, so importing anything from it pulled every target, shader, and decoder. The three measured runtime graphs collapsed to within three bytes of each other, which is what a bundler carrying all three techniques into every application looks like, and it worked against the package's sideEffects declaration. Move each registration into @pmndrs/text/three/bitmap, /three/mtsdf, and /three/slug. Each re-exports its portable technique and registers its program, so an application writes one import rather than two and a bundler drops the techniques it never names. /three keeps only technique-agnostic surface, and the batch now implements the unified ThreeRasterTargetOwner rather than the three structurally identical per-technique owner interfaces. sideEffects becomes an explicit list of those three modules. They genuinely have one — they register a program when evaluated — and a blanket false would let a bundler legally drop them. Separation is measured, not asserted: raw graphs go from 178,792 / 178,789 / 178,790 to 89,604 / 94,145 / 79,169, a spread of 14,976 bytes where it was 3, and Bitmap's brotli falls from 24,500 to 14,229. Slug is smallest, which is the expected shape: no atlas decoder and no strike or page partitioning. The CPU reference compositors deliberately keep importing /raster/*, since pulling a renderer into them would invert the boundary this split exists to hold. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A frame that misses its budget says nothing about which phase spent it, and a sampling profile answers that only for the window it recorded, in self time that fragments a phase across its callees. Unicode analysis reads as 7% of self time across three functions and 24.6% inclusive. Instrument the pipeline with opt-in phase spans and add the benchmark that reads them. Nothing records until an application installs a profiler, so the cost while idle is one comparison per phase. The installed profiler receives the raw span, so a consumer can total it, forward it to the User Timing timeline for a browser profile, or both. The benchmark keeps the invalidation classes apart rather than averaging them, because they invalidate different caches, and reports a median of warmed repetitions with its relative standard deviation so a reader can tell a real change from sampling noise. The first result: every class costs the same as a cold build. Changing a layout width costs 131.66ms at 25515 glyphs where building the paragraph from nothing costs 134.66ms, flat at ~5.1us/glyph regardless of what changed.
Preparation resolves Unicode segmentation, bidi levels, style and run segmentation, shaping and cluster metrics from the text, fonts, spans and style. Three things made every update pay for all of it: The batch built a fresh paragraph per update, so the five caches inside it were dead on arrival and the shape reuse landed earlier never fired. Retain the paragraph in a layout session instead. A content box is a layout constraint the retained paragraph already answers per call, so a width change no longer enters preparation at all. Font fallback probed for `.notdef` by laying the paragraph out, which broke lines and positioned every glyph for a result it discarded, and made font selection depend on where the text happened to wrap. Shaping already knows which clusters fell back, so ask it. Unicode analysis and bidi resolution are decided by the text and its base direction and by nothing else, so retain them across any change that leaves both alone. At 29889 glyphs a resize goes 5.126 -> 2.210us/glyph and a reflow 5.160 -> 1.857us/glyph, measured by text:layout-benchmark. Layout output is unchanged.
Positioning accumulated fourteen plain arrays by push and then copied every one of them through `TypedArray.from`. It also materialized the selected glyphs of a run as an array holding one entry per glyph, to describe a span that is always contiguous and ascending, and walked it with `entries()`, which allocates a two-element array per glyph. Write the output in place instead, sized from the shaped runs that bound it and grown if that bound is ever wrong, and select with two indices. Resolving a text offset to a cluster becomes a load from a table built once per preparation, replacing the lower-bound search that ran twice at every cluster boundary of every glyph and was the hottest JavaScript frame in a Chrome profile of the layout path. Both position axes accumulate in double precision and narrow once at the end. Alignment and justification read `x` back and add to it, so single precision rounded at the store and again at the adjustment and drifted from the mixed-direction goldens. The same will hold for `y` under vertical alignment, so the accumulator follows the axis rather than today's caller. Positioning falls from 26.29ms to 3.90ms at 29889 glyphs. Against the original baseline a resize is 3.6x and a reflow 4.7x, measured by text:layout-benchmark. Layout output is unchanged.
The positioning phase reported one number for two unrelated costs. Splitting them shows fragment building at 0.88ms and boundary reshaping at 7.04ms of a 33.72ms resize at 25515 glyphs, which is a fifth of the update spent re-entering the shaper.
D-159 states the tiers and that a change enters at its own. D-160 states that phase spans decide phase-level questions and sampling does not: self time reported Unicode analysis at 7% where it is 24.6% inclusive, and inlining moved the same chain's self time between the caller and the leaf across two runs of identical code.
The reviewed ceilings pushed back exactly as their comment intends: raw growth reached 55,623 of 54,000, gzip 9,290 of 9,200, and Brotli 7,452 of 7,400. Every other entry, including all four Wasm modules, hashed identically, so the growth is this work alone. 646 Brotli bytes bought a retained layout session, text-analysis reuse, in-place positioning, and the profiler that measures them, against a resize falling from 130.78ms to 33.72ms at 25515 glyphs. The raised ceiling keeps the same one-or-two-feature gap rather than absorbing whatever lands next.
Script itemization received a fresh substring for every grapheme, walked it as strings so every scalar allocated another, and materialized an array for each code point's script extensions and again for the non-neutral filter. It also carried an object per grapheme holding its own candidate array, and rebuilt a script item by spread on every merge. Grapheme boundaries and script itemization each ran their own segmentation pass over the same text. Segment once, resolve scripts into parallel arrays with candidates in one flat run addressed per grapheme, read the source text by index, and intersect candidates in a reused scratch buffer, so an ASCII grapheme allocates nothing. Analysis falls from 37.69ms to 21.46ms at 25515 glyphs; a cold build goes 96.30 -> 78.81ms and a text edit 84.82 -> 66.11ms. Collecting line breaks in one pass rather than spreading the iterator and mapping it, and accumulating grapheme boundaries into a typed array rather than converting a plain one, did not move the number measurably. Both are kept because they are strictly less work, not because they were shown to pay. Every official Unicode 17 grapheme and line-break vector still passes.
Shaping appends one ellipsis run per source run, clustered past the end of the text, so overflow can be measured. Reading fallback from shaped glyph identity exposed those runs for the first time: a primary font without U+2026 shapes them to .notdef, so the probe substituted a font for a cluster outside the text and preparation threw for the whole batch, not one paragraph. An icon font ahead of a text font is the ordinary stack that reaches this. The existing multi-font test passed only because its fallback span ends at the final cluster, which is the one arrangement where the stray entry is dropped harmlessly; the added test places it mid-text and fails without this change. Also clamp the cluster-index lookup. An offset past the table used to return the cluster count and now returned zero, inverting a prefix difference instead of yielding nothing. Shaped clusters cross the Wasm boundary, so a malformed one must degrade rather than mis-space silently. The benchmark now installs a discarding profiler during warmup, so the compiler optimizes the instrumented branch the recorded repetitions actually take.
A paragraph allocated one `MeasuredCluster` object per extended grapheme cluster on every update, so a 25k-glyph paragraph produced tens of thousands of short-lived objects per frame and made the garbage collector the largest single entry in a Node CPU profile of the layout path. Measurement now writes the same values into parallel typed arrays whose buffers are retained across updates by the existing `reuseTypedArray` high-watermark helper: starts, ends, and style indices as `Uint32Array`, the safe-before, required-break, and hard-break predicates packed into one `Uint8Array` of flags, and the style reference replaced by an index into the already-retained style segments. Cluster advances stay `Float64Array`. Line breaking accumulates a line advance from them one cluster at a time and compares the running total against the width limit, so narrowing them to single precision would move where lines break and change layout output. `clusterStarts` was a byte-for-byte copy of the new `starts` array and is dropped rather than rebuilt. Line metrics take the cluster range instead of a slice of it, which removes the per-line slice, filter, and map, and lets a line whose clusters all share one style resolve its font and scale once. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Fragment collection allocated three objects for every fragment: a logical entry, a decorated copy built by a `.map` with an object spread, and a final copy that added the line index. It also copied every line's bidi levels out of the paragraph analysis and copied the fragment array again to reorder it. Fragments are now appended once to the output array, and the shaping flags and reshape decision are filled in place after the line's first and last fragments are known. Reordering runs over the line's own range of that array rather than a copy, and the levels of every line are resolved into a single paragraph-sized scratch buffer. Fragment count is bounded by lines and runs rather than by glyphs, so the fragments stay objects; only the duplicated intermediates are removed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Cluster measurement collected shaped advances into a `Map` and shaping boundaries into two `Set`s, all keyed by text offset. Every glyph of the paragraph wrote to all three, so their entries were themselves a per-glyph allocation on the update path, and every cluster then paid three hash lookups to read them back. The three are now typed arrays indexed by text offset, with the shaped boundary and unsafe-to-break predicates packed into one flag byte. The required line breaks become an offset-indexed byte for the same reason. Advances accumulate in the same shaped order and in double precision, so the per-cluster totals are unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Instance packing built a `${resource}\0${pipelineVariant}` template string for
every glyph to key the batching map, allocating and hashing one string per
glyph per update. The key is really a resource/pipeline-variant pair, so it now
resolves through nested maps behind a last-selection memo that consecutive
glyphs almost always hit. `orderedEntries` preserves the first-seen entry order
that batch identity and run offsets depend on, so emitted instances stay
byte-identical.
A single chunk already spans its whole entry, so writing it no longer copies
the glyph array, and run resolution reads the chunk capacity it recorded
instead of rescanning every prepared batch.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Resolving paint and render variant materialized two glyph-length arrays per prepared revision and ran a binary search over cascade starts for every glyph, even though a paragraph without spans resolves one segment that attributes every cluster identically. Attribution is now a discriminated union: a single covering segment, or an empty cascade, yields one `uniform` value, and only a genuinely segmented cascade builds the parallel arrays. The arrays it does build are preallocated to the glyph count rather than grown by `push`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Packing allocated one `RasterGlyphInput` per glyph per update and retained it until the batch was written, so every update handed the collector a glyph-sized set of objects that survived long enough to be scavenged. No prepared batch retains those inputs past `pack`, so the batch now owns the objects and overwrites them in place, alongside the spare storage it already reuses. A slot is only consumed once a selection accepts it, so a technique that declines a glyph reuses the same slot. `RasterGlyphInput` documents the call-scoped lifetime this relies on, and `dispose` drops the pool. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Packing now reuses one `RasterGlyphInput` per glyph across updates, which constrains any technique implementing `select` or `writeStorage`. The package concept states that lifetime, and its `source_digest` catches up with the packing and technique-contract sources. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`extensionSet` lost its last caller when script resolution stopped materializing an array per code point, and package lint fails on it. Refresh the package digests the last four source commits invalidated.
…daries Boundary reshaping requested the whole run as shaping context. That is the context the retained paragraph shape was produced with, so the shaper returned the glyphs it had already returned, on roughly every line, on every layout. The buffer's beginning- and end-of-text flags did not rescue it: they describe the buffer edge and the surrounding text shipped as context overrides them. Three independent lines of evidence agree it changes nothing. The mechanism above. A measurement over 640 ranges and 20280 glyphs across Latin word wrap, Arabic word wrap, and Arabic character wrap narrow enough to break inside joined words, where every reshaped glyph matched the retained shape. And the pinned natural, wide, and narrow layout hashes plus the whole alignment, clipping, max-lines, ellipsis, and justification contract, which are unchanged with it removed. `ReshapeRange` stays. A narrowed context is a real future need — a truncated line whose last letter should take its final form, or a line composed as an isolated unit for per-line widths — and the contract tests now assert zero crossings, so reintroducing one is deliberate and visible rather than silent. At 25515 glyphs a resize goes 21.54 -> 11.98ms and a reflow 17.37 -> 8.12ms, which is inside the 8.33ms budget at 120Hz. Against the pre-optimization baseline on an identical workload the resize is 8.6x, the reflow 13.6x, and a text edit 3.4x.
The headless paragraph layout and policy scenes pinned reshape crossing counts alongside their output hashes. With boundary reshaping removed the hashes are unchanged — `bb15bbcc:4f111a3f:e8c0e9d5` still reproduces with shape=1, batched=2, layouts=3, glyphs=165 — and only the crossing counts move, so they now assert zero. Both validators failed with one opaque message covering six conditions, which said nothing about which drifted. They now report the observed values. Raise the browser-core raw ceiling and the Unicode analysis raw and minified ceilings for the structure-of-arrays cluster measurement, the pooled instance packing, and the allocation-free grapheme resolution. The Unicode growth is comment-dominated: +3,010 raw against +298 Brotli. Raw has now moved twice in one workstream, which is the signal to reclaim rather than raise again.
The profiler's evidence is recorded, so it comes out of the shipped graph: 3,026 raw and 253 Brotli bytes, mostly from its call sites rather than the module. The browser-core ceilings drop to track what the tree now measures, so the next feature meets resistance instead of inheriting slack the profiler was holding open. The benchmark keeps its per-class medians and relative standard deviation. The comparison workload debounced by discarding work inside its own update path: successive configurations were merged into the pending one, so a dragged control reported the cost of the two updates that survived rather than the twenty it requested. That measures the queue, not the workload. Debouncing moves to the control, where dropping a superseded value is free, and the scene's queue becomes first-in-first-out and applies everything it is handed.
The debounce landed in the viewport effect, which is also how the paragraph-stress motion drives layout width and font size: it ramps the width roughly every 42ms across its first 1.76s, inside the 48ms window, so each step reset the timer and the workload would have stalled instead of animating. Applying immediately there is what the motion needs. The control that wanted settling is workload amount, the one whose every intermediate value rebuilds the scene from nothing. It settles in the handler a person drags, which the animation does not go through, so the two paths stay separate.
`./raster/mtsdf` and `./three/mtsdf` sat beside `./bakers/msdf`, so a consumer wrote one spelling to bake and another to render. The export paths and the symbols reachable through them now read msdf. Nothing a consumer cannot see moves. The glTF extension encoding value, the packaged schema enum, msdfgen's own `mtsdf` CLI mode — which is a different algorithm from its `msdf` mode and would have silently changed what the native oracle generates — the validator's diagnostic codes, the Rust crate and bin target names, the generated ABI module, the baker Wasm filenames, and every fixture filename all keep their spelling, because each is persisted somewhere or belongs to a tool that is not ours to rename. The benchmark application keeps `mtsdf` throughout: its conformance scenario identifiers and `?technique=mtsdf` URL vocabulary appear in checked-in performance evidence, so moving them would mean regenerating GPU results for a spelling change. It consumes the renamed package symbols by aliasing them at its ten import sites instead.
Three entries the last four commits owed. The reshape entry carries the three independent proofs that it could not change its output, and the false start that briefly looked like proof it mattered. The rename entry records the rule that made it tractable — identifiers move, string literals do not — and names the four literals a first attempt broke, including msdfgen's own `mtsdf` CLI mode, which is a different algorithm from its `msdf` mode and would have changed the native oracle silently. Two planning concepts and the package concept pointed at the pre-rename raster source, which the validator caught as dangling local sources.
`GLYPH_UNSAFE_TO_CONCAT` and `fragmentHasFlag` lost their only caller when boundary reshaping came out, and the rename turned aliases such as `MSDF_KIND as MTSDF_KIND` into self-renames. Both are lint failures, and eight files needed formatting. None of it was caught locally because the last several commits were verified with `test` and `build` rather than `check`, which is what CI runs and where lint, format, and the OKF gate live. `pnpm check` now exits 0 at the repository root.
thejustinwalsh
marked this pull request as ready for review
August 8, 2026 06:01
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Implements the target-v1 core API and its Three.js integration on top of #44, and takes the layout hot path from "every change costs a full rebuild" to a reflow inside the 120 Hz budget.
Stacked on
feat/core-api-planning. The core-API / Three / shaping-perf split described in the plan is not done — this is one branch. See Outstanding below.Core API and Three integration
TextRuntime,ParagraphBatch,Paragraphhandles,FontStack, canonical CPU instance storage with adjacent-revision dirty ranges.FontLoader→TextGroup→Textover canonical TSL shaders, with aThreeRasterProgramregistry keyed on technique id so a third party can register its own program. Registering every built-in at/threemodule scope had collapsed the three runtime graphs to within three bytes of each other, defeatingsideEffects; technique-paired subpaths restore the boundary.bitmapShader,msdfShader,slugShaderexported so a composed program alters output without reimplementing atlas sampling, median decode, or the band walk.Six real defects found and fixed along the way, none catchable by threshold checks: Bitmap mirrored atlas rows plus missing pixel snapping (23,172 bytes off the CPU reference → 0), Slug mirrored em space (MAE 22.94 → 0.223), three span bugs, and font-feature over-strictness on empty paragraphs.
Layout performance
Measured with
pnpm scripts run text:layout-benchmark, identical workload, pre-optimization commit vs this branch, at 25,515 glyphs:Every one of these was work that never needed doing, not a loop made faster:
.notdef, breaking lines and positioning every glyph for a discarded result, and making font selection depend on where text happened to wrap. Shaping already knows.ReshapeRangestays for a genuinely narrowed context, and the contract tests now assert zero crossings so reintroducing one is deliberate.TypedArray.from; an offset→cluster table replaces a lower-bound search that ran twice per cluster boundary per glyph. Clusters and line fragments moved to parallel typed arrays, and instance packing lost its per-glyph key string and input object.Both position axes accumulate in double precision and narrow once — alignment and justification read an axis back after storing it, and the mixed-direction Amiri golden caught that as a last-digit drift.
Naming
./raster/msdfand./three/msdfreplace themtsdfspellings that sat beside./bakers/msdf. Nothing persisted moves: the glTF extension encoding value, packaged schema enum, msdfgen's ownmtsdfCLI mode (a different algorithm from itsmsdfmode), validator diagnostic codes, Rust crate and bin target names, and every fixture filename keep their spelling. The benchmark app keepsmtsdfthroughout because its conformance scenario ids and?technique=mtsdfURL vocabulary appear in checked-in GPU performance evidence.Evidence
bb15bbcc:4f111a3f:e8c0e9d5withshape=1, reshape=0, batched=2, layouts=3, glyphs=165, andbitmap-text-webgl2ata47930d3…e893with zero mismatched bytes.docs/planning/rust-layout-engine.mdcarries the follow-on plan.Outstanding
🤖 Generated with Claude Code