399 lines
18 KiB
C++
399 lines
18 KiB
C++
|
|
//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;
|
||
|
|
}
|