//SPDX-License-Identifier: LGPL-3.0-only //SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® // Issue #55: the font atlas packed glyphs onto shelves and never gave a place // back. Once its 1024x1024 image was full, EnsureAndGet returned nullptr for // every new glyph before caching anything, so each new string holding one // rasterised it again, and ShapeText skipped it together with its advance: the // rest of the line closed up over the gap, and stayed that way. Now a glyph // that finds no free room keeps its metrics and draws blank, and when the next // frame begins it gets a cell, taken from the glyphs drawn least recently but // never from one drawn in the last framesInFlight + 1 frames. // // Frames are counted on Device::frameCounter with three frames in flight, as a // Window counts them. Lines 1 to 30 fill the atlas first, then line 0, then // lines up to the first that no longer fits (line F), all in frame A; frame // A+1 draws them all again but line 0, the oldest from then on, though not the // first one found. What is asserted: // - the line that no longer fits reports its whole advance, and the glyphs // it does draw sit where an atlas with room puts them // - its missing glyphs are cached without a cell, and not rasterised, by // another string holding them nor in the frames everything is in flight // - in frame A+4, when only line 0 is cold, line F's glyphs get cells, and // only line 0's glyphs give way; they are rasterised once each // - a frame's own misses evict nothing in that frame: the glyphs it drew // earlier keep their cells // - in frame A+5, when frame A+1's glyphs are cold too, line F and twenty // more lines are whole, pixel for pixel, a glyph drawn in a frame still in // flight keeps its cell, and the evictions take a larger share of line // 0's glyphs than of frame A+1's: the least recently used go first // - a cached run whose glyphs were evicted is shaped again, pixel for pixel // - every atlas texel outside a resident glyph is zero: an evicted cell is // cleared, so a neighbour's filtering never picks up a stale glyph // - UIRenderer::InvalidateFont forgets the font's glyphs in the atlas too // // Needs a headless Vulkan device (the atlas is a real GPU image) and the // ShapeTextCache font, which covers enough codepoints to fill the atlas. #include "vulkan/vulkan.h" #include "../../lib/stb_truetype.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); } std::filesystem::path FindFont() { // 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", "../../tests/ShapeTextCache/font.ttf" }) { if (std::filesystem::exists(cand)) return cand; } return "font.ttf"; } constexpr int kSize = FontAtlas::kAtlasSize; constexpr float kPx = 24.0f; constexpr std::size_t kLineLength = 24; void AppendUtf8(std::string& s, std::uint32_t cp) { if (cp < 0x80) { s += static_cast(cp); } else if (cp < 0x800) { s += static_cast(0xC0 | (cp >> 6)); s += static_cast(0x80 | (cp & 0x3F)); } else { s += static_cast(0xE0 | (cp >> 12)); s += static_cast(0x80 | ((cp >> 6) & 0x3F)); s += static_cast(0x80 | (cp & 0x3F)); } } // Lines of kLineLength codepoints that the font maps and that have ink at the // atlas's base size, so a line wholly in the atlas draws one GlyphItem per // codepoint. The ink test is the one stbtt_GetGlyphSDF makes. std::vector InkedLines(Font& font, std::vector>& codepoints) { const float scale = stbtt_ScaleForPixelHeight(&font.font, FontAtlas::kBaseSize); std::vector pool; for (auto [lo, hi] : { std::pair{0x21u, 0x7Eu}, {0xA1u, 0x377u}, {0x3A3u, 0x3E1u}, {0x3F0u, 0x52Fu}, {0x1D00u, 0x1DF9u}, {0x1DFBu, 0x1F15u}, {0x1F80u, 0x1FB4u}, {0x2000u, 0x2064u}, {0x2100u, 0x215Fu}, {0x2DE0u, 0x2E5Du}, {0xA640u, 0xA69Fu}, {0xA700u, 0xA7CAu}, {0xAB30u, 0xAB6Bu} }) { for (std::uint32_t cp = lo; cp <= hi; ++cp) { if (stbtt_FindGlyphIndex(&font.font, static_cast(cp)) == 0) continue; int x0 = 0, y0 = 0, x1 = 0, y1 = 0; stbtt_GetCodepointBitmapBox(&font.font, static_cast(cp), scale, scale, &x0, &y0, &x1, &y1); if (x0 != x1 && y0 != y1) pool.push_back(cp); } } std::vector lines; for (std::size_t i = 0; i + kLineLength <= pool.size(); i += kLineLength) { std::string line; for (std::size_t k = 0; k < kLineLength; ++k) AppendUtf8(line, pool[i + k]); lines.push_back(std::move(line)); codepoints.emplace_back(pool.begin() + i, pool.begin() + i + kLineLength); } return lines; } struct Shaped { std::vector glyphs; float advance = 0.0f; }; Shaped Shape(UIRenderer& ui, Font& font, std::string_view utf8) { Shaped s; s.glyphs.resize(kLineLength + 8); s.glyphs.resize(ui.ShapeText(font, kPx, 0.0f, 100.0f, utf8, {1, 1, 1, 1}, s.glyphs.data(), static_cast(s.glyphs.size()), &s.advance)); return s; } // A glyph's cell in the atlas, in texels. The UVs are texel / kAtlasSize, which // a float holds exactly. struct Cell { int x0, y0, x1, y1; }; Cell CellOf(float u0, float v0, float u1, float v1) { return { static_cast(u0 * kSize), static_cast(v0 * kSize), static_cast(u1 * kSize), static_cast(v1 * kSize) }; } std::vector Pixels(FontAtlas& atlas, const GlyphItem& g) { const Cell c = CellOf(g.u0, g.v0, g.u1, g.v1); const std::uint8_t* p = atlas.PixelPtr(); std::vector out; for (int y = c.y0; y < c.y1; ++y) out.insert(out.end(), p + y * kSize + c.x0, p + y * kSize + c.x1); return out; } bool SamePlace(const GlyphItem& a, const GlyphItem& b) { return a.x == b.x && a.y == b.y && a.w == b.w && a.h == b.h; } // `drawn` is `full` with some glyphs left out, the rest in the same order and // the same places. A line that closed up over a missing glyph has the glyphs // after it further left, where `full` has none. bool LeavesGapsOnly(std::span drawn, std::span full) { std::size_t j = 0; for (const GlyphItem& g : drawn) { while (j < full.size() && !SamePlace(full[j], g)) ++j; if (j == full.size()) return false; ++j; } return true; } // The same glyphs in the same places, with the same pixels under each in its // own atlas, wherever each atlas keeps them. bool SameText(const Shaped& a, FontAtlas& atlasA, const Shaped& b, FontAtlas& atlasB) { if (a.glyphs.size() != b.glyphs.size() || a.advance != b.advance) return false; for (std::size_t i = 0; i < a.glyphs.size(); ++i) { if (!SamePlace(a.glyphs[i], b.glyphs[i])) return false; if (Pixels(atlasA, a.glyphs[i]) != Pixels(atlasB, b.glyphs[i])) return false; } return true; } std::vector> PixelsOf(FontAtlas& atlas, const Shaped& s) { std::vector> out; for (const GlyphItem& g : s.glyphs) out.push_back(Pixels(atlas, g)); return out; } // The end of a frame as Window::Render has it for the atlas: what changed is // uploaded in the frame's command buffer, then the frame clock moves on. void EndFrame(FontAtlas& atlas) { VkCommandBuffer cmd = BeginCmd(); atlas.Update(cmd); SubmitWait(cmd); Device::frameCounter++; } } // namespace int main() { Device::Initialize(); Device::framesInFlight = 3; FontAtlas atlas; // filled until it is full FontAtlas roomy; // shapes only the lines compared, so it always has room { VkCommandBuffer cmd = BeginCmd(); atlas.Initialize(cmd); roomy.Initialize(cmd); SubmitWait(cmd); } UIRenderer ui; ui.fontAtlas = &atlas; UIRenderer roomyUi; roomyUi.fontAtlas = &roomy; Font font(FindFont()); std::vector> lineCodepoints; const std::vector lines = InkedLines(font, lineCodepoints); std::println("{} lines of {} inked codepoints", lines.size(), kLineLength); auto resident = [&](std::size_t first, std::size_t last) { std::size_t n = 0; for (std::size_t i = first; i <= last; ++i) { for (std::uint32_t cp : lineCodepoints[i]) { const Glyph* g = atlas.Lookup(font, cp); if (g != nullptr && g->Resident()) ++n; } } return n; }; // ── Frame A: lines 1 to 30, line 0, then lines until one comes back short. ─ constexpr std::size_t kFirst = 30; const std::uint64_t frameA = 1000; Device::frameCounter = frameA; std::vector shaped(lines.size()); for (std::size_t i = 1; i <= kFirst; ++i) shaped[i] = Shape(ui, font, lines[i]); shaped[0] = Shape(ui, font, lines[0]); std::size_t full = 0; for (std::size_t i = kFirst + 1; i < lines.size(); ++i) { shaped[i] = Shape(ui, font, lines[i]); if (shaped[i].glyphs.size() < kLineLength) { full = i; break; } } Check(full > kFirst + 1 && shaped[0].glyphs.size() == kLineLength, std::format("the atlas fills up after line 0 (line {} of {} no longer fits)", full, lines.size())); if (full <= kFirst + 1) return EXIT_FAILURE; const std::size_t missing = kLineLength - shaped[full].glyphs.size(); const std::string& lineF = lines[full]; const Shaped whole = Shape(roomyUi, font, lineF); Check(whole.glyphs.size() == kLineLength, "an atlas with room draws the whole line"); Check(shaped[full].advance == whole.advance, std::format("the line that no longer fits keeps its whole advance ({} vs {})", shaped[full].advance, whole.advance)); Check(LeavesGapsOnly(shaped[full].glyphs, whole.glyphs), "the glyphs it draws sit where they belong (gaps, not a line closed up)"); // Another string with the same glyphs misses the shaped-run cache and asks // the atlas for each glyph again: the ones without room are cached, // measured, and not rasterised. const std::uint64_t rasterised = atlas.GlyphsRasterized(); std::string reversed; for (auto it = lineCodepoints[full].rbegin(); it != lineCodepoints[full].rend(); ++it) AppendUtf8(reversed, *it); Check(Shape(ui, font, reversed).advance == Shape(roomyUi, font, reversed).advance, "the same glyphs in another order keep their whole advance too"); Check(atlas.GlyphsRasterized() == rasterised, "a glyph without room is not rasterised again by another string holding it"); std::size_t waiting = 0; for (std::uint32_t cp : lineCodepoints[full]) { const Glyph* g = atlas.Lookup(font, cp); if (g != nullptr && !g->Resident()) ++waiting; } Check(waiting == missing, std::format("each missing glyph is cached without a cell ({} of them)", waiting)); EndFrame(atlas); // ── Frames A+1 to A+3: every glyph frame A drew may still be in flight. ─ Shaped line1; for (std::uint64_t f = frameA + 1; f <= frameA + 3; ++f) { if (f == frameA + 1) { for (std::size_t i = 1; i < full; ++i) Shape(ui, font, lines[i]); // all but line 0 } const Shaped again = Shape(ui, font, lineF); Check(again.glyphs.size() == shaped[full].glyphs.size() && again.advance == whole.advance, std::format("frame A+{}: nothing gives way while it may be in flight, and the advance stays whole", f - frameA)); if (f == frameA + 2) line1 = Shape(ui, font, lines[1]); // in flight until A+6 EndFrame(atlas); } Check(atlas.GlyphsRasterized() == rasterised, "nothing is rasterised while nothing can be evicted"); const auto line1Pixels = PixelsOf(atlas, line1); // ── Frame A+4: line 0, drawn last in A, is all that has gone cold. ────── const std::size_t line0Before = resident(0, 0); const Shaped partly = Shape(ui, font, lineF); const std::size_t placed = partly.glyphs.size() - shaped[full].glyphs.size(); Check(placed > 0, std::format("frame A+4: line F's missing glyphs get cells ({} of {})", placed, missing)); Check(resident(0, 0) < line0Before && resident(1, full - 1) == (full - 1) * kLineLength, std::format("only the cold line 0 gives way ({} of its glyphs), no glyph drawn since", line0Before - resident(0, 0))); Check(atlas.GlyphsRasterized() == rasterised + placed, "each glyph that got a cell was rasterised once"); // Twenty more new lines in the same frame: they find no free room, and a // frame's own misses evict nothing in it, so the glyphs it drew stay. constexpr std::size_t kMore = 20; Check(lines.size() > full + kMore, "the font has lines enough to go on"); const auto partlyPixels = PixelsOf(atlas, partly); const std::size_t residentBefore = resident(0, full); for (std::size_t i = full + 1; i <= full + kMore; ++i) Shape(ui, font, lines[i]); Check(resident(0, full) == residentBefore, "a frame's own misses evict nothing in it, not even the cold line 0's glyphs"); Check(PixelsOf(atlas, partly) == partlyPixels, "glyphs drawn earlier in the frame keep their cells"); EndFrame(atlas); // ── Frame A+5: frame A+1's glyphs are cold now, line 1's (A+2) not. ───── const std::size_t oldBefore = resident(0, 0); const std::size_t newerBefore = resident(2, full - 1); const Shaped back = Shape(ui, font, lineF); Check(SameText(back, atlas, whole, roomy), std::format("frame A+5: the line is whole again ({} of {} glyphs), pixel for pixel", back.glyphs.size(), kLineLength)); std::size_t wholeLines = 0; for (std::size_t i = full + 1; i <= full + kMore; ++i) { if (Shape(ui, font, lines[i]).glyphs.size() == kLineLength) ++wholeLines; } Check(wholeLines == kMore, std::format("frame A+5: {} of {} more lines find room", wholeLines, kMore)); Check(PixelsOf(atlas, line1) == line1Pixels, "glyphs a frame in flight drew keep their cells"); const double oldShare = oldBefore == 0 ? 1.0 : 1.0 - double(resident(0, 0)) / double(oldBefore); const double newerShare = 1.0 - double(resident(2, full - 1)) / double(newerBefore); Check(oldShare > newerShare, std::format("the least recently used go first: {:.0f}% of line 0's glyphs gave way, {:.0f}% of frame A+1's", oldShare * 100.0, newerShare * 100.0)); // Line 0's cached run now points at cells holding other glyphs: shaping // it again must notice and shape it from scratch. Its glyphs wait for the // next frame's cells. Shape(ui, font, lines[0]); EndFrame(atlas); const Shaped line0Again = Shape(ui, font, lines[0]); Check(SameText(line0Again, atlas, Shape(roomyUi, font, lines[0]), roomy), "a cached run whose glyphs were evicted is shaped again, pixel for pixel"); // Nothing is left of an evicted glyph outside the cells that replaced it: // a glyph's filtering at its edges reads its neighbours' texels, which // must be another glyph's padding or zero. { std::vector covered(static_cast(kSize) * kSize, 0); std::size_t residentGlyphs = 0; for (const auto& cps : lineCodepoints) { for (std::uint32_t cp : cps) { const Glyph* g = atlas.Lookup(font, cp); if (g == nullptr || !g->Resident()) continue; ++residentGlyphs; const Cell c = CellOf(g->u0, g->v0, g->u1, g->v1); for (int y = c.y0; y < c.y1; ++y) for (int x = c.x0; x < c.x1; ++x) covered[static_cast(y) * kSize + x] = 1; } } const std::uint8_t* p = atlas.PixelPtr(); std::size_t stale = 0; for (std::size_t i = 0; i < covered.size(); ++i) if (!covered[i] && p[i] != 0) ++stale; Check(stale == 0, std::format("every texel outside the {} resident glyphs is zero ({} are not)", residentGlyphs, stale)); } EndFrame(atlas); // A Font replaced at the same address must find nothing of the old one. ui.InvalidateFont(font); bool forgotten = true; for (const auto& cps : lineCodepoints) { for (std::uint32_t cp : cps) forgotten = forgotten && atlas.Lookup(font, cp) == nullptr; } Check(forgotten, "InvalidateFont forgets the font's glyphs in the atlas too"); if (std::getenv("CRAFTER_GRAPHICS_VALIDATION")) { Check(Device::validationErrorCount == 0, std::format("no validation errors ({} seen)", Device::validationErrorCount)); } if (failures == 0) std::println("\nAll font atlas eviction checks passed."); else std::println("\n{} font atlas eviction check(s) FAILED.", failures); return failures == 0 ? EXIT_SUCCESS : EXIT_FAILURE; }