diff --git a/implementations/Crafter.Graphics-UI-Shared.cpp b/implementations/Crafter.Graphics-UI-Shared.cpp index 435f728..9e75653 100644 --- a/implementations/Crafter.Graphics-UI-Shared.cpp +++ b/implementations/Crafter.Graphics-UI-Shared.cpp @@ -48,39 +48,79 @@ std::uint32_t UIRenderer::ShapeText(Font& font, float pxSize, std::string_view utf8, std::array color, GlyphItem* out, std::uint32_t outCapacity, - float* outAdvance) { + float* outAdvance, + TextAlign align) { if (fontAtlas == nullptr) { std::println("UIRenderer::ShapeText: no FontAtlas (set fontAtlas before Initialize)"); std::abort(); } - const float scale = pxSize / FontAtlas::kBaseSize; - float cursor = x; - std::uint32_t written = 0; + // Look up (or build) the origin-relative shaped run. It is stored with + // 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); + 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 — + // a hit skips this, which is safe because atlas entries are never + // evicted once placed. + const float scale = pxSize / FontAtlas::kBaseSize; + ShapedRun run; + float cursor = 0.0f; + std::size_t i = 0; + while (i < utf8.size()) { + std::uint32_t cp = DecodeUtf8(utf8, i); + if (cp == 0) break; + if (cp == '\n') { continue; } - std::size_t i = 0; - while (i < utf8.size() && written < outCapacity) { - std::uint32_t cp = DecodeUtf8(utf8, i); - if (cp == 0) break; - if (cp == '\n') { continue; } + fontAtlas->Ensure(font, cp); + const Glyph* g = fontAtlas->Lookup(font, cp); + if (g == nullptr) continue; - fontAtlas->Ensure(font, cp); - const Glyph* g = fontAtlas->Lookup(font, cp); - if (g == nullptr) continue; - - if (g->w > 0 && g->h > 0) { - GlyphItem& gi = out[written++]; - gi.x = cursor + g->xoff * scale; - gi.y = baselineY + g->yoff * scale; - gi.w = g->w * scale; - gi.h = g->h * scale; - gi.u0 = g->u0; gi.v0 = g->v0; - gi.u1 = g->u1; gi.v1 = g->v1; - gi.r = color[0]; gi.g = color[1]; gi.b = color[2]; gi.a = color[3]; + if (g->w > 0 && g->h > 0) { + GlyphItem gi; + gi.x = cursor + g->xoff * scale; + gi.y = g->yoff * scale; + gi.w = g->w * scale; + gi.h = g->h * scale; + gi.u0 = g->u0; gi.v0 = g->v0; + gi.u1 = g->u1; gi.v1 = g->v1; + gi.r = color[0]; gi.g = color[1]; gi.b = color[2]; gi.a = color[3]; + run.glyphs.push_back(gi); + } + cursor += g->advance * scale; } - cursor += g->advance * scale; + run.advance = cursor; + + if (shapedRuns_.size() >= kMaxShapedRuns) shapedRuns_.clear(); + it = shapedRuns_.emplace(std::move(key), std::move(run)).first; } - if (outAdvance) *outAdvance = cursor - x; + const ShapedRun& run = it->second; + if (outAdvance) *outAdvance = run.advance; + + // Fold the alignment shift into the translate so callers don't need a + // second pass over the glyphs. + const float alignShift = (align == TextAlign::Center) ? -run.advance * 0.5f + : (align == TextAlign::Right) ? -run.advance + : 0.0f; + const float tx = x + alignShift; + + std::uint32_t written = 0; + for (const GlyphItem& src : run.glyphs) { + if (written >= outCapacity) break; + GlyphItem& gi = out[written++]; + gi = src; + gi.x += tx; + gi.y += baselineY; + } return written; } + +void UIRenderer::InvalidateFont(const Font& font) noexcept { + std::erase_if(shapedRuns_, [&font](const auto& kv) { + return kv.first.font == &font; + }); +} diff --git a/implementations/Crafter.Graphics-UIComponents.cpp b/implementations/Crafter.Graphics-UIComponents.cpp index f4d240d..450737d 100644 --- a/implementations/Crafter.Graphics-UIComponents.cpp +++ b/implementations/Crafter.Graphics-UIComponents.cpp @@ -58,17 +58,12 @@ namespace { GlyphItem* writePos = buf.glyphs + before; float baseline = centerY + fontSize * 0.32f; float advance = 0.0f; + // ShapeText folds the -advance/2 centering shift into its own pass. std::uint32_t n = buf.renderer->ShapeText( font, fontSize, centerX, baseline, - label, color, writePos, cap, &advance + label, color, writePos, cap, &advance, TextAlign::Center ); *buf.glyphCount = before + n; - - // Center: shift each glyph's x by -advance/2. - float shift = -advance * 0.5f; - for (std::uint32_t i = 0; i < n; ++i) { - writePos[i].x += shift; - } return advance; } } @@ -202,16 +197,12 @@ float Crafter::DrawText(UIBuffer& buf, std::string_view text, GlyphItem* writePos = buf.glyphs + before; float advance = 0.0f; + // Alignment is applied inside ShapeText (folded into its single pass). std::uint32_t n = buf.renderer->ShapeText( font, fontSize, x, baselineY, - text, color, writePos, cap, &advance + text, color, writePos, cap, &advance, align ); *buf.glyphCount = before + n; - - if (align != TextAlign::Left && n > 0) { - float shift = (align == TextAlign::Center) ? -advance * 0.5f : -advance; - for (std::uint32_t i = 0; i < n; ++i) writePos[i].x += shift; - } return advance; } diff --git a/interfaces/Crafter.Graphics-UI.cppm b/interfaces/Crafter.Graphics-UI.cppm index dead5c7..b5760f9 100644 --- a/interfaces/Crafter.Graphics-UI.cppm +++ b/interfaces/Crafter.Graphics-UI.cppm @@ -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 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 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{}(k.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(c))); + mix(std::hash{}(k.text)); + return h; + } + }; + struct ShapedRun { + std::vector 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 shapedRuns_; + ImageSlot outImageSlot_; ImageSlot fontAtlasImageSlot_; SamplerSlot fontAtlasSamplerSlot_; diff --git a/interfaces/Crafter.Graphics-UIComponents.cppm b/interfaces/Crafter.Graphics-UIComponents.cppm index 42de1ec..ec8c346 100644 --- a/interfaces/Crafter.Graphics-UIComponents.cppm +++ b/interfaces/Crafter.Graphics-UIComponents.cppm @@ -96,8 +96,7 @@ export namespace Crafter { float cornerRadius = 0; }; - // Where the X coordinate sits relative to the emitted glyph run. - enum class TextAlign : std::uint8_t { Left, Center, Right }; + // TextAlign lives in the :UI partition (used by UIRenderer::ShapeText). // ─── component functions ─────────────────────────────────────────── diff --git a/project.cpp b/project.cpp index 7d45dd3..1915b59 100644 --- a/project.cpp +++ b/project.cpp @@ -295,6 +295,37 @@ extern "C" Configuration CrafterBuildProject(std::span a blasImpls.emplace_back("tests/BLASBuildOptions/main"); bc.GetInterfacesAndImplementations(ifaces, blasImpls); cfg.tests.push_back(std::move(blasTest)); + + // Issue #52: shaped-run cache for UIRenderer::ShapeText. Shapes a + // string against a real CPU-side FontAtlas and asserts that a cache + // hit is byte-equivalent to the uncached path, that hits don't touch + // the atlas, and that translate / alignment / truncation / + // InvalidateFont all behave. Needs a headless Vulkan device (the + // atlas image is a real GPU image) but no swapchain — same native + // build settings as the others. The font file is copied next to the + // binary; the test also probes the project-root path. + Test textTest; + Configuration& xc = textTest.config; + xc.path = cfg.path; + xc.name = "ShapeTextCache"; + xc.outputName = "ShapeTextCache"; + xc.type = ConfigurationType::Executable; + xc.target = cfg.target; + xc.march = cfg.march; + xc.mtune = cfg.mtune; + xc.debug = cfg.debug; + xc.sysroot = cfg.sysroot; + xc.dependencies = cfg.dependencies; + xc.externalDependencies = cfg.externalDependencies; + xc.compileFlags = cfg.compileFlags; + xc.linkFlags = cfg.linkFlags; + xc.defines = cfg.defines; + xc.cFiles = cfg.cFiles; + xc.files = { fs::path("tests/ShapeTextCache/font.ttf") }; + std::vector textImpls(impls.begin(), impls.end()); + textImpls.emplace_back("tests/ShapeTextCache/main"); + xc.GetInterfacesAndImplementations(ifaces, textImpls); + cfg.tests.push_back(std::move(textTest)); } return cfg; diff --git a/tests/ShapeTextCache/font.ttf b/tests/ShapeTextCache/font.ttf new file mode 100644 index 0000000..f27f4ff Binary files /dev/null and b/tests/ShapeTextCache/font.ttf differ diff --git a/tests/ShapeTextCache/main.cpp b/tests/ShapeTextCache/main.cpp new file mode 100644 index 0000000..3ef990b --- /dev/null +++ b/tests/ShapeTextCache/main.cpp @@ -0,0 +1,231 @@ +/* +Crafter®.Graphics +Copyright (C) 2026 Catcrafts® +catcrafts.net + +This library is free software; you can redistribute it and/or +modify it under the terms of the GNU Lesser General Public +License version 3.0 as published by the Free Software Foundation; + +This library is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +Lesser General Public License for more details. + +You should have received a copy of the GNU Lesser General Public +License along with this library; if not, write to the Free Software +Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA +*/ + +// Issue #52: shaped-run cache for UIRenderer::ShapeText. The slow path +// (UTF-8 decode + per-glyph atlas lookup + layout) is memoized into an +// origin-relative run keyed by (Font*, pxSize, color, utf8); a hit only +// translates the cached run into the output buffer. This must be exactly +// byte-equivalent to the uncached path, since the glyph buffer is rewritten +// and re-flushed every frame regardless. +// +// What is asserted: +// - A cache HIT produces byte-identical glyphs to the original (MISS) call. +// - The HIT path does NOT touch the atlas (no Ensure → atlas stays clean), +// while the MISS path rasterises new glyphs and dirties it. +// - Translating the same string to a different (x, baselineY) shifts every +// glyph by exactly that delta (cache stores origin-relative geometry). +// - Center / Right alignment fold the advance-based shift into ShapeText +// (Center = -advance/2, Right = -advance), matching the old two-pass code. +// - outCapacity truncates the written count but still reports full advance. +// - InvalidateFont drops the run; a re-shape afterwards is still correct. +// - Distinct color / pxSize / font are distinct cache entries. +// +// Needs a headless Vulkan device (the FontAtlas image is a real GPU image), +// but no swapchain/window — ShapeText only touches the CPU-side atlas. The +// UIRenderer is used without Initialize(): ShapeText reads only fontAtlas. + +#include "vulkan/vulkan.h" +#include + +import Crafter.Graphics; +import std; + +using namespace Crafter; + +namespace { + +int failures = 0; + +void Check(bool ok, std::string_view what) { + std::println("{} {}", ok ? "PASS" : "FAIL", what); + if (!ok) ++failures; +} + +VkCommandBuffer BeginCmd() { + VkCommandBufferAllocateInfo allocInfo { + .sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_ALLOCATE_INFO, + .commandPool = Device::commandPool, + .level = VK_COMMAND_BUFFER_LEVEL_PRIMARY, + .commandBufferCount = 1, + }; + VkCommandBuffer cmd = VK_NULL_HANDLE; + Device::CheckVkResult(vkAllocateCommandBuffers(Device::device, &allocInfo, &cmd)); + VkCommandBufferBeginInfo beginInfo { + .sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO, + .flags = VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT, + }; + Device::CheckVkResult(vkBeginCommandBuffer(cmd, &beginInfo)); + return cmd; +} + +void SubmitWait(VkCommandBuffer cmd) { + Device::CheckVkResult(vkEndCommandBuffer(cmd)); + VkSubmitInfo submitInfo { + .sType = VK_STRUCTURE_TYPE_SUBMIT_INFO, + .commandBufferCount = 1, + .pCommandBuffers = &cmd, + }; + Device::CheckVkResult(vkQueueSubmit(Device::queue, 1, &submitInfo, VK_NULL_HANDLE)); + Device::CheckVkResult(vkQueueWaitIdle(Device::queue)); + vkFreeCommandBuffers(Device::device, Device::commandPool, 1, &cmd); +} + +bool GlyphEq(const GlyphItem& a, const GlyphItem& b) { + return a.x == b.x && a.y == b.y && a.w == b.w && a.h == b.h + && a.u0 == b.u0 && a.v0 == b.v0 && a.u1 == b.u1 && a.v1 == b.v1 + && a.r == b.r && a.g == b.g && a.b == b.b && a.a == b.a; +} + +bool RunsEqual(std::span a, std::span b) { + if (a.size() != b.size()) return false; + for (std::size_t i = 0; i < a.size(); ++i) + if (!GlyphEq(a[i], b[i])) return false; + return true; +} + +std::filesystem::path FindFont() { + // The runner's cwd is not guaranteed: cfg.files copies the font next to + // the binary, but `crafter-build test` may also run from the project root. + for (const char* cand : { + "font.ttf", + "tests/ShapeTextCache/font.ttf", + "../../examples/HelloUI/font.ttf" }) { + if (std::filesystem::exists(cand)) return cand; + } + return "font.ttf"; +} + +} // namespace + +int main() { + Device::Initialize(); + + FontAtlas atlas; + { + VkCommandBuffer cmd = BeginCmd(); + atlas.Initialize(cmd); + SubmitWait(cmd); + } + + Font font(FindFont()); + + UIRenderer ui; + ui.fontAtlas = &atlas; // ShapeText needs only the atlas — no Initialize. + + const std::array white{1, 1, 1, 1}; + constexpr float kSize = 18.0f; + const std::string_view kText = "Hover me"; + + std::array bufMiss{}; + std::array bufHit{}; + + // ── 1. First call is a MISS: it rasterises glyphs → atlas goes dirty. ── + atlas.dirty = false; // pretend a prior Update() flushed the atlas clean + float advMiss = 0.0f; + std::uint32_t nMiss = ui.ShapeText(font, kSize, 100.0f, 200.0f, kText, white, + bufMiss.data(), bufMiss.size(), &advMiss); + Check(nMiss > 0, "MISS produced glyphs for a non-empty string"); + Check(advMiss > 0.0f, "MISS reported a positive advance"); + Check(atlas.dirty, "MISS rasterised new glyphs and dirtied the atlas"); + + // ── 2. Second call is a HIT: byte-identical, and atlas stays clean. ──── + atlas.dirty = false; + float advHit = 0.0f; + std::uint32_t nHit = ui.ShapeText(font, kSize, 100.0f, 200.0f, kText, white, + bufHit.data(), bufHit.size(), &advHit); + Check(nHit == nMiss, "HIT wrote the same glyph count as MISS"); + Check(advHit == advMiss, "HIT reported the same advance as MISS"); + Check(RunsEqual({bufMiss.data(), nMiss}, {bufHit.data(), nHit}), + "HIT is byte-identical to MISS"); + Check(!atlas.dirty, "HIT did not touch the atlas (no re-rasterise)"); + + // ── 3. Translation: a different origin shifts every glyph by the delta. ─ + std::array bufMoved{}; + const float dx = 37.0f, dy = -15.0f; + std::uint32_t nMoved = ui.ShapeText(font, kSize, 100.0f + dx, 200.0f + dy, + kText, white, bufMoved.data(), + bufMoved.size(), nullptr); + bool shifted = (nMoved == nHit); + for (std::uint32_t i = 0; i < nMoved && shifted; ++i) { + shifted = std::abs((bufMoved[i].x - bufHit[i].x) - dx) < 1e-3f + && std::abs((bufMoved[i].y - bufHit[i].y) - dy) < 1e-3f + && bufMoved[i].w == bufHit[i].w && bufMoved[i].h == bufHit[i].h; + } + Check(shifted, "translate-only move shifts every glyph by exactly (dx, dy)"); + + // ── 4. Alignment folded into ShapeText (Center = -adv/2, Right = -adv). ─ + std::array bufCenter{}; + std::array bufRight{}; + float advCenter = 0.0f, advRight = 0.0f; + std::uint32_t nCenter = ui.ShapeText(font, kSize, 100.0f, 200.0f, kText, white, + bufCenter.data(), bufCenter.size(), + &advCenter, TextAlign::Center); + std::uint32_t nRight = ui.ShapeText(font, kSize, 100.0f, 200.0f, kText, white, + bufRight.data(), bufRight.size(), + &advRight, TextAlign::Right); + bool centerOk = (nCenter == nHit) && (advCenter == advHit); + for (std::uint32_t i = 0; i < nCenter && centerOk; ++i) + centerOk = std::abs((bufCenter[i].x - bufHit[i].x) - (-advHit * 0.5f)) < 1e-3f; + Check(centerOk, "Center alignment shifts the run left by advance/2"); + + bool rightOk = (nRight == nHit) && (advRight == advHit); + for (std::uint32_t i = 0; i < nRight && rightOk; ++i) + rightOk = std::abs((bufRight[i].x - bufHit[i].x) - (-advHit)) < 1e-3f; + Check(rightOk, "Right alignment shifts the run left by full advance"); + + // ── 5. outCapacity truncates count but still reports full advance. ───── + std::array bufTrunc{}; + const std::uint32_t capLimit = (nHit > 1) ? (nHit - 1) : 0; + float advTrunc = 0.0f; + std::uint32_t nTrunc = ui.ShapeText(font, kSize, 100.0f, 200.0f, kText, white, + bufTrunc.data(), capLimit, &advTrunc); + Check(nTrunc == capLimit, "writing is capped at outCapacity"); + Check(advTrunc == advHit, "advance is full even when the buffer truncates"); + + // ── 6. InvalidateFont drops the run; a re-shape is still correct. ────── + ui.InvalidateFont(font); + std::array bufReshape{}; + float advReshape = 0.0f; + std::uint32_t nReshape = ui.ShapeText(font, kSize, 100.0f, 200.0f, kText, white, + bufReshape.data(), bufReshape.size(), + &advReshape); + Check(nReshape == nHit && advReshape == advHit + && RunsEqual({bufReshape.data(), nReshape}, {bufHit.data(), nHit}), + "re-shape after InvalidateFont matches the original output"); + + // ── 7. Distinct color and pxSize are distinct entries (different runs). ─ + std::array bufRed{}; + std::uint32_t nRed = ui.ShapeText(font, kSize, 100.0f, 200.0f, kText, + {1, 0, 0, 1}, bufRed.data(), bufRed.size(), + nullptr); + bool colorDistinct = (nRed == nHit); + for (std::uint32_t i = 0; i < nRed && colorDistinct; ++i) + colorDistinct = bufRed[i].r == 1.0f && bufRed[i].g == 0.0f && bufRed[i].b == 0.0f; + Check(colorDistinct, "a different color yields a correctly-colored run"); + + std::array bufBig{}; + float advBig = 0.0f; + ui.ShapeText(font, kSize * 2.0f, 100.0f, 200.0f, kText, white, + bufBig.data(), bufBig.size(), &advBig); + Check(advBig > advHit * 1.5f, "a larger pxSize produces a wider advance"); + + 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; +}