Crafter.Graphics/examples/HelloUI/main.cpp
Jorijn van der Graaf a4be6f1a9c
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>
2026-10-31 19:43:58 +01:00

226 lines
No EOL
8.4 KiB
C++

//SPDX-License-Identifier: MIT
//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
// Smoke test for the Tier 1+2+3 UI architecture. Opens a window, draws a
// background, a button (Tier 3 component), a slider (Tier 3 component), a
// progress bar (Tier 3 component), and a circle that follows the mouse
// (Tier 2 standard shader, dispatched directly). Hit-testing for the button
// and slider is the user's responsibility — see the onMouseMove listener.
#ifndef CRAFTER_GRAPHICS_WINDOW_DOM
#include "vulkan/vulkan.h"
#endif
import Crafter.Graphics;
import Crafter.Event;
import std;
using namespace Crafter;
int main() {
Device::Initialize();
#ifdef CRAFTER_GRAPHICS_WINDOW_DOM
static Window window(1280, 720, "Hello UI");
#else
Window window(1280, 720, "Hello UI");
#endif
auto init = window.StartInit();
GraphicsDescriptorHeap heap;
heap.Initialize(/*images*/ 16, /*buffers*/ 16, /*samplers*/ 4);
window.descriptorHeap = &heap;
Font font("font.ttf");
FontAtlas atlas;
atlas.Initialize(init);
UIRenderer ui;
ui.fontAtlas = &atlas;
ui.Initialize(window, heap, init);
window.passes.push_back(&ui);
// User-owned per-shader buffers. Mapped, written each frame, dispatched
// by the user. Capacity is up to the user; resize means re-Register.
// One set per frame in flight, picked by UIBuildArgs::frameIdx: a frame
// still on the GPU reads its own items, which a later frame's onBuild
// would otherwise be rewriting, with glyphs the font atlas may already
// have moved into cells that frame drew other glyphs in.
struct FrameItems {
GraphicsBuffer<QuadItem, true> quads;
GraphicsBuffer<CircleItem, true> circles;
GraphicsBuffer<GlyphItem, true> glyphs;
BufferSlot quadsSlot, circlesSlot, glyphsSlot;
};
std::array<FrameItems, Window::numFrames> frames;
for (FrameItems& f : frames) {
#ifndef CRAFTER_GRAPHICS_WINDOW_DOM
f.quads.Create(
VK_BUFFER_USAGE_STORAGE_BUFFER_BIT | VK_BUFFER_USAGE_SHADER_DEVICE_ADDRESS_BIT,
VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT, 256);
f.circles.Create(
VK_BUFFER_USAGE_STORAGE_BUFFER_BIT | VK_BUFFER_USAGE_SHADER_DEVICE_ADDRESS_BIT,
VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT, 64);
f.glyphs.Create(
VK_BUFFER_USAGE_STORAGE_BUFFER_BIT | VK_BUFFER_USAGE_SHADER_DEVICE_ADDRESS_BIT,
VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT, 4096);
#else
f.quads.Create(256);
f.circles.Create(64);
f.glyphs.Create(4096);
#endif
f.quadsSlot = ui.RegisterBuffer(f.quads);
f.circlesSlot = ui.RegisterBuffer(f.circles);
f.glyphsSlot = ui.RegisterBuffer(f.glyphs);
}
// Application-side state. Library doesn't track this; the user owns it.
Rect btnRect{};
Rect sliderRect{};
bool hovered = false;
bool sliderHovered = false;
bool dragging = false;
float sliderT = 0.42f;
float progress = 0.0f;
// Hit-testing — purely user code. EventListener objects hold the
// subscription; their destructors unregister at scope exit.
EventListener<void> moveSub(&window.onMouseMove, [&]() {
float mx = window.currentMousePos.x;
float my = window.currentMousePos.y;
hovered = btnRect.Contains(mx, my);
sliderHovered = sliderRect.Contains(mx, my);
if (dragging && sliderRect.w > 0) {
sliderT = std::clamp((mx - sliderRect.x) / sliderRect.w, 0.0f, 1.0f);
}
});
EventListener<void> clickSub(&window.onMouseLeftClick, [&]() {
if (sliderHovered) dragging = true;
});
EventListener<void> releaseSub(&window.onMouseLeftRelease, [&]() {
dragging = false;
});
// Application color palette. No library Theme — just a struct.
ButtonColors btnPalette{
.bg = {0.20f, 0.22f, 0.28f, 1.0f},
.bgHover = {0.30f, 0.55f, 0.95f, 1.0f},
.bgPressed = {0.18f, 0.40f, 0.78f, 1.0f},
.text = {1.0f, 1.0f, 1.0f, 1.0f},
.border = {0.0f, 0.0f, 0.0f, 0.0f},
.cornerRadius = 8.0f,
.borderThickness = 0.0f,
};
SliderColors sliderPalette{
.track = {0.20f, 0.20f, 0.25f, 1.0f},
.trackFilled = {0.30f, 0.55f, 0.95f, 1.0f},
.thumb = {0.85f, 0.85f, 0.90f, 1.0f},
.thumbHover = {1.00f, 1.00f, 1.00f, 1.0f},
.trackHeight = 6.0f,
.thumbRadius = 10.0f,
};
ProgressColors progressPalette{
.bg = {0.15f, 0.15f, 0.18f, 1.0f},
.fill = {0.40f, 0.85f, 0.50f, 1.0f},
.cornerRadius = 4.0f,
};
EventListener<UIBuildArgs> buildSub(&ui.onBuild, [&](UIBuildArgs a) {
auto cmd = a.cmd;
FrameItems& f = frames[a.frameIdx];
// Update demo progress.
progress = std::fmod(progress + 0.005f, 1.0f);
// Layout via SubRect — resize-safe.
Rect canvas = Rect::FromWindow(window);
Rect topBar = canvas.SubRect(80, Rect::Anchor::Top).Inset(20, 20, 10, 20);
btnRect = topBar.SubRect(160, Rect::Anchor::Left);
sliderRect = canvas.Inset(60).SubRect(20, Rect::Anchor::Top);
sliderRect.y = canvas.h * 0.5f;
sliderRect.x = 60.0f;
sliderRect.w = canvas.w - 120.0f;
Rect progressRect{ 60.0f, canvas.h * 0.5f + 60.0f, canvas.w - 120.0f, 16.0f };
// Reset per-frame counters.
std::uint32_t qc = 0, cc = 0, gc = 0;
UIBuffer buf{
.quads = f.quads.value,
.quadCount = &qc,
.quadCap = 256,
.glyphs = f.glyphs.value,
.glyphCount = &gc,
.glyphCap = 4096,
.atlas = &atlas,
.renderer = &ui,
};
// Background quad — required because the swapchain is not cleared
// (no TRANSFER_DST_BIT on the swapchain image).
QuadItem bg{
canvas.x, canvas.y, canvas.w, canvas.h,
0.f, 0, 0, 1.f,
0, 0, 0, 0,
0, 0, 0, 0,
};
if (qc < buf.quadCap) buf.quads[qc++] = bg;
// Tier 3 components.
DrawButton(buf, btnRect,
hovered ? "Hovered!" : "Hover me",
hovered, /*pressed*/ false,
font, 18.0f, btnPalette);
DrawSlider(buf, sliderRect, sliderT, dragging, sliderPalette);
DrawProgressBar(buf, progressRect, progress, progressPalette);
// Tier 2 standard shader, used directly: a circle following the mouse.
f.circles.value[cc++] = CircleItem{
window.currentMousePos.x, window.currentMousePos.y, 6.0f, 0.0f,
1.0f, 1.0f, 1.0f, 0.85f,
0, 0, 0, 0,
};
// Flush the buffers the GPU is about to read, then composite all three
// categories — background quads, the mouse circle, and the button/label
// text — in ONE fused dispatch (issue #47). The frame runs these passes
// back-to-back with no custom shader interleaved, so DispatchFused loads
// and stores the swapchain image once instead of three times and skips
// the two inter-pass barriers DispatchQuads→Circles→Text would insert.
// Canonical order is quads → circles → images → text; here there are no
// images, so that category is a free no-op.
if (qc > 0) {
#ifndef CRAFTER_GRAPHICS_WINDOW_DOM
f.quads.FlushDevice(cmd, VK_ACCESS_SHADER_READ_BIT, VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT);
#else
f.quads.FlushDevice();
#endif
}
if (cc > 0) {
#ifndef CRAFTER_GRAPHICS_WINDOW_DOM
f.circles.FlushDevice(cmd, VK_ACCESS_SHADER_READ_BIT, VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT);
#else
f.circles.FlushDevice();
#endif
}
if (gc > 0) {
#ifndef CRAFTER_GRAPHICS_WINDOW_DOM
f.glyphs.FlushDevice(cmd, VK_ACCESS_SHADER_READ_BIT, VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT);
#else
f.glyphs.FlushDevice();
#endif
}
ui.DispatchFused(cmd,
{f.quadsSlot, qc},
{f.circlesSlot, cc},
{}, // no images this frame
{f.glyphsSlot, gc});
});
window.FinishInit();
window.Render();
window.StartUpdate();
window.StartSync();
}