Crafter.Graphics/tests/ShapeTextCache/main.cpp

299 lines
14 KiB
C++
Raw Normal View History

2026-07-22 18:09:06 +02:00
//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.
fix(font-atlas): give glyphs that find no room a cell when the next frame begins, evicting the least recently drawn The atlas packed glyphs onto shelves and never gave a place back (#55). Once its 1024x1024 image was full, EnsureAndGet returned nullptr for each new glyph before caching anything, so every new string holding one rasterised it again, and ShapeText dropped it together with its advance: the rest of the line closed up over the gap, and stayed that way. FontAtlas - A glyph is measured, not rasterised, until it gets a cell. One that finds no free room keeps its advance and box and draws blank for a frame. EnsureAndGet always returns the glyph, now a reference; Glyph::Resident() says whether it has a cell, Glyph::handle which one (slot and generation). - Evictions wait for the next frame: its first EnsureAndGet, Touch or Update places the glyphs that waited, once every glyph the frame before drew has been marked. Evicting while a frame is still asking for glyphs took ones it drew further down, which then took others in turn: a page switched back to could rasterise most of itself again rather than what it was missing, and on the DOM backend an over-full view could do so every frame. - A glyph keeps its cell for framesInFlight + 1 frames after it was drawn. The frame loop waits frame u's fence at the start of frame u + framesInFlight, but Device::frameCounter reads that from the end of the frame before, and input callbacks and onBeforeUpdate run in between. - FontAtlas::Packer places glyphs. Until the rows run out it fills shelves exactly as before. Then a glyph takes the same columns of one shelf at least as tall, or of several in a row as tall together, the free rows below the last shelf counting as one more: free texels, and, when the frame begins, glyphs nothing in flight draws, the least recently drawn first. A glyph spanning shelves lets in one taller than every shelf, and a few glyphs drawn every frame pin only their columns. Merging whole shelves instead, tried first, left a random workload without room for a glyph while a median 43% of the atlas was in flight; spanning, at 65%. - A box that found no room in a frame is remembered, with every box at least as big, as a small set per mode, until the frame ends or (without eviction) something is evicted: a saturated atlas no longer searches every shelf for each of its missing glyphs. - An evicted glyph is forgotten, and its texels are zeroed and uploaded with the glyph that takes its place: a neighbour's filtering at its edge must read padding or zero, not a stale glyph. - Touch(handles) marks every glyph whose handle still holds its cell, and says whether all did. InvalidateFont(font) forgets the font's glyphs and those waiting for a cell, whose Font* the next frame would read. The glyph index is looked up at rasterisation, so a Font replaced in place never gets one past its glyphs. GlyphsRasterized() counts, for tests. UIRenderer - A shaped run keeps a handle per glyph it draws. A hit touches them all, so the atlas keeps what is drawn every frame, and shapes the run again when one was evicted or one had no room. A glyph without room draws blank but advances the pen. - InvalidateFont clears the atlas's glyphs of the font too, as its comment and AGENTS.md always said it did. - Record calls the atlas's Update before onBuild, every frame, so the waiting glyphs are placed and uploaded first. Dispatch uploads the atlas before any kernel, a Tier 1 one sampling FontAtlasImageSlot() included: a glyph placed earlier in onBuild can sit in a cell another glyph had. DispatchText and DispatchFused no longer do it themselves. Device, Window (DOM) - Device::frameCounter and Device::framesInFlight exist on the DOM backend too, the counter bumped at the end of Window::Render, with one frame in flight: a frame's writeTexture lands before that frame's submit but after every earlier one. HelloUI - One set of item buffers and slots per frame in flight, by frameIdx. A frame still on the GPU read the items the next frame's onBuild was rewriting; with cells changing hands, those can name cells it drew other glyphs in. Tests - FontAtlasPacker: the packer with made-up boxes and no device: the fill order, least recently drawn first, framesInFlight + 1 frames held, nothing without evict, wide glyphs, tall glyphs spanning shelves and the free rows, Touch past a stale handle, stale handles, dirty spans, and the invariants over a random workload placed the way the atlas places. - FontAtlasEviction: fills the atlas through ShapeText frame by frame. The line that no longer fits keeps its advance and its glyphs' places; its missing glyphs aren't rasterised while nothing can go; when only line 0 is cold, only its glyphs give way; a frame's own misses evict nothing in it; a frame later the line and twenty more are whole, pixel for pixel, line 0 losing a larger share than the glyphs drawn after it; a stale cached run is shaped again; no stale texel is left; InvalidateFont reaches the atlas. Against master, with its two calls to the new API stubbed out, an earlier version failed ten checks. - UIDispatchAtlasFlush: a custom ui.Dispatch after ShapeText leaves the atlas uploaded. Needs a compositor. - ShapeTextCache: a hit rasterises nothing; after InvalidateFont the re-shaped run draws the same, from new cells. Docs - README and AGENTS.consumer.md: the atlas's eviction, the frame a glyph waits, shaping text every frame into per-frame item buffers, InvalidateFont, EnsureAndGet's signature and Lookup's lifetime, and that the atlas, like the deletion queue, assumes one rendering window and evicts nothing without one. - AGENTS.md: FontAtlas::Packer among the pure-logic helpers, eviction without a Window, and InvalidateFont. Breaking: FontAtlas::EnsureAndGet returns const Glyph& and never fails; check Glyph::Resident() before drawing from its UVs. FontAtlas::Touch is no longer noexcept. A GlyphItem is good for the frame it was shaped for, once the atlas has filled up, and item buffers must be per frame. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 09:10:42 +01:00
// - The HIT path rasterises nothing (it only marks the run's glyphs used, so
// the 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;
}
fix(font-atlas): give glyphs that find no room a cell when the next frame begins, evicting the least recently drawn The atlas packed glyphs onto shelves and never gave a place back (#55). Once its 1024x1024 image was full, EnsureAndGet returned nullptr for each new glyph before caching anything, so every new string holding one rasterised it again, and ShapeText dropped it together with its advance: the rest of the line closed up over the gap, and stayed that way. FontAtlas - A glyph is measured, not rasterised, until it gets a cell. One that finds no free room keeps its advance and box and draws blank for a frame. EnsureAndGet always returns the glyph, now a reference; Glyph::Resident() says whether it has a cell, Glyph::handle which one (slot and generation). - Evictions wait for the next frame: its first EnsureAndGet, Touch or Update places the glyphs that waited, once every glyph the frame before drew has been marked. Evicting while a frame is still asking for glyphs took ones it drew further down, which then took others in turn: a page switched back to could rasterise most of itself again rather than what it was missing, and on the DOM backend an over-full view could do so every frame. - A glyph keeps its cell for framesInFlight + 1 frames after it was drawn. The frame loop waits frame u's fence at the start of frame u + framesInFlight, but Device::frameCounter reads that from the end of the frame before, and input callbacks and onBeforeUpdate run in between. - FontAtlas::Packer places glyphs. Until the rows run out it fills shelves exactly as before. Then a glyph takes the same columns of one shelf at least as tall, or of several in a row as tall together, the free rows below the last shelf counting as one more: free texels, and, when the frame begins, glyphs nothing in flight draws, the least recently drawn first. A glyph spanning shelves lets in one taller than every shelf, and a few glyphs drawn every frame pin only their columns. Merging whole shelves instead, tried first, left a random workload without room for a glyph while a median 43% of the atlas was in flight; spanning, at 65%. - A box that found no room in a frame is remembered, with every box at least as big, as a small set per mode, until the frame ends or (without eviction) something is evicted: a saturated atlas no longer searches every shelf for each of its missing glyphs. - An evicted glyph is forgotten, and its texels are zeroed and uploaded with the glyph that takes its place: a neighbour's filtering at its edge must read padding or zero, not a stale glyph. - Touch(handles) marks every glyph whose handle still holds its cell, and says whether all did. InvalidateFont(font) forgets the font's glyphs and those waiting for a cell, whose Font* the next frame would read. The glyph index is looked up at rasterisation, so a Font replaced in place never gets one past its glyphs. GlyphsRasterized() counts, for tests. UIRenderer - A shaped run keeps a handle per glyph it draws. A hit touches them all, so the atlas keeps what is drawn every frame, and shapes the run again when one was evicted or one had no room. A glyph without room draws blank but advances the pen. - InvalidateFont clears the atlas's glyphs of the font too, as its comment and AGENTS.md always said it did. - Record calls the atlas's Update before onBuild, every frame, so the waiting glyphs are placed and uploaded first. Dispatch uploads the atlas before any kernel, a Tier 1 one sampling FontAtlasImageSlot() included: a glyph placed earlier in onBuild can sit in a cell another glyph had. DispatchText and DispatchFused no longer do it themselves. Device, Window (DOM) - Device::frameCounter and Device::framesInFlight exist on the DOM backend too, the counter bumped at the end of Window::Render, with one frame in flight: a frame's writeTexture lands before that frame's submit but after every earlier one. HelloUI - One set of item buffers and slots per frame in flight, by frameIdx. A frame still on the GPU read the items the next frame's onBuild was rewriting; with cells changing hands, those can name cells it drew other glyphs in. Tests - FontAtlasPacker: the packer with made-up boxes and no device: the fill order, least recently drawn first, framesInFlight + 1 frames held, nothing without evict, wide glyphs, tall glyphs spanning shelves and the free rows, Touch past a stale handle, stale handles, dirty spans, and the invariants over a random workload placed the way the atlas places. - FontAtlasEviction: fills the atlas through ShapeText frame by frame. The line that no longer fits keeps its advance and its glyphs' places; its missing glyphs aren't rasterised while nothing can go; when only line 0 is cold, only its glyphs give way; a frame's own misses evict nothing in it; a frame later the line and twenty more are whole, pixel for pixel, line 0 losing a larger share than the glyphs drawn after it; a stale cached run is shaped again; no stale texel is left; InvalidateFont reaches the atlas. Against master, with its two calls to the new API stubbed out, an earlier version failed ten checks. - UIDispatchAtlasFlush: a custom ui.Dispatch after ShapeText leaves the atlas uploaded. Needs a compositor. - ShapeTextCache: a hit rasterises nothing; after InvalidateFont the re-shaped run draws the same, from new cells. Docs - README and AGENTS.consumer.md: the atlas's eviction, the frame a glyph waits, shaping text every frame into per-frame item buffers, InvalidateFont, EnsureAndGet's signature and Lookup's lifetime, and that the atlas, like the deletion queue, assumes one rendering window and evicts nothing without one. - AGENTS.md: FontAtlas::Packer among the pure-logic helpers, eviction without a Window, and InvalidateFont. Breaking: FontAtlas::EnsureAndGet returns const Glyph& and never fails; check Glyph::Resident() before drawing from its UVs. FontAtlas::Touch is no longer noexcept. A GlyphItem is good for the frame it was shaped for, once the atlas has filled up, and item buffers must be per frame. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 09:10:42 +01:00
// The atlas texels under a glyph's UVs, which are texel / kAtlasSize.
std::vector<std::uint8_t> AtlasPixels(FontAtlas& atlas, const GlyphItem& g) {
constexpr int kSize = FontAtlas::kAtlasSize;
const int x0 = static_cast<int>(g.u0 * kSize), y0 = static_cast<int>(g.v0 * kSize);
const int x1 = static_cast<int>(g.u1 * kSize), y1 = static_cast<int>(g.v1 * kSize);
std::vector<std::uint8_t> out;
for (int y = y0; y < y1; ++y) out.insert(out.end(), atlas.PixelPtr() + y * kSize + x0, atlas.PixelPtr() + y * kSize + x1);
return out;
}
// The same glyphs in the same places and colours, each drawn from the same
// atlas texels, wherever in the atlas they sit.
bool RunsDrawEqual(FontAtlas& atlas, 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) {
GlyphItem moved = b[i];
moved.u0 = a[i].u0; moved.v0 = a[i].v0; moved.u1 = a[i].u1; moved.v1 = a[i].v1;
if (!GlyphEq(a[i], moved) || AtlasPixels(atlas, a[i]) != AtlasPixels(atlas, 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");
fix(font-atlas): give glyphs that find no room a cell when the next frame begins, evicting the least recently drawn The atlas packed glyphs onto shelves and never gave a place back (#55). Once its 1024x1024 image was full, EnsureAndGet returned nullptr for each new glyph before caching anything, so every new string holding one rasterised it again, and ShapeText dropped it together with its advance: the rest of the line closed up over the gap, and stayed that way. FontAtlas - A glyph is measured, not rasterised, until it gets a cell. One that finds no free room keeps its advance and box and draws blank for a frame. EnsureAndGet always returns the glyph, now a reference; Glyph::Resident() says whether it has a cell, Glyph::handle which one (slot and generation). - Evictions wait for the next frame: its first EnsureAndGet, Touch or Update places the glyphs that waited, once every glyph the frame before drew has been marked. Evicting while a frame is still asking for glyphs took ones it drew further down, which then took others in turn: a page switched back to could rasterise most of itself again rather than what it was missing, and on the DOM backend an over-full view could do so every frame. - A glyph keeps its cell for framesInFlight + 1 frames after it was drawn. The frame loop waits frame u's fence at the start of frame u + framesInFlight, but Device::frameCounter reads that from the end of the frame before, and input callbacks and onBeforeUpdate run in between. - FontAtlas::Packer places glyphs. Until the rows run out it fills shelves exactly as before. Then a glyph takes the same columns of one shelf at least as tall, or of several in a row as tall together, the free rows below the last shelf counting as one more: free texels, and, when the frame begins, glyphs nothing in flight draws, the least recently drawn first. A glyph spanning shelves lets in one taller than every shelf, and a few glyphs drawn every frame pin only their columns. Merging whole shelves instead, tried first, left a random workload without room for a glyph while a median 43% of the atlas was in flight; spanning, at 65%. - A box that found no room in a frame is remembered, with every box at least as big, as a small set per mode, until the frame ends or (without eviction) something is evicted: a saturated atlas no longer searches every shelf for each of its missing glyphs. - An evicted glyph is forgotten, and its texels are zeroed and uploaded with the glyph that takes its place: a neighbour's filtering at its edge must read padding or zero, not a stale glyph. - Touch(handles) marks every glyph whose handle still holds its cell, and says whether all did. InvalidateFont(font) forgets the font's glyphs and those waiting for a cell, whose Font* the next frame would read. The glyph index is looked up at rasterisation, so a Font replaced in place never gets one past its glyphs. GlyphsRasterized() counts, for tests. UIRenderer - A shaped run keeps a handle per glyph it draws. A hit touches them all, so the atlas keeps what is drawn every frame, and shapes the run again when one was evicted or one had no room. A glyph without room draws blank but advances the pen. - InvalidateFont clears the atlas's glyphs of the font too, as its comment and AGENTS.md always said it did. - Record calls the atlas's Update before onBuild, every frame, so the waiting glyphs are placed and uploaded first. Dispatch uploads the atlas before any kernel, a Tier 1 one sampling FontAtlasImageSlot() included: a glyph placed earlier in onBuild can sit in a cell another glyph had. DispatchText and DispatchFused no longer do it themselves. Device, Window (DOM) - Device::frameCounter and Device::framesInFlight exist on the DOM backend too, the counter bumped at the end of Window::Render, with one frame in flight: a frame's writeTexture lands before that frame's submit but after every earlier one. HelloUI - One set of item buffers and slots per frame in flight, by frameIdx. A frame still on the GPU read the items the next frame's onBuild was rewriting; with cells changing hands, those can name cells it drew other glyphs in. Tests - FontAtlasPacker: the packer with made-up boxes and no device: the fill order, least recently drawn first, framesInFlight + 1 frames held, nothing without evict, wide glyphs, tall glyphs spanning shelves and the free rows, Touch past a stale handle, stale handles, dirty spans, and the invariants over a random workload placed the way the atlas places. - FontAtlasEviction: fills the atlas through ShapeText frame by frame. The line that no longer fits keeps its advance and its glyphs' places; its missing glyphs aren't rasterised while nothing can go; when only line 0 is cold, only its glyphs give way; a frame's own misses evict nothing in it; a frame later the line and twenty more are whole, pixel for pixel, line 0 losing a larger share than the glyphs drawn after it; a stale cached run is shaped again; no stale texel is left; InvalidateFont reaches the atlas. Against master, with its two calls to the new API stubbed out, an earlier version failed ten checks. - UIDispatchAtlasFlush: a custom ui.Dispatch after ShapeText leaves the atlas uploaded. Needs a compositor. - ShapeTextCache: a hit rasterises nothing; after InvalidateFont the re-shaped run draws the same, from new cells. Docs - README and AGENTS.consumer.md: the atlas's eviction, the frame a glyph waits, shaping text every frame into per-frame item buffers, InvalidateFont, EnsureAndGet's signature and Lookup's lifetime, and that the atlas, like the deletion queue, assumes one rendering window and evicts nothing without one. - AGENTS.md: FontAtlas::Packer among the pure-logic helpers, eviction without a Window, and InvalidateFont. Breaking: FontAtlas::EnsureAndGet returns const Glyph& and never fails; check Glyph::Resident() before drawing from its UVs. FontAtlas::Touch is no longer noexcept. A GlyphItem is good for the frame it was shaped for, once the atlas has filled up, and item buffers must be per frame. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 09:10:42 +01:00
Check(!atlas.dirty, "HIT left the atlas clean (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. ──────
fix(font-atlas): give glyphs that find no room a cell when the next frame begins, evicting the least recently drawn The atlas packed glyphs onto shelves and never gave a place back (#55). Once its 1024x1024 image was full, EnsureAndGet returned nullptr for each new glyph before caching anything, so every new string holding one rasterised it again, and ShapeText dropped it together with its advance: the rest of the line closed up over the gap, and stayed that way. FontAtlas - A glyph is measured, not rasterised, until it gets a cell. One that finds no free room keeps its advance and box and draws blank for a frame. EnsureAndGet always returns the glyph, now a reference; Glyph::Resident() says whether it has a cell, Glyph::handle which one (slot and generation). - Evictions wait for the next frame: its first EnsureAndGet, Touch or Update places the glyphs that waited, once every glyph the frame before drew has been marked. Evicting while a frame is still asking for glyphs took ones it drew further down, which then took others in turn: a page switched back to could rasterise most of itself again rather than what it was missing, and on the DOM backend an over-full view could do so every frame. - A glyph keeps its cell for framesInFlight + 1 frames after it was drawn. The frame loop waits frame u's fence at the start of frame u + framesInFlight, but Device::frameCounter reads that from the end of the frame before, and input callbacks and onBeforeUpdate run in between. - FontAtlas::Packer places glyphs. Until the rows run out it fills shelves exactly as before. Then a glyph takes the same columns of one shelf at least as tall, or of several in a row as tall together, the free rows below the last shelf counting as one more: free texels, and, when the frame begins, glyphs nothing in flight draws, the least recently drawn first. A glyph spanning shelves lets in one taller than every shelf, and a few glyphs drawn every frame pin only their columns. Merging whole shelves instead, tried first, left a random workload without room for a glyph while a median 43% of the atlas was in flight; spanning, at 65%. - A box that found no room in a frame is remembered, with every box at least as big, as a small set per mode, until the frame ends or (without eviction) something is evicted: a saturated atlas no longer searches every shelf for each of its missing glyphs. - An evicted glyph is forgotten, and its texels are zeroed and uploaded with the glyph that takes its place: a neighbour's filtering at its edge must read padding or zero, not a stale glyph. - Touch(handles) marks every glyph whose handle still holds its cell, and says whether all did. InvalidateFont(font) forgets the font's glyphs and those waiting for a cell, whose Font* the next frame would read. The glyph index is looked up at rasterisation, so a Font replaced in place never gets one past its glyphs. GlyphsRasterized() counts, for tests. UIRenderer - A shaped run keeps a handle per glyph it draws. A hit touches them all, so the atlas keeps what is drawn every frame, and shapes the run again when one was evicted or one had no room. A glyph without room draws blank but advances the pen. - InvalidateFont clears the atlas's glyphs of the font too, as its comment and AGENTS.md always said it did. - Record calls the atlas's Update before onBuild, every frame, so the waiting glyphs are placed and uploaded first. Dispatch uploads the atlas before any kernel, a Tier 1 one sampling FontAtlasImageSlot() included: a glyph placed earlier in onBuild can sit in a cell another glyph had. DispatchText and DispatchFused no longer do it themselves. Device, Window (DOM) - Device::frameCounter and Device::framesInFlight exist on the DOM backend too, the counter bumped at the end of Window::Render, with one frame in flight: a frame's writeTexture lands before that frame's submit but after every earlier one. HelloUI - One set of item buffers and slots per frame in flight, by frameIdx. A frame still on the GPU read the items the next frame's onBuild was rewriting; with cells changing hands, those can name cells it drew other glyphs in. Tests - FontAtlasPacker: the packer with made-up boxes and no device: the fill order, least recently drawn first, framesInFlight + 1 frames held, nothing without evict, wide glyphs, tall glyphs spanning shelves and the free rows, Touch past a stale handle, stale handles, dirty spans, and the invariants over a random workload placed the way the atlas places. - FontAtlasEviction: fills the atlas through ShapeText frame by frame. The line that no longer fits keeps its advance and its glyphs' places; its missing glyphs aren't rasterised while nothing can go; when only line 0 is cold, only its glyphs give way; a frame's own misses evict nothing in it; a frame later the line and twenty more are whole, pixel for pixel, line 0 losing a larger share than the glyphs drawn after it; a stale cached run is shaped again; no stale texel is left; InvalidateFont reaches the atlas. Against master, with its two calls to the new API stubbed out, an earlier version failed ten checks. - UIDispatchAtlasFlush: a custom ui.Dispatch after ShapeText leaves the atlas uploaded. Needs a compositor. - ShapeTextCache: a hit rasterises nothing; after InvalidateFont the re-shaped run draws the same, from new cells. Docs - README and AGENTS.consumer.md: the atlas's eviction, the frame a glyph waits, shaping text every frame into per-frame item buffers, InvalidateFont, EnsureAndGet's signature and Lookup's lifetime, and that the atlas, like the deletion queue, assumes one rendering window and evicts nothing without one. - AGENTS.md: FontAtlas::Packer among the pure-logic helpers, eviction without a Window, and InvalidateFont. Breaking: FontAtlas::EnsureAndGet returns const Glyph& and never fails; check Glyph::Resident() before drawing from its UVs. FontAtlas::Touch is no longer noexcept. A GlyphItem is good for the frame it was shaped for, once the atlas has filled up, and item buffers must be per frame. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 09:10:42 +01:00
// The atlas forgets the font's glyphs too (#55), so the re-shape puts
// them into new cells: the old ones stay until evicted, as a frame in
// flight may still draw them. Everything but the UVs matches, and the
// texels under the new UVs are those under the old.
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
fix(font-atlas): give glyphs that find no room a cell when the next frame begins, evicting the least recently drawn The atlas packed glyphs onto shelves and never gave a place back (#55). Once its 1024x1024 image was full, EnsureAndGet returned nullptr for each new glyph before caching anything, so every new string holding one rasterised it again, and ShapeText dropped it together with its advance: the rest of the line closed up over the gap, and stayed that way. FontAtlas - A glyph is measured, not rasterised, until it gets a cell. One that finds no free room keeps its advance and box and draws blank for a frame. EnsureAndGet always returns the glyph, now a reference; Glyph::Resident() says whether it has a cell, Glyph::handle which one (slot and generation). - Evictions wait for the next frame: its first EnsureAndGet, Touch or Update places the glyphs that waited, once every glyph the frame before drew has been marked. Evicting while a frame is still asking for glyphs took ones it drew further down, which then took others in turn: a page switched back to could rasterise most of itself again rather than what it was missing, and on the DOM backend an over-full view could do so every frame. - A glyph keeps its cell for framesInFlight + 1 frames after it was drawn. The frame loop waits frame u's fence at the start of frame u + framesInFlight, but Device::frameCounter reads that from the end of the frame before, and input callbacks and onBeforeUpdate run in between. - FontAtlas::Packer places glyphs. Until the rows run out it fills shelves exactly as before. Then a glyph takes the same columns of one shelf at least as tall, or of several in a row as tall together, the free rows below the last shelf counting as one more: free texels, and, when the frame begins, glyphs nothing in flight draws, the least recently drawn first. A glyph spanning shelves lets in one taller than every shelf, and a few glyphs drawn every frame pin only their columns. Merging whole shelves instead, tried first, left a random workload without room for a glyph while a median 43% of the atlas was in flight; spanning, at 65%. - A box that found no room in a frame is remembered, with every box at least as big, as a small set per mode, until the frame ends or (without eviction) something is evicted: a saturated atlas no longer searches every shelf for each of its missing glyphs. - An evicted glyph is forgotten, and its texels are zeroed and uploaded with the glyph that takes its place: a neighbour's filtering at its edge must read padding or zero, not a stale glyph. - Touch(handles) marks every glyph whose handle still holds its cell, and says whether all did. InvalidateFont(font) forgets the font's glyphs and those waiting for a cell, whose Font* the next frame would read. The glyph index is looked up at rasterisation, so a Font replaced in place never gets one past its glyphs. GlyphsRasterized() counts, for tests. UIRenderer - A shaped run keeps a handle per glyph it draws. A hit touches them all, so the atlas keeps what is drawn every frame, and shapes the run again when one was evicted or one had no room. A glyph without room draws blank but advances the pen. - InvalidateFont clears the atlas's glyphs of the font too, as its comment and AGENTS.md always said it did. - Record calls the atlas's Update before onBuild, every frame, so the waiting glyphs are placed and uploaded first. Dispatch uploads the atlas before any kernel, a Tier 1 one sampling FontAtlasImageSlot() included: a glyph placed earlier in onBuild can sit in a cell another glyph had. DispatchText and DispatchFused no longer do it themselves. Device, Window (DOM) - Device::frameCounter and Device::framesInFlight exist on the DOM backend too, the counter bumped at the end of Window::Render, with one frame in flight: a frame's writeTexture lands before that frame's submit but after every earlier one. HelloUI - One set of item buffers and slots per frame in flight, by frameIdx. A frame still on the GPU read the items the next frame's onBuild was rewriting; with cells changing hands, those can name cells it drew other glyphs in. Tests - FontAtlasPacker: the packer with made-up boxes and no device: the fill order, least recently drawn first, framesInFlight + 1 frames held, nothing without evict, wide glyphs, tall glyphs spanning shelves and the free rows, Touch past a stale handle, stale handles, dirty spans, and the invariants over a random workload placed the way the atlas places. - FontAtlasEviction: fills the atlas through ShapeText frame by frame. The line that no longer fits keeps its advance and its glyphs' places; its missing glyphs aren't rasterised while nothing can go; when only line 0 is cold, only its glyphs give way; a frame's own misses evict nothing in it; a frame later the line and twenty more are whole, pixel for pixel, line 0 losing a larger share than the glyphs drawn after it; a stale cached run is shaped again; no stale texel is left; InvalidateFont reaches the atlas. Against master, with its two calls to the new API stubbed out, an earlier version failed ten checks. - UIDispatchAtlasFlush: a custom ui.Dispatch after ShapeText leaves the atlas uploaded. Needs a compositor. - ShapeTextCache: a hit rasterises nothing; after InvalidateFont the re-shaped run draws the same, from new cells. Docs - README and AGENTS.consumer.md: the atlas's eviction, the frame a glyph waits, shaping text every frame into per-frame item buffers, InvalidateFont, EnsureAndGet's signature and Lookup's lifetime, and that the atlas, like the deletion queue, assumes one rendering window and evicts nothing without one. - AGENTS.md: FontAtlas::Packer among the pure-logic helpers, eviction without a Window, and InvalidateFont. Breaking: FontAtlas::EnsureAndGet returns const Glyph& and never fails; check Glyph::Resident() before drawing from its UVs. FontAtlas::Touch is no longer noexcept. A GlyphItem is good for the frame it was shaped for, once the atlas has filled up, and item buffers must be per frame. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 09:10:42 +01:00
&& RunsDrawEqual(atlas, {bufHit.data(), nHit}, {bufReshape.data(), nReshape}),
"re-shape after InvalidateFont draws 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;
}