perf(text): cache shaped runs in ShapeText (#52)
ShapeText re-decoded UTF-8, re-looked-up every glyph, and recomputed layout every frame for static strings (DrawText / EmitCenteredLabel run inside per-frame onBuild). DrawText and EmitCenteredLabel also did a second full pass over the glyphs to apply alignment. Memoize the shaped run, keyed by (Font*, pxSize, color, utf8), as an origin-relative vector<GlyphItem>. A cache hit skips decode/lookup/layout and just translates the cached run into the output buffer by (x + alignShift, baselineY). The alignment shift (Center = -advance/2, Right = -advance) is folded into ShapeText's single pass, dropping the second loop in both callers. TextAlign moves to the :UI partition so ShapeText can take it. Pure CPU memoization — the glyph buffer is fully rewritten and re-flushed every frame, so a hit is byte-equivalent. A miss still calls Ensure so new glyphs rasterise and dirty the atlas; hits don't (atlas entries are never evicted). InvalidateFont drops a font's runs before it is destroyed (the cache, like FontAtlas's glyph cache, keys on the Font pointer). A soft cap bounds memory against pathological per-frame-unique text. Adds the ShapeTextCache test (15 checks): hit == miss byte-for-byte, hits don't touch the atlas, translate/alignment/truncation/InvalidateFont. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
58e9e0a747
commit
ca48f79465
7 changed files with 391 additions and 40 deletions
|
|
@ -90,6 +90,12 @@ export namespace Crafter {
|
|||
};
|
||||
static_assert(sizeof(GlyphItem) == 48);
|
||||
|
||||
// Where the X coordinate sits relative to the emitted glyph run. Used by
|
||||
// UIRenderer::ShapeText and the Tier 3 text components (DrawText). Lives
|
||||
// here (not in :UIComponents) so ShapeText can fold the alignment shift
|
||||
// into its single pass.
|
||||
enum class TextAlign : std::uint8_t { Left, Center, Right };
|
||||
|
||||
// ─── tiny rect-carving helper ───────────────────────────────────────
|
||||
struct Rect {
|
||||
float x = 0, y = 0, w = 0, h = 0;
|
||||
|
|
@ -206,12 +212,30 @@ export namespace Crafter {
|
|||
#endif
|
||||
SamplerSlot RegisterLinearClampSampler();
|
||||
|
||||
// Shapes `utf8` into `out` (up to `outCapacity` glyphs), positioning
|
||||
// the run at `(x, baselineY)` with horizontal `align`. Returns the
|
||||
// number of glyphs written; `*outAdvance` (if non-null) receives the
|
||||
// full advance width regardless of truncation.
|
||||
//
|
||||
// The origin-relative shaped run is memoized in a CPU cache keyed by
|
||||
// (Font*, pxSize, color, utf8); a hit skips UTF-8 decode, glyph
|
||||
// lookup, and layout, translating the cached run into `out` instead.
|
||||
// Caching is byte-equivalent — the glyph buffer is fully rewritten
|
||||
// and re-flushed every frame regardless. Call InvalidateFont before
|
||||
// destroying a Font (the cache keys on the Font pointer).
|
||||
std::uint32_t ShapeText(Font& font, float pxSize,
|
||||
float x, float baselineY,
|
||||
std::string_view utf8,
|
||||
std::array<float,4> color,
|
||||
GlyphItem* out, std::uint32_t outCapacity,
|
||||
float* outAdvance = nullptr);
|
||||
float* outAdvance = nullptr,
|
||||
TextAlign align = TextAlign::Left);
|
||||
|
||||
// Drop every cached shaped run that references `font`. Must be called
|
||||
// before a Font is destroyed: the run cache (and FontAtlas's glyph
|
||||
// cache) key on the Font pointer, so a Font freed and a new one
|
||||
// reallocated at the same address would otherwise alias stale runs.
|
||||
void InvalidateFont(const Font& font) noexcept;
|
||||
|
||||
std::uint16_t FontAtlasImageSlot() const noexcept { return fontAtlasImageSlot_; }
|
||||
std::uint16_t FontAtlasSamplerSlot() const noexcept { return fontAtlasSamplerSlot_; }
|
||||
|
|
@ -222,6 +246,41 @@ export namespace Crafter {
|
|||
Window* window_ = nullptr;
|
||||
GraphicsDescriptorHeap* heap_ = nullptr;
|
||||
|
||||
// ─── shaped-run cache ───────────────────────────────────────────
|
||||
// Glyphs are stored origin-relative (pen at x=0, baseline=0); a hit
|
||||
// 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.
|
||||
struct ShapedRunKey {
|
||||
const Font* font;
|
||||
float pxSize;
|
||||
std::array<float, 4> color;
|
||||
std::string text;
|
||||
bool operator==(const ShapedRunKey&) const = default;
|
||||
};
|
||||
struct ShapedRunKeyHash {
|
||||
std::size_t operator()(const ShapedRunKey& k) const noexcept {
|
||||
std::size_t h = std::hash<const void*>{}(k.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>(c)));
|
||||
mix(std::hash<std::string_view>{}(k.text));
|
||||
return h;
|
||||
}
|
||||
};
|
||||
struct ShapedRun {
|
||||
std::vector<GlyphItem> glyphs; // origin-relative
|
||||
float advance = 0;
|
||||
};
|
||||
// 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).
|
||||
static constexpr std::size_t kMaxShapedRuns = 8192;
|
||||
std::unordered_map<ShapedRunKey, ShapedRun, ShapedRunKeyHash> shapedRuns_;
|
||||
|
||||
ImageSlot outImageSlot_;
|
||||
ImageSlot fontAtlasImageSlot_;
|
||||
SamplerSlot fontAtlasSamplerSlot_;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue