Crafter.Graphics/tests/FontAtlasDirtyRect/main.cpp
catbot 97c79c23ee perf(font): upload only the dirty sub-rect of the glyph atlas (#51)
FontAtlas::Update re-uploaded the entire 1024x1024 R8 atlas (1 MiB)
whenever a single glyph was rasterized, so warm-up / language-switch /
size-churn cost roughly one full-atlas copy per frame that added a
codepoint.

Accumulate a dirty bounding box (FontAtlas::DirtyRect) in MarkDirty as
glyphs are placed, and copy only that sub-rect. On Vulkan this is a new
ImageVulkan::UpdateRegion: the staging buffer keeps the full atlas row
stride, so bufferRowLength stays the atlas width and bufferOffset points
at the rect origin while imageOffset/imageExtent carve out the rect.
Layout transitions stay whole-image (cheap); only the copy extent
shrinks. WebGPU passes the rect straight to wgpuWriteAtlasRegion, which
already took x/y/w/h.

The freshly-zeroed atlas marks its whole extent dirty in Initialize so
the first Update clears the image once; thereafter every untouched texel
stays the zero it was uploaded as, keeping the image byte-identical to
the staging copy.

Tested: new FontAtlasDirtyRect unit test covers the accumulation /
clamp / lockstep-with-`dirty` math (no GPU needed); HelloUI renders text
correctly on both Vulkan (NVIDIA, validation-clean) and WebGPU.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 15:40:37 +00:00

143 lines
5.8 KiB
C++
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/*
Crafter®.Graphics
Copyright (C) 2026 Catcrafts®
catcrafts.net
This library is free software; you can redistribute it and/or
modify it under the terms of the GNU Lesser General Public
License version 3.0 as published by the Free Software Foundation;
This library is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
Lesser General Public License for more details.
You should have received a copy of the GNU Lesser General Public
License along with this library; if not, write to the Free Software
Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
*/
// Regression test for issue #51: FontAtlas::Update used to re-upload the
// whole 1024×1024 atlas every time a single glyph was rasterized. It now
// accumulates a dirty bounding box (FontAtlas::DirtyRect) and copies only
// that sub-rect. The bookkeeping is pure CPU math, so this test drives it
// directly — no Vulkan device or WebGPU context needed:
//
// - a fresh rect is empty; the first Add seeds exact bounds
// - further Adds grow the half-open union, never shrink it
// - degenerate glyphs (w<=0 / h<=0) leave the rect untouched
// - UploadBox converts (min,max) → (origin, extent) and clamps to the
// atlas, and reports emptiness so Update can early-out
// - FontAtlas::MarkDirty keeps the public `dirty` flag in lockstep with
// the rect, and Reset() clears both notions of dirtiness
//
// The actual GPU copy params (bufferOffset / bufferRowLength on Vulkan,
// dst origin / bytesPerRow on WebGPU) are exercised end-to-end by rendering
// text in the HelloUI example on both backends.
#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;
}
// Read UploadBox into locals and compare against an expected (x,y,w,h).
bool BoxIs(const FontAtlas::DirtyRect& r, int limit,
std::uint32_t ex, std::uint32_t ey, std::uint32_t ew, std::uint32_t eh) {
std::uint32_t x = 99999, y = 99999, w = 99999, h = 99999;
if (!r.UploadBox(limit, x, y, w, h)) return false;
return x == ex && y == ey && w == ew && h == eh;
}
} // namespace
int main() {
// --- DirtyRect: empty / seed / grow ------------------------------------
{
FontAtlas::DirtyRect r;
Check(r.Empty(), "fresh rect is empty");
std::uint32_t x, y, w, h;
Check(!r.UploadBox(1024, x, y, w, h), "UploadBox on empty rect returns false");
r.Add(10, 20, 4, 6);
Check(!r.Empty(), "rect is non-empty after first Add");
Check(BoxIs(r, 1024, 10, 20, 4, 6), "first Add seeds exact (origin, extent)");
// A box fully inside the current one must not change anything.
r.Add(11, 21, 1, 1);
Check(BoxIs(r, 1024, 10, 20, 4, 6), "contained Add does not shrink or move the box");
// Grow up-left and down-right; result is the union.
r.Add(5, 8, 2, 2); // extends min corner to (5,8)
r.Add(100, 200, 10, 10); // extends max corner to (110,210)
Check(BoxIs(r, 1024, 5, 8, 105, 202), "Adds grow to the half-open union");
}
// --- DirtyRect: degenerate glyphs are no-ops ---------------------------
{
FontAtlas::DirtyRect r;
r.Add(3, 3, 0, 5);
r.Add(3, 3, 5, 0);
r.Add(3, 3, -1, -1);
Check(r.Empty(), "zero/negative-extent Adds leave the rect empty");
r.Add(3, 3, 5, 5);
r.Add(50, 50, 0, 0); // must not move the max corner out
Check(BoxIs(r, 1024, 3, 3, 5, 5), "degenerate Add after a real one is ignored");
}
// --- DirtyRect: clamp to atlas extent ----------------------------------
{
FontAtlas::DirtyRect r;
r.Add(-4, -4, 8, 8); // straddles the top-left corner
Check(BoxIs(r, 1024, 0, 0, 4, 4), "negative origin clamps to 0, extent trimmed");
FontAtlas::DirtyRect r2;
r2.Add(1020, 1020, 16, 16); // straddles the bottom-right corner
Check(BoxIs(r2, 1024, 1020, 1020, 4, 4), "over-extent box clamps to the atlas edge");
}
// --- FontAtlas::MarkDirty keeps `dirty` and the rect in sync -----------
{
FontAtlas atlas;
Check(!atlas.dirty && atlas.dirtyRect.Empty(), "default FontAtlas is clean");
atlas.MarkDirty(0, 0, FontAtlas::kAtlasSize, FontAtlas::kAtlasSize);
Check(atlas.dirty, "MarkDirty sets the public dirty flag");
Check(BoxIs(atlas.dirtyRect, FontAtlas::kAtlasSize,
0, 0, FontAtlas::kAtlasSize, FontAtlas::kAtlasSize),
"full-atlas MarkDirty covers the whole extent (the Initialize clear)");
atlas.MarkDirty(8, 8, 16, 16); // contained — full extent already dirty
Check(BoxIs(atlas.dirtyRect, FontAtlas::kAtlasSize,
0, 0, FontAtlas::kAtlasSize, FontAtlas::kAtlasSize),
"subsequent contained MarkDirty keeps the full extent");
// Simulate the reset Update performs after a successful upload.
atlas.dirty = false;
atlas.dirtyRect.Reset();
Check(!atlas.dirty && atlas.dirtyRect.Empty(), "post-upload reset clears dirtiness");
// After reset, a single glyph marks only its own rect.
atlas.MarkDirty(64, 128, 12, 20);
Check(atlas.dirty, "a glyph after reset re-arms dirty");
Check(BoxIs(atlas.dirtyRect, FontAtlas::kAtlasSize, 64, 128, 12, 20),
"post-reset MarkDirty tracks just the new glyph, not the old full extent");
}
if (failures != 0) {
std::println("{} check(s) failed", failures);
return EXIT_FAILURE;
}
std::println("all checks passed");
return EXIT_SUCCESS;
}