perf(ui): transparent shaped-run cache lookup, no string copy on hit (#122) #141

Merged
catbot merged 1 commit from claude/issue-122 into master 2026-06-18 15:19:43 +02:00
2 changed files with 51 additions and 9 deletions

View file

@ -75,8 +75,11 @@ std::uint32_t UIRenderer::ShapeText(Font& font, float pxSize,
// 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);
// Probe with a borrowing view key (text is a string_view onto utf8) so a
// cache hit copies and hashes no string. The owning std::string is only
// built on a miss, below, for the emplace.
ShapedRunViewKey viewKey{&font, pxSize, color, utf8};
auto it = shapedRuns_.find(viewKey);
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 —
@ -110,6 +113,7 @@ std::uint32_t UIRenderer::ShapeText(Font& font, float pxSize,
run.advance = cursor;
if (shapedRuns_.size() >= kMaxShapedRuns) shapedRuns_.clear();
ShapedRunKey key{&font, pxSize, color, std::string(utf8)};
it = shapedRuns_.emplace(std::move(key), std::move(run)).first;
}

View file

@ -339,25 +339,63 @@ export namespace Crafter {
// 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.
//
// The map owns its key string, but lookups happen every frame on the
// onBuild path. To avoid copying the string into a key on each cache
// hit, the hash and equality functors are transparent (is_transparent)
// and accept a borrowing view key whose text is a std::string_view;
// the owning std::string is only materialised on a miss, for emplace.
struct ShapedRunKey {
const Font* font;
float pxSize;
std::array<float, 4> color;
std::string text;
bool operator==(const ShapedRunKey&) const = default;
};
struct ShapedRunViewKey {
const Font* font;
float pxSize;
std::array<float, 4> color;
std::string_view text;
};
struct ShapedRunKeyHash {
std::size_t operator()(const ShapedRunKey& k) const noexcept {
std::size_t h = std::hash<const void*>{}(k.font);
using is_transparent = void;
static std::size_t Hash(const Font* font, float pxSize,
const std::array<float, 4>& color,
std::string_view text) noexcept {
std::size_t h = std::hash<const void*>{}(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>(pxSize)));
for (float c : color)
mix(std::hash<std::uint32_t>{}(std::bit_cast<std::uint32_t>(c)));
mix(std::hash<std::string_view>{}(k.text));
mix(std::hash<std::string_view>{}(text));
return h;
}
std::size_t operator()(const ShapedRunKey& k) const noexcept {
return Hash(k.font, k.pxSize, k.color, k.text);
}
std::size_t operator()(const ShapedRunViewKey& k) const noexcept {
return Hash(k.font, k.pxSize, k.color, k.text);
}
};
struct ShapedRunKeyEqual {
using is_transparent = void;
static bool Eq(const Font* fa, float pa, const std::array<float, 4>& ca,
std::string_view ta,
const Font* fb, float pb, const std::array<float, 4>& cb,
std::string_view tb) noexcept {
return fa == fb && pa == pb && ca == cb && ta == tb;
}
bool operator()(const ShapedRunKey& a, const ShapedRunKey& b) const noexcept {
return Eq(a.font, a.pxSize, a.color, a.text, b.font, b.pxSize, b.color, b.text);
}
bool operator()(const ShapedRunViewKey& a, const ShapedRunKey& b) const noexcept {
return Eq(a.font, a.pxSize, a.color, a.text, b.font, b.pxSize, b.color, b.text);
}
bool operator()(const ShapedRunKey& a, const ShapedRunViewKey& b) const noexcept {
return Eq(a.font, a.pxSize, a.color, a.text, b.font, b.pxSize, b.color, b.text);
}
};
struct ShapedRun {
std::vector<GlyphItem> glyphs; // origin-relative
@ -367,7 +405,7 @@ export namespace Crafter {
// 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_;
std::unordered_map<ShapedRunKey, ShapedRun, ShapedRunKeyHash, ShapedRunKeyEqual> shapedRuns_;
ImageSlot outImageSlot_;
ImageSlot fontAtlasImageSlot_;