diff --git a/implementations/Crafter.Graphics-UI-Shared.cpp b/implementations/Crafter.Graphics-UI-Shared.cpp index 5c7fcf7..298c227 100644 --- a/implementations/Crafter.Graphics-UI-Shared.cpp +++ b/implementations/Crafter.Graphics-UI-Shared.cpp @@ -112,9 +112,25 @@ std::uint32_t UIRenderer::ShapeText(Font& font, float pxSize, } run.advance = cursor; - if (shapedRuns_.size() >= kMaxShapedRuns) shapedRuns_.clear(); + // Evict the least-recently-used run (LRU list back) on overflow rather + // than clearing the whole cache. The back is whatever has gone longest + // without a reshape — a stale unique string, never a stable label + // (those are reshaped every frame and sit at the front), so the hot set + // survives and the next frame doesn't trigger a full-UI reshape. + if (shapedRuns_.size() >= kMaxShapedRuns) { + const ShapedRunKey* victim = shapedRunsLru_.back(); + shapedRunsLru_.pop_back(); + shapedRuns_.erase(*victim); + } + // Materialise the owning key (the probe above borrowed utf8) only now, + // on the miss path, for the emplace. ShapedRunKey key{&font, pxSize, color, std::string(utf8)}; it = shapedRuns_.emplace(std::move(key), std::move(run)).first; + it->second.lruIt = shapedRunsLru_.insert(shapedRunsLru_.begin(), &it->first); + } else { + // Hit: promote to most-recently-used. splice is O(1) and keeps lruIt + // (and every other iterator) valid. + shapedRunsLru_.splice(shapedRunsLru_.begin(), shapedRunsLru_, it->second.lruIt); } const ShapedRun& run = it->second; @@ -139,7 +155,21 @@ std::uint32_t UIRenderer::ShapeText(Font& font, float pxSize, } void UIRenderer::InvalidateFont(const Font& font) noexcept { - std::erase_if(shapedRuns_, [&font](const auto& kv) { - return kv.first.font == &font; - }); + // Drop matching runs from both the map and the LRU order list, keeping the + // two in sync (the list points at keys owned by the map). + for (auto it = shapedRuns_.begin(); it != shapedRuns_.end(); ) { + if (it->first.font == &font) { + shapedRunsLru_.erase(it->second.lruIt); + it = shapedRuns_.erase(it); + } else { + ++it; + } + } +} + +bool UIRenderer::IsShapedRunCached(const Font& font, float pxSize, + std::array color, + std::string_view utf8) const { + return shapedRuns_.find(ShapedRunViewKey{&font, pxSize, color, utf8}) + != shapedRuns_.end(); } diff --git a/interfaces/Crafter.Graphics-UI.cppm b/interfaces/Crafter.Graphics-UI.cppm index a47d71f..859f08d 100644 --- a/interfaces/Crafter.Graphics-UI.cppm +++ b/interfaces/Crafter.Graphics-UI.cppm @@ -325,6 +325,17 @@ export namespace Crafter { // reallocated at the same address would otherwise alias stale runs. void InvalidateFont(const Font& font) noexcept; + // Shaped-run cache introspection (issue #123). Hits/misses are + // byte-identical and a re-shape of an already-rasterised string leaves + // the atlas clean, so the LRU eviction policy isn't observable through + // output alone — these let tests assert that the cache stays bounded + // and that the hot set survives an overflow. IsShapedRunCached is a + // pure query: it does not bump recency. + std::size_t ShapedRunCacheSize() const noexcept { return shapedRuns_.size(); } + bool IsShapedRunCached(const Font& font, float pxSize, + std::array color, + std::string_view utf8) const; + std::uint16_t FontAtlasImageSlot() const noexcept { return fontAtlasImageSlot_; } std::uint16_t FontAtlasSamplerSlot() const noexcept { return fontAtlasSamplerSlot_; } @@ -400,12 +411,25 @@ export namespace Crafter { struct ShapedRun { std::vector glyphs; // origin-relative float advance = 0; + // Position of this run's key in shapedRunsLru_ (front = most + // recently used). Lets a hit splice the entry to the front and an + // overflow pop the least-recently-used entry, both O(1). + std::list::iterator lruIt{}; }; // 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). + // would grow this without bound; on overflow we evict the single + // least-recently-used run (issue #123) rather than clearing the whole + // cache. The hot set of stable labels — reshaped every frame, so always + // near the front — stays resident, while a churn of unique strings + // (FPS counters, timers) only recycles the cold tail. Correctness is + // unaffected either way; this just avoids the periodic full-UI reshape + // spike the old clear() caused. static constexpr std::size_t kMaxShapedRuns = 8192; std::unordered_map shapedRuns_; + // LRU recency order for shapedRuns_; front = most recently used. Holds + // pointers to the keys owned by the map — stable across rehash because + // unordered_map is node-based — so eviction never re-hashes the world. + std::list shapedRunsLru_; ImageSlot outImageSlot_; ImageSlot fontAtlasImageSlot_; diff --git a/tests/ShapeTextCache/main.cpp b/tests/ShapeTextCache/main.cpp index 3ef990b..a877a19 100644 --- a/tests/ShapeTextCache/main.cpp +++ b/tests/ShapeTextCache/main.cpp @@ -225,6 +225,63 @@ int main() { 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;