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

Merged
catbot merged 1 commit from claude/issue-129 into master 2026-06-18 15:36:33 +02:00
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); MarkDirty(0, 0, kAtlasSize, kAtlasSize);
} }
bool FontAtlas::ShelfPlace(int w, int h, int& outX, int& outY) { bool FontAtlas::ShelfPlace(int w, int h, int& outX, int& outY, int& outShelf) {
for (Shelf& s : shelves_) { for (std::size_t i = 0; i < shelves_.size(); ++i) {
Shelf& s = shelves_[i];
if (h <= s.height && s.cursorX + w <= kAtlasSize) { if (h <= s.height && s.cursorX + w <= kAtlasSize) {
outX = s.cursorX; outX = s.cursorX;
outY = s.y; outY = s.y;
outShelf = static_cast<int>(i);
s.cursorX += w; s.cursorX += w;
return true; return true;
} }
@ -75,6 +77,7 @@ bool FontAtlas::ShelfPlace(int w, int h, int& outX, int& outY) {
s.cursorX = w; s.cursorX = w;
outX = 0; outX = 0;
outY = s.y; outY = s.y;
outShelf = static_cast<int>(shelves_.size());
shelves_.push_back(s); shelves_.push_back(s);
nextShelfY_ += h; nextShelfY_ += h;
return true; return true;
@ -106,8 +109,8 @@ const Glyph* FontAtlas::EnsureAndGet(Font& font, std::uint32_t codepoint) {
g.yoff = static_cast<float>(yoff); g.yoff = static_cast<float>(yoff);
if (sdf && sw > 0 && sh > 0) { if (sdf && sw > 0 && sh > 0) {
int px = 0, py = 0; int px = 0, py = 0, shelf = 0;
if (!ShelfPlace(sw, sh, px, py)) { if (!ShelfPlace(sw, sh, px, py, shelf)) {
stbtt_FreeSDF(sdf, nullptr); stbtt_FreeSDF(sdf, nullptr);
return 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.v0 = static_cast<float>(py) / kAtlasSize;
g.u1 = static_cast<float>(px + sw) / kAtlasSize; g.u1 = static_cast<float>(px + sw) / kAtlasSize;
g.v1 = static_cast<float>(py + sh) / 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; return &cache_.emplace(key, g).first->second;
@ -141,18 +147,18 @@ const Glyph* FontAtlas::Lookup(Font& font, std::uint32_t codepoint) const {
void FontAtlas::Update(GraphicsCommandBuffer cmd) { void FontAtlas::Update(GraphicsCommandBuffer cmd) {
if (!dirty) return; if (!dirty) return;
// Clamp the accumulated box to the atlas and convert to (origin, // Issue one tight copy per dirty span and reset it. Clamping to the atlas
// extent). Add() works in glyph coordinates that are always in-bounds, // keeps each extent provably valid even though Add() already works in
// but clamping keeps the copy extent provably valid. The `dirty` guard // in-bounds glyph coordinates; UploadBox skips spans that never armed.
// above guarantees the rect is non-empty, so UploadBox always fills the // The spans are the whole-atlas zero-clear (dirtyRect, Initialize only)
// outputs. // 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; std::uint32_t x = 0, y = 0, w = 0, h = 0;
dirtyRect.UploadBox(kAtlasSize, x, y, w, h); if (!r.UploadBox(kAtlasSize, x, y, w, h)) return;
#ifndef CRAFTER_GRAPHICS_WINDOW_DOM #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 #else
(void)cmd;
// The staging buffer keeps the full atlas row stride, so srcBytesPerRow // The staging buffer keeps the full atlas row stride, so srcBytesPerRow
// stays kAtlasSize and (dstX, dstY) index the sub-rect's first texel. // stays kAtlasSize and (dstX, dstY) index the sub-rect's first texel.
WebGPU::wgpuWriteAtlasRegion( WebGPU::wgpuWriteAtlasRegion(
@ -162,6 +168,14 @@ void FontAtlas::Update(GraphicsCommandBuffer cmd) {
static_cast<std::int32_t>(w), static_cast<std::int32_t>(h) static_cast<std::int32_t>(w), static_cast<std::int32_t>(h)
); );
#endif #endif
r.Reset();
};
#ifdef CRAFTER_GRAPHICS_WINDOW_DOM
(void)cmd;
#endif
upload(dirtyRect);
for (Shelf& s : shelves_) upload(s.dirty);
dirty = false; dirty = false;
dirtyRect.Reset();
} }

View file

@ -102,9 +102,12 @@ export namespace Crafter {
std::vector<std::uint8_t> staging; std::vector<std::uint8_t> staging;
#endif #endif
// `dirty` stays the cheap "is there anything to flush?" flag the // `dirty` stays the cheap "is there anything to flush?" flag the
// renderer polls each frame; `dirtyRect` carries the bounds Update // renderer polls each frame; it is the OR of every dirty span Update
// copies. The two are always set and cleared together (dirty == // copies. `dirtyRect` is the *whole-atlas* span — used only for the
// !dirtyRect.Empty()). // 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; bool dirty = false;
DirtyRect dirtyRect; DirtyRect dirtyRect;
@ -131,7 +134,11 @@ export namespace Crafter {
} }
private: 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<Shelf> shelves_; std::vector<Shelf> shelves_;
int nextShelfY_ = 0; int nextShelfY_ = 0;
@ -149,6 +156,8 @@ export namespace Crafter {
}; };
std::unordered_map<Key, Glyph, KeyHash> cache_; std::unordered_map<Key, Glyph, KeyHash> 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);
}; };
} }