From 16d291b0adc116f3c52981e1b05bffb352a80321 Mon Sep 17 00:00:00 2001 From: catbot Date: Thu, 18 Jun 2026 13:35:54 +0000 Subject: [PATCH] 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 --- .../Crafter.Graphics-FontAtlas.cpp | 62 ++++++++++++------- interfaces/Crafter.Graphics-FontAtlas.cppm | 19 ++++-- 2 files changed, 52 insertions(+), 29 deletions(-) diff --git a/implementations/Crafter.Graphics-FontAtlas.cpp b/implementations/Crafter.Graphics-FontAtlas.cpp index d6f14e7..791c03f 100644 --- a/implementations/Crafter.Graphics-FontAtlas.cpp +++ b/implementations/Crafter.Graphics-FontAtlas.cpp @@ -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(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(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(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(py) / kAtlasSize; g.u1 = static_cast(px + sw) / kAtlasSize; g.v1 = static_cast(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(x), static_cast(y), - static_cast(w), static_cast(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(x), static_cast(y), + static_cast(w), static_cast(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(); } diff --git a/interfaces/Crafter.Graphics-FontAtlas.cppm b/interfaces/Crafter.Graphics-FontAtlas.cppm index 105f011..7f2ebef 100644 --- a/interfaces/Crafter.Graphics-FontAtlas.cppm +++ b/interfaces/Crafter.Graphics-FontAtlas.cppm @@ -102,9 +102,12 @@ export namespace Crafter { std::vector 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()). + // renderer polls each frame; it is the OR of every dirty span Update + // copies. `dirtyRect` is the *whole-atlas* span — used only for the + // one-shot zero-clear in Initialize; per-glyph dirt is tracked tight + // per shelf (Shelf::dirty) instead of inflating one tall union box + // across scattered shelves (#129). `dirty` and the spans are always + // armed and cleared together. bool dirty = false; DirtyRect dirtyRect; @@ -131,7 +134,11 @@ export namespace Crafter { } private: - struct Shelf { int y = 0; int height = 0; int cursorX = 0; }; + // A shelf packs glyphs left-to-right at a fixed top (`y`). Its `dirty` + // span therefore stays naturally tight: a contiguous X run capped by + // the shelf height — far smaller than a union box spanning every + // shelf a frame happened to touch. + struct Shelf { int y = 0; int height = 0; int cursorX = 0; DirtyRect dirty; }; std::vector shelves_; int nextShelfY_ = 0; @@ -149,6 +156,8 @@ export namespace Crafter { }; std::unordered_map cache_; - bool ShelfPlace(int w, int h, int& outX, int& outY); + // On success, outShelf is the index into shelves_ of the placed + // glyph, so the caller can mark that shelf's dirty span. + bool ShelfPlace(int w, int h, int& outX, int& outY, int& outShelf); }; } -- 2.54.0