272 lines
13 KiB
C++
272 lines
13 KiB
C++
//SPDX-License-Identifier: LGPL-3.0-only
|
|
//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
|
|
|
// 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 <cstdlib>
|
|
|
|
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<const GlyphItem> a, std::span<const GlyphItem> 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<float, 4> white{1, 1, 1, 1};
|
|
constexpr float kSize = 18.0f;
|
|
const std::string_view kText = "Hover me";
|
|
|
|
std::array<GlyphItem, 64> bufMiss{};
|
|
std::array<GlyphItem, 64> 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<GlyphItem, 64> 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<GlyphItem, 64> bufCenter{};
|
|
std::array<GlyphItem, 64> 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<GlyphItem, 64> 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<GlyphItem, 64> 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<GlyphItem, 64> 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<GlyphItem, 64> 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");
|
|
|
|
// ── 8. Overflow evicts the least-recently-used run, not the whole cache. ─
|
|
// (Issue #123) The old policy did a full clear() at the cap, periodically
|
|
// nuking the stable labels and forcing a full-UI reshape the next frame.
|
|
// The LRU policy instead pins the cache at its cap (evict one, add one) and
|
|
// keeps the hot, reshaped-every-frame set resident. A hit/miss and a
|
|
// re-shape of already-rasterised glyphs are not observable through the
|
|
// output or atlas.dirty, so this is asserted via the cache introspection
|
|
// hooks added for the issue.
|
|
ui.InvalidateFont(font);
|
|
Check(ui.ShapedRunCacheSize() == 0, "cache empty after InvalidateFont");
|
|
|
|
std::array<GlyphItem, 8> sink{};
|
|
auto shapeUnique = [&](std::size_t n) {
|
|
// Small fixed glyph set (digits + "run#"), so the atlas rasterises once
|
|
// and every call is still a distinct cache key.
|
|
std::string s = std::format("run#{}", n);
|
|
ui.ShapeText(font, kSize, 0, 0, s, white, sink.data(), sink.size(), nullptr);
|
|
};
|
|
|
|
// Insert distinct keys until the size stops growing on a brand-new insert.
|
|
// Under evict-one LRU that plateau IS the cap (evict one, add one → size
|
|
// unchanged). Under the old clear()-all policy a new insert at the cap
|
|
// drops the size to 1, so it would never plateau and `cap` would stay 0.
|
|
std::size_t cap = 0, prev = 0;
|
|
for (std::size_t i = 0; i < 100000 && cap == 0; ++i) {
|
|
shapeUnique(i);
|
|
std::size_t now = ui.ShapedRunCacheSize();
|
|
if (now == prev) cap = now;
|
|
prev = now;
|
|
}
|
|
Check(cap > 0, "cache size plateaus at a fixed cap (evict-one LRU, not clear-all)");
|
|
|
|
// Hot, every-frame label stays resident across a churn far exceeding the
|
|
// cap, because reshaping it each iteration keeps it most-recently-used.
|
|
const std::string_view kHot = "Hot Label";
|
|
ui.ShapeText(font, kSize, 0, 0, kHot, white, sink.data(), sink.size(), nullptr);
|
|
bool hotStays = ui.IsShapedRunCached(font, kSize, white, kHot);
|
|
bool stayedAtCap = true;
|
|
for (std::size_t i = 0; i < cap * 2 + 1000 && hotStays; ++i) {
|
|
shapeUnique(1'000'000 + i); // fresh keys
|
|
ui.ShapeText(font, kSize, 0, 0, kHot, white, sink.data(), sink.size(), nullptr);
|
|
hotStays = ui.IsShapedRunCached(font, kSize, white, kHot);
|
|
stayedAtCap = stayedAtCap && ui.ShapedRunCacheSize() == cap;
|
|
}
|
|
Check(hotStays, "hot label survives a churn of unique strings past the cap");
|
|
Check(stayedAtCap, "cache stays pinned at the cap (no full clear)");
|
|
|
|
// A cold string left untouched while the cache churns past it IS evicted —
|
|
// confirms the cap is enforced by eviction, not by refusing new inserts.
|
|
const std::string_view kCold = "Cold Once";
|
|
ui.ShapeText(font, kSize, 0, 0, kCold, white, sink.data(), sink.size(), nullptr);
|
|
Check(ui.IsShapedRunCached(font, kSize, white, kCold),
|
|
"cold string cached when first shaped");
|
|
for (std::size_t i = 0; i < cap + 16; ++i) shapeUnique(2'000'000 + i); // never re-touch kCold
|
|
Check(!ui.IsShapedRunCached(font, kSize, white, kCold),
|
|
"an untouched cold string is eventually evicted (LRU tail recycled)");
|
|
|
|
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;
|
|
}
|