fix(font-atlas): give glyphs that find no room a cell when the next frame begins, evicting the least recently drawn

The atlas packed glyphs onto shelves and never gave a place back (#55).
Once its 1024x1024 image was full, EnsureAndGet returned nullptr for each
new glyph before caching anything, so every new string holding one
rasterised it again, and ShapeText dropped it together with its advance:
the rest of the line closed up over the gap, and stayed that way.

FontAtlas
- A glyph is measured, not rasterised, until it gets a cell. One that
  finds no free room keeps its advance and box and draws blank for a
  frame. EnsureAndGet always returns the glyph, now a reference;
  Glyph::Resident() says whether it has a cell, Glyph::handle which one
  (slot and generation).
- Evictions wait for the next frame: its first EnsureAndGet, Touch or
  Update places the glyphs that waited, once every glyph the frame before
  drew has been marked. Evicting while a frame is still asking for glyphs
  took ones it drew further down, which then took others in turn: a page
  switched back to could rasterise most of itself again rather than what
  it was missing, and on the DOM backend an over-full view could do so
  every frame.
- A glyph keeps its cell for framesInFlight + 1 frames after it was drawn.
  The frame loop waits frame u's fence at the start of frame
  u + framesInFlight, but Device::frameCounter reads that from the end of
  the frame before, and input callbacks and onBeforeUpdate run in between.
- FontAtlas::Packer places glyphs. Until the rows run out it fills shelves
  exactly as before. Then a glyph takes the same columns of one shelf at
  least as tall, or of several in a row as tall together, the free rows
  below the last shelf counting as one more: free texels, and, when the
  frame begins, glyphs nothing in flight draws, the least recently drawn
  first. A glyph spanning shelves lets in one taller than every shelf, and
  a few glyphs drawn every frame pin only their columns. Merging whole
  shelves instead, tried first, left a random workload without room for a
  glyph while a median 43% of the atlas was in flight; spanning, at 65%.
- A box that found no room in a frame is remembered, with every box at
  least as big, as a small set per mode, until the frame ends or (without
  eviction) something is evicted: a saturated atlas no longer searches
  every shelf for each of its missing glyphs.
- An evicted glyph is forgotten, and its texels are zeroed and uploaded
  with the glyph that takes its place: a neighbour's filtering at its edge
  must read padding or zero, not a stale glyph.
- Touch(handles) marks every glyph whose handle still holds its cell, and
  says whether all did. InvalidateFont(font) forgets the font's glyphs and
  those waiting for a cell, whose Font* the next frame would read. The
  glyph index is looked up at rasterisation, so a Font replaced in place
  never gets one past its glyphs. GlyphsRasterized() counts, for tests.

UIRenderer
- A shaped run keeps a handle per glyph it draws. A hit touches them all,
  so the atlas keeps what is drawn every frame, and shapes the run again
  when one was evicted or one had no room. A glyph without room draws
  blank but advances the pen.
- InvalidateFont clears the atlas's glyphs of the font too, as its comment
  and AGENTS.md always said it did.
- Record calls the atlas's Update before onBuild, every frame, so the
  waiting glyphs are placed and uploaded first. Dispatch uploads the atlas
  before any kernel, a Tier 1 one sampling FontAtlasImageSlot() included:
  a glyph placed earlier in onBuild can sit in a cell another glyph had.
  DispatchText and DispatchFused no longer do it themselves.

Device, Window (DOM)
- Device::frameCounter and Device::framesInFlight exist on the DOM backend
  too, the counter bumped at the end of Window::Render, with one frame in
  flight: a frame's writeTexture lands before that frame's submit but after
  every earlier one.

HelloUI
- One set of item buffers and slots per frame in flight, by frameIdx. A
  frame still on the GPU read the items the next frame's onBuild was
  rewriting; with cells changing hands, those can name cells it drew other
  glyphs in.

Tests
- FontAtlasPacker: the packer with made-up boxes and no device: the fill
  order, least recently drawn first, framesInFlight + 1 frames held,
  nothing without evict, wide glyphs, tall glyphs spanning shelves and the
  free rows, Touch past a stale handle, stale handles, dirty spans, and the
  invariants over a random workload placed the way the atlas places.
- FontAtlasEviction: fills the atlas through ShapeText frame by frame. The
  line that no longer fits keeps its advance and its glyphs' places; its
  missing glyphs aren't rasterised while nothing can go; when only line 0
  is cold, only its glyphs give way; a frame's own misses evict nothing in
  it; a frame later the line and twenty more are whole, pixel for pixel,
  line 0 losing a larger share than the glyphs drawn after it; a stale
  cached run is shaped again; no stale texel is left; InvalidateFont
  reaches the atlas. Against master, with its two calls to the new API
  stubbed out, an earlier version failed ten checks.
- UIDispatchAtlasFlush: a custom ui.Dispatch after ShapeText leaves the
  atlas uploaded. Needs a compositor.
- ShapeTextCache: a hit rasterises nothing; after InvalidateFont the
  re-shaped run draws the same, from new cells.

Docs
- README and AGENTS.consumer.md: the atlas's eviction, the frame a glyph
  waits, shaping text every frame into per-frame item buffers,
  InvalidateFont, EnsureAndGet's signature and Lookup's lifetime, and that
  the atlas, like the deletion queue, assumes one rendering window and
  evicts nothing without one.
- AGENTS.md: FontAtlas::Packer among the pure-logic helpers, eviction
  without a Window, and InvalidateFont.

Breaking: FontAtlas::EnsureAndGet returns const Glyph& and never fails;
check Glyph::Resident() before drawing from its UVs. FontAtlas::Touch is
no longer noexcept. A GlyphItem is good for the frame it was shaped for,
once the atlas has filled up, and item buffers must be per frame.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Jorijn van der Graaf 2026-10-31 09:10:42 +01:00
commit a4be6f1a9c
Signed by: jorijnvdgraaf
GPG key ID: 2937E59CDCC1BCFB
17 changed files with 1632 additions and 187 deletions

View file

@ -15,9 +15,9 @@ module Crafter.Graphics:FontAtlas_impl;
import :FontAtlas;
import :Font;
import :GraphicsTypes;
import :Device;
#ifndef CRAFTER_GRAPHICS_WINDOW_DOM
import :ImageVulkan;
import :Device;
#else
import :WebGPU;
#endif
@ -25,6 +25,241 @@ import std;
using namespace Crafter;
// ─── Packer ─────────────────────────────────────────────────────────────
std::uint32_t FontAtlas::Packer::Allocate(int w, int h, std::uint64_t now, std::uint32_t framesInFlight, bool evict) {
evicted_.clear();
cleared_.clear();
if (w <= 0 || h <= 0 || w > size_ || h > size_) return GlyphHandle::kNone;
if (failedFrame_ != now || failedDepth_ != framesInFlight) {
failedFrame_ = now;
failedDepth_ = framesInFlight;
failedEvicting_.clear();
failedFree_.clear();
}
auto refused = [&](const std::vector<Box>& boxes) {
return std::ranges::any_of(boxes, [&](const Box& b) { return w >= b.w && h >= b.h; });
};
// A box no eviction found room for has no free room either.
if (refused(failedEvicting_) || (!evict && refused(failedFree_))) return GlyphHandle::kNone;
// A glyph used in frame u is drawn by that frame's dispatches, which the
// frame loop has waited for at the start of frame u + framesInFlight; by
// the end of the frame before, and the callbacks that run in between,
// Device::frameCounter already reads u + framesInFlight. So a cell goes
// from frame u + framesInFlight + 1 on: never sooner than two frames
// after its use, even before a Window has set framesInFlight, and on the
// DOM backend (one frame in flight) a glyph the last frame drew, the
// likeliest to be drawn again, stays.
const std::uint64_t depth = std::uint64_t{std::max<std::uint32_t>(framesInFlight, 1)} + 1;
auto cold = [&](std::uint32_t slot) { return slots_[slot].lastUse + depth <= now; };
auto end = [&](std::uint32_t slot) { return slots_[slot].cell.x + slots_[slot].cell.w; };
// The first gap wide enough on the first shelf tall enough, else a new
// shelf as tall as the glyph. Until something is evicted a shelf's only
// gap is its tail, so this fills the atlas exactly as the shelves always
// did.
for (std::size_t i = 0; i < shelves_.size(); ++i) {
const Shelf& s = shelves_[i];
if (s.height < h) continue;
int x = 0;
bool gap = false;
for (std::uint32_t slot : s.slots) {
if (slots_[slot].cell.x - x >= w) { gap = true; break; }
x = end(slot);
}
if (gap || size_ - x >= w) return Insert(i, i, x, w, h, now);
}
if (size_ - nextShelfY_ >= h) {
OpenShelf(h);
return Insert(shelves_.size() - 1, shelves_.size() - 1, 0, w, h, now);
}
// The same w columns of the fewest shelves from some shelf on that are
// together at least h tall: one shelf as tall, or several shorter ones,
// with the free rows below the last for the rows they are short. The
// glyphs in flight there cut the columns into stretches of free texels
// and cold glyphs; in each stretch at least w wide, a window starts at
// either end and on either side of each cold glyph. The best window's
// newest glyph was used longest ago (one over free texels alone has
// none, and wins); then it evicts the fewest glyphs, then it leaves the
// fewest rows of the run below the glyph. Without `evict` only windows
// over free texels count.
struct Window {
std::size_t first = 0, last = 0; // the shelves it covers
int x = 0;
int extra = 0; // the free rows it opens as one more shelf
std::uint64_t newest = 0;
std::size_t count = 0;
int waste = 0;
bool found = false;
} best;
for (std::size_t i = 0; i < shelves_.size(); ++i) {
std::size_t j = i;
int height = shelves_[i].height;
while (height < h && j + 1 < shelves_.size()) height += shelves_[++j].height;
const int extra = std::max(h - height, 0);
if (extra > size_ - nextShelfY_) break; // nor from any shelf further down
// The run's glyphs by column, each once: one on several shelves is on
// each one's list. A single shelf's list is in order already.
std::span<const std::uint32_t> glyphs = shelves_[i].slots;
if (j > i) {
run_.clear();
for (std::size_t k = i; k <= j; ++k) run_.insert(run_.end(), shelves_[k].slots.begin(), shelves_[k].slots.end());
std::ranges::sort(run_, [&](std::uint32_t a, std::uint32_t b) {
return slots_[a].cell.x != slots_[b].cell.x ? slots_[a].cell.x < slots_[b].cell.x : a < b;
});
run_.erase(std::unique(run_.begin(), run_.end()), run_.end());
glyphs = run_;
}
int widest = 0;
for (std::uint32_t slot : glyphs) widest = std::max(widest, slots_[slot].cell.w);
auto consider = [&](int x) {
Window win{ .first = i, .last = j, .x = x, .extra = extra,
.waste = height + extra - h, .found = true };
// A glyph over [x, x + w) starts before x + w and ends after x,
// so it starts at x - widest or later.
auto it = std::ranges::lower_bound(glyphs, x - widest, {},
[&](std::uint32_t slot) { return slots_[slot].cell.x; });
for (; it != glyphs.end() && slots_[*it].cell.x < x + w; ++it) {
if (end(*it) <= x) continue;
win.newest = std::max(win.newest, slots_[*it].lastUse);
++win.count;
}
if (win.count > 0 && !evict) return;
if (!best.found || win.newest < best.newest
|| (win.newest == best.newest && (win.count < best.count
|| (win.count == best.count && win.waste < best.waste)))) {
best = win;
}
};
int left = 0; // where the stretch starts: past every glyph in flight so far
auto stretch = [&](int right) {
if (right - left < w) return;
consider(left);
consider(right - w);
for (std::uint32_t slot : glyphs) {
const Cell& c = slots_[slot].cell;
if (!cold(slot) || c.x + c.w <= left || c.x >= right) continue;
consider(std::clamp(c.x, left, right - w));
consider(std::clamp(c.x + c.w, left, right - w));
}
};
for (std::uint32_t slot : glyphs) {
if (cold(slot)) continue;
stretch(slots_[slot].cell.x);
left = std::max(left, end(slot));
}
stretch(size_);
}
if (best.found) {
// Collected before evicting: Evict edits the lists.
run_.clear();
for (std::size_t k = best.first; k <= best.last; ++k) {
for (std::uint32_t slot : shelves_[k].slots) {
if (slots_[slot].cell.x < best.x + w && end(slot) > best.x) run_.push_back(slot);
}
}
std::ranges::sort(run_);
run_.erase(std::unique(run_.begin(), run_.end()), run_.end());
for (std::uint32_t slot : run_) Evict(slot);
if (best.extra > 0) {
OpenShelf(best.extra);
best.last = shelves_.size() - 1;
}
return Insert(best.first, best.last, best.x, w, h, now);
}
// No box this one holds needs remembering any more.
std::vector<Box>& failed = evict ? failedEvicting_ : failedFree_;
std::erase_if(failed, [&](const Box& b) { return b.w >= w && b.h >= h; });
failed.push_back({ w, h });
return GlyphHandle::kNone;
}
std::uint32_t FontAtlas::Packer::Insert(std::size_t first, std::size_t last, int x, int w, int h, std::uint64_t now) {
std::uint32_t slot;
if (!freeSlots_.empty()) {
slot = freeSlots_.back();
freeSlots_.pop_back();
} else {
slot = static_cast<std::uint32_t>(slots_.size());
slots_.emplace_back();
}
const int y = shelves_[first].y;
slots_[slot].cell = { x, y, w, h };
slots_[slot].lastUse = now;
for (std::size_t k = first; k <= last; ++k) {
std::vector<std::uint32_t>& v = shelves_[k].slots;
v.insert(std::ranges::find_if(v, [&](std::uint32_t other) { return slots_[other].cell.x > x; }), slot);
}
shelves_[first].dirty.Add(x, y, w, h);
return slot;
}
void FontAtlas::Packer::Evict(std::uint32_t slot) {
const Cell c = slots_[slot].cell;
const std::size_t first = ShelfAt(c.y);
for (std::size_t k = first; k < shelves_.size() && shelves_[k].y < c.y + c.h; ++k) {
std::vector<std::uint32_t>& v = shelves_[k].slots;
if (auto it = std::ranges::find(v, slot); it != v.end()) v.erase(it);
}
cleared_.push_back(c);
shelves_[first].dirty.Add(c.x, c.y, c.w, c.h);
++slots_[slot].generation;
freeSlots_.push_back(slot);
evicted_.push_back(slot);
failedFree_.clear();
}
void FontAtlas::Packer::OpenShelf(int height) {
shelves_.push_back(Shelf{ .y = nextShelfY_, .height = height });
nextShelfY_ += height;
}
std::size_t FontAtlas::Packer::ShelfAt(int y) const noexcept {
const auto above = std::ranges::upper_bound(shelves_, y, {}, &Shelf::y);
return static_cast<std::size_t>(above - shelves_.begin()) - 1;
}
bool FontAtlas::Packer::Touch(std::span<const GlyphHandle> handles, std::uint64_t now) noexcept {
bool all = true;
for (const GlyphHandle& handle : handles) {
if (handle.slot < slots_.size() && slots_[handle.slot].generation == handle.generation) {
slots_[handle.slot].lastUse = now;
} else {
all = false;
}
}
return all;
}
// ─── FontAtlas ──────────────────────────────────────────────────────────
namespace {
// A glyph's advance and box at the atlas's base size, without rasterising
// it. The box is the one stbtt_GetGlyphSDF rasterises into, the glyph's
// bitmap box grown by the padding: a glyph is laid out, and its cell
// found, before its SDF exists, and without one when there is no room.
void Measure(const Font& font, std::uint32_t codepoint, Glyph& g) {
const float fontScale = stbtt_ScaleForPixelHeight(&font.font, FontAtlas::kBaseSize);
const int index = stbtt_FindGlyphIndex(&font.font, static_cast<int>(codepoint));
int advanceUnits = 0, lsb = 0;
stbtt_GetGlyphHMetrics(&font.font, index, &advanceUnits, &lsb);
g.advance = advanceUnits * fontScale;
int x0 = 0, y0 = 0, x1 = 0, y1 = 0;
stbtt_GetGlyphBitmapBoxSubpixel(&font.font, index, fontScale, fontScale, 0.0f, 0.0f, &x0, &y0, &x1, &y1);
if (x0 == x1 || y0 == y1) return; // no ink (a space): nothing to place
g.xoff = static_cast<float>(x0 - FontAtlas::kPadding);
g.yoff = static_cast<float>(y0 - FontAtlas::kPadding);
g.w = static_cast<float>(x1 - x0 + 2 * FontAtlas::kPadding);
g.h = static_cast<float>(y1 - y0 + 2 * FontAtlas::kPadding);
}
}
std::uint8_t* FontAtlas::PixelPtr() noexcept {
#ifndef CRAFTER_GRAPHICS_WINDOW_DOM
return image.buffer.value;
@ -59,95 +294,128 @@ void FontAtlas::Initialize(GraphicsCommandBuffer cmd) {
// 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.
// An evicted glyph's texels are zeroed again and uploaded with the glyph
// that takes its place, which keeps that so.
MarkDirty(0, 0, kAtlasSize, kAtlasSize);
}
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;
}
}
if (nextShelfY_ + h > kAtlasSize) return false;
Shelf s{};
s.y = nextShelfY_;
s.height = h;
s.cursorX = w;
outX = 0;
outY = s.y;
outShelf = static_cast<int>(shelves_.size());
shelves_.push_back(s);
nextShelfY_ += h;
return true;
}
bool FontAtlas::Ensure(Font& font, std::uint32_t codepoint) {
return EnsureAndGet(font, codepoint) != nullptr;
const Glyph& g = EnsureAndGet(font, codepoint);
return g.w == 0 || g.Resident();
}
const Glyph* FontAtlas::EnsureAndGet(Font& font, std::uint32_t codepoint) {
Key key{&font, codepoint};
if (auto it = cache_.find(key); it != cache_.end()) return &it->second;
const Glyph& FontAtlas::EnsureAndGet(Font& font, std::uint32_t codepoint) {
const std::uint64_t now = Device::frameCounter;
if (settled_ != now) Settle(now);
const Key key{&font, codepoint};
auto [it, inserted] = cache_.try_emplace(key);
Entry& e = it->second;
if (inserted) Measure(font, codepoint, e.glyph);
if (e.glyph.w > 0) {
if (e.glyph.Resident()) {
packer_.Touch(e.glyph.handle.slot, now);
} else if (!Place(font, key, e, now, /*evict*/ false) && e.waiting != now) {
e.waiting = now;
waiting_.push_back(key);
}
}
return e.glyph;
}
float fontScale = stbtt_ScaleForPixelHeight(&font.font, kBaseSize);
void FontAtlas::Settle(std::uint64_t now) {
settled_ = now;
// Every glyph the last frame drew was marked in it, and the Packer
// keeps those: an eviction now takes only glyphs that frame did not draw,
// where one made while a frame is still asking for glyphs takes any it
// has yet to ask for. The waiting glyphs go in the order first asked for.
const std::vector<Key> waiting = std::exchange(waiting_, {});
for (const Key& key : waiting) {
auto it = cache_.find(key);
if (it == cache_.end() || it->second.glyph.Resident()) continue;
Place(*key.font, key, it->second, now, /*evict*/ true);
}
}
int advanceUnits = 0, lsb = 0;
stbtt_GetCodepointHMetrics(&font.font, static_cast<int>(codepoint), &advanceUnits, &lsb);
bool FontAtlas::Place(const Font& font, const Key& key, Entry& e, std::uint64_t now, bool evict) {
Glyph& g = e.glyph;
const std::uint32_t slot = packer_.Allocate(static_cast<int>(g.w), static_cast<int>(g.h), now, Device::framesInFlight, evict);
// The glyphs whose cells it took are forgotten, so their next use
// measures and places them again; `e`, which has no cell yet, is never
// among them. A key cached afresh since its glyph got the cell is left.
for (std::uint32_t victim : packer_.Evicted()) {
auto owner = cache_.find(owners_[victim]);
if (owner != cache_.end() && owner->second.glyph.handle.slot == victim) cache_.erase(owner);
}
std::uint8_t* pixels = PixelPtr();
for (const Packer::Cell& c : packer_.Cleared()) {
for (int row = c.y; row < c.y + c.h; ++row) {
std::memset(pixels + static_cast<std::size_t>(row) * kAtlasSize + c.x, 0, static_cast<std::size_t>(c.w));
}
}
if (slot == GlyphHandle::kNone) return false;
const Packer::Cell& cell = packer_.CellOf(slot);
const float fontScale = stbtt_ScaleForPixelHeight(&font.font, kBaseSize);
// The index from the font as it is now: a Font replaced in place keeps
// its address, and an index measured in the old one can lie past the
// new one's glyphs.
const int index = stbtt_FindGlyphIndex(&font.font, static_cast<int>(key.cp));
int sw = 0, sh = 0, xoff = 0, yoff = 0;
unsigned char* sdf = stbtt_GetCodepointSDF(
&font.font, fontScale, static_cast<int>(codepoint),
unsigned char* sdf = stbtt_GetGlyphSDF(
&font.font, fontScale, index,
kPadding, static_cast<unsigned char>(kOnEdgeValue), kPixelDistScale,
&sw, &sh, &xoff, &yoff
);
Glyph g{};
g.advance = advanceUnits * fontScale;
g.xoff = static_cast<float>(xoff);
g.yoff = static_cast<float>(yoff);
if (sdf && sw > 0 && sh > 0) {
int px = 0, py = 0, shelf = 0;
if (!ShelfPlace(sw, sh, px, py, shelf)) {
stbtt_FreeSDF(sdf, nullptr);
return nullptr;
}
std::uint8_t* dst = PixelPtr();
for (int row = 0; row < sh; ++row) {
std::memcpy(
dst + (py + row) * kAtlasSize + px,
sdf + row * sw,
static_cast<std::size_t>(sw)
);
if (sdf != nullptr) {
// Measure took the box the way stbtt_GetGlyphSDF does, so the SDF is
// the cell's size; the check only keeps the copy inside the cell.
if (sw == cell.w && sh == cell.h) {
for (int row = 0; row < sh; ++row) {
std::memcpy(
pixels + static_cast<std::size_t>(cell.y + row) * kAtlasSize + cell.x,
sdf + row * sw,
static_cast<std::size_t>(sw)
);
}
}
stbtt_FreeSDF(sdf, nullptr);
g.w = static_cast<float>(sw);
g.h = static_cast<float>(sh);
g.u0 = static_cast<float>(px) / kAtlasSize;
g.v0 = static_cast<float>(py) / kAtlasSize;
g.u1 = static_cast<float>(px + sw) / kAtlasSize;
g.v1 = static_cast<float>(py + sh) / kAtlasSize;
// 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;
}
++rasterized_;
return &cache_.emplace(key, g).first->second;
g.u0 = static_cast<float>(cell.x) / kAtlasSize;
g.v0 = static_cast<float>(cell.y) / kAtlasSize;
g.u1 = static_cast<float>(cell.x + cell.w) / kAtlasSize;
g.v1 = static_cast<float>(cell.y + cell.h) / kAtlasSize;
g.handle = packer_.Handle(slot);
if (owners_.size() <= slot) owners_.resize(slot + 1);
owners_[slot] = key;
// The packer has added the cell, and any it cleared, to its shelves'
// spans (#129).
dirty = true;
return true;
}
const Glyph* FontAtlas::Lookup(Font& font, std::uint32_t codepoint) const {
auto it = cache_.find(Key{&font, codepoint});
return it == cache_.end() ? nullptr : &it->second;
return it == cache_.end() ? nullptr : &it->second.glyph;
}
bool FontAtlas::Touch(std::span<const GlyphHandle> handles) {
const std::uint64_t now = Device::frameCounter;
if (settled_ != now) Settle(now);
return packer_.Touch(handles, now);
}
void FontAtlas::InvalidateFont(const Font& font) noexcept {
std::erase_if(cache_, [&](const auto& entry) { return entry.first.font == &font; });
std::erase_if(waiting_, [&](const Key& key) { return key.font == &font; });
}
void FontAtlas::Update(GraphicsCommandBuffer cmd) {
const std::uint64_t now = Device::frameCounter;
if (settled_ != now) Settle(now);
if (!dirty) return;
// Issue one tight copy per dirty span and reset it. Clamping to the atlas
@ -178,7 +446,7 @@ void FontAtlas::Update(GraphicsCommandBuffer cmd) {
(void)cmd;
#endif
upload(dirtyRect);
for (Shelf& s : shelves_) upload(s.dirty);
packer_.ForEachDirty(upload);
dirty = false;
}

View file

@ -84,36 +84,9 @@ std::uint32_t UIRenderer::ShapeText(Font& font, float pxSize,
ShapedRunViewKey viewKey{&font, pxSize, color, utf8};
auto it = shapedRuns_.find(viewKey);
if (it == shapedRuns_.end()) {
// Miss: shape from scratch. Ensure() each glyph so brand-new ones
// rasterise into the atlas and mark it dirty for the next upload —
// a hit skips this, which is safe because atlas entries are never
// evicted once placed.
const float scale = pxSize / FontAtlas::kBaseSize;
// Miss: shape from scratch.
ShapedRun run;
float cursor = 0.0f;
std::size_t i = 0;
while (i < utf8.size()) {
std::uint32_t cp = DecodeUtf8(utf8, i);
if (cp == 0) break;
if (cp == '\n') { continue; }
const Glyph* g = fontAtlas->EnsureAndGet(font, cp);
if (g == nullptr) continue;
if (g->w > 0 && g->h > 0) {
GlyphItem gi;
gi.x = cursor + g->xoff * scale;
gi.y = g->yoff * scale;
gi.w = g->w * scale;
gi.h = g->h * scale;
gi.u0 = g->u0; gi.v0 = g->v0;
gi.u1 = g->u1; gi.v1 = g->v1;
gi.r = color[0]; gi.g = color[1]; gi.b = color[2]; gi.a = color[3];
run.glyphs.push_back(gi);
}
cursor += g->advance * scale;
}
run.advance = cursor;
ShapeRun(font, pxSize, utf8, color, run);
// Evict the least-recently-used run (LRU list back) on overflow rather
// than clearing the whole cache. The back is whatever has gone longest
@ -134,6 +107,18 @@ std::uint32_t UIRenderer::ShapeText(Font& font, float pxSize,
// Hit: promote to most-recently-used. splice is O(1) and keeps lruIt
// (and every other iterator) valid.
shapedRunsLru_.splice(shapedRunsLru_.begin(), shapedRunsLru_, it->second.lruIt);
// The atlas only sees the glyphs it is asked for, so a hit tells it
// the run's are in use, or it would take their cells once full (#55).
// If it already has taken one, the run samples whatever glyph holds
// the cell now: shape it again, as a run with a glyph that had no
// room must be until the glyph gets one. Touched first either way, so
// the run's glyphs that still hold their cells are marked before the
// shaping asks for its missing ones.
ShapedRun& hit = it->second;
const bool cellsHeld = fontAtlas->Touch(hit.cells);
if (!hit.complete || !cellsHeld) {
ShapeRun(font, pxSize, utf8, color, hit);
}
}
const ShapedRun& run = it->second;
@ -157,6 +142,45 @@ std::uint32_t UIRenderer::ShapeText(Font& font, float pxSize,
return written;
}
void UIRenderer::ShapeRun(Font& font, float pxSize, std::string_view utf8,
std::array<float,4> color, ShapedRun& run) {
// EnsureAndGet each glyph: brand-new ones rasterise into the atlas and
// mark it dirty for the next upload, and every one is marked used in this
// frame. A glyph that finds no room draws nothing, but still advances
// the pen, so the rest of the line stays where it belongs.
const float scale = pxSize / FontAtlas::kBaseSize;
run.glyphs.clear();
run.cells.clear();
run.complete = true;
float cursor = 0.0f;
std::size_t i = 0;
while (i < utf8.size()) {
std::uint32_t cp = DecodeUtf8(utf8, i);
if (cp == 0) break;
if (cp == '\n') { continue; }
const Glyph& g = fontAtlas->EnsureAndGet(font, cp);
if (g.w > 0 && g.h > 0) {
if (g.Resident()) {
GlyphItem gi;
gi.x = cursor + g.xoff * scale;
gi.y = g.yoff * scale;
gi.w = g.w * scale;
gi.h = g.h * scale;
gi.u0 = g.u0; gi.v0 = g.v0;
gi.u1 = g.u1; gi.v1 = g.v1;
gi.r = color[0]; gi.g = color[1]; gi.b = color[2]; gi.a = color[3];
run.glyphs.push_back(gi);
run.cells.push_back(g.handle);
} else {
run.complete = false;
}
}
cursor += g.advance * scale;
}
run.advance = cursor;
}
void UIRenderer::InvalidateFont(const Font& font) noexcept {
// Drop matching runs from both the map and the LRU order list, keeping the
// two in sync (the list points at keys owned by the map).
@ -168,6 +192,10 @@ void UIRenderer::InvalidateFont(const Font& font) noexcept {
++it;
}
}
// The atlas's glyphs of the font too: another Font at the same address
// would get their metrics and cells, and the glyphs waiting for a cell
// keep the Font* until the next frame.
if (fontAtlas != nullptr) fontAtlas->InvalidateFont(font);
}
bool UIRenderer::IsShapedRunCached(const Font& font, float pxSize,

View file

@ -58,7 +58,11 @@ void UIRenderer::Initialize(Window& window, GraphicsDescriptorHeap& heap, Graphi
}
void UIRenderer::Record(GraphicsCommandBuffer cmd, std::uint32_t frameIdx, Window& window) {
if (fontAtlas != nullptr && fontAtlas->dirty) {
// The atlas's first call of the frame places the glyphs the last frame
// found no free room for (#55), before onBuild shapes anything. The
// writes after onBuild still land before this frame's submit, ahead of
// every dispatch in it.
if (fontAtlas != nullptr) {
fontAtlas->Update(cmd);
}
onBuild.Invoke({cmd, frameIdx});

View file

@ -66,15 +66,12 @@ void UIRenderer::Record(GraphicsCommandBuffer cmd, std::uint32_t frameIdx, Windo
// Reset per-frame state.
firstDispatchThisFrame_ = true;
// If text is in use, flush any glyphs that user-side ShapeText calls
// produced during a previous frame's onBuild. (Ensure() during the
// current onBuild also marks the atlas dirty; that's flushed on the
// NEXT Record. For v1 this is fine because the current frame's text
// dispatch reads whatever's already been uploaded, and brand-new glyphs
// missing from the atlas this frame will simply render blank for one
// frame and resolve next frame. To get them this frame, the user can
// call atlas->Update(cmd) themselves at the top of onBuild.)
if (fontAtlas != nullptr && fontAtlas->dirty) {
// The atlas's first call of the frame: it gives the glyphs the last frame
// found no free room for their cells, evicting what that frame did not
// draw (#55), and uploads them with whatever else is pending, before
// onBuild shapes anything. Glyphs placed during onBuild are uploaded by
// Dispatch, before the kernel that may sample them.
if (fontAtlas != nullptr) {
fontAtlas->Update(cmd);
}
@ -135,11 +132,6 @@ void UIRenderer::DispatchText(GraphicsCommandBuffer cmd, std::uint32_t bufferSlo
if (!fontAtlasImageSlot_) {
throw std::runtime_error("UIRenderer::DispatchText: no FontAtlas registered (set fontAtlas before Initialize)");
}
// Flush any glyphs that ShapeText calls (during this onBuild) just
// rasterised, so the dispatch below sees them.
if (fontAtlas != nullptr && fontAtlas->dirty) {
fontAtlas->Update(cmd);
}
struct PC {
UIDispatchHeader hdr;
std::uint32_t fontTextureSlot;
@ -179,11 +171,6 @@ void UIRenderer::DispatchFused(GraphicsCommandBuffer cmd,
if (text.itemCount != 0 && !fontAtlasImageSlot_) {
throw std::runtime_error("UIRenderer::DispatchFused: text batch given but no FontAtlas registered (set fontAtlas before Initialize)");
}
// Flush any glyphs ShapeText rasterised during this onBuild, mirroring
// DispatchText — only relevant when there is text to draw.
if (text.itemCount != 0 && fontAtlas != nullptr && fontAtlas->dirty) {
fontAtlas->Update(cmd);
}
UIFusedHeader pc{};
pc.itemBuffers[0] = quads.bufferSlot;
@ -224,6 +211,13 @@ void UIRenderer::DispatchFused(GraphicsCommandBuffer cmd,
void UIRenderer::Dispatch(GraphicsCommandBuffer cmd, const GraphicsComputeShader& shader,
const void* push, std::uint32_t pushBytes,
std::uint32_t gx, std::uint32_t gy, std::uint32_t gz) {
// Upload the glyphs ShapeText placed earlier in this onBuild before any
// kernel runs, the text ones and a Tier 1 one sampling the atlas through
// FontAtlasImageSlot alike: a glyph can sit in a cell another glyph had
// (#55), which would otherwise show that glyph for the frame.
if (fontAtlas != nullptr && fontAtlas->dirty) {
fontAtlas->Update(cmd);
}
if (!firstDispatchThisFrame_) {
// Every UI pass read-modify-writes the same swapchain storage image
// (later passes overdraw earlier ones), so each dispatch must observe

View file

@ -2126,6 +2126,9 @@ void Window::Render() {
if (p) p->Record(/*cmd*/ 0u, currentFrame, *this);
}
Crafter::WebGPU::wgpuFrameEnd();
// After the submit, as on native: what this frame drew is now behind
// every atlas write to come.
Device::frameCounter++;
}
WebGPUCommandEncoderRef Window::StartInit() {