perf(ui): per-shelf dirty spans for tight atlas uploads (#129)

FontAtlas::Update used a single union bounding box for the dirty rect.
Scattered new glyphs landing on different shelves produced a tall union
box that re-uploaded mostly-unchanged texels between the shelves.

Track a dirty span per shelf instead and issue one tight UpdateRegion /
wgpuWriteAtlasRegion copy per armed span. A shelf packs glyphs
left-to-right at a fixed top, so each span stays a contiguous X run
capped by the shelf height. The whole-atlas zero-clear in Initialize
keeps using the existing dirtyRect span. `dirty` is now the OR of every
span and is cleared together with them in Update.

Verified: full test suite green (23 passed); HelloUI text renders
crisply on the WebGPU backend through the new multi-span upload path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
catbot 2026-06-18 13:35:54 +00:00
commit 16d291b0ad
2 changed files with 51 additions and 28 deletions

View file

@ -59,11 +59,13 @@ void FontAtlas::Initialize(GraphicsCommandBuffer cmd) {
MarkDirty(0, 0, kAtlasSize, kAtlasSize);
}
bool FontAtlas::ShelfPlace(int w, int h, int& outX, int& outY) {
for (Shelf& s : shelves_) {
bool FontAtlas::ShelfPlace(int w, int h, int& outX, int& outY, int& outShelf) {
for (std::size_t i = 0; i < shelves_.size(); ++i) {
Shelf& s = shelves_[i];
if (h <= s.height && s.cursorX + w <= kAtlasSize) {
outX = s.cursorX;
outY = s.y;
outShelf = static_cast<int>(i);
s.cursorX += w;
return true;
}
@ -75,6 +77,7 @@ bool FontAtlas::ShelfPlace(int w, int h, int& outX, int& outY) {
s.cursorX = w;
outX = 0;
outY = s.y;
outShelf = static_cast<int>(shelves_.size());
shelves_.push_back(s);
nextShelfY_ += h;
return true;
@ -106,8 +109,8 @@ const Glyph* FontAtlas::EnsureAndGet(Font& font, std::uint32_t codepoint) {
g.yoff = static_cast<float>(yoff);
if (sdf && sw > 0 && sh > 0) {
int px = 0, py = 0;
if (!ShelfPlace(sw, sh, px, py)) {
int px = 0, py = 0, shelf = 0;
if (!ShelfPlace(sw, sh, px, py, shelf)) {
stbtt_FreeSDF(sdf, nullptr);
return nullptr;
}
@ -127,7 +130,10 @@ const Glyph* FontAtlas::EnsureAndGet(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;
MarkDirty(px, py, sw, sh);
// Mark the *shelf's* span, not one global box — keeps each upload
// tight when glyphs land on different shelves the same frame (#129).
shelves_[shelf].dirty.Add(px, py, sw, sh);
dirty = true;
}
return &cache_.emplace(key, g).first->second;
@ -141,27 +147,35 @@ 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);
// Issue one tight copy per dirty span and reset it. Clamping to the atlas
// keeps each extent provably valid even though Add() already works in
// in-bounds glyph coordinates; UploadBox skips spans that never armed.
// The spans are the whole-atlas zero-clear (dirtyRect, Initialize only)
// plus each shelf's accumulated glyph run — so scattered glyphs upload as
// several small rects, not one tall union covering the gaps between them.
auto upload = [&](DirtyRect& r) {
std::uint32_t x = 0, y = 0, w = 0, h = 0;
if (!r.UploadBox(kAtlasSize, x, y, w, h)) return;
#ifndef CRAFTER_GRAPHICS_WINDOW_DOM
image.UpdateRegion(cmd, VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL, x, y, w, h);
image.UpdateRegion(cmd, VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL, x, y, w, h);
#else
(void)cmd;
// 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,
static_cast<std::int32_t>(x), static_cast<std::int32_t>(y),
static_cast<std::int32_t>(w), static_cast<std::int32_t>(h)
);
// 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,
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
r.Reset();
};
#ifdef CRAFTER_GRAPHICS_WINDOW_DOM
(void)cmd;
#endif
upload(dirtyRect);
for (Shelf& s : shelves_) upload(s.dirty);
dirty = false;
dirtyRect.Reset();
}