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>
This commit is contained in:
catbot 2026-06-16 15:40:37 +00:00
commit 97c79c23ee
5 changed files with 276 additions and 6 deletions

View file

@ -51,13 +51,62 @@ export namespace Crafter {
static constexpr int kOnEdgeValue = 128;
static constexpr float kPixelDistScale = 32.0f;
// Bounding box of the atlas pixels touched since the last upload,
// used to copy only the sub-rect that actually changed instead of
// the whole 1 MiB atlas. Half-open: covers [minX, maxX) × [minY,
// maxY). `active` distinguishes "nothing dirty" from a zero-origin
// rect.
struct DirtyRect {
int minX = 0, minY = 0, maxX = 0, maxY = 0;
bool active = false;
bool Empty() const noexcept { return !active; }
void Reset() noexcept { active = false; }
// Grow the box to include [x, x+w) × [y, y+h). No-op for an
// empty rect (a glyph that rasterized to nothing marks nothing).
void Add(int x, int y, int w, int h) noexcept {
if (w <= 0 || h <= 0) return;
if (!active) {
minX = x; minY = y; maxX = x + w; maxY = y + h;
active = true;
} else {
minX = std::min(minX, x);
minY = std::min(minY, y);
maxX = std::max(maxX, x + w);
maxY = std::max(maxY, y + h);
}
}
// Clamp the box to [0, limit] on both axes and emit it as an
// (origin, extent) pair for a GPU copy. Returns false when
// empty, leaving the outputs untouched.
bool UploadBox(int limit, std::uint32_t& x, std::uint32_t& y, std::uint32_t& w, std::uint32_t& h) const noexcept {
if (!active) return false;
const int x0 = std::clamp(minX, 0, limit);
const int y0 = std::clamp(minY, 0, limit);
const int x1 = std::clamp(maxX, 0, limit);
const int y1 = std::clamp(maxY, 0, limit);
x = static_cast<std::uint32_t>(x0);
y = static_cast<std::uint32_t>(y0);
w = static_cast<std::uint32_t>(x1 - x0);
h = static_cast<std::uint32_t>(y1 - y0);
return true;
}
};
#ifndef CRAFTER_GRAPHICS_WINDOW_DOM
ImageVulkan<std::uint8_t> image;
#else
WebGPUTextureRef textureHandle = 0;
std::vector<std::uint8_t> staging;
#endif
// `dirty` stays the cheap "is there anything to flush?" flag the
// renderer polls each frame; `dirtyRect` carries the bounds Update
// copies. The two are always set and cleared together (dirty ==
// !dirtyRect.Empty()).
bool dirty = false;
DirtyRect dirtyRect;
void Initialize(GraphicsCommandBuffer cmd);
@ -69,6 +118,13 @@ export namespace Crafter {
const Glyph* Lookup(Font& font, std::uint32_t codepoint) const;
void Update(GraphicsCommandBuffer cmd);
// Flag the [x, x+w) × [y, y+h) atlas region as needing re-upload,
// keeping `dirty` in lockstep with the accumulated `dirtyRect`.
void MarkDirty(int x, int y, int w, int h) noexcept {
dirtyRect.Add(x, y, w, h);
dirty = !dirtyRect.Empty();
}
private:
struct Shelf { int y = 0; int height = 0; int cursorX = 0; };
std::vector<Shelf> shelves_;