perf(text): precompute glyph band transform and reslot run edits in place - #520
perf(text): precompute glyph band transform and reslot run edits in place#520ryantrem wants to merge 9 commits into
Conversation
…lace Speeds up TextData run editing by removing per-instance work from the glyph packing path and by keeping same-curve-set replaceRun edits inside their existing draw group. glyph-storage.ts - AtlasSlot now caches the glyph's font-unit bounds (xMin/yMin/xMax/yMax) and the precomputed band-space transform (bandScaleX/Y, bandOffsetX/Y), filled in once by packAppendGlyph. Slots are append-only and never moved, so these can never go stale. Replaces the vBandCount/hBandCount fields. text-data.ts - packGlyphAtSlot drops the curveSet.curves.get() lookup and the per-instance band math, reading everything glyph-invariant straight off the atlas slot. - writeRunToSlots no longer allocates a liveSlots array in the common case where every glyph lands; it returns the caller's slots array and only materializes a copy once a glyph actually misses the atlas. - applyReplaceRun stays in-group for any same-curve-set replacement, including changed glyph counts (which now free + reallocate slots instead of routing through remove + add). Empty replacements still take the remove path so an emptied group can be retired. - New resolveRunIndex helper avoids the O(run count) _runs.indexOf() scan when the caller already passed a numeric run index. - allocateSlots and applyReset build their slot arrays with .fill(-1). This is load-bearing, not cosmetic: writeRunToSlots now hands these arrays straight to a run record, so a bare new Array(n) would leave them HOLEY_SMI_ELEMENTS in V8 and that holeyness leaks into shiftSlotsAtOrAfter's hot per-slot loop, roughly halving its throughput. Measured (Node CPU micro-benchmark, 660 runs x 18 glyphs, median ms): addRun (full doc) 4.92 -> 4.83 replaceRun same-group same-size 0.56 -> 0.35 (-37%) replaceRun cross-group 6.15 -> 5.68 (-8%) replaceRun same-group resize 5.46 -> 5.09 (-7%) Measured (LineLayoutPad, Babylon outline, ~11.9k glyphs, drawGlyphRun phase): bold flip (remove+add) 9.95 ms -> 9.55 ms colour flip (same-size replaceRun) 1.50 ms -> 1.30 ms (-13%) Rendering output is unchanged - the same values are written to each instance, just sourced from the atlas slot instead of recomputed per instance. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: d54a24a7-1149-4edd-be89-61315a3d69bb
AtlasSlot gains 8 precomputed fields, adding ~130 raw bytes to each of the three text scenes. All still well under their maxRawKB ceilings (27.7/30, 44.0/46, 42.0/42.5). Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: d54a24a7-1149-4edd-be89-61315a3d69bb
Lite Playground - Static SiteBuild 20260801.5 - merge @ 275e820 |
There was a problem hiding this comment.
Pull request overview
Optimizes TextData run editing and per-glyph packing by removing per-edit O(runCount) scans and per-glyph recomputation that were invariant per glyph, improving scaling for many-run documents (e.g., spreadsheet-like layouts) while keeping rendering output unchanged.
Changes:
- Cached glyph-invariant bounds and band-space transform in
AtlasSlot, and updatedpackGlyphAtSlotto consume the snapshot (fewer lookups/arithmetic per glyph). - Made
replaceRun/removeRunedits avoid unnecessary_runs.indexOf()scans viaresolveRunIndex, and reslotted same-group glyph-count edits in place instead of remove+add. - Added focused unit coverage for
replaceRun/atlas-miss behaviors and a regression test for glyph re-registration geometry stability.
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated no comments.
Show a summary per file
| File | Description |
|---|---|
| packages/babylon-lite/src/text/glyph-storage.ts | Extends AtlasSlot to snapshot glyph bounds + precomputed band transform at pack time. |
| packages/babylon-lite/src/text/text-data.ts | Uses slot snapshot in packing; reduces edit complexity by avoiding index scans and reslotting in place; avoids per-call live-slots allocations on common path. |
| tests/lite/unit/text-glyph-storage.test.ts | Adds regression test ensuring re-registering an existing glyph id cannot change packed geometry. |
| tests/lite/unit/text-data-run-edits.test.ts | New unit suite covering replaceRun behaviors, atlas misses, and slot bookkeeping invariants. |
| lab/public/bundle/manifest/scene180.json | Updates tracked per-scene bundle metrics after runtime byte changes. |
| lab/public/bundle/manifest/scene181.json | Updates tracked per-scene bundle metrics after runtime byte changes. |
| lab/public/bundle/manifest/scene275.json | Updates tracked per-scene bundle metrics after runtime byte changes. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Lab - Static SiteBuild 20260801.5 - merge @ 275e820 |
…edit-fast-paths # Conflicts: # lab/public/bundle/manifest/scene181.json # lab/public/bundle/manifest/scene275.json
scene181 and scene275 rawBytes shift by -18 each after merging master's shader/overlay changes (#519, #521). Other manifests were rebuild churn (JSON number formatting and gzip rounding jitter) and are left untouched. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: d54a24a7-1149-4edd-be89-61315a3d69bb
Lite Playground - Static SiteBuild 20260802.1 - merge @ c3be834 |
Lab - Static SiteBuild 20260802.1 - merge @ c3be834 |
deltakosh
left a comment
There was a problem hiding this comment.
Found one draw-order regression in the new in-place reslot path.
…edit-fast-paths # Conflicts: # lab/public/bundle/manifest/scene181.json # lab/public/bundle/manifest/scene275.json
Instances are drawn in slot order, so a run's glyphs must sit on ascending slots. The resize path freed the run's whole slot block to the group free list and re-allocated it, and since freeSlots pushes in glyph order while allocateSlots pops LIFO, the run came back reversed: growing [0, 1] to three glyphs yielded [1, 0, 2]. Overlapping glyphs with distinct per-glyph colors or alpha then composited in the wrong order. Resize the run's own slot list in place instead -- trim the tail when shrinking, append newly allocated slots when growing -- so surviving slots keep their order. Slots reclaimed from the free list can still land below the ones the run already holds, so the appended range is checked and the list sorted only when that happens; growth off the group tail skips the sort entirely. Done in place rather than with slice/concat so the run's slot array is never reallocated, preserving its PACKED_SMI elements kind (verified with %HasHoleyElements across length-truncation, push, and sort). Adds regression coverage asserting ascending slot order after grow, shrink, and 40 rounds of mixed resizes. Reported-by: @deltakosh Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: d54a24a7-1149-4edd-be89-61315a3d69bb
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 7 out of 7 changed files in this pull request and generated no new comments.
Suppressed comments (2)
packages/babylon-lite/src/text/text-data.ts:667
applyReplaceRunrelies onrec.slotsalready being in ascending order when deciding whether to sort after appendingextraslots. HoweverallocateSlots()can hand out reused slots in arbitrary/LIFO order (viagroup.freeSlots.pop()), so an existing run can already have a non-monotonicrec.slots. In that case, the current check (prevSlot = slots[prevSlotCount - 1]) can miss the disorder and leaveslotsnon-ascending, contradicting the comment above about slot-order affecting compositing.
const extra = allocateSlots(data, group, newRun.glyphs.length - prevSlotCount);
// Slots appended past the group's tail already sort after the ones this run holds;
// only slots reclaimed from the free list can land below them.
let sorted = true;
let prevSlot = prevSlotCount > 0 ? slots[prevSlotCount - 1]! : -1;
lab/public/bundle/manifest/scene181.json:3
- PR description's bundle impact table reports much smaller movement for scene181 (43.9 → 44.0 rawKB, ~+130 bytes), but this committed manifest shows 43.9 → 44.3 rawKB and rawBytes 44961 → 45318 (+357). Please reconcile the numbers (update the PR description or regenerate manifests from the deterministic build output) so reviewers can accurately judge bundle impact vs ceilings.
"rawKB": 44.3,
"gzipKB": 16.9,
Lite Playground - Static SiteBuild 20260803.14 - merge @ fc6c1fb |
Lab - Static SiteBuild 20260803.14 - merge @ fc6c1fb |
…rder The previous fix kept resized runs ascending by resizing the run's slot list in place. That covered the resize path but left the same reversal reachable through `applyAddRun`, and it cost 233 raw bytes. Move the guarantee into `allocateSlots` instead, so every caller gets ascending slots: track order while the free list is being drained and sort only when a reclaimed block actually came back reversed. `sort` runs a comparator call per element even on already-ordered input, so an unconditional sort measurably hurt the paths that allocate straight off the group tail -- cross-group replaceRun went from -4.9% to +1.5% against master. Guarding it recovers that fully. Nothing refills the free list mid-drain, so the loop breaks out at the first miss instead of calling `popFreeSlot` on an empty list for every remaining glyph, and the extension writes the tail of `out` directly rather than rescanning for -1 placeholders. Interleaved A/B against master (660 runs x 18 glyphs, median): addRun full doc -16.2% replaceRun same-size -22.9% replaceRun resize -2.7% replaceRun cross-group -4.7% Bundle cost of the ordering fix drops from 233 to 69 raw bytes over the unoptimised branch (scene180 +207, scene181 +193), with no change in gzip. Adds coverage for the add path reusing freed slots, which reproduces [3, 2] without this change. Reported-by: @deltakosh Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: d54a24a7-1149-4edd-be89-61315a3d69bb
Lite Playground - Static SiteBuild 20260803.18 - merge @ a801a19 |
Lab - Static SiteBuild 20260803.18 - merge @ a801a19 |
Motivation
Editing text in a
TextDatawas O(total run count) per edit, not O(1). In an Excel-like benchmark (30×28 grid = 840 cells, each cell its ownGlyphRunin oneTextData), a single-cell edit got slower as the grid grew — quadratic over a full sweep. Two independent causes: unconditional_runs.indexOf()scans, and glyph-count changes falling through to a remove + add path.Changes
glyph-storage.tsAtlasSlotnow caches the glyph's font-unit bounds (xMin/yMin/xMax/yMax) and the precomputed band-space transform (bandScaleX/Y,bandOffsetX/Y), filled in once bypackAppendGlyph. These replacevBandCount/hBandCount.The per-instance packer was re-deriving these (a Map lookup + 2 divides + 2 multiplies) for every glyph of every edit, even though they're glyph-invariant. Slots are append-only and never moved, so the snapshot can't go stale.
text-data.tspackGlyphAtSlotreads the precomputed fields and drops thecurveSet.curves.get()lookup entirely — the atlas-slot lookup already implies validity, since both maps are written together and never pruned. One Map lookup instead of two, no arithmetic.resolveRunIndex(new) — a numeric ref is the index, so it costs nothing; only object refs need theindexOf. Replaces 4 unconditional_runs.indexOfscans. This is the main O(n) → O(1) fix.writeRunToSlotsno longer allocates aliveSlotsarray per call. It returns the caller'sslotswhen every glyph lands (the overwhelmingly common case), materializing a copy only on the first atlas miss.applyReplaceRunnow reslots in place: when the curve set matches and the new run is non-empty, a glyph-count change frees + reallocates within the same group instead of remove + add. No_runssplice, no index scan. Adds aprev === newRunfast path for the in-place-mutation pattern apps actually use. Empty replacements still take the remove path so an emptied group can be retired.allocateSlots/applyResetusenew Array(n).fill(-1). Load-bearing, not cosmetic: a barenew Array(n)isHOLEY_SMI_ELEMENTSin V8, and this array is now handed straight to a run record, so the holeyness leaks intoshiftSlotsAtOrAfter's hot per-slot loop and roughly halves its throughput. Verified withnode --allow-natives-syntax+%HasHoleyElements. Comments added so nobody "simplifies" it back.Tests
tests/lite/unit/text-data-run-edits.test.ts(new, 20 tests) — the first test coveragereplaceRunhas ever had. Covers replaceRun basics, atlas-miss slot positions, in-place reslot grow/shrink/same-size, empty replacement, same-reference mutation, and a 40-cycle slot-partition invariant.tests/lite/unit/text-glyph-storage.test.ts(+1) — tripwire that re-registering a glyph id cannot change its packed geometry, guarding the new snapshot approach.Measured results
Rendering output is unchanged — the same values are written to each instance, just sourced from the atlas slot instead of recomputed per instance.
Per-edit cost by run count (localized sweep vs stock 1.11.0, speedup at 210 / 840 / 3,360 / 13,440 runs):
indexOffix aloneGlyph-count-change edits (the reslot fix), per-edit µs at 210 / 420 / 840 / 1680 runs:
Linear → flat (O(n) → O(1)), 9.4x at 1680. Full-sweep grow case: 4.835 ms → 0.52 ms.
Node CPU micro-benchmark (660 runs × 18 glyphs, median ms):
End-to-end (LineLayoutPad, Babylon outline, ~11.9k glyphs,
drawGlyphRunphase, n=120, alternating rounds):Cost
AtlasSlotgrows 40 → 108 bytes (measured via CDP heap profiling). That's ≈ +2.2% per glyph overall, and that's an upper bound — theGlyphCurveswrapper, bounds object, curve array and two Map entries weren't counted. Bundle impact is ~130 raw bytes on each of the three text scenes, all still under their ceilings (no ceilings changed):Notes for reviewers
Slot assignment order changes. The reslot path frees then reallocates, and
freeSlotsis LIFO, so glyphs can legitimately land in different slots than before with identical contents. A positional/bitwise instance-buffer diff against old builds will show differences that are not bugs. Correctness was confirmed with a live-instance multiset comparison (identical across all phases) plus visual checks. Flagging it because a positional comparison is the obvious thing to reach for.Visual parity verified. Babylon outline rendering is pixel-identical between published
@babylonjs/lite@1.11.0, master, and this branch.Known pre-existing defect this interacts with (not fixed here).
RunRecord.slotsstores only live slots. Glyphs with no outline (e.g. space) never enter the atlas, so for any run containing a spacenewRun.glyphs.length === rec.slots.lengthis permanently false — the equal-count in-place fast path is effectively dead code for real text, and such runs always take the reslot branch (correct, but a needless free/realloc). Follow-up: store allocated slots (misses kept but dead-marked), or track an allocated count onRunRecord. This caps the real-world benefit.Remaining follow-up opportunities.
markDirtyuses a single union interval, so a one-character edit dirties 2,346 of 6,721 instances — the biggest remaining win in this path.shiftSlotsAtOrAfteris also still O(all glyphs) pergrowGroup(44–67% of self time in profiles). Both deliberately out of scope.Validation
pnpm run lint(ESLint +tsc --noEmitacross all 7 projects) — cleannpx vitest run --project unit— 1307/1307 passed (178 files)pnpm build:bundle-scenes— regenerated per-scene manifests committedpnpm test:parity— 449 passed, 7 skipped, 2 failedThe 2 parity failures are pre-existing on
origin/master, verified by re-running both specs on a pristine detachedorigin/mastercheckout and getting identical MAD values:scene-config.jsonalready documents it as flaky (skipParityOnCI: true, BJS live-reference capture unreliable)Neither scene uses the text path. No golden references and no ceilings were modified. (Several specs force-recapture their goldens live on every run; that churn was reverted rather than committed.)
pnpm test:perfwas not run, per the agent guardrail inGUIDANCE.md.