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

@ -39,13 +39,17 @@ void FontAtlas::Initialize(GraphicsCommandBuffer cmd) {
VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL
);
std::memset(image.buffer.value, 0, kAtlasSize * kAtlasSize);
dirty = true;
#else
(void)cmd;
staging.assign(kAtlasSize * kAtlasSize, 0);
textureHandle = WebGPU::wgpuCreateAtlasTexture(kAtlasSize, kAtlasSize);
dirty = true;
#endif
// The freshly-zeroed atlas backs an image whose contents are undefined
// (Vulkan) / unwritten (WebGPU). Mark the whole extent dirty so the
// first Update clears it once; thereafter only glyph sub-rects flip
// dirty, and every untouched texel stays the zero it was uploaded as —
// so partial uploads keep the image byte-identical to the staging copy.
MarkDirty(0, 0, kAtlasSize, kAtlasSize);
}
bool FontAtlas::ShelfPlace(int w, int h, int& outX, int& outY) {
@ -112,7 +116,7 @@ bool FontAtlas::Ensure(Font& font, std::uint32_t codepoint) {
g.v0 = static_cast<float>(py) / kAtlasSize;
g.u1 = static_cast<float>(px + sw) / kAtlasSize;
g.v1 = static_cast<float>(py + sh) / kAtlasSize;
dirty = true;
MarkDirty(px, py, sw, sh);
}
cache_.emplace(key, g);
@ -126,16 +130,28 @@ const Glyph* FontAtlas::Lookup(Font& font, std::uint32_t codepoint) const {
void FontAtlas::Update(GraphicsCommandBuffer cmd) {
if (!dirty) return;
// Clamp the accumulated box to the atlas and convert to (origin,
// extent). Add() works in glyph coordinates that are always in-bounds,
// but clamping keeps the copy extent provably valid. The `dirty` guard
// above guarantees the rect is non-empty, so UploadBox always fills the
// outputs.
std::uint32_t x = 0, y = 0, w = 0, h = 0;
dirtyRect.UploadBox(kAtlasSize, x, y, w, h);
#ifndef CRAFTER_GRAPHICS_WINDOW_DOM
image.Update(cmd, VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL);
image.UpdateRegion(cmd, VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL, x, y, w, h);
#else
(void)cmd;
// Full-atlas upload. Future: track dirty region.
// The staging buffer keeps the full atlas row stride, so srcBytesPerRow
// stays kAtlasSize and (dstX, dstY) index the sub-rect's first texel.
WebGPU::wgpuWriteAtlasRegion(
textureHandle, staging.data(),
kAtlasSize, kAtlasSize, kAtlasSize,
0, 0, kAtlasSize, kAtlasSize
static_cast<std::int32_t>(x), static_cast<std::int32_t>(y),
static_cast<std::int32_t>(w), static_cast<std::int32_t>(h)
);
#endif
dirty = false;
dirtyRect.Reset();
}