From c8495f548b330ff91657c7bc9e2113078011a161 Mon Sep 17 00:00:00 2001 From: catbot Date: Thu, 18 Jun 2026 13:19:11 +0000 Subject: [PATCH 1/2] perf(ui): transparent shaped-run cache lookup, no string copy on hit (#122) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ShapeText built ShapedRunKey{..., std::string(utf8)} before find() unconditionally — even on cache hits, on the per-frame onBuild path. N labels meant N string copies + hashes (+ frees for non-SSO strings) every frame, partially defeating the run cache. Add is_transparent hash + equality functors on shapedRuns_ and probe with a borrowing ShapedRunViewKey {const Font*, float, array, string_view}. The owning std::string is now materialised only on a miss, for emplace. Cache hits copy and hash no string. Co-Authored-By: Claude Opus 4.8 --- .../Crafter.Graphics-UI-Shared.cpp | 8 ++- interfaces/Crafter.Graphics-UI.cppm | 52 ++++++++++++++++--- 2 files changed, 51 insertions(+), 9 deletions(-) diff --git a/implementations/Crafter.Graphics-UI-Shared.cpp b/implementations/Crafter.Graphics-UI-Shared.cpp index c3dee39..5c7fcf7 100644 --- a/implementations/Crafter.Graphics-UI-Shared.cpp +++ b/implementations/Crafter.Graphics-UI-Shared.cpp @@ -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; } diff --git a/interfaces/Crafter.Graphics-UI.cppm b/interfaces/Crafter.Graphics-UI.cppm index 0eec8f2..a47d71f 100644 --- a/interfaces/Crafter.Graphics-UI.cppm +++ b/interfaces/Crafter.Graphics-UI.cppm @@ -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 color; std::string text; - bool operator==(const ShapedRunKey&) const = default; + }; + struct ShapedRunViewKey { + const Font* font; + float pxSize; + std::array color; + std::string_view text; }; struct ShapedRunKeyHash { - std::size_t operator()(const ShapedRunKey& k) const noexcept { - std::size_t h = std::hash{}(k.font); + using is_transparent = void; + static std::size_t Hash(const Font* font, float pxSize, + const std::array& color, + std::string_view text) noexcept { + std::size_t h = std::hash{}(font); auto mix = [&h](std::size_t v) noexcept { h ^= v + 0x9e3779b97f4a7c15ULL + (h << 6) + (h >> 2); }; - mix(std::hash{}(std::bit_cast(k.pxSize))); - for (float c : k.color) + mix(std::hash{}(std::bit_cast(pxSize))); + for (float c : color) mix(std::hash{}(std::bit_cast(c))); - mix(std::hash{}(k.text)); + mix(std::hash{}(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& ca, + std::string_view ta, + const Font* fb, float pb, const std::array& 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 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 shapedRuns_; + std::unordered_map shapedRuns_; ImageSlot outImageSlot_; ImageSlot fontAtlasImageSlot_; From ac9180076ccf766ffa4e8ca33a1bad08ff84d385 Mon Sep 17 00:00:00 2001 From: catbot Date: Thu, 18 Jun 2026 13:22:07 +0000 Subject: [PATCH 2/2] perf(ui): hoist loop-invariant per-item math out of the per-pixel loop (#125) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cooperative-load section already streams each item into shared memory once per workgroup, but the per-pixel inner loop then recomputed values that are constant for the whole item/glyph. The compiler can't hoist them itself — they read shared memory at a varying index. Precompute them once per item at load time into new shared slots: - ui-images / ui-text / ui-fused (images+text phases): invRectSize = 1.0/rect.zw, turning the per-pixel `(sp-rect.xy)/rect.zw` vec2 divide into a multiply. - ui-text / ui-fused (text phase): the SDF AA `band` (a vec2 divide + two maxes depending only on the glyph's uv span and rect size). In ui-fused the new slots (s_inv, s_band) are reused across phases like the existing s_v* scratch, so the LDS bump is per-category, not summed. Co-Authored-By: Claude Opus 4.8 --- shaders/ui-fused.comp.glsl | 27 +++++++++++++++++++-------- shaders/ui-images.comp.glsl | 8 +++++++- shaders/ui-text.comp.glsl | 30 +++++++++++++++++++----------- 3 files changed, 45 insertions(+), 20 deletions(-) diff --git a/shaders/ui-fused.comp.glsl b/shaders/ui-fused.comp.glsl index 998061b..28eb5ea 100644 --- a/shaders/ui-fused.comp.glsl +++ b/shaders/ui-fused.comp.glsl @@ -70,6 +70,13 @@ shared vec4 s_v1[UI_CHUNK]; shared vec4 s_v2[UI_CHUNK]; shared vec4 s_v3[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_order[UI_CHUNK]; shared uint s_count; @@ -232,6 +239,7 @@ void main() { s_v1[lid] = uiImageHeap[heap].items[idx].uv; s_v2[lid] = uiImageHeap[heap].items[idx].tint; 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, tileMin, tileMax); } @@ -249,7 +257,7 @@ void main() { if (sp.x < lo.x || sp.y < lo.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); uint texSlot = s_v4[c].x; @@ -282,6 +290,13 @@ void main() { s_v0[lid] = uiGlyphHeap[heap].items[idx].rect; s_v1[lid] = uiGlyphHeap[heap].items[idx].uv; 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, tileMin, tileMax); } @@ -299,7 +314,7 @@ void main() { if (sp.x < lo.x || sp.y < lo.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); // Font slots are push constants — provably dynamically uniform, @@ -312,12 +327,8 @@ void main() { float dAtlas = (ON_EDGE - sdf) * DIST_SCALE; - vec2 uvSpan = s_v1[c].zw - s_v1[c].xy; - vec2 atlasPerScreen = (uvSpan * 1024.0) / s_v0[c].zw; - float scalePx = max(atlasPerScreen.x, atlasPerScreen.y); - float band = max(scalePx, 0.0001); - - float a = clamp(0.5 - dAtlas / band, 0.0, 1.0); + // band (the AA scale) was precomputed once per glyph at load. + float a = clamp(0.5 - dAtlas / s_band[c], 0.0, 1.0); if (a <= 0.0) continue; vec4 col = s_v2[c]; diff --git a/shaders/ui-images.comp.glsl b/shaders/ui-images.comp.glsl index a4424a6..061ee89 100644 --- a/shaders/ui-images.comp.glsl +++ b/shaders/ui-images.comp.glsl @@ -16,6 +16,11 @@ shared vec4 s_rect[UI_CHUNK]; shared vec4 s_uv[UI_CHUNK]; shared vec4 s_tint[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_order[UI_CHUNK]; shared uint s_count; @@ -43,6 +48,7 @@ void main() { s_uv[lid] = uiImageHeap[pc.hdr.itemBuffer].items[idx].uv; s_tint[lid] = uiImageHeap[pc.hdr.itemBuffer].items[idx].tint; 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, tileMin, tileMax); } @@ -67,7 +73,7 @@ void main() { if (sp.x < lo.x || sp.y < lo.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); uint texSlot = s_slots[c].x; diff --git a/shaders/ui-text.comp.glsl b/shaders/ui-text.comp.glsl index a64abbf..9c716c2 100644 --- a/shaders/ui-text.comp.glsl +++ b/shaders/ui-text.comp.glsl @@ -23,6 +23,13 @@ const float DIST_SCALE = 32.0; shared vec4 s_rect[UI_CHUNK]; shared vec4 s_uv[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_order[UI_CHUNK]; shared uint s_count; @@ -49,6 +56,15 @@ void main() { s_rect[lid] = uiGlyphHeap[pc.hdr.itemBuffer].items[idx].rect; s_uv[lid] = uiGlyphHeap[pc.hdr.itemBuffer].items[idx].uv; 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, tileMin, tileMax); } @@ -73,7 +89,7 @@ void main() { if (sp.x < lo.x || sp.y < lo.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); // Font slots are push constants — provably dynamically uniform, so no @@ -88,16 +104,8 @@ void main() { // Distance in atlas-pixels (negative inside the glyph). float dAtlas = (ON_EDGE - sdf) * DIST_SCALE; - // Atlas-px per screen-px along this glyph's transform — keeps AA crisp - // at any rendering size. uvSpan * atlasSize / screenSpan. - 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); + // band (the AA scale) was precomputed once per glyph at load. + float a = clamp(0.5 - dAtlas / s_band[c], 0.0, 1.0); if (a <= 0.0) continue; vec4 col = s_color[c];