perf(ui): LRU-evict the shaped-run cache instead of clearing it (#123) #143

Merged
catbot merged 2 commits from claude/issue-123 into master 2026-06-18 15:30:08 +02:00
5 changed files with 99 additions and 30 deletions
Showing only changes of commit 88541bf44e - Show all commits

Merge remote-tracking branch 'origin/master' into claude/issue-123

# Conflicts:
#	implementations/Crafter.Graphics-UI-Shared.cpp
#	interfaces/Crafter.Graphics-UI.cppm
catbot 2026-06-18 13:29:49 +00:00

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 // 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 // translate below. The whole string is shaped (no outCapacity limit) so
// a later call with a larger buffer still gets the full run. // a later call with a larger buffer still gets the full run.
ShapedRunKey key{&font, pxSize, color, std::string(utf8)}; // Probe with a borrowing view key (text is a string_view onto utf8) so a
auto it = shapedRuns_.find(key); // 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()) { if (it == shapedRuns_.end()) {
// Miss: shape from scratch. Ensure() each glyph so brand-new ones // Miss: shape from scratch. Ensure() each glyph so brand-new ones
// rasterise into the atlas and mark it dirty for the next upload — // rasterise into the atlas and mark it dirty for the next upload —
@ -119,6 +122,9 @@ std::uint32_t UIRenderer::ShapeText(Font& font, float pxSize,
shapedRunsLru_.pop_back(); shapedRunsLru_.pop_back();
shapedRuns_.erase(*victim); 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 = shapedRuns_.emplace(std::move(key), std::move(run)).first;
it->second.lruIt = shapedRunsLru_.insert(shapedRunsLru_.begin(), &it->first); it->second.lruIt = shapedRunsLru_.insert(shapedRunsLru_.begin(), &it->first);
} else { } else {
@ -164,6 +170,6 @@ void UIRenderer::InvalidateFont(const Font& font) noexcept {
bool UIRenderer::IsShapedRunCached(const Font& font, float pxSize, bool UIRenderer::IsShapedRunCached(const Font& font, float pxSize,
std::array<float,4> color, std::array<float,4> color,
std::string_view utf8) const { std::string_view utf8) const {
return shapedRuns_.find(ShapedRunKey{&font, pxSize, color, std::string(utf8)}) return shapedRuns_.find(ShapedRunViewKey{&font, pxSize, color, utf8})
!= shapedRuns_.end(); != shapedRuns_.end();
} }

View file

@ -350,25 +350,63 @@ export namespace Crafter {
// only needs a translate by (x + alignShift, baselineY). The full // only needs a translate by (x + alignShift, baselineY). The full
// string is part of the key (compared on lookup) so hash collisions // string is part of the key (compared on lookup) so hash collisions
// can't return the wrong run. // 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 { struct ShapedRunKey {
const Font* font; const Font* font;
float pxSize; float pxSize;
std::array<float, 4> color; std::array<float, 4> color;
std::string text; 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 { struct ShapedRunKeyHash {
std::size_t operator()(const ShapedRunKey& k) const noexcept { using is_transparent = void;
std::size_t h = std::hash<const void*>{}(k.font); 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 { auto mix = [&h](std::size_t v) noexcept {
h ^= v + 0x9e3779b97f4a7c15ULL + (h << 6) + (h >> 2); h ^= v + 0x9e3779b97f4a7c15ULL + (h << 6) + (h >> 2);
}; };
mix(std::hash<std::uint32_t>{}(std::bit_cast<std::uint32_t>(k.pxSize))); mix(std::hash<std::uint32_t>{}(std::bit_cast<std::uint32_t>(pxSize)));
for (float c : k.color) for (float c : color)
mix(std::hash<std::uint32_t>{}(std::bit_cast<std::uint32_t>(c))); 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; 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 { struct ShapedRun {
std::vector<GlyphItem> glyphs; // origin-relative std::vector<GlyphItem> glyphs; // origin-relative
@ -387,7 +425,7 @@ export namespace Crafter {
// unaffected either way; this just avoids the periodic full-UI reshape // unaffected either way; this just avoids the periodic full-UI reshape
// spike the old clear() caused. // spike the old clear() caused.
static constexpr std::size_t kMaxShapedRuns = 8192; static constexpr std::size_t kMaxShapedRuns = 8192;
std::unordered_map<ShapedRunKey, ShapedRun, ShapedRunKeyHash> shapedRuns_; std::unordered_map<ShapedRunKey, ShapedRun, ShapedRunKeyHash, ShapedRunKeyEqual> shapedRuns_;
// LRU recency order for shapedRuns_; front = most recently used. Holds // LRU recency order for shapedRuns_; front = most recently used. Holds
// pointers to the keys owned by the map — stable across rehash because // pointers to the keys owned by the map — stable across rehash because
// unordered_map is node-based — so eviction never re-hashes the world. // unordered_map is node-based — so eviction never re-hashes the world.

View file

@ -70,6 +70,13 @@ shared vec4 s_v1[UI_CHUNK];
shared vec4 s_v2[UI_CHUNK]; shared vec4 s_v2[UI_CHUNK];
shared vec4 s_v3[UI_CHUNK]; shared vec4 s_v3[UI_CHUNK];
shared uvec4 s_v4[UI_CHUNK]; shared uvec4 s_v4[UI_CHUNK];
// Per-item constants precomputed once at cooperative-load time, REUSED across
// phases like the s_v* scratch (the per-pixel inner loops read them instead of
// recomputing per pixel × item):
// images: s_inv = 1.0/rect.zw
// text: s_inv = 1.0/rect.zw, s_band = SDF AA scale (a divide + two maxes)
shared vec2 s_inv[UI_CHUNK];
shared float s_band[UI_CHUNK];
shared uint s_keep[UI_CHUNK]; shared uint s_keep[UI_CHUNK];
shared uint s_order[UI_CHUNK]; shared uint s_order[UI_CHUNK];
shared uint s_count; shared uint s_count;
@ -232,6 +239,7 @@ void main() {
s_v1[lid] = uiImageHeap[heap].items[idx].uv; s_v1[lid] = uiImageHeap[heap].items[idx].uv;
s_v2[lid] = uiImageHeap[heap].items[idx].tint; s_v2[lid] = uiImageHeap[heap].items[idx].tint;
s_v4[lid] = uiImageHeap[heap].items[idx].slots; s_v4[lid] = uiImageHeap[heap].items[idx].slots;
s_inv[lid] = 1.0 / s_v0[lid].zw;
keep = uiAabbOverlapsTile(s_v0[lid].xy, s_v0[lid].xy + s_v0[lid].zw, keep = uiAabbOverlapsTile(s_v0[lid].xy, s_v0[lid].xy + s_v0[lid].zw,
tileMin, tileMax); tileMin, tileMax);
} }
@ -249,7 +257,7 @@ void main() {
if (sp.x < lo.x || sp.y < lo.y) continue; if (sp.x < lo.x || sp.y < lo.y) continue;
if (sp.x >= hi.x || sp.y >= hi.y) continue; if (sp.x >= hi.x || sp.y >= hi.y) continue;
vec2 t = (sp - s_v0[c].xy) / s_v0[c].zw; vec2 t = (sp - s_v0[c].xy) * s_inv[c];
vec2 uv = mix(s_v1[c].xy, s_v1[c].zw, t); vec2 uv = mix(s_v1[c].xy, s_v1[c].zw, t);
uint texSlot = s_v4[c].x; uint texSlot = s_v4[c].x;
@ -282,6 +290,13 @@ void main() {
s_v0[lid] = uiGlyphHeap[heap].items[idx].rect; s_v0[lid] = uiGlyphHeap[heap].items[idx].rect;
s_v1[lid] = uiGlyphHeap[heap].items[idx].uv; s_v1[lid] = uiGlyphHeap[heap].items[idx].uv;
s_v2[lid] = uiGlyphHeap[heap].items[idx].color; s_v2[lid] = uiGlyphHeap[heap].items[idx].color;
s_inv[lid] = 1.0 / s_v0[lid].zw;
// SDF AA band — atlas-px per screen-px, constant per glyph
// (uvSpan * kAtlasSize(1024) / screenSpan), max'd to 1px floor.
vec2 uvSpan = s_v1[lid].zw - s_v1[lid].xy;
vec2 atlasPerScreen = (uvSpan * 1024.0) * s_inv[lid];
float scalePx = max(atlasPerScreen.x, atlasPerScreen.y);
s_band[lid] = max(scalePx, 0.0001);
keep = uiAabbOverlapsTile(s_v0[lid].xy, s_v0[lid].xy + s_v0[lid].zw, keep = uiAabbOverlapsTile(s_v0[lid].xy, s_v0[lid].xy + s_v0[lid].zw,
tileMin, tileMax); tileMin, tileMax);
} }
@ -299,7 +314,7 @@ void main() {
if (sp.x < lo.x || sp.y < lo.y) continue; if (sp.x < lo.x || sp.y < lo.y) continue;
if (sp.x >= hi.x || sp.y >= hi.y) continue; if (sp.x >= hi.x || sp.y >= hi.y) continue;
vec2 t = (sp - s_v0[c].xy) / s_v0[c].zw; vec2 t = (sp - s_v0[c].xy) * s_inv[c];
vec2 uv = mix(s_v1[c].xy, s_v1[c].zw, t); vec2 uv = mix(s_v1[c].xy, s_v1[c].zw, t);
// Font slots are push constants — provably dynamically uniform, // Font slots are push constants — provably dynamically uniform,
@ -312,12 +327,8 @@ void main() {
float dAtlas = (ON_EDGE - sdf) * DIST_SCALE; float dAtlas = (ON_EDGE - sdf) * DIST_SCALE;
vec2 uvSpan = s_v1[c].zw - s_v1[c].xy; // band (the AA scale) was precomputed once per glyph at load.
vec2 atlasPerScreen = (uvSpan * 1024.0) / s_v0[c].zw; float a = clamp(0.5 - dAtlas / s_band[c], 0.0, 1.0);
float scalePx = max(atlasPerScreen.x, atlasPerScreen.y);
float band = max(scalePx, 0.0001);
float a = clamp(0.5 - dAtlas / band, 0.0, 1.0);
if (a <= 0.0) continue; if (a <= 0.0) continue;
vec4 col = s_v2[c]; vec4 col = s_v2[c];

View file

@ -16,6 +16,11 @@ shared vec4 s_rect[UI_CHUNK];
shared vec4 s_uv[UI_CHUNK]; shared vec4 s_uv[UI_CHUNK];
shared vec4 s_tint[UI_CHUNK]; shared vec4 s_tint[UI_CHUNK];
shared uvec4 s_slots[UI_CHUNK]; shared uvec4 s_slots[UI_CHUNK];
// 1.0/rect.zw, precomputed once per item at cooperative-load time so the
// per-pixel inner loop multiplies instead of recomputing a vec2 reciprocal of
// the (loop-invariant, but shared-mem + varying-index, so non-hoistable by the
// compiler) rect size for every pixel × item.
shared vec2 s_invRectSize[UI_CHUNK];
shared uint s_keep[UI_CHUNK]; shared uint s_keep[UI_CHUNK];
shared uint s_order[UI_CHUNK]; shared uint s_order[UI_CHUNK];
shared uint s_count; shared uint s_count;
@ -43,6 +48,7 @@ void main() {
s_uv[lid] = uiImageHeap[pc.hdr.itemBuffer].items[idx].uv; s_uv[lid] = uiImageHeap[pc.hdr.itemBuffer].items[idx].uv;
s_tint[lid] = uiImageHeap[pc.hdr.itemBuffer].items[idx].tint; s_tint[lid] = uiImageHeap[pc.hdr.itemBuffer].items[idx].tint;
s_slots[lid] = uiImageHeap[pc.hdr.itemBuffer].items[idx].slots; s_slots[lid] = uiImageHeap[pc.hdr.itemBuffer].items[idx].slots;
s_invRectSize[lid] = 1.0 / s_rect[lid].zw;
keep = uiAabbOverlapsTile(s_rect[lid].xy, s_rect[lid].xy + s_rect[lid].zw, keep = uiAabbOverlapsTile(s_rect[lid].xy, s_rect[lid].xy + s_rect[lid].zw,
tileMin, tileMax); tileMin, tileMax);
} }
@ -67,7 +73,7 @@ void main() {
if (sp.x < lo.x || sp.y < lo.y) continue; if (sp.x < lo.x || sp.y < lo.y) continue;
if (sp.x >= hi.x || sp.y >= hi.y) continue; if (sp.x >= hi.x || sp.y >= hi.y) continue;
vec2 t = (sp - s_rect[c].xy) / s_rect[c].zw; vec2 t = (sp - s_rect[c].xy) * s_invRectSize[c];
vec2 uv = mix(s_uv[c].xy, s_uv[c].zw, t); vec2 uv = mix(s_uv[c].xy, s_uv[c].zw, t);
uint texSlot = s_slots[c].x; uint texSlot = s_slots[c].x;

View file

@ -23,6 +23,13 @@ const float DIST_SCALE = 32.0;
shared vec4 s_rect[UI_CHUNK]; shared vec4 s_rect[UI_CHUNK];
shared vec4 s_uv[UI_CHUNK]; shared vec4 s_uv[UI_CHUNK];
shared vec4 s_color[UI_CHUNK]; shared vec4 s_color[UI_CHUNK];
// Per-glyph constants precomputed once at cooperative-load time and read by the
// per-pixel inner loop, instead of being recomputed for every pixel × glyph:
// s_invRectSize = 1.0/rect.zw (turns the per-pixel divide into a multiply)
// s_band = the SDF AA scale (a divide + two maxes that depend only on
// the glyph's uv span and rect size — fully loop-invariant)
shared vec2 s_invRectSize[UI_CHUNK];
shared float s_band[UI_CHUNK];
shared uint s_keep[UI_CHUNK]; shared uint s_keep[UI_CHUNK];
shared uint s_order[UI_CHUNK]; shared uint s_order[UI_CHUNK];
shared uint s_count; shared uint s_count;
@ -49,6 +56,15 @@ void main() {
s_rect[lid] = uiGlyphHeap[pc.hdr.itemBuffer].items[idx].rect; s_rect[lid] = uiGlyphHeap[pc.hdr.itemBuffer].items[idx].rect;
s_uv[lid] = uiGlyphHeap[pc.hdr.itemBuffer].items[idx].uv; s_uv[lid] = uiGlyphHeap[pc.hdr.itemBuffer].items[idx].uv;
s_color[lid] = uiGlyphHeap[pc.hdr.itemBuffer].items[idx].color; s_color[lid] = uiGlyphHeap[pc.hdr.itemBuffer].items[idx].color;
s_invRectSize[lid] = 1.0 / s_rect[lid].zw;
// Atlas-px per screen-px along this glyph's transform — keeps AA
// crisp at any rendering size. uvSpan * atlasSize / screenSpan,
// with FontAtlas::kAtlasSize = 1024. Constant for the whole glyph.
vec2 uvSpan = s_uv[lid].zw - s_uv[lid].xy;
vec2 atlasPerScreen = (uvSpan * 1024.0) * s_invRectSize[lid];
float scalePx = max(atlasPerScreen.x, atlasPerScreen.y);
// 1-screen-px AA band, expressed in atlas-pixel units of dAtlas.
s_band[lid] = max(scalePx, 0.0001);
keep = uiAabbOverlapsTile(s_rect[lid].xy, s_rect[lid].xy + s_rect[lid].zw, keep = uiAabbOverlapsTile(s_rect[lid].xy, s_rect[lid].xy + s_rect[lid].zw,
tileMin, tileMax); tileMin, tileMax);
} }
@ -73,7 +89,7 @@ void main() {
if (sp.x < lo.x || sp.y < lo.y) continue; if (sp.x < lo.x || sp.y < lo.y) continue;
if (sp.x >= hi.x || sp.y >= hi.y) continue; if (sp.x >= hi.x || sp.y >= hi.y) continue;
vec2 t = (sp - s_rect[c].xy) / s_rect[c].zw; vec2 t = (sp - s_rect[c].xy) * s_invRectSize[c];
vec2 uv = mix(s_uv[c].xy, s_uv[c].zw, t); vec2 uv = mix(s_uv[c].xy, s_uv[c].zw, t);
// Font slots are push constants — provably dynamically uniform, so no // Font slots are push constants — provably dynamically uniform, so no
@ -88,16 +104,8 @@ void main() {
// Distance in atlas-pixels (negative inside the glyph). // Distance in atlas-pixels (negative inside the glyph).
float dAtlas = (ON_EDGE - sdf) * DIST_SCALE; float dAtlas = (ON_EDGE - sdf) * DIST_SCALE;
// Atlas-px per screen-px along this glyph's transform — keeps AA crisp // band (the AA scale) was precomputed once per glyph at load.
// at any rendering size. uvSpan * atlasSize / screenSpan. float a = clamp(0.5 - dAtlas / s_band[c], 0.0, 1.0);
vec2 uvSpan = s_uv[c].zw - s_uv[c].xy;
// FontAtlas::kAtlasSize = 1024.
vec2 atlasPerScreen = (uvSpan * 1024.0) / s_rect[c].zw;
float scalePx = max(atlasPerScreen.x, atlasPerScreen.y);
// 1-screen-px AA band, expressed in atlas-pixel units of dAtlas.
float band = max(scalePx, 0.0001);
float a = clamp(0.5 - dAtlas / band, 0.0, 1.0);
if (a <= 0.0) continue; if (a <= 0.0) continue;
vec4 col = s_color[c]; vec4 col = s_color[c];