Skip to content

feat: target-v1 core API, Three integration, and layout performance - #45

Open
thejustinwalsh wants to merge 73 commits into
feat/core-api-planningfrom
feat/three-api
Open

feat: target-v1 core API, Three integration, and layout performance#45
thejustinwalsh wants to merge 73 commits into
feat/core-api-planningfrom
feat/three-api

Conversation

@thejustinwalsh

Copy link
Copy Markdown
Collaborator

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, Paragraph handles, FontStack, canonical CPU instance storage with adjacent-revision dirty ranges.
  • FontLoaderTextGroupText over canonical TSL shaders, with a ThreeRasterProgram registry keyed on technique id so a third party can register its own program. Registering every built-in at /three module scope had collapsed the three runtime graphs to within three bytes of each other, defeating sideEffects; technique-paired subpaths restore the boundary.
  • bitmapShader, msdfShader, slugShader exported so a composed program alters output without reimplementing atlas sampling, median decode, or the band walk.
  • v0 deleted with its extension proof ported rather than retired. Net −3,225 lines at that point.

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:

case before after
reflow 110.40 ms 8.09 ms 13.6× — inside the 8.33 ms 120 Hz budget
resize 103.54 ms 10.59 ms 9.8× — inside 60 Hz
text edit 109.66 ms 33.62 ms 3.3×

Every one of these was work that never needed doing, not a loop made faster:

  1. The paragraph was rebuilt per update, so its five constraint-keyed caches were dead on arrival and the shape-reuse cache never fired. A retained layout session fixes it, and a content-box change — a constraint the paragraph already answers per call — no longer enters preparation at all.
  2. Font fallback laid the paragraph out to find .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.
  3. Unicode analysis and bidi are decided by text and base direction alone, so they are retained across any change that alters neither.
  4. Boundary reshaping requested the whole run as context — the context the retained shape was already produced with — so it returned the glyphs it already held, on ~83% of lines, every layout. Proven redundant three ways: the mechanism; 640 ranges and 20,280 glyphs measured byte-identical across Latin word wrap, Arabic word wrap, and Arabic character wrap narrow enough to break inside joined words; and the pinned layout hashes plus the whole alignment/clipping/max-lines/ellipsis/justification contract unchanged with it removed. ReshapeRange stays for a genuinely narrowed context, and the contract tests now assert zero crossings so reintroducing one is deliberate.
  5. Positioning writes typed arrays in place instead of accumulating fourteen plain arrays and copying each through 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/msdf and ./three/msdf replace the mtsdf spellings that sat beside ./bakers/msdf. Nothing persisted moves: the glTF extension encoding value, packaged schema enum, msdfgen's own mtsdf CLI mode (a different algorithm from its msdf mode), validator diagnostic codes, Rust crate and bin target names, and every fixture filename keep their spelling. The benchmark app keeps mtsdf throughout because its conformance scenario ids and ?technique=mtsdf URL vocabulary appear in checked-in GPU performance evidence.

Evidence

  • 190 package tests, 117 benchmark tests, 20/20 headless conformance scenes.
  • Pinned hashes reproduce: bb15bbcc:4f111a3f:e8c0e9d5 with shape=1, reshape=0, batched=2, layouts=3, glyphs=165, and bitmap-text-webgl2 at a47930d3…e893 with zero mismatched bytes.
  • Size evidence regenerated; browser-core ceilings raised for the work and then lowered again when the layout profiler came out, so they track what the tree measures.
  • OKF validates at zero errors. D-159 and D-160 record the tiering and the measurement discipline; docs/planning/rust-layout-engine.md carries the follow-on plan.

Outstanding

  • The three-way stack split is not done. This is one branch of 115 commits. The intended split is core API → Three → shaping perf, landing together and stacked only for review.
  • The benchmark app's live-workload and comparison surfaces still share one scene; its stats aggregation has not been audited for smoothing.
  • Wasm builds remain non-reproducible (pre-existing, tracked separately).

🤖 Generated with Claude Code

thejustinwalsh and others added 30 commits August 7, 2026 00:37
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>
thejustinwalsh and others added 29 commits August 7, 2026 18:49
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
thejustinwalsh marked this pull request as ready for review August 8, 2026 06:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant