perf(text): cache shaped runs in ShapeText (#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>
This commit is contained in:
catbot 2026-06-16 15:40:52 +00:00
commit ca48f79465
7 changed files with 391 additions and 40 deletions

View file

@ -295,6 +295,37 @@ extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> a
blasImpls.emplace_back("tests/BLASBuildOptions/main");
bc.GetInterfacesAndImplementations(ifaces, blasImpls);
cfg.tests.push_back(std::move(blasTest));
// Issue #52: shaped-run cache for UIRenderer::ShapeText. Shapes a
// string against a real CPU-side FontAtlas and asserts that a cache
// hit is byte-equivalent to the uncached path, that hits don't touch
// the atlas, and that translate / alignment / truncation /
// InvalidateFont all behave. Needs a headless Vulkan device (the
// atlas image is a real GPU image) but no swapchain — same native
// build settings as the others. The font file is copied next to the
// binary; the test also probes the project-root path.
Test textTest;
Configuration& xc = textTest.config;
xc.path = cfg.path;
xc.name = "ShapeTextCache";
xc.outputName = "ShapeTextCache";
xc.type = ConfigurationType::Executable;
xc.target = cfg.target;
xc.march = cfg.march;
xc.mtune = cfg.mtune;
xc.debug = cfg.debug;
xc.sysroot = cfg.sysroot;
xc.dependencies = cfg.dependencies;
xc.externalDependencies = cfg.externalDependencies;
xc.compileFlags = cfg.compileFlags;
xc.linkFlags = cfg.linkFlags;
xc.defines = cfg.defines;
xc.cFiles = cfg.cFiles;
xc.files = { fs::path("tests/ShapeTextCache/font.ttf") };
std::vector<fs::path> textImpls(impls.begin(), impls.end());
textImpls.emplace_back("tests/ShapeTextCache/main");
xc.GetInterfacesAndImplementations(ifaces, textImpls);
cfg.tests.push_back(std::move(textTest));
}
return cfg;