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>
This commit is contained in:
Jorijn van der Graaf 2026-10-31 09:10:42 +01:00
commit a4be6f1a9c
Signed by: jorijnvdgraaf
GPG key ID: 2937E59CDCC1BCFB
17 changed files with 1632 additions and 187 deletions

View file

@ -0,0 +1,399 @@
//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 <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);
}
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<char>(cp);
} else if (cp < 0x800) {
s += static_cast<char>(0xC0 | (cp >> 6));
s += static_cast<char>(0x80 | (cp & 0x3F));
} else {
s += static_cast<char>(0xE0 | (cp >> 12));
s += static_cast<char>(0x80 | ((cp >> 6) & 0x3F));
s += static_cast<char>(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<std::string> InkedLines(Font& font, std::vector<std::vector<std::uint32_t>>& codepoints) {
const float scale = stbtt_ScaleForPixelHeight(&font.font, FontAtlas::kBaseSize);
std::vector<std::uint32_t> 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<int>(cp)) == 0) continue;
int x0 = 0, y0 = 0, x1 = 0, y1 = 0;
stbtt_GetCodepointBitmapBox(&font.font, static_cast<int>(cp), scale, scale, &x0, &y0, &x1, &y1);
if (x0 != x1 && y0 != y1) pool.push_back(cp);
}
}
std::vector<std::string> 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<GlyphItem> 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<std::uint32_t>(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<int>(u0 * kSize), static_cast<int>(v0 * kSize),
static_cast<int>(u1 * kSize), static_cast<int>(v1 * kSize) };
}
std::vector<std::uint8_t> 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<std::uint8_t> 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<const GlyphItem> drawn, std::span<const GlyphItem> 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<std::vector<std::uint8_t>> PixelsOf(FontAtlas& atlas, const Shaped& s) {
std::vector<std::vector<std::uint8_t>> 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<std::vector<std::uint32_t>> lineCodepoints;
const std::vector<std::string> 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> 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<std::uint8_t> covered(static_cast<std::size_t>(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<std::size_t>(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;
}