//SPDX-License-Identifier: LGPL-3.0-only //SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® // 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 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 a, std::span 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 white{1, 1, 1, 1}; constexpr float kSize = 18.0f; const std::string_view kText = "Hover me"; std::array bufMiss{}; std::array 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 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 bufCenter{}; std::array 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 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 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 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 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"); // ── 8. Overflow evicts the least-recently-used run, not the whole cache. ─ // (Issue #123) The old policy did a full clear() at the cap, periodically // nuking the stable labels and forcing a full-UI reshape the next frame. // The LRU policy instead pins the cache at its cap (evict one, add one) and // keeps the hot, reshaped-every-frame set resident. A hit/miss and a // re-shape of already-rasterised glyphs are not observable through the // output or atlas.dirty, so this is asserted via the cache introspection // hooks added for the issue. ui.InvalidateFont(font); Check(ui.ShapedRunCacheSize() == 0, "cache empty after InvalidateFont"); std::array sink{}; auto shapeUnique = [&](std::size_t n) { // Small fixed glyph set (digits + "run#"), so the atlas rasterises once // and every call is still a distinct cache key. std::string s = std::format("run#{}", n); ui.ShapeText(font, kSize, 0, 0, s, white, sink.data(), sink.size(), nullptr); }; // Insert distinct keys until the size stops growing on a brand-new insert. // Under evict-one LRU that plateau IS the cap (evict one, add one → size // unchanged). Under the old clear()-all policy a new insert at the cap // drops the size to 1, so it would never plateau and `cap` would stay 0. std::size_t cap = 0, prev = 0; for (std::size_t i = 0; i < 100000 && cap == 0; ++i) { shapeUnique(i); std::size_t now = ui.ShapedRunCacheSize(); if (now == prev) cap = now; prev = now; } Check(cap > 0, "cache size plateaus at a fixed cap (evict-one LRU, not clear-all)"); // Hot, every-frame label stays resident across a churn far exceeding the // cap, because reshaping it each iteration keeps it most-recently-used. const std::string_view kHot = "Hot Label"; ui.ShapeText(font, kSize, 0, 0, kHot, white, sink.data(), sink.size(), nullptr); bool hotStays = ui.IsShapedRunCached(font, kSize, white, kHot); bool stayedAtCap = true; for (std::size_t i = 0; i < cap * 2 + 1000 && hotStays; ++i) { shapeUnique(1'000'000 + i); // fresh keys ui.ShapeText(font, kSize, 0, 0, kHot, white, sink.data(), sink.size(), nullptr); hotStays = ui.IsShapedRunCached(font, kSize, white, kHot); stayedAtCap = stayedAtCap && ui.ShapedRunCacheSize() == cap; } Check(hotStays, "hot label survives a churn of unique strings past the cap"); Check(stayedAtCap, "cache stays pinned at the cap (no full clear)"); // A cold string left untouched while the cache churns past it IS evicted — // confirms the cap is enforced by eviction, not by refusing new inserts. const std::string_view kCold = "Cold Once"; ui.ShapeText(font, kSize, 0, 0, kCold, white, sink.data(), sink.size(), nullptr); Check(ui.IsShapedRunCached(font, kSize, white, kCold), "cold string cached when first shaped"); for (std::size_t i = 0; i < cap + 16; ++i) shapeUnique(2'000'000 + i); // never re-touch kCold Check(!ui.IsShapedRunCached(font, kSize, white, kCold), "an untouched cold string is eventually evicted (LRU tail recycled)"); 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; }