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

@ -32,7 +32,7 @@ window.StartSync(); // the event loop
- `Update()` computes `mouseDelta` and calls `Render()`, which waits for the fence of its frame slot (three frames in flight, `Window::numFrames`; the slot is `window.currentFrame`), acquires a swapchain image (`window.currentImage`; the swapchain has as many images as its present mode needs, not necessarily three), writes it into this window's swapchain slots of the heap, reclaims deferred deletions, begins `drawCmdBuffers[currentFrame]`, binds the heap (or the descriptor set), fires `onUpdate(FrameTime)`, records every pass in `passes` in order with a barrier on the swapchain image between them, transitions the image to present, submits and presents. Per-frame GPU work of your own (a TLAS rebuild, a physics dispatch) is recorded from `onUpdate` into `window.drawCmdBuffers[window.currentFrame]`; the heaps are bound by then and the passes come after. A `Resize` or `SetPresentMode` from a listener of the frame (an input action ticked in `onUpdate`, say) takes effect after its present. `onResize` fires after a swapchain rebuild (a new size or present mode); the swapchain slots follow by themselves, and anything else that holds the old images or is sized after the window is made again there.
- A `RenderPass` is `Record(cmd, frameIdx, window)` plus `SwapchainStage()`, which must return the stage that touches the swapchain image (`COMPUTE_SHADER`, `RAY_TRACING_SHADER` or `TRANSFER`): the frame's barriers take their stage and access masks from it. `frameIdx` is the frame slot, `window.currentFrame`, not the image: the frame's image is `window.images[window.currentImage]`. The image stays in `VK_IMAGE_LAYOUT_GENERAL` through the whole pass list, with `STORAGE | TRANSFER_SRC` usage and `TRANSFER_DST` where the surface allows it (`window.swapchainUsage`). Nothing clears it: draw a background quad, or clear from a transfer pass where `TRANSFER_DST` is there.
- `window.width` and `height` are physical pixels, as is `currentMousePos`. `CaptureNextFrame(path)` saves the next frame as a PNG. `SetTitle`, `SetAppId`, `SetCursorImage`, `SetDefaultCursor`; `SetTextInput(active, x, y, w, h, purpose)` tells the compositor's input method a text field has focus, which opens an on-screen keyboard on a phone.
- Several windows can exist (on Wayland `Device::windows` lists them; the other backends keep no list), and may share a heap, each writing only its own swapchain slots, but they share one frame clock: one rendering window is the assumption behind the deletion queue and the staging rings.
- Several windows can exist (on Wayland `Device::windows` lists them; the other backends keep no list), and may share a heap, each writing only its own swapchain slots, but they share one frame clock: one rendering window is the assumption behind the deletion queue, the staging rings and the font atlas's evictions. Without a `Window`, nothing advances `Device::frameCounter`: a program that draws text without one steps it itself after each frame's work, or its full atlas never frees a cell and new glyphs stay blank.
### Device and features
@ -51,7 +51,7 @@ Frames are pipelined three deep and nothing waits for the GPU at steady state, s
- `Window` is neither movable nor copyable, and its destructor waits for the device, so a window can be closed and another opened.
- `ImageVulkan` has no destructor: call `Destroy()` once the GPU is done with it. `FontAtlas` waits for the queue and destroys its own image.
- Heap slots (`ImageSlot`, `BufferSlot`, `SamplerSlot`) are RAII handles that return the slot on destruction, and a returned slot is handed out again at once. Free a slot only when no frame in flight reads it (after a `vkQueueWaitIdle`, or at a scene switch), and before its heap goes. In descriptor-set mode a slot write destroys the previous view immediately: same rule.
- `RenderingElement3D::elements` holds raw pointers: `Add`/`Remove` elements whose storage does not move (reserve the vector, or make them `static`). `UIRenderer::ShapeText` and `FontAtlas` cache by `Font*`: call `ui.InvalidateFont(font)` before destroying a `Font`.
- `RenderingElement3D::elements` holds raw pointers: `Add`/`Remove` elements whose storage does not move (reserve the vector, or make them `static`). `UIRenderer::ShapeText` and `FontAtlas` cache by `Font*`: call `ui.InvalidateFont(font)`, which clears both, before destroying or replacing a `Font` (the atlas keeps the `Font*` of a glyph waiting for a cell until the next frame).
### The bindless heap
@ -77,13 +77,13 @@ Setup: `FontAtlas atlas; atlas.Initialize(init);` then `UIRenderer ui; ui.fontAt
Per frame, in an `EventListener<UIBuildArgs>` on `ui.onBuild` (fired from the pass's `Record` with `{cmd, frameIdx}`):
1. Rebuild the item arrays in your mapped buffers, in draw order: later items overdraw earlier ones. Lay out with `Rect::FromWindow(window)`, `SubRect(size, Anchor)` and `Inset(...)`.
1. Rebuild the item arrays in your mapped buffers, in draw order: later items overdraw earlier ones. Lay out with `Rect::FromWindow(window)`, `SubRect(size, Anchor)` and `Inset(...)`. Keep one set of item buffers (and slots) per frame in flight, `Window::numFrames` of them, and fill the one at `frameIdx`: a frame still on the GPU reads its items as it runs, and a later frame's items written into the same buffer can name atlas cells that frame drew other glyphs in (`examples/HelloUI`).
2. `buf.FlushDevice(cmd, VK_ACCESS_SHADER_READ_BIT, VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT)` for each buffer with items.
3. Dispatch: `ui.DispatchQuads(cmd, slot, count, clipRectPx = {0, 0, 1e9, 1e9})`, `DispatchCircles`, `DispatchImages`, `DispatchText`, or `ui.DispatchFused(cmd, {quadsSlot, qc}, {circlesSlot, cc}, {imagesSlot, ic}, {glyphsSlot, gc})`, which composites the four categories in that fixed order (quads, circles, images, text) with one load and one store; an empty category costs nothing. The two forms mix; a custom `ui.Dispatch` between two `DispatchFused` calls is the flush point.
- The item structs are `std430` PODs mirrored byte for byte in `shaders/ui-shared.glsl`, and frozen: `QuadItem` (64 bytes: `x, y, w, h`, `r, g, b, a`, corner radii `cTL, cTR, cBR, cBL`, `outline, oR, oG, oB`), `CircleItem` (48: `cx, cy, radius, _p0`, colour, outline), `ImageItem` (64: rect, `u0, v0, u1, v1`, tint, `texSlot, sampSlot, _p1, _p2`), `GlyphItem` (48: rect, atlas uv, colour). Colours are straight alpha, blended source-over; coordinates are window pixels.
- Cost: every kernel dispatches one workgroup per 8x8 screen tile and each pixel walks every item (behind a per-tile cull), so a dispatch is `O(W*H*N)`, fine up to a few hundred items at 1080p. Splitting one buffer into several dispatches does not help. Thousands of items want a different shader (tile binning), which is Tier 1.
- Text: `Font font("font.ttf")` (TrueType through stb_truetype); `font.GetLineWidth(text, px)`, `LineHeight(px)`, `AscentPx(px)`, `NearestCursorByte(text, px, x)`. The atlas is a 1024 by 1024 SDF atlas at a 32 px base size, filled lazily per codepoint and re-uploaded by dirty rectangle; glyphs scale from it, so very large text goes soft. `ui.ShapeText(font, px, x, baselineY, utf8, color, out, capacity, &advance, TextAlign::Left)` writes `GlyphItem`s: single line, no kerning, no wrapping, a shaped-run cache behind it.
- Text: `Font font("font.ttf")` (TrueType through stb_truetype); `font.GetLineWidth(text, px)`, `LineHeight(px)`, `AscentPx(px)`, `NearestCursorByte(text, px, x)`. The atlas is a 1024 by 1024 SDF atlas at a 32 px base size, filled lazily per codepoint and re-uploaded by dirty rectangle; glyphs scale from it, so very large text goes soft. `ui.ShapeText(font, px, x, baselineY, utf8, color, out, capacity, &advance, TextAlign::Left)` writes `GlyphItem`s: single line, no kerning, no wrapping, a shaped-run cache behind it. Once the atlas is full, a glyph that finds no free room draws blank for one frame but keeps its advance, and gets a cell when the next frame begins, taken from the glyphs drawn least recently, never one drawn in the last `Device::framesInFlight + 1` frames. So a `GlyphItem` holds for the frame it was shaped for: shape text every frame (the Tier 3 components do, and a cached run costs a copy) rather than drawing a glyph buffer filled in an earlier frame. `atlas.EnsureAndGet(font, codepoint)` returns `const Glyph&` and never fails; its UVs hold only when `Resident()`, and a `Lookup` pointer only until the atlas's next `EnsureAndGet`, `Touch` or `Update`. A Tier 1 kernel that samples `FontAtlasImageSlot()` through `ui.Dispatch` sees the frame's new glyphs: every dispatch uploads the atlas first.
- Tier 3: fill a `UIBuffer` (`quads`, `quadCount`, `quadCap`; `glyphs`, `glyphCount`, `glyphCap`; `images`, `imageCount`, `imageCap`; `atlas`; `renderer`; a null pointer makes the components that need it no-ops) and call `DrawButton(buf, rect, label, hovered, pressed, font, px, ButtonColors)`, `DrawCheckbox(buf, rect, checked, hovered, CheckboxColors)`, `DrawSlider(buf, rect, t01, dragging, SliderColors)`, `DrawProgressBar(buf, rect, t01, ProgressColors)`, `DrawText(buf, text, x, baselineY, font, px, color, align)` (returns the advance) and `DrawImage(buf, rect, textureSlot, samplerSlot, tint, uv)`. Colours are small `*Colors` aggregates you hold; there is no `Theme`.
- `InputField` is the model for a stateful component: a POD (`value`, `type`, `focused`, `cursorPos`), free functions you call from your own listeners (`InputField_OnText(field, utf8)` from `onTextInput`, `InputField_OnKey(field, code)` from `onRawKeyDown`, `InputField_HitTestCursor(field, rect, clickX, font, px, colors)`), `InputField_TryParse<T>(field)`, and `DrawInputField(buf, field, rect, font, px, colors, caretVisible)`; the blink is yours.
- Images in the UI: `auto tex = ui.RegisterImage(image, VK_FORMAT_R8G8B8A8_UNORM);` (an `ImageSlot`; the layout defaults to `SHADER_READ_ONLY_OPTIMAL`), `auto samp = ui.RegisterLinearClampSampler();`, then both go into `ImageItem::texSlot` and `sampSlot`. `ui.OutImageSlot()`, `FontAtlasImageSlot()` and `FontAtlasSamplerSlot()` expose the renderer's own slots.