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
7 changed files with 391 additions and 40 deletions
Showing only changes of commit ca48f79465 - Show all commits

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>
catbot 2026-06-16 15:40:52 +00:00

View file

@ -48,39 +48,79 @@ std::uint32_t UIRenderer::ShapeText(Font& font, float pxSize,
std::string_view utf8,
std::array<float,4> color,
GlyphItem* out, std::uint32_t outCapacity,
float* outAdvance) {
float* outAdvance,
TextAlign align) {
if (fontAtlas == nullptr) {
std::println("UIRenderer::ShapeText: no FontAtlas (set fontAtlas before Initialize)");
std::abort();
}
const float scale = pxSize / FontAtlas::kBaseSize;
float cursor = x;
std::uint32_t written = 0;
// Look up (or build) the origin-relative shaped run. It is stored with
// the pen starting at x=0, baseline=0 so a cache hit only needs the
// translate below. The whole string is shaped (no outCapacity limit) so
// a later call with a larger buffer still gets the full run.
ShapedRunKey key{&font, pxSize, color, std::string(utf8)};
auto it = shapedRuns_.find(key);
if (it == shapedRuns_.end()) {
// Miss: shape from scratch. Ensure() each glyph so brand-new ones
// rasterise into the atlas and mark it dirty for the next upload —
// a hit skips this, which is safe because atlas entries are never
// evicted once placed.
const float scale = pxSize / FontAtlas::kBaseSize;
ShapedRun run;
float cursor = 0.0f;
std::size_t i = 0;
while (i < utf8.size()) {
std::uint32_t cp = DecodeUtf8(utf8, i);
if (cp == 0) break;
if (cp == '\n') { continue; }
std::size_t i = 0;
while (i < utf8.size() && written < outCapacity) {
std::uint32_t cp = DecodeUtf8(utf8, i);
if (cp == 0) break;
if (cp == '\n') { continue; }
fontAtlas->Ensure(font, cp);
const Glyph* g = fontAtlas->Lookup(font, cp);
if (g == nullptr) continue;
fontAtlas->Ensure(font, cp);
const Glyph* g = fontAtlas->Lookup(font, cp);
if (g == nullptr) continue;
if (g->w > 0 && g->h > 0) {
GlyphItem& gi = out[written++];
gi.x = cursor + g->xoff * scale;
gi.y = baselineY + g->yoff * scale;
gi.w = g->w * scale;
gi.h = g->h * scale;
gi.u0 = g->u0; gi.v0 = g->v0;
gi.u1 = g->u1; gi.v1 = g->v1;
gi.r = color[0]; gi.g = color[1]; gi.b = color[2]; gi.a = color[3];
if (g->w > 0 && g->h > 0) {
GlyphItem gi;
gi.x = cursor + g->xoff * scale;
gi.y = g->yoff * scale;
gi.w = g->w * scale;
gi.h = g->h * scale;
gi.u0 = g->u0; gi.v0 = g->v0;
gi.u1 = g->u1; gi.v1 = g->v1;
gi.r = color[0]; gi.g = color[1]; gi.b = color[2]; gi.a = color[3];
run.glyphs.push_back(gi);
}
cursor += g->advance * scale;
}
cursor += g->advance * scale;
run.advance = cursor;
if (shapedRuns_.size() >= kMaxShapedRuns) shapedRuns_.clear();
it = shapedRuns_.emplace(std::move(key), std::move(run)).first;
}
if (outAdvance) *outAdvance = cursor - x;
const ShapedRun& run = it->second;
if (outAdvance) *outAdvance = run.advance;
// Fold the alignment shift into the translate so callers don't need a
// second pass over the glyphs.
const float alignShift = (align == TextAlign::Center) ? -run.advance * 0.5f
: (align == TextAlign::Right) ? -run.advance
: 0.0f;
const float tx = x + alignShift;
std::uint32_t written = 0;
for (const GlyphItem& src : run.glyphs) {
if (written >= outCapacity) break;
GlyphItem& gi = out[written++];
gi = src;
gi.x += tx;
gi.y += baselineY;
}
return written;
}
void UIRenderer::InvalidateFont(const Font& font) noexcept {
std::erase_if(shapedRuns_, [&font](const auto& kv) {
return kv.first.font == &font;
});
}

View file

@ -58,17 +58,12 @@ namespace {
GlyphItem* writePos = buf.glyphs + before;
float baseline = centerY + fontSize * 0.32f;
float advance = 0.0f;
// ShapeText folds the -advance/2 centering shift into its own pass.
std::uint32_t n = buf.renderer->ShapeText(
font, fontSize, centerX, baseline,
label, color, writePos, cap, &advance
label, color, writePos, cap, &advance, TextAlign::Center
);
*buf.glyphCount = before + n;
// Center: shift each glyph's x by -advance/2.
float shift = -advance * 0.5f;
for (std::uint32_t i = 0; i < n; ++i) {
writePos[i].x += shift;
}
return advance;
}
}
@ -202,16 +197,12 @@ float Crafter::DrawText(UIBuffer& buf, std::string_view text,
GlyphItem* writePos = buf.glyphs + before;
float advance = 0.0f;
// Alignment is applied inside ShapeText (folded into its single pass).
std::uint32_t n = buf.renderer->ShapeText(
font, fontSize, x, baselineY,
text, color, writePos, cap, &advance
text, color, writePos, cap, &advance, align
);
*buf.glyphCount = before + n;
if (align != TextAlign::Left && n > 0) {
float shift = (align == TextAlign::Center) ? -advance * 0.5f : -advance;
for (std::uint32_t i = 0; i < n; ++i) writePos[i].x += shift;
}
return advance;
}

View file

@ -90,6 +90,12 @@ export namespace Crafter {
};
static_assert(sizeof(GlyphItem) == 48);
// Where the X coordinate sits relative to the emitted glyph run. Used by
// UIRenderer::ShapeText and the Tier 3 text components (DrawText). Lives
// here (not in :UIComponents) so ShapeText can fold the alignment shift
// into its single pass.
enum class TextAlign : std::uint8_t { Left, Center, Right };
// ─── tiny rect-carving helper ───────────────────────────────────────
struct Rect {
float x = 0, y = 0, w = 0, h = 0;
@ -206,12 +212,30 @@ export namespace Crafter {
#endif
SamplerSlot RegisterLinearClampSampler();
// Shapes `utf8` into `out` (up to `outCapacity` glyphs), positioning
// the run at `(x, baselineY)` with horizontal `align`. Returns the
// number of glyphs written; `*outAdvance` (if non-null) receives the
// full advance width regardless of truncation.
//
// The origin-relative shaped run is memoized in a CPU cache keyed by
// (Font*, pxSize, color, utf8); a hit skips UTF-8 decode, glyph
// lookup, and layout, translating the cached run into `out` instead.
// Caching is byte-equivalent — the glyph buffer is fully rewritten
// and re-flushed every frame regardless. Call InvalidateFont before
// destroying a Font (the cache keys on the Font pointer).
std::uint32_t ShapeText(Font& font, float pxSize,
float x, float baselineY,
std::string_view utf8,
std::array<float,4> color,
GlyphItem* out, std::uint32_t outCapacity,
float* outAdvance = nullptr);
float* outAdvance = nullptr,
TextAlign align = TextAlign::Left);
// Drop every cached shaped run that references `font`. Must be called
// before a Font is destroyed: the run cache (and FontAtlas's glyph
// cache) key on the Font pointer, so a Font freed and a new one
// reallocated at the same address would otherwise alias stale runs.
void InvalidateFont(const Font& font) noexcept;
std::uint16_t FontAtlasImageSlot() const noexcept { return fontAtlasImageSlot_; }
std::uint16_t FontAtlasSamplerSlot() const noexcept { return fontAtlasSamplerSlot_; }
@ -222,6 +246,41 @@ export namespace Crafter {
Window* window_ = nullptr;
GraphicsDescriptorHeap* heap_ = nullptr;
// ─── shaped-run cache ───────────────────────────────────────────
// Glyphs are stored origin-relative (pen at x=0, baseline=0); a hit
// only needs a translate by (x + alignShift, baselineY). The full
// string is part of the key (compared on lookup) so hash collisions
// can't return the wrong run.
struct ShapedRunKey {
const Font* font;
float pxSize;
std::array<float, 4> color;
std::string text;
bool operator==(const ShapedRunKey&) const = default;
};
struct ShapedRunKeyHash {
std::size_t operator()(const ShapedRunKey& k) const noexcept {
std::size_t h = std::hash<const void*>{}(k.font);
auto mix = [&h](std::size_t v) noexcept {
h ^= v + 0x9e3779b97f4a7c15ULL + (h << 6) + (h >> 2);
};
mix(std::hash<std::uint32_t>{}(std::bit_cast<std::uint32_t>(k.pxSize)));
for (float c : k.color)
mix(std::hash<std::uint32_t>{}(std::bit_cast<std::uint32_t>(c)));
mix(std::hash<std::string_view>{}(k.text));
return h;
}
};
struct ShapedRun {
std::vector<GlyphItem> glyphs; // origin-relative
float advance = 0;
};
// Soft cap. A pathological caller drawing a fresh string every frame
// would grow this without bound; on overflow we drop everything and
// rebuild (correctness is unaffected, only the hit rate after a flush).
static constexpr std::size_t kMaxShapedRuns = 8192;
std::unordered_map<ShapedRunKey, ShapedRun, ShapedRunKeyHash> shapedRuns_;
ImageSlot outImageSlot_;
ImageSlot fontAtlasImageSlot_;
SamplerSlot fontAtlasSamplerSlot_;

View file

@ -96,8 +96,7 @@ export namespace Crafter {
float cornerRadius = 0;
};
// Where the X coordinate sits relative to the emitted glyph run.
enum class TextAlign : std::uint8_t { Left, Center, Right };
// TextAlign lives in the :UI partition (used by UIRenderer::ShapeText).
// ─── component functions ───────────────────────────────────────────

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;

Binary file not shown.

View file

@ -0,0 +1,231 @@
/*
Crafter®.Graphics
Copyright (C) 2026 Catcrafts®
catcrafts.net
This library is free software; you can redistribute it and/or
modify it under the terms of the GNU Lesser General Public
License version 3.0 as published by the Free Software Foundation;
This library is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
Lesser General Public License for more details.
You should have received a copy of the GNU Lesser General Public
License along with this library; if not, write to the Free Software
Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
*/
// Issue #52: shaped-run cache for UIRenderer::ShapeText. The slow path
// (UTF-8 decode + per-glyph atlas lookup + layout) is memoized into an
// origin-relative run keyed by (Font*, pxSize, color, utf8); a hit only
// translates the cached run into the output buffer. This must be exactly
// byte-equivalent to the uncached path, since the glyph buffer is rewritten
// and re-flushed every frame regardless.
//
// What is asserted:
// - A cache HIT produces byte-identical glyphs to the original (MISS) call.
// - The HIT path does NOT touch the atlas (no Ensure → atlas stays clean),
// while the MISS path rasterises new glyphs and dirties it.
// - Translating the same string to a different (x, baselineY) shifts every
// glyph by exactly that delta (cache stores origin-relative geometry).
// - Center / Right alignment fold the advance-based shift into ShapeText
// (Center = -advance/2, Right = -advance), matching the old two-pass code.
// - outCapacity truncates the written count but still reports full advance.
// - InvalidateFont drops the run; a re-shape afterwards is still correct.
// - Distinct color / pxSize / font are distinct cache entries.
//
// Needs a headless Vulkan device (the FontAtlas image is a real GPU image),
// but no swapchain/window — ShapeText only touches the CPU-side atlas. The
// UIRenderer is used without Initialize(): ShapeText reads only fontAtlas.
#include "vulkan/vulkan.h"
#include <cstdlib>
import Crafter.Graphics;
import std;
using namespace Crafter;
namespace {
int failures = 0;
void Check(bool ok, std::string_view what) {
std::println("{} {}", ok ? "PASS" : "FAIL", what);
if (!ok) ++failures;
}
VkCommandBuffer BeginCmd() {
VkCommandBufferAllocateInfo allocInfo {
.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_ALLOCATE_INFO,
.commandPool = Device::commandPool,
.level = VK_COMMAND_BUFFER_LEVEL_PRIMARY,
.commandBufferCount = 1,
};
VkCommandBuffer cmd = VK_NULL_HANDLE;
Device::CheckVkResult(vkAllocateCommandBuffers(Device::device, &allocInfo, &cmd));
VkCommandBufferBeginInfo beginInfo {
.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO,
.flags = VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT,
};
Device::CheckVkResult(vkBeginCommandBuffer(cmd, &beginInfo));
return cmd;
}
void SubmitWait(VkCommandBuffer cmd) {
Device::CheckVkResult(vkEndCommandBuffer(cmd));
VkSubmitInfo submitInfo {
.sType = VK_STRUCTURE_TYPE_SUBMIT_INFO,
.commandBufferCount = 1,
.pCommandBuffers = &cmd,
};
Device::CheckVkResult(vkQueueSubmit(Device::queue, 1, &submitInfo, VK_NULL_HANDLE));
Device::CheckVkResult(vkQueueWaitIdle(Device::queue));
vkFreeCommandBuffers(Device::device, Device::commandPool, 1, &cmd);
}
bool GlyphEq(const GlyphItem& a, const GlyphItem& b) {
return a.x == b.x && a.y == b.y && a.w == b.w && a.h == b.h
&& a.u0 == b.u0 && a.v0 == b.v0 && a.u1 == b.u1 && a.v1 == b.v1
&& a.r == b.r && a.g == b.g && a.b == b.b && a.a == b.a;
}
bool RunsEqual(std::span<const GlyphItem> a, std::span<const GlyphItem> b) {
if (a.size() != b.size()) return false;
for (std::size_t i = 0; i < a.size(); ++i)
if (!GlyphEq(a[i], b[i])) return false;
return true;
}
std::filesystem::path FindFont() {
// The runner's cwd is not guaranteed: cfg.files copies the font next to
// the binary, but `crafter-build test` may also run from the project root.
for (const char* cand : {
"font.ttf",
"tests/ShapeTextCache/font.ttf",
"../../examples/HelloUI/font.ttf" }) {
if (std::filesystem::exists(cand)) return cand;
}
return "font.ttf";
}
} // namespace
int main() {
Device::Initialize();
FontAtlas atlas;
{
VkCommandBuffer cmd = BeginCmd();
atlas.Initialize(cmd);
SubmitWait(cmd);
}
Font font(FindFont());
UIRenderer ui;
ui.fontAtlas = &atlas; // ShapeText needs only the atlas — no Initialize.
const std::array<float, 4> white{1, 1, 1, 1};
constexpr float kSize = 18.0f;
const std::string_view kText = "Hover me";
std::array<GlyphItem, 64> bufMiss{};
std::array<GlyphItem, 64> bufHit{};
// ── 1. First call is a MISS: it rasterises glyphs → atlas goes dirty. ──
atlas.dirty = false; // pretend a prior Update() flushed the atlas clean
float advMiss = 0.0f;
std::uint32_t nMiss = ui.ShapeText(font, kSize, 100.0f, 200.0f, kText, white,
bufMiss.data(), bufMiss.size(), &advMiss);
Check(nMiss > 0, "MISS produced glyphs for a non-empty string");
Check(advMiss > 0.0f, "MISS reported a positive advance");
Check(atlas.dirty, "MISS rasterised new glyphs and dirtied the atlas");
// ── 2. Second call is a HIT: byte-identical, and atlas stays clean. ────
atlas.dirty = false;
float advHit = 0.0f;
std::uint32_t nHit = ui.ShapeText(font, kSize, 100.0f, 200.0f, kText, white,
bufHit.data(), bufHit.size(), &advHit);
Check(nHit == nMiss, "HIT wrote the same glyph count as MISS");
Check(advHit == advMiss, "HIT reported the same advance as MISS");
Check(RunsEqual({bufMiss.data(), nMiss}, {bufHit.data(), nHit}),
"HIT is byte-identical to MISS");
Check(!atlas.dirty, "HIT did not touch the atlas (no re-rasterise)");
// ── 3. Translation: a different origin shifts every glyph by the delta. ─
std::array<GlyphItem, 64> bufMoved{};
const float dx = 37.0f, dy = -15.0f;
std::uint32_t nMoved = ui.ShapeText(font, kSize, 100.0f + dx, 200.0f + dy,
kText, white, bufMoved.data(),
bufMoved.size(), nullptr);
bool shifted = (nMoved == nHit);
for (std::uint32_t i = 0; i < nMoved && shifted; ++i) {
shifted = std::abs((bufMoved[i].x - bufHit[i].x) - dx) < 1e-3f
&& std::abs((bufMoved[i].y - bufHit[i].y) - dy) < 1e-3f
&& bufMoved[i].w == bufHit[i].w && bufMoved[i].h == bufHit[i].h;
}
Check(shifted, "translate-only move shifts every glyph by exactly (dx, dy)");
// ── 4. Alignment folded into ShapeText (Center = -adv/2, Right = -adv). ─
std::array<GlyphItem, 64> bufCenter{};
std::array<GlyphItem, 64> bufRight{};
float advCenter = 0.0f, advRight = 0.0f;
std::uint32_t nCenter = ui.ShapeText(font, kSize, 100.0f, 200.0f, kText, white,
bufCenter.data(), bufCenter.size(),
&advCenter, TextAlign::Center);
std::uint32_t nRight = ui.ShapeText(font, kSize, 100.0f, 200.0f, kText, white,
bufRight.data(), bufRight.size(),
&advRight, TextAlign::Right);
bool centerOk = (nCenter == nHit) && (advCenter == advHit);
for (std::uint32_t i = 0; i < nCenter && centerOk; ++i)
centerOk = std::abs((bufCenter[i].x - bufHit[i].x) - (-advHit * 0.5f)) < 1e-3f;
Check(centerOk, "Center alignment shifts the run left by advance/2");
bool rightOk = (nRight == nHit) && (advRight == advHit);
for (std::uint32_t i = 0; i < nRight && rightOk; ++i)
rightOk = std::abs((bufRight[i].x - bufHit[i].x) - (-advHit)) < 1e-3f;
Check(rightOk, "Right alignment shifts the run left by full advance");
// ── 5. outCapacity truncates count but still reports full advance. ─────
std::array<GlyphItem, 64> bufTrunc{};
const std::uint32_t capLimit = (nHit > 1) ? (nHit - 1) : 0;
float advTrunc = 0.0f;
std::uint32_t nTrunc = ui.ShapeText(font, kSize, 100.0f, 200.0f, kText, white,
bufTrunc.data(), capLimit, &advTrunc);
Check(nTrunc == capLimit, "writing is capped at outCapacity");
Check(advTrunc == advHit, "advance is full even when the buffer truncates");
// ── 6. InvalidateFont drops the run; a re-shape is still correct. ──────
ui.InvalidateFont(font);
std::array<GlyphItem, 64> bufReshape{};
float advReshape = 0.0f;
std::uint32_t nReshape = ui.ShapeText(font, kSize, 100.0f, 200.0f, kText, white,
bufReshape.data(), bufReshape.size(),
&advReshape);
Check(nReshape == nHit && advReshape == advHit
&& RunsEqual({bufReshape.data(), nReshape}, {bufHit.data(), nHit}),
"re-shape after InvalidateFont matches the original output");
// ── 7. Distinct color and pxSize are distinct entries (different runs). ─
std::array<GlyphItem, 64> bufRed{};
std::uint32_t nRed = ui.ShapeText(font, kSize, 100.0f, 200.0f, kText,
{1, 0, 0, 1}, bufRed.data(), bufRed.size(),
nullptr);
bool colorDistinct = (nRed == nHit);
for (std::uint32_t i = 0; i < nRed && colorDistinct; ++i)
colorDistinct = bufRed[i].r == 1.0f && bufRed[i].g == 0.0f && bufRed[i].b == 0.0f;
Check(colorDistinct, "a different color yields a correctly-colored run");
std::array<GlyphItem, 64> bufBig{};
float advBig = 0.0f;
ui.ShapeText(font, kSize * 2.0f, 100.0f, 200.0f, kText, white,
bufBig.data(), bufBig.size(), &advBig);
Check(advBig > advHit * 1.5f, "a larger pxSize produces a wider advance");
if (failures == 0) std::println("\nAll ShapeText cache checks passed.");
else std::println("\n{} ShapeText cache check(s) FAILED.", failures);
return failures == 0 ? EXIT_SUCCESS : EXIT_FAILURE;
}