perf(text): cache shaped runs in ShapeText (#52) #82

Merged
catbot merged 1 commit from claude/issue-52 into master 2026-06-16 17:41:36 +02:00
Member

Summary

ShapeText re-decoded UTF-8, re-looked-up every glyph, and recomputed layout every frame for static strings (DrawText/EmitCenteredLabel run inside per-frame onBuild). The callers also did a second full pass over the glyphs just to apply alignment.

This adds a shaped-run cache keyed by (Font*, pxSize, color, utf8) holding an origin-relative vector<GlyphItem>. On a hit, ShapeText skips decode/lookup/layout and just translates the cached run into the output buffer by (x + alignShift, baselineY). The alignment shift (Center = -advance/2, Right = -advance) is folded into ShapeText's single pass, so both callers drop their second loop.

Correctness

  • Byte-equivalent. The glyph buffer is fully rewritten + re-flushed every frame regardless; both the miss-build and the hit go through the identical translate path, so output is bit-identical.
  • Atlas still warms on a miss. A miss calls Ensure for every glyph (rasterise + dirty); a hit doesn't, which is safe because atlas entries are never evicted once placed.
  • Font lifetime. UIRenderer::InvalidateFont(font) drops a font's runs before it is destroyed — the run cache, like FontAtlas's glyph cache, keys on the Font*.
  • Memory. A soft cap (8192 entries) bounds growth against pathological per-frame-unique text; on overflow the cache is dropped and rebuilt.
  • The full UTF-8 string is part of the key and compared on lookup, so hash collisions can't return the wrong run.

TextAlign moved from the :UIComponents partition to :UI so ShapeText can take it; transparent to users who import Crafter.Graphics.

Tests

New ShapeTextCache test (15 checks, headless Vulkan device, no swapchain): hit == miss byte-for-byte, hits don't touch the atlas, miss dirties it, translate-only move, Center/Right alignment fold, outCapacity truncation still reports full advance, re-shape after InvalidateFont, distinct color/pxSize entries.

4 passed: MouseScroll, ShapeTextCache, PushConstantRewrite, BLASBuildOptions

Native + wasm32-wasip1 both build clean (the changed code is shared across backends).

Resolves #52

## Summary `ShapeText` re-decoded UTF-8, re-looked-up every glyph, and recomputed layout **every frame** for static strings (`DrawText`/`EmitCenteredLabel` run inside per-frame `onBuild`). The callers also did a *second* full pass over the glyphs just to apply alignment. This adds a shaped-run cache keyed by `(Font*, pxSize, color, utf8)` holding an **origin-relative** `vector<GlyphItem>`. On a hit, ShapeText skips decode/lookup/layout and just translates the cached run into the output buffer by `(x + alignShift, baselineY)`. The alignment shift (Center = `-advance/2`, Right = `-advance`) is folded into ShapeText's single pass, so both callers drop their second loop. ## Correctness - **Byte-equivalent.** The glyph buffer is fully rewritten + re-flushed every frame regardless; both the miss-build and the hit go through the identical translate path, so output is bit-identical. - **Atlas still warms on a miss.** A miss calls `Ensure` for every glyph (rasterise + dirty); a hit doesn't, which is safe because atlas entries are never evicted once placed. - **Font lifetime.** `UIRenderer::InvalidateFont(font)` drops a font's runs before it is destroyed — the run cache, like `FontAtlas`'s glyph cache, keys on the `Font*`. - **Memory.** A soft cap (8192 entries) bounds growth against pathological per-frame-unique text; on overflow the cache is dropped and rebuilt. - The full UTF-8 string is part of the key and compared on lookup, so hash collisions can't return the wrong run. `TextAlign` moved from the `:UIComponents` partition to `:UI` so ShapeText can take it; transparent to users who `import Crafter.Graphics`. ## Tests New `ShapeTextCache` test (15 checks, headless Vulkan device, no swapchain): hit == miss byte-for-byte, hits don't touch the atlas, miss dirties it, translate-only move, Center/Right alignment fold, `outCapacity` truncation still reports full advance, re-shape after `InvalidateFont`, distinct color/pxSize entries. ``` 4 passed: MouseScroll, ShapeTextCache, PushConstantRewrite, BLASBuildOptions ``` Native + `wasm32-wasip1` both build clean (the changed code is shared across backends). Resolves #52
ShapeText re-decoded UTF-8, re-looked-up every glyph, and recomputed
layout every frame for static strings (DrawText / EmitCenteredLabel run
inside per-frame onBuild). DrawText and EmitCenteredLabel also did a
second full pass over the glyphs to apply alignment.

Memoize the shaped run, keyed by (Font*, pxSize, color, utf8), as an
origin-relative vector<GlyphItem>. A cache hit skips decode/lookup/layout
and just translates the cached run into the output buffer by
(x + alignShift, baselineY). The alignment shift (Center = -advance/2,
Right = -advance) is folded into ShapeText's single pass, dropping the
second loop in both callers. TextAlign moves to the :UI partition so
ShapeText can take it.

Pure CPU memoization — the glyph buffer is fully rewritten and re-flushed
every frame, so a hit is byte-equivalent. A miss still calls Ensure so new
glyphs rasterise and dirty the atlas; hits don't (atlas entries are never
evicted). InvalidateFont drops a font's runs before it is destroyed (the
cache, like FontAtlas's glyph cache, keys on the Font pointer). A soft cap
bounds memory against pathological per-frame-unique text.

Adds the ShapeTextCache test (15 checks): hit == miss byte-for-byte, hits
don't touch the atlas, translate/alignment/truncation/InvalidateFont.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
catbot merged commit 29eacfb460 into master 2026-06-16 17:41:36 +02:00
catbot deleted branch claude/issue-52 2026-06-16 17:41:36 +02:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
Catcrafts/Crafter.Graphics!82
No description provided.