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>
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>
ImageVulkan kept its host-visible staging `buffer` (sized w*h, persistently
mapped) alive for the whole life of the image and Destroy() never freed it, so
static textures (e.g. Sponza albedo) pinned HOST_VISIBLE / small-BAR memory
forever.
Update now releases the staging via VulkanBuffer::DeferredClear() right after
recording the buffer→image copy — the same fence-keyed deletion queue
(#101/#102) the compressed Mesh path already uses — so it is freed once the
upload submit's frame has cleared. A new `streamed` flag (set by FontAtlas)
keeps the persistent map for images re-uploaded from a CPU-side staging buffer
every frame; its uploads go through UpdateRegion, which never releases. Destroy
now also frees any staging the image still owns, gated on a live handle so a
released static texture can't double-free.
Adds tests/ImageStagingRelease driving the real upload + readback on a headless
device: asserts the staging is enqueued (not pinned), the image reads back
byte-equal (released staging outlived the submit), the entry retires only after
framesInFlight frames, and a streamed image keeps then frees its staging.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
ImageVulkan hardcoded VK_PIPELINE_STAGE_RAY_TRACING_SHADER_BIT_KHR as the
consuming stage in every layout transition. The UI font atlas is sampled by
a *compute* shader, so the upload barrier's dst scope never covered the real
consumer — a correctness gap, not just lost overlap.
Parameterize the consuming stage per image: store it once at Create
(defaulting to RT, preserving existing RT/example callers) and reuse it in
every Update / UpdateRegion / compressed-Update transition. FontAtlas passes
VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Per glyph, ShapeText did cache_.contains(key) (in Ensure) then
cache_.find(key) (in Lookup) — two independent hashes + node-chases of
the same key. Add EnsureAndGet returning const Glyph* with a single
find, insert-on-miss, returning &it->second. It returns nullptr only on
ShelfPlace failure, preserving the existing `if (g==nullptr) continue;`
behavior. Ensure now delegates to it.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>