diff --git a/AGENTS.consumer.md b/AGENTS.consumer.md index aa22182..175e3f6 100644 --- a/AGENTS.consumer.md +++ b/AGENTS.consumer.md @@ -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` 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(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. diff --git a/AGENTS.md b/AGENTS.md index 2ad7012..7393157 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -70,7 +70,7 @@ Tests are declared in `project.cpp`, not discovered, through the `LibraryTest(na A test is an `int main()`: exit 0 passes, nonzero fails, 77 skips (for a missing capability, never for a failed check). Each runs in its own process, in parallel, with a 60 s default timeout, and the output of a failed, crashed or timed-out test is in `build/test-logs/.log`; a later pass does not delete the log, so check its timestamp. What the tests need, in three groups: -- Pure logic, no GPU: they stage `Device`'s statics or feed synthetic data to the decision functions that were factored out for them (`Device::ResolveFeatures`, `ChooseDevice`, `CheckShaderCapabilities`, `GetMemoryType`, `PreferDirectDeviceWrite`, `PipelineCacheDataCompatible`, `AlignMappedFlushRange`, `BuildMipChainFinalBarriers`, `SwapchainStageUnion`, `BuildTlasWriteBarrier`, `UIRenderer::ClipFlags`, `Window::SwapchainMinImageCount`, `WorkaroundNvidiaAS::Patch`, `DescriptorHeapVulkan::ComputeLayout`, the font and input-field helpers). `PushConstantRewrite` and `UIFusedShader` compile GLSL with glslang and check the SPIR-V with spirv-val at runtime, and `ExampleShaders` compiles every GLSL shader under `examples/` the way crafter-build would, the descriptor-set build included, so a shader contract change that breaks an example fails here without building the example; `MouseScroll` and `PointerEnterLeave` drive the Wayland listener callbacks directly, and `TitleWayland` reads the requests a window sends off a `wl_display` connected to a socketpair. +- Pure logic, no GPU: they stage `Device`'s statics or feed synthetic data to the decision functions that were factored out for them (`Device::ResolveFeatures`, `ChooseDevice`, `CheckShaderCapabilities`, `GetMemoryType`, `PreferDirectDeviceWrite`, `PipelineCacheDataCompatible`, `AlignMappedFlushRange`, `BuildMipChainFinalBarriers`, `SwapchainStageUnion`, `BuildTlasWriteBarrier`, `UIRenderer::ClipFlags`, `Window::SwapchainMinImageCount`, `WorkaroundNvidiaAS::Patch`, `DescriptorHeapVulkan::ComputeLayout`, `FontAtlas::Packer`, the font and input-field helpers). `PushConstantRewrite` and `UIFusedShader` compile GLSL with glslang and check the SPIR-V with spirv-val at runtime, and `ExampleShaders` compiles every GLSL shader under `examples/` the way crafter-build would, the descriptor-set build included, so a shader contract change that breaks an example fails here without building the example; `MouseScroll` and `PointerEnterLeave` drive the Wayland listener callbacks directly, and `TitleWayland` reads the requests a window sends off a `wl_display` connected to a socketpair. - A headless device: `Device::Initialize()` and no `Window` (`HeapSlotReuse`, `DeferredDeletion`, `MeshLifetime`, `ImageStagingRelease`, `BLASBuildOptions`, the `TLAS*` tests, ...). Headless still means a Wayland display on Linux. Most of the RT ones check `Device::rayTracingSupported` and return 77 without it; `BLASBuildOptions`, `TLASInstanceDirtyTracking` and `MeshDecompressStagingRelease` do not, and crash on a GPU without ray tracing (`Device::RequireRayTracing` throws out of `main`). `DeviceSelection` skips with one GPU. - A window and a live compositor: `FrameLoopSync`, `SetupCmdBufferReuse`, `UIFallbackParity`, `SwapchainTransferDst`, `WindowUpdateClock`, `HeapASRead`, `HeapDynamicIndexHit`, `RTPassPushData`, `WindowOptionalGlobals` (hides xdg-decoration, viewporter and fractional scale from the window in turn), `SwapchainImageCount` (every present mode, and swapchains with more images than frames in flight, in child processes on llvmpipe where the host's GPU never makes one), and `FeatureRequests` for its retry case. They render, read the frame back with `Window::CaptureNextFrame` (a PNG, decoded with a private `STB_IMAGE_STATIC` copy of stb_image) or a transfer pass of their own, and check pixels. @@ -142,9 +142,9 @@ These were decided deliberately. Do not undo them without the maintainer's say-s - Buffer slot indices: `AllocateBufferSlots` returns an index relative to the heap's buffer region, while GLSL `descriptor_heap` SSBO arrays index in buffer-descriptor units from heap byte 0. `BufferSlot` converts to `bufferStartElement + raw`; raw code (the RT examples) passes `heap.bufferStartElement` as a `uint16_t` spec constant, which needs `shaderInt16`. In descriptor-set mode `bufferStartElement` is 0. Image and sampler slots are used as they are. A wrong index reads zeros, which looks like "every item field is zero". - `UIRenderer::RegisterBuffer` refuses a buffer created without `VK_BUFFER_USAGE_SHADER_DEVICE_ADDRESS_BIT`. Freed slots are handed out again at once, so a slot may only be freed when no frame in flight reads it (after `vkQueueWaitIdle`, or at a scene switch). In descriptor-set mode a slot write destroys the previous view immediately. - The swapchain image stays in `VK_IMAGE_LAYOUT_GENERAL` across the pass list and is never cleared; `Window::clearColor` is unused. `swapchainUsage` says whether `TRANSFER_DST` was available. `onResize` fires after a swapchain rebuild; slots listed with `AddSwapchainSlot` follow by themselves, and anything else holding the old images is made again there. A rebuild asked for during a frame (`Resize` or `SetPresentMode` from a listener) waits for that frame's present, since the rest of the frame indexes the swapchain it acquired from. `RenderPass::Record`'s `frameIdx` is the frame slot, not the image. -- `Device::frameCounter` is one clock for every window, so the deletion queue and the staging rings assume one rendering window. +- `Device::frameCounter` is one clock for every window, so the deletion queue and the staging rings assume one rendering window. The font atlas evicts on it too, on both backends: without a `Window` advancing it nothing is ever evicted, and a full atlas leaves new glyphs blank, so a headless test steps it itself (`FontAtlasEviction`). - `ImageVulkan::Create`'s default `consumerStage` is the RT stage, corrected to compute on a device without ray tracing; the font atlas passes compute explicitly. `VulkanTransition`'s `GENERAL` mapping does the same. -- `UIRenderer::ShapeText` and `FontAtlas` key their caches on the `Font*`: call `UIRenderer::InvalidateFont` before destroying a `Font`, or a new one at the same address aliases stale runs. +- `UIRenderer::ShapeText` and `FontAtlas` key their caches on the `Font*`: call `UIRenderer::InvalidateFont`, which clears both, before destroying a `Font`, or a new one at the same address aliases stale runs and glyphs. The atlas also keeps the `Font*` of a glyph waiting for a cell until the next frame's first call, where it rasterises from it. - `RenderingElement3D::elements` stores raw pointers; a `std::vector` that grows after `Add` dangles them (Sponza reserves up front). `BuildTLAS` on an index whose earlier submission has not completed overwrites a TLAS in use; building `tlases[window.currentFrame]` from `onUpdate` is safe, and `tlases[i].address` changes when a TLAS outgrows its storage. - `PipelineRTVulkan::Init` defaults `maxRecursionDepth` to 1; a closest-hit that traces needs 2, and tracing deeper than the pipeline allows loses the device. - `BuildTlasWriteBarrier`'s source access mask carries no read bit on purpose: synchronization validation 1.4.363 otherwise reports two builds in one command buffer as a write-after-write hazard. diff --git a/README.md b/README.md index 78afdab..a9be05e 100644 --- a/README.md +++ b/README.md @@ -174,6 +174,17 @@ shaded through an intersection shader with an any-hit cut-out. native and as WGSL strings baked into the WebGPU dispatcher. - **FontAtlas** — single-channel SDF atlas (1024×1024, 32pt base, shelf-packed, lazy `Ensure` per codepoint, dirty-flush via `Update`). + Once it is full, a glyph that finds no free room draws blank for a + frame but keeps its advance; when the next frame begins it gets a cell, + rasterised then, taken from the glyphs drawn least recently but never + one drawn in the last `Device::framesInFlight + 1` frames + (`Device::frameCounter`, which a `Window` advances). Evicting only then, + and not while a frame is still asking for glyphs, keeps a frame from + evicting glyphs it draws further down. A glyph taller than the shelves + spans several. A `GlyphItem` is good for the frame it was shaped for: + shape text every frame, as the Tier 3 components do (`ShapeText`'s run + cache makes that a copy), into item buffers of the frame's own, one per + frame in flight (`UIBuildArgs::frameIdx`, as in HelloUI). Backend-agnostic. - **Mesh / RenderingElement3D / Animation** — BLAS/TLAS construction and 3D scene plumbing. Vulkan calls `vkCmdBuildAccelerationStructures`; diff --git a/examples/HelloUI/main.cpp b/examples/HelloUI/main.cpp index d9d4bc9..8988f77 100644 --- a/examples/HelloUI/main.cpp +++ b/examples/HelloUI/main.cpp @@ -42,28 +42,37 @@ int main() { // User-owned per-shader buffers. Mapped, written each frame, dispatched // by the user. Capacity is up to the user; resize means re-Register. - GraphicsBuffer quadsBuf; - GraphicsBuffer circlesBuf; - GraphicsBuffer glyphsBuf; + // 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 quads; + GraphicsBuffer circles; + GraphicsBuffer glyphs; + BufferSlot quadsSlot, circlesSlot, glyphsSlot; + }; + std::array frames; + for (FrameItems& f : frames) { #ifndef CRAFTER_GRAPHICS_WINDOW_DOM - quadsBuf.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); - circlesBuf.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); - glyphsBuf.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); + 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 - quadsBuf.Create(256); - circlesBuf.Create(64); - glyphsBuf.Create(4096); + f.quads.Create(256); + f.circles.Create(64); + f.glyphs.Create(4096); #endif - - auto quadsSlot = ui.RegisterBuffer(quadsBuf); - auto circlesSlot = ui.RegisterBuffer(circlesBuf); - auto glyphsSlot = ui.RegisterBuffer(glyphsBuf); + 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{}; @@ -118,6 +127,7 @@ int main() { EventListener 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); @@ -136,10 +146,10 @@ int main() { std::uint32_t qc = 0, cc = 0, gc = 0; UIBuffer buf{ - .quads = quadsBuf.value, + .quads = f.quads.value, .quadCount = &qc, .quadCap = 256, - .glyphs = glyphsBuf.value, + .glyphs = f.glyphs.value, .glyphCount = &gc, .glyphCap = 4096, .atlas = &atlas, @@ -167,7 +177,7 @@ int main() { DrawProgressBar(buf, progressRect, progress, progressPalette); // Tier 2 standard shader, used directly: a circle following the mouse. - circlesBuf.value[cc++] = CircleItem{ + 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, @@ -183,30 +193,30 @@ int main() { // images, so that category is a free no-op. if (qc > 0) { #ifndef CRAFTER_GRAPHICS_WINDOW_DOM - quadsBuf.FlushDevice(cmd, VK_ACCESS_SHADER_READ_BIT, VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT); + f.quads.FlushDevice(cmd, VK_ACCESS_SHADER_READ_BIT, VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT); #else - quadsBuf.FlushDevice(); + f.quads.FlushDevice(); #endif } if (cc > 0) { #ifndef CRAFTER_GRAPHICS_WINDOW_DOM - circlesBuf.FlushDevice(cmd, VK_ACCESS_SHADER_READ_BIT, VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT); + f.circles.FlushDevice(cmd, VK_ACCESS_SHADER_READ_BIT, VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT); #else - circlesBuf.FlushDevice(); + f.circles.FlushDevice(); #endif } if (gc > 0) { #ifndef CRAFTER_GRAPHICS_WINDOW_DOM - glyphsBuf.FlushDevice(cmd, VK_ACCESS_SHADER_READ_BIT, VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT); + f.glyphs.FlushDevice(cmd, VK_ACCESS_SHADER_READ_BIT, VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT); #else - glyphsBuf.FlushDevice(); + f.glyphs.FlushDevice(); #endif } ui.DispatchFused(cmd, - {quadsSlot, qc}, - {circlesSlot, cc}, - {}, // no images this frame - {glyphsSlot, gc}); + {f.quadsSlot, qc}, + {f.circlesSlot, cc}, + {}, // no images this frame + {f.glyphsSlot, gc}); }); window.FinishInit(); diff --git a/implementations/Crafter.Graphics-FontAtlas.cpp b/implementations/Crafter.Graphics-FontAtlas.cpp index f354b5b..6b939f5 100644 --- a/implementations/Crafter.Graphics-FontAtlas.cpp +++ b/implementations/Crafter.Graphics-FontAtlas.cpp @@ -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& 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(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 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& 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(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& 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& 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(above - shelves_.begin()) - 1; +} + +bool FontAtlas::Packer::Touch(std::span 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(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(x0 - FontAtlas::kPadding); + g.yoff = static_cast(y0 - FontAtlas::kPadding); + g.w = static_cast(x1 - x0 + 2 * FontAtlas::kPadding); + g.h = static_cast(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(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(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 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(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(g.w), static_cast(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(row) * kAtlasSize + c.x, 0, static_cast(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(key.cp)); int sw = 0, sh = 0, xoff = 0, yoff = 0; - unsigned char* sdf = stbtt_GetCodepointSDF( - &font.font, fontScale, static_cast(codepoint), + unsigned char* sdf = stbtt_GetGlyphSDF( + &font.font, fontScale, index, kPadding, static_cast(kOnEdgeValue), kPixelDistScale, &sw, &sh, &xoff, &yoff ); - - Glyph g{}; - g.advance = advanceUnits * fontScale; - g.xoff = static_cast(xoff); - g.yoff = static_cast(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(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(cell.y + row) * kAtlasSize + cell.x, + sdf + row * sw, + static_cast(sw) + ); + } } stbtt_FreeSDF(sdf, nullptr); - - g.w = static_cast(sw); - g.h = static_cast(sh); - g.u0 = static_cast(px) / kAtlasSize; - g.v0 = static_cast(py) / kAtlasSize; - g.u1 = static_cast(px + sw) / kAtlasSize; - g.v1 = static_cast(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(cell.x) / kAtlasSize; + g.v0 = static_cast(cell.y) / kAtlasSize; + g.u1 = static_cast(cell.x + cell.w) / kAtlasSize; + g.v1 = static_cast(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 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; } diff --git a/implementations/Crafter.Graphics-UI-Shared.cpp b/implementations/Crafter.Graphics-UI-Shared.cpp index ce5fa13..9b8fef9 100644 --- a/implementations/Crafter.Graphics-UI-Shared.cpp +++ b/implementations/Crafter.Graphics-UI-Shared.cpp @@ -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 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, diff --git a/implementations/Crafter.Graphics-UI-WebGPU.cpp b/implementations/Crafter.Graphics-UI-WebGPU.cpp index bd92ea6..18af26e 100644 --- a/implementations/Crafter.Graphics-UI-WebGPU.cpp +++ b/implementations/Crafter.Graphics-UI-WebGPU.cpp @@ -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}); diff --git a/implementations/Crafter.Graphics-UI.cpp b/implementations/Crafter.Graphics-UI.cpp index 420d6e8..d661f3f 100644 --- a/implementations/Crafter.Graphics-UI.cpp +++ b/implementations/Crafter.Graphics-UI.cpp @@ -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 diff --git a/implementations/Crafter.Graphics-Window.cpp b/implementations/Crafter.Graphics-Window.cpp index 29601e6..af7c63a 100644 --- a/implementations/Crafter.Graphics-Window.cpp +++ b/implementations/Crafter.Graphics-Window.cpp @@ -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() { diff --git a/interfaces/Crafter.Graphics-Device.cppm b/interfaces/Crafter.Graphics-Device.cppm index 99aa859..a277188 100644 --- a/interfaces/Crafter.Graphics-Device.cppm +++ b/interfaces/Crafter.Graphics-Device.cppm @@ -574,6 +574,14 @@ export namespace Crafter { // cross-platform. Browser key repeat is delivered through the // KeyboardEvent.repeat flag directly — no manual synthesis. static void TickKeyRepeats() {} + + // The frame clock, as on native: bumped at the end of every + // Window::Render. The font atlas evicts by it (#55). One frame in + // flight: a frame's queue.writeTexture runs before that frame's own + // submit but after every earlier one, so only the frame being built + // can still sample a texel the atlas writes. + inline static std::atomic frameCounter = 0; + inline static std::uint8_t framesInFlight = 1; #endif }; } diff --git a/interfaces/Crafter.Graphics-FontAtlas.cppm b/interfaces/Crafter.Graphics-FontAtlas.cppm index f5f2ea1..724df5d 100644 --- a/interfaces/Crafter.Graphics-FontAtlas.cppm +++ b/interfaces/Crafter.Graphics-FontAtlas.cppm @@ -17,15 +17,31 @@ import :WebGPU; #endif export namespace Crafter { + // A glyph's cell in the atlas, as of when the glyph got it. Evicting the + // glyph bumps the cell's generation, so a handle kept across frames (a + // shaped run keeps one per glyph it draws) can tell that its cell, and the + // UVs into it, now belong to another glyph. + struct GlyphHandle { + static constexpr std::uint32_t kNone = ~std::uint32_t{0}; + std::uint32_t slot = kNone; + std::uint32_t generation = 0; + }; + // Per-glyph metrics. UVs are 0..1 in atlas space; on-screen sizes / // offsets / advance are in *atlas pixels at the base size* and scale - // linearly with the requested font size at draw time. + // linearly with the requested font size at draw time. w and h are 0 for a + // glyph with no ink (a space). A glyph with ink that is not Resident() + // found no room in the atlas: its UVs mean nothing, it draws blank, and its + // advance and box still hold, so the text around it keeps its layout. struct Glyph { float u0 = 0, v0 = 0; float u1 = 0, v1 = 0; float w = 0, h = 0; float xoff = 0, yoff = 0; float advance = 0; + GlyphHandle handle; + + bool Resident() const noexcept { return handle.slot != GlyphHandle::kNone; } }; class FontAtlas { @@ -80,6 +96,121 @@ export namespace Crafter { } }; + // Where glyphs go in the atlas, and which of them give up their cells + // once it is full (#55). Glyphs sit on shelves: rows as tall as the + // glyph that opened them, filled left to right. A glyph goes into the + // first gap wide enough on a shelf at least as tall, else onto a new + // shelf below the last. When there is neither, it takes the same + // columns on one shelf at least as tall, or on several in a row that + // are as tall together (the free rows below the last shelf counting + // as one more), from the first one's top: free texels, and, when the + // caller allows it, the cells of glyphs no frame in flight uses, the + // least recently used first. A glyph spanning shelves is a glyph on + // each. Without that, a glyph taller than the shelves opened before + // the rows ran out would never fit, and a few glyphs drawn every + // frame would pin every shelf they sit on rather than just their + // columns. + // A glyph stays in flight for framesInFlight + 1 frames after it was + // used, the frame of its use included: a frame's GlyphItems hold its + // UVs, and its uploads and dispatches may not have run until the + // frame loop has waited its fence, at the start of the frame + // framesInFlight later. Device::frameCounter already reads that frame + // from the end of the frame before, when the deletion queue has not + // yet moved on, and text can be shaped in between (an input callback, + // onBeforeUpdate). Pure CPU and pixel-free, so a test drives it with + // made-up boxes; FontAtlas rasterises into the cells and clears what + // it reports. + class Packer { + public: + struct Cell { int x = 0, y = 0, w = 0, h = 0; }; + + explicit Packer(int size = kAtlasSize) : size_(size) {} + + // A w×h cell for a glyph used in frame `now`, as a slot, or + // GlyphHandle::kNone when nothing can make room. With `evict`, it + // may take cold glyphs' cells: Evicted() then lists the slots it + // took, whose glyphs are gone, and Cleared() the texels they + // covered, for the caller to zero, since the new glyph covers only + // part of them and a neighbour's filtering must not pick up a stale + // glyph. The shelves' dirty spans already include both. A failure + // evicts nothing. + std::uint32_t Allocate(int w, int h, std::uint64_t now, std::uint32_t framesInFlight, bool evict); + std::span Evicted() const noexcept { return evicted_; } + std::span Cleared() const noexcept { return cleared_; } + + // The glyph in `slot` was used in frame `now`. + void Touch(std::uint32_t slot, std::uint64_t now) noexcept { slots_[slot].lastUse = now; } + // The same for the glyph of every handle that still holds its + // cell; false when one doesn't. All of them, not only those before + // the first that doesn't: the caller shapes the run again, and the + // run's own glyphs must not then look cold to its missing ones. + bool Touch(std::span handles, std::uint64_t now) noexcept; + + GlyphHandle Handle(std::uint32_t slot) const noexcept { return { slot, slots_[slot].generation }; } + const Cell& CellOf(std::uint32_t slot) const noexcept { return slots_[slot].cell; } + + // Hands `upload` each shelf's span of texels changed since it was + // last uploaded; `upload` copies the span up and resets it. + template + void ForEachDirty(F&& upload) { + for (Shelf& s : shelves_) upload(s.dirty); + } + + private: + struct Slot { + Cell cell; + std::uint64_t lastUse = 0; + std::uint32_t generation = 0; + }; + // A span per shelf (#129) stays a short run of rows, where one box + // over every shelf written in a frame would take in the rows + // between them too. + struct Shelf { + int y = 0; + int height = 0; + // Its glyphs, left to right: each covers the shelf's top row, + // so their columns never overlap. + std::vector slots; + DirtyRect dirty; + }; + + int size_; + // The shelves cover rows [0, nextShelfY_), each starting where the + // one above ends, and stay as they are opened; the rows below are + // free, and zero. + int nextShelfY_ = 0; + std::vector shelves_; + std::vector slots_; + std::vector freeSlots_; + std::vector evicted_; + std::vector cleared_; + std::vector run_; // Allocate's scratch: the glyphs on a run of shelves + // The boxes that found no room in this frame, none holding another, + // with eviction and without. Within a frame no glyph turns cold, + // a placement only turns free or cold texels into its own glyph's, + // and a shelf opens on free rows that the runs above it could + // already reach (a box that fails is taller than they are), so the + // room a box could get only shrinks. A box at least as wide and as + // tall as one of these fails at once, so the glyphs of a frame that + // doesn't fit don't each search every shelf. An eviction frees room + // for placements without one, which forget theirs. + struct Box { int w = 0, h = 0; }; + std::vector failedEvicting_; + std::vector failedFree_; + std::uint64_t failedFrame_ = ~std::uint64_t{0}; + std::uint32_t failedDepth_ = 0; + + // A cell at column x on shelves [first, last], from first's top. + std::uint32_t Insert(std::size_t first, std::size_t last, int x, int w, int h, std::uint64_t now); + // Takes the glyph in `slot` off every shelf it is on, lists its + // cell as cleared and dirty, and frees the slot for another glyph. + void Evict(std::uint32_t slot); + // Opens a shelf `height` rows tall below the last. + void OpenShelf(int height); + // The shelf whose rows hold row y. + std::size_t ShelfAt(int y) const noexcept; + }; + #ifndef CRAFTER_GRAPHICS_WINDOW_DOM ImageVulkan image; #else @@ -90,7 +221,7 @@ export namespace Crafter { // renderer polls each frame; it is the OR of every dirty span Update // copies. `dirtyRect` is the *whole-atlas* span — used only for the // one-shot zero-clear in Initialize; per-glyph dirt is tracked tight - // per shelf (Shelf::dirty) instead of inflating one tall union box + // per shelf (in the Packer) instead of inflating one tall union box // across scattered shelves (#129). `dirty` and the spans are always // armed and cleared together. bool dirty = false; @@ -116,13 +247,38 @@ export namespace Crafter { // Same shape on both backends. std::uint8_t* PixelPtr() noexcept; + // The glyph for `codepoint`: its metrics, cached per (Font*, + // codepoint), and, when it has ink, a cell it is rasterised into on + // first use if there is free room. Marks the glyph used in the current + // frame (Device::frameCounter), which keeps its cell its own while a + // frame in flight may sample it. Never fails: a glyph that finds no + // free room comes back not Resident(), keeping its advance and box, + // and draws blank. It waits for the next frame, whose first call here, + // to Touch or to Update gives it a cell, evicting what the Packer + // allows, once every glyph the frame before drew has been marked: an + // eviction made while the frame is still asking for glyphs would take + // ones it draws further down, which would then take others in turn. + // The atlas keeps the Font* of a waiting glyph until then, so call + // InvalidateFont before destroying a Font. The reference holds for + // the frame: a glyph is evicted no sooner than framesInFlight + 1 + // frames after its last use. + const Glyph& EnsureAndGet(Font& font, std::uint32_t codepoint); + // EnsureAndGet, saying whether the glyph can be drawn: it has a cell, + // or no ink to put in one. bool Ensure(Font& font, std::uint32_t codepoint); - // Ensure the glyph is in the atlas and return a pointer to it in one - // hash+probe. Returns nullptr only when a brand-new glyph cannot be - // placed (ShelfPlace failure); a glyph with no SDF coverage (e.g. - // space) still returns a valid pointer. - const Glyph* EnsureAndGet(Font& font, std::uint32_t codepoint); + // The cached glyph, or nullptr; neither places it nor marks it used. + // The pointer holds until the next EnsureAndGet, Touch or Update, + // which may evict the glyph and drop it. const Glyph* Lookup(Font& font, std::uint32_t codepoint) const; + // Marks used in the current frame the glyphs whose handles a caller + // kept (UIRenderer's shaped runs). False when one of them has been + // evicted since: whatever was built from its UVs must be built again. + bool Touch(std::span handles); + // Forgets every glyph of `font`: its metrics, and the glyphs waiting + // for a cell, whose Font* the atlas would otherwise read next frame. + // Their cells stay until they are evicted, as a frame in flight may + // still draw them. UIRenderer::InvalidateFont calls it. + void InvalidateFont(const Font& font) noexcept; void Update(GraphicsCommandBuffer cmd); // Flag the [x, x+w) × [y, y+h) atlas region as needing re-upload, @@ -132,15 +288,13 @@ export namespace Crafter { dirty = !dirtyRect.Empty(); } - private: - // 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 shelves_; - int nextShelfY_ = 0; + // How many glyphs have been rasterised into a cell, counting a glyph + // again each time it comes back after an eviction. For tests: a glyph + // that finds no room must not be rasterised for nothing, nor one the + // same frame draws further down evicted for it. + std::uint64_t GlyphsRasterized() const noexcept { return rasterized_; } + private: struct Key { const Font* font; std::uint32_t cp; @@ -153,10 +307,28 @@ export namespace Crafter { return h1 ^ (h2 + 0x9e3779b9 + (h1 << 6) + (h1 >> 2)); } }; - std::unordered_map cache_; + struct Entry { + Glyph glyph; + std::uint64_t waiting = ~std::uint64_t{0}; // the frame it last joined waiting_ in + }; + // A glyph with no cell stays here, measured, so that it keeps its + // layout and is not measured again; an evicted glyph is dropped. + std::unordered_map cache_; + // owners_[slot]: the glyph holding a cell, to drop when it is evicted. + std::vector owners_; + // The glyphs asked for since the last Settle that found no free room, + // in the order they were first asked for. + std::vector waiting_; + std::uint64_t settled_ = ~std::uint64_t{0}; // the frame Settle last ran for + Packer packer_; + std::uint64_t rasterized_ = 0; - // 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); + // Gives the glyphs in waiting_ cells, evicting for them, at a frame's + // first EnsureAndGet, Touch or Update. + void Settle(std::uint64_t now); + // Asks the packer for a cell for `e`, drops and clears the glyphs it + // evicted for it, and rasterises `e` into the cell it got. False when + // it got none. + bool Place(const Font& font, const Key& key, Entry& e, std::uint64_t now, bool evict); }; } diff --git a/interfaces/Crafter.Graphics-UI.cppm b/interfaces/Crafter.Graphics-UI.cppm index ecba244..c08306d 100644 --- a/interfaces/Crafter.Graphics-UI.cppm +++ b/interfaces/Crafter.Graphics-UI.cppm @@ -296,10 +296,24 @@ export namespace Crafter { // // The origin-relative shaped run is memoized in a CPU cache keyed by // (Font*, pxSize, color, utf8); a hit skips UTF-8 decode, glyph - // lookup, and layout, translating the cached run into `out` instead. - // Caching is byte-equivalent — the glyph buffer is fully rewritten - // and re-flushed every frame regardless. Call InvalidateFont before - // destroying a Font (the cache keys on the Font pointer). + // lookup, and layout, translating the cached run into `out` instead, + // and marks the run's glyphs used in this frame so the atlas keeps + // them. Caching is byte-equivalent — the glyph buffer is fully + // rewritten and re-flushed every frame regardless. A run is shaped + // again when the atlas has evicted one of its glyphs, or when it was + // shaped while a glyph had no room (which draws blank but keeps its + // advance, and gets a cell when the next frame begins). Call + // InvalidateFont before destroying a Font (the cache keys on the Font + // pointer). + // + // The GlyphItems written are good for the frame they are shaped for: + // once the atlas is full it gives a glyph's cell to another glyph + // framesInFlight + 1 frames after the glyph was last used. Shape text + // again every frame, as the Tier 3 components do (a hit costs a + // copy), into an item buffer of the frame's own, one per frame in + // flight by UIBuildArgs::frameIdx: a frame in flight reads its items + // as it runs, and a later frame's items written over them can name + // cells that frame drew other glyphs in. std::uint32_t ShapeText(Font& font, float pxSize, float x, float baselineY, std::string_view utf8, @@ -308,10 +322,12 @@ export namespace Crafter { float* outAdvance = nullptr, TextAlign align = TextAlign::Left); - // Drop every cached shaped run that references `font`. Must be called - // before a Font is destroyed: the run cache (and FontAtlas's glyph - // cache) key on the Font pointer, so a Font freed and a new one - // reallocated at the same address would otherwise alias stale runs. + // Drop every cached shaped run that references `font`, and the font + // atlas's glyphs of it (FontAtlas::InvalidateFont). Must be called + // before a Font is destroyed or replaced: both caches key on the Font + // pointer, so a Font at the same address would otherwise alias stale + // runs and glyphs, and the atlas keeps the Font* of a glyph waiting + // for a cell until the next frame. void InvalidateFont(const Font& font) noexcept; // Shaped-run cache introspection (issue #123). Hits/misses are @@ -400,11 +416,21 @@ export namespace Crafter { struct ShapedRun { std::vector glyphs; // origin-relative float advance = 0; + // The atlas cells `glyphs` sample, for a hit to mark them used + // and to find out whether one has gone to another glyph (#55). + std::vector cells; + // False when a glyph with ink had no room in the atlas and draws + // blank: the run is shaped again on every hit until it fits. + bool complete = true; // Position of this run's key in shapedRunsLru_ (front = most // recently used). Lets a hit splice the entry to the front and an // overflow pop the least-recently-used entry, both O(1). std::list::iterator lruIt{}; }; + // Shapes `utf8` into `run` from scratch, origin-relative, asking the + // atlas for every glyph. + void ShapeRun(Font& font, float pxSize, std::string_view utf8, + std::array color, ShapedRun& run); // Soft cap. A pathological caller drawing a fresh string every frame // would grow this without bound; on overflow we evict the single // least-recently-used run (issue #123) rather than clearing the whole diff --git a/project.cpp b/project.cpp index 2a7c54e..312f7b1 100644 --- a/project.cpp +++ b/project.cpp @@ -408,6 +408,18 @@ extern "C" Configuration CrafterBuildProject(std::span a parityTest.config.files.emplace_back(fs::path("examples/HelloUI/font.ttf")); cfg.tests.push_back(std::move(parityTest)); + // Issue #55: a glyph ShapeText places may take a cell another + // glyph had, so every kernel UIRenderer dispatches uploads the font + // atlas first, a Tier 1 one through ui.Dispatch included. Shapes + // new glyphs in onBuild and checks the atlas is clean after a + // custom ui.Dispatch. Loads the UI kernels like UIFallbackParity. + // Needs the Wayland backend + a real compositor. + Test atlasFlushTest = LibraryTest("UIDispatchAtlasFlush"); + atlasFlushTest.config.shaders = cfg.shaders; + atlasFlushTest.config.buildFiles = cfg.buildFiles; + atlasFlushTest.config.files.emplace_back(fs::path("examples/HelloUI/font.ttf")); + cfg.tests.push_back(std::move(atlasFlushTest)); + // Issue #160: a swapchain has as many images as its present mode // needs, and the frame loop takes any count. Every offered mode // is kept (no fallback to FIFO), and every frame presents what it @@ -538,10 +550,19 @@ extern "C" Configuration CrafterBuildProject(std::span a // text on both backends). cfg.tests.push_back(LibraryTest("FontAtlasDirtyRect")); + // Issue #55: FontAtlas::Packer, the atlas's shelves and its evictions + // once full: the fill order, least recently used first, never a glyph + // a frame in flight may sample, nothing without `evict`, a glyph taller + // than every shelf spanning several (and the free rows below), Touch + // past a stale handle, dirty spans over everything it hands out or + // clears, and the invariants over a random workload. Pure CPU, with + // made-up boxes: no GPU device at runtime. + cfg.tests.push_back(LibraryTest("FontAtlasPacker")); + // Issue #52: shaped-run cache for UIRenderer::ShapeText. Shapes a // string against a real CPU-side FontAtlas and asserts that a cache - // hit is byte-equivalent to the uncached path, that hits don't touch - // the atlas, and that translate / alignment / truncation / + // hit is byte-equivalent to the uncached path, that hits rasterise + // nothing into the atlas, and that translate / alignment / truncation / // InvalidateFont all behave. Needs a headless Vulkan device (the // atlas image is a real GPU image) but no swapchain — same native // build settings as the others. The font file is copied next to the @@ -550,6 +571,19 @@ extern "C" Configuration CrafterBuildProject(std::span a textTest.config.files = { fs::path("tests/ShapeTextCache/font.ttf") }; cfg.tests.push_back(std::move(textTest)); + // Issue #55: a glyph without free room keeps its advance, is not + // rasterised until it gets a cell, and gets one when the next frame + // begins, from the glyphs drawn least recently but never one a frame + // in flight may sample. Fills the atlas through ShapeText frame by + // frame on Device::frameCounter and checks the layout, the frames in + // flight, the order of the evictions, the recovery, the cleared + // texels and InvalidateFont. Needs a headless Vulkan device (the + // atlas is a real GPU image) and the ShapeTextCache font, which covers + // enough codepoints to fill the atlas. + Test evictionTest = LibraryTest("FontAtlasEviction"); + evictionTest.config.files = { fs::path("tests/ShapeTextCache/font.ttf") }; + cfg.tests.push_back(std::move(evictionTest)); + // Issue #59: Device::GetMemoryType gained a `preferred` mask and a // required-only fallback so callers combining a mandatory flag with a // perf-only one (HOST_VISIBLE | DEVICE_LOCAL descriptor heaps) no diff --git a/tests/FontAtlasEviction/main.cpp b/tests/FontAtlasEviction/main.cpp new file mode 100644 index 0000000..bdc737c --- /dev/null +++ b/tests/FontAtlasEviction/main.cpp @@ -0,0 +1,399 @@ +//SPDX-License-Identifier: LGPL-3.0-only +//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® + +// Issue #55: the font atlas packed glyphs onto shelves and never gave a place +// back. Once its 1024x1024 image was full, EnsureAndGet returned nullptr for +// every new glyph before caching anything, so each new string holding one +// rasterised it again, and ShapeText skipped it together with its advance: the +// rest of the line closed up over the gap, and stayed that way. Now a glyph +// that finds no free room keeps its metrics and draws blank, and when the next +// frame begins it gets a cell, taken from the glyphs drawn least recently but +// never from one drawn in the last framesInFlight + 1 frames. +// +// Frames are counted on Device::frameCounter with three frames in flight, as a +// Window counts them. Lines 1 to 30 fill the atlas first, then line 0, then +// lines up to the first that no longer fits (line F), all in frame A; frame +// A+1 draws them all again but line 0, the oldest from then on, though not the +// first one found. What is asserted: +// - the line that no longer fits reports its whole advance, and the glyphs +// it does draw sit where an atlas with room puts them +// - its missing glyphs are cached without a cell, and not rasterised, by +// another string holding them nor in the frames everything is in flight +// - in frame A+4, when only line 0 is cold, line F's glyphs get cells, and +// only line 0's glyphs give way; they are rasterised once each +// - a frame's own misses evict nothing in that frame: the glyphs it drew +// earlier keep their cells +// - in frame A+5, when frame A+1's glyphs are cold too, line F and twenty +// more lines are whole, pixel for pixel, a glyph drawn in a frame still in +// flight keeps its cell, and the evictions take a larger share of line +// 0's glyphs than of frame A+1's: the least recently used go first +// - a cached run whose glyphs were evicted is shaped again, pixel for pixel +// - every atlas texel outside a resident glyph is zero: an evicted cell is +// cleared, so a neighbour's filtering never picks up a stale glyph +// - UIRenderer::InvalidateFont forgets the font's glyphs in the atlas too +// +// Needs a headless Vulkan device (the atlas is a real GPU image) and the +// ShapeTextCache font, which covers enough codepoints to fill the atlas. + +#include "vulkan/vulkan.h" +#include "../../lib/stb_truetype.h" +#include + +import Crafter.Graphics; +import std; + +using namespace Crafter; + +namespace { + +int failures = 0; + +void Check(bool ok, std::string_view what) { + std::println("{} {}", ok ? "PASS" : "FAIL", what); + if (!ok) ++failures; +} + +VkCommandBuffer BeginCmd() { + VkCommandBufferAllocateInfo allocInfo { + .sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_ALLOCATE_INFO, + .commandPool = Device::commandPool, + .level = VK_COMMAND_BUFFER_LEVEL_PRIMARY, + .commandBufferCount = 1, + }; + VkCommandBuffer cmd = VK_NULL_HANDLE; + Device::CheckVkResult(vkAllocateCommandBuffers(Device::device, &allocInfo, &cmd)); + VkCommandBufferBeginInfo beginInfo { + .sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO, + .flags = VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT, + }; + Device::CheckVkResult(vkBeginCommandBuffer(cmd, &beginInfo)); + return cmd; +} + +void SubmitWait(VkCommandBuffer cmd) { + Device::CheckVkResult(vkEndCommandBuffer(cmd)); + VkSubmitInfo submitInfo { + .sType = VK_STRUCTURE_TYPE_SUBMIT_INFO, + .commandBufferCount = 1, + .pCommandBuffers = &cmd, + }; + Device::CheckVkResult(vkQueueSubmit(Device::queue, 1, &submitInfo, VK_NULL_HANDLE)); + Device::CheckVkResult(vkQueueWaitIdle(Device::queue)); + vkFreeCommandBuffers(Device::device, Device::commandPool, 1, &cmd); +} + +std::filesystem::path FindFont() { + // cfg.files copies the font next to the binary, but `crafter-build test` + // may also run from the project root. + for (const char* cand : { + "font.ttf", + "tests/ShapeTextCache/font.ttf", + "../../tests/ShapeTextCache/font.ttf" }) { + if (std::filesystem::exists(cand)) return cand; + } + return "font.ttf"; +} + +constexpr int kSize = FontAtlas::kAtlasSize; +constexpr float kPx = 24.0f; +constexpr std::size_t kLineLength = 24; + +void AppendUtf8(std::string& s, std::uint32_t cp) { + if (cp < 0x80) { + s += static_cast(cp); + } else if (cp < 0x800) { + s += static_cast(0xC0 | (cp >> 6)); + s += static_cast(0x80 | (cp & 0x3F)); + } else { + s += static_cast(0xE0 | (cp >> 12)); + s += static_cast(0x80 | ((cp >> 6) & 0x3F)); + s += static_cast(0x80 | (cp & 0x3F)); + } +} + +// Lines of kLineLength codepoints that the font maps and that have ink at the +// atlas's base size, so a line wholly in the atlas draws one GlyphItem per +// codepoint. The ink test is the one stbtt_GetGlyphSDF makes. +std::vector InkedLines(Font& font, std::vector>& codepoints) { + const float scale = stbtt_ScaleForPixelHeight(&font.font, FontAtlas::kBaseSize); + std::vector pool; + for (auto [lo, hi] : { std::pair{0x21u, 0x7Eu}, {0xA1u, 0x377u}, {0x3A3u, 0x3E1u}, + {0x3F0u, 0x52Fu}, {0x1D00u, 0x1DF9u}, {0x1DFBu, 0x1F15u}, + {0x1F80u, 0x1FB4u}, {0x2000u, 0x2064u}, {0x2100u, 0x215Fu}, + {0x2DE0u, 0x2E5Du}, {0xA640u, 0xA69Fu}, {0xA700u, 0xA7CAu}, + {0xAB30u, 0xAB6Bu} }) { + for (std::uint32_t cp = lo; cp <= hi; ++cp) { + if (stbtt_FindGlyphIndex(&font.font, static_cast(cp)) == 0) continue; + int x0 = 0, y0 = 0, x1 = 0, y1 = 0; + stbtt_GetCodepointBitmapBox(&font.font, static_cast(cp), scale, scale, &x0, &y0, &x1, &y1); + if (x0 != x1 && y0 != y1) pool.push_back(cp); + } + } + std::vector lines; + for (std::size_t i = 0; i + kLineLength <= pool.size(); i += kLineLength) { + std::string line; + for (std::size_t k = 0; k < kLineLength; ++k) AppendUtf8(line, pool[i + k]); + lines.push_back(std::move(line)); + codepoints.emplace_back(pool.begin() + i, pool.begin() + i + kLineLength); + } + return lines; +} + +struct Shaped { + std::vector glyphs; + float advance = 0.0f; +}; + +Shaped Shape(UIRenderer& ui, Font& font, std::string_view utf8) { + Shaped s; + s.glyphs.resize(kLineLength + 8); + s.glyphs.resize(ui.ShapeText(font, kPx, 0.0f, 100.0f, utf8, {1, 1, 1, 1}, + s.glyphs.data(), static_cast(s.glyphs.size()), + &s.advance)); + return s; +} + +// A glyph's cell in the atlas, in texels. The UVs are texel / kAtlasSize, which +// a float holds exactly. +struct Cell { int x0, y0, x1, y1; }; + +Cell CellOf(float u0, float v0, float u1, float v1) { + return { static_cast(u0 * kSize), static_cast(v0 * kSize), + static_cast(u1 * kSize), static_cast(v1 * kSize) }; +} + +std::vector Pixels(FontAtlas& atlas, const GlyphItem& g) { + const Cell c = CellOf(g.u0, g.v0, g.u1, g.v1); + const std::uint8_t* p = atlas.PixelPtr(); + std::vector out; + for (int y = c.y0; y < c.y1; ++y) out.insert(out.end(), p + y * kSize + c.x0, p + y * kSize + c.x1); + return out; +} + +bool SamePlace(const GlyphItem& a, const GlyphItem& b) { + return a.x == b.x && a.y == b.y && a.w == b.w && a.h == b.h; +} + +// `drawn` is `full` with some glyphs left out, the rest in the same order and +// the same places. A line that closed up over a missing glyph has the glyphs +// after it further left, where `full` has none. +bool LeavesGapsOnly(std::span drawn, std::span full) { + std::size_t j = 0; + for (const GlyphItem& g : drawn) { + while (j < full.size() && !SamePlace(full[j], g)) ++j; + if (j == full.size()) return false; + ++j; + } + return true; +} + +// The same glyphs in the same places, with the same pixels under each in its +// own atlas, wherever each atlas keeps them. +bool SameText(const Shaped& a, FontAtlas& atlasA, const Shaped& b, FontAtlas& atlasB) { + if (a.glyphs.size() != b.glyphs.size() || a.advance != b.advance) return false; + for (std::size_t i = 0; i < a.glyphs.size(); ++i) { + if (!SamePlace(a.glyphs[i], b.glyphs[i])) return false; + if (Pixels(atlasA, a.glyphs[i]) != Pixels(atlasB, b.glyphs[i])) return false; + } + return true; +} + +std::vector> PixelsOf(FontAtlas& atlas, const Shaped& s) { + std::vector> out; + for (const GlyphItem& g : s.glyphs) out.push_back(Pixels(atlas, g)); + return out; +} + +// The end of a frame as Window::Render has it for the atlas: what changed is +// uploaded in the frame's command buffer, then the frame clock moves on. +void EndFrame(FontAtlas& atlas) { + VkCommandBuffer cmd = BeginCmd(); + atlas.Update(cmd); + SubmitWait(cmd); + Device::frameCounter++; +} + +} // namespace + +int main() { + Device::Initialize(); + Device::framesInFlight = 3; + + FontAtlas atlas; // filled until it is full + FontAtlas roomy; // shapes only the lines compared, so it always has room + { + VkCommandBuffer cmd = BeginCmd(); + atlas.Initialize(cmd); + roomy.Initialize(cmd); + SubmitWait(cmd); + } + UIRenderer ui; + ui.fontAtlas = &atlas; + UIRenderer roomyUi; + roomyUi.fontAtlas = &roomy; + + Font font(FindFont()); + std::vector> lineCodepoints; + const std::vector lines = InkedLines(font, lineCodepoints); + std::println("{} lines of {} inked codepoints", lines.size(), kLineLength); + + auto resident = [&](std::size_t first, std::size_t last) { + std::size_t n = 0; + for (std::size_t i = first; i <= last; ++i) { + for (std::uint32_t cp : lineCodepoints[i]) { + const Glyph* g = atlas.Lookup(font, cp); + if (g != nullptr && g->Resident()) ++n; + } + } + return n; + }; + + // ── Frame A: lines 1 to 30, line 0, then lines until one comes back short. ─ + constexpr std::size_t kFirst = 30; + const std::uint64_t frameA = 1000; + Device::frameCounter = frameA; + std::vector shaped(lines.size()); + for (std::size_t i = 1; i <= kFirst; ++i) shaped[i] = Shape(ui, font, lines[i]); + shaped[0] = Shape(ui, font, lines[0]); + std::size_t full = 0; + for (std::size_t i = kFirst + 1; i < lines.size(); ++i) { + shaped[i] = Shape(ui, font, lines[i]); + if (shaped[i].glyphs.size() < kLineLength) { full = i; break; } + } + Check(full > kFirst + 1 && shaped[0].glyphs.size() == kLineLength, + std::format("the atlas fills up after line 0 (line {} of {} no longer fits)", full, lines.size())); + if (full <= kFirst + 1) return EXIT_FAILURE; + const std::size_t missing = kLineLength - shaped[full].glyphs.size(); + + const std::string& lineF = lines[full]; + const Shaped whole = Shape(roomyUi, font, lineF); + Check(whole.glyphs.size() == kLineLength, "an atlas with room draws the whole line"); + Check(shaped[full].advance == whole.advance, + std::format("the line that no longer fits keeps its whole advance ({} vs {})", + shaped[full].advance, whole.advance)); + Check(LeavesGapsOnly(shaped[full].glyphs, whole.glyphs), + "the glyphs it draws sit where they belong (gaps, not a line closed up)"); + + // Another string with the same glyphs misses the shaped-run cache and asks + // the atlas for each glyph again: the ones without room are cached, + // measured, and not rasterised. + const std::uint64_t rasterised = atlas.GlyphsRasterized(); + std::string reversed; + for (auto it = lineCodepoints[full].rbegin(); it != lineCodepoints[full].rend(); ++it) AppendUtf8(reversed, *it); + Check(Shape(ui, font, reversed).advance == Shape(roomyUi, font, reversed).advance, + "the same glyphs in another order keep their whole advance too"); + Check(atlas.GlyphsRasterized() == rasterised, + "a glyph without room is not rasterised again by another string holding it"); + std::size_t waiting = 0; + for (std::uint32_t cp : lineCodepoints[full]) { + const Glyph* g = atlas.Lookup(font, cp); + if (g != nullptr && !g->Resident()) ++waiting; + } + Check(waiting == missing, std::format("each missing glyph is cached without a cell ({} of them)", waiting)); + EndFrame(atlas); + + // ── Frames A+1 to A+3: every glyph frame A drew may still be in flight. ─ + Shaped line1; + for (std::uint64_t f = frameA + 1; f <= frameA + 3; ++f) { + if (f == frameA + 1) { + for (std::size_t i = 1; i < full; ++i) Shape(ui, font, lines[i]); // all but line 0 + } + const Shaped again = Shape(ui, font, lineF); + Check(again.glyphs.size() == shaped[full].glyphs.size() && again.advance == whole.advance, + std::format("frame A+{}: nothing gives way while it may be in flight, and the advance stays whole", f - frameA)); + if (f == frameA + 2) line1 = Shape(ui, font, lines[1]); // in flight until A+6 + EndFrame(atlas); + } + Check(atlas.GlyphsRasterized() == rasterised, "nothing is rasterised while nothing can be evicted"); + const auto line1Pixels = PixelsOf(atlas, line1); + + // ── Frame A+4: line 0, drawn last in A, is all that has gone cold. ────── + const std::size_t line0Before = resident(0, 0); + const Shaped partly = Shape(ui, font, lineF); + const std::size_t placed = partly.glyphs.size() - shaped[full].glyphs.size(); + Check(placed > 0, std::format("frame A+4: line F's missing glyphs get cells ({} of {})", placed, missing)); + Check(resident(0, 0) < line0Before && resident(1, full - 1) == (full - 1) * kLineLength, + std::format("only the cold line 0 gives way ({} of its glyphs), no glyph drawn since", + line0Before - resident(0, 0))); + Check(atlas.GlyphsRasterized() == rasterised + placed, "each glyph that got a cell was rasterised once"); + + // Twenty more new lines in the same frame: they find no free room, and a + // frame's own misses evict nothing in it, so the glyphs it drew stay. + constexpr std::size_t kMore = 20; + Check(lines.size() > full + kMore, "the font has lines enough to go on"); + const auto partlyPixels = PixelsOf(atlas, partly); + const std::size_t residentBefore = resident(0, full); + for (std::size_t i = full + 1; i <= full + kMore; ++i) Shape(ui, font, lines[i]); + Check(resident(0, full) == residentBefore, + "a frame's own misses evict nothing in it, not even the cold line 0's glyphs"); + Check(PixelsOf(atlas, partly) == partlyPixels, "glyphs drawn earlier in the frame keep their cells"); + EndFrame(atlas); + + // ── Frame A+5: frame A+1's glyphs are cold now, line 1's (A+2) not. ───── + const std::size_t oldBefore = resident(0, 0); + const std::size_t newerBefore = resident(2, full - 1); + const Shaped back = Shape(ui, font, lineF); + Check(SameText(back, atlas, whole, roomy), + std::format("frame A+5: the line is whole again ({} of {} glyphs), pixel for pixel", + back.glyphs.size(), kLineLength)); + std::size_t wholeLines = 0; + for (std::size_t i = full + 1; i <= full + kMore; ++i) { + if (Shape(ui, font, lines[i]).glyphs.size() == kLineLength) ++wholeLines; + } + Check(wholeLines == kMore, std::format("frame A+5: {} of {} more lines find room", wholeLines, kMore)); + Check(PixelsOf(atlas, line1) == line1Pixels, "glyphs a frame in flight drew keep their cells"); + const double oldShare = oldBefore == 0 ? 1.0 : 1.0 - double(resident(0, 0)) / double(oldBefore); + const double newerShare = 1.0 - double(resident(2, full - 1)) / double(newerBefore); + Check(oldShare > newerShare, + std::format("the least recently used go first: {:.0f}% of line 0's glyphs gave way, {:.0f}% of frame A+1's", + oldShare * 100.0, newerShare * 100.0)); + + // Line 0's cached run now points at cells holding other glyphs: shaping + // it again must notice and shape it from scratch. Its glyphs wait for the + // next frame's cells. + Shape(ui, font, lines[0]); + EndFrame(atlas); + const Shaped line0Again = Shape(ui, font, lines[0]); + Check(SameText(line0Again, atlas, Shape(roomyUi, font, lines[0]), roomy), + "a cached run whose glyphs were evicted is shaped again, pixel for pixel"); + + // Nothing is left of an evicted glyph outside the cells that replaced it: + // a glyph's filtering at its edges reads its neighbours' texels, which + // must be another glyph's padding or zero. + { + std::vector covered(static_cast(kSize) * kSize, 0); + std::size_t residentGlyphs = 0; + for (const auto& cps : lineCodepoints) { + for (std::uint32_t cp : cps) { + const Glyph* g = atlas.Lookup(font, cp); + if (g == nullptr || !g->Resident()) continue; + ++residentGlyphs; + const Cell c = CellOf(g->u0, g->v0, g->u1, g->v1); + for (int y = c.y0; y < c.y1; ++y) + for (int x = c.x0; x < c.x1; ++x) covered[static_cast(y) * kSize + x] = 1; + } + } + const std::uint8_t* p = atlas.PixelPtr(); + std::size_t stale = 0; + for (std::size_t i = 0; i < covered.size(); ++i) if (!covered[i] && p[i] != 0) ++stale; + Check(stale == 0, std::format("every texel outside the {} resident glyphs is zero ({} are not)", residentGlyphs, stale)); + } + EndFrame(atlas); + + // A Font replaced at the same address must find nothing of the old one. + ui.InvalidateFont(font); + bool forgotten = true; + for (const auto& cps : lineCodepoints) { + for (std::uint32_t cp : cps) forgotten = forgotten && atlas.Lookup(font, cp) == nullptr; + } + Check(forgotten, "InvalidateFont forgets the font's glyphs in the atlas too"); + + if (std::getenv("CRAFTER_GRAPHICS_VALIDATION")) { + Check(Device::validationErrorCount == 0, + std::format("no validation errors ({} seen)", Device::validationErrorCount)); + } + + if (failures == 0) std::println("\nAll font atlas eviction checks passed."); + else std::println("\n{} font atlas eviction check(s) FAILED.", failures); + return failures == 0 ? EXIT_SUCCESS : EXIT_FAILURE; +} diff --git a/tests/FontAtlasPacker/main.cpp b/tests/FontAtlasPacker/main.cpp new file mode 100644 index 0000000..e1783fd --- /dev/null +++ b/tests/FontAtlasPacker/main.cpp @@ -0,0 +1,359 @@ +//SPDX-License-Identifier: LGPL-3.0-only +//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® + +// Issue #55: FontAtlas::Packer decides where a glyph goes in the atlas and, +// once the atlas is full, which glyphs give up their cells. It knows no +// pixels, so this drives it with made-up boxes and frames, without a device: +// - until something is evicted it fills shelves as the old allocator did: +// the first shelf tall enough with room at its end, else a new shelf +// - while every glyph was used within framesInFlight + 1 frames, a full +// packer gives nothing away; after that it does, the least recently used +// first, and touching a glyph keeps it; without `evict` it never does +// - a glyph wider than a cell takes the coldest adjacent cells, never one +// whose glyph is in flight +// - a glyph taller than every shelf spans several, taking free columns +// across them before it evicts anything, and the free rows below the +// last shelf too, and blocks just its columns on each; glyphs in flight +// on every shelf block just theirs +// - Touch marks every handle that still holds its cell, even after one +// that doesn't, so the glyphs of a run shaped again stay +// - a handle whose cell went to another glyph touches nothing +// - every cell handed out and every region cleared is in a dirty span +// - over a long random workload, placed the way FontAtlas places glyphs +// (free room within a frame, evictions when the next one begins): cells +// stay inside the atlas and apart, only cold glyphs are evicted, the +// cleared regions cover the evicted cells and no live one, and a failure +// or a placement without `evict` evicts nothing +// +// Pure CPU: no Vulkan device or WebGPU context at runtime. + +#include + +import Crafter.Graphics; +import std; + +using namespace Crafter; + +namespace { + +int failures = 0; + +void Check(bool ok, std::string_view what) { + std::println("{} {}", ok ? "PASS" : "FAIL", what); + if (!ok) ++failures; +} + +using Packer = FontAtlas::Packer; +using Cell = Packer::Cell; + +constexpr std::uint32_t kNone = GlyphHandle::kNone; +constexpr std::uint32_t kDepth = 3; // frames in flight, as a Window has them +constexpr std::uint64_t kHold = kDepth + 1; // frames a glyph keeps its cell after its use + +std::uint32_t Place(Packer& p, int w, int h, std::uint64_t frame) { + return p.Allocate(w, h, frame, kDepth, /*evict*/ true); +} + +bool SameCell(const Cell& a, const Cell& b) { + return a.x == b.x && a.y == b.y && a.w == b.w && a.h == b.h; +} + +bool Overlap(const Cell& a, const Cell& b) { + return a.x < b.x + b.w && b.x < a.x + a.w && a.y < b.y + b.h && b.y < a.y + a.h; +} + +bool Contains(const Cell& outer, const Cell& inner) { + return inner.x >= outer.x && inner.y >= outer.y + && inner.x + inner.w <= outer.x + outer.w && inner.y + inner.h <= outer.y + outer.h; +} + +// Some shelf's pending span covers `c`. Reads the spans without uploading. +bool Dirty(Packer& p, const Cell& c) { + bool covered = false; + p.ForEachDirty([&](FontAtlas::DirtyRect& r) { + covered = covered || (r.active && c.x >= r.minX && c.y >= r.minY + && c.x + c.w <= r.maxX && c.y + c.h <= r.maxY); + }); + return covered; +} + +void Upload(Packer& p) { + p.ForEachDirty([](FontAtlas::DirtyRect& r) { r.Reset(); }); +} + +// `count` cells of w×h, all used in frame `frame`. +std::vector Fill(Packer& p, int count, int w, int h, std::uint64_t frame) { + std::vector slots; + for (int i = 0; i < count; ++i) slots.push_back(Place(p, w, h, frame)); + return slots; +} + +bool AllPlaced(std::span slots) { + return std::ranges::none_of(slots, [](std::uint32_t s) { return s == kNone; }); +} + +struct WorkloadResult { + std::size_t placed = 0, evicted = 0, failed = 0, settled = 0; + bool inside = true, apart = true, onlyCold = true, covers = true, spares = true; + bool cleanFailures = true, freeEvictsNothing = true, dirty = true, handles = true; +}; + +// Glyphs of random sizes, a few to a dozen a frame, in epochs of short ones +// and of tall ones, so the short ones fill the rows with low shelves that the +// tall ones need more than. Drawn each frame, as on a screen: what came in +// the last twenty frames, and twenty glyphs from the first frames that never +// stop being drawn (a UI's fixed labels), spread over the first shelves. A +// new glyph gets free room in its frame, or waits: the next frame begins by +// placing the waiting ones with evictions, as FontAtlas does. +WorkloadResult Workload(int size, std::uint32_t seed) { + Packer p(size); + std::mt19937 rng(seed); + struct Live { Cell cell; std::uint64_t born; std::uint64_t lastUse; GlyphHandle handle; bool fixed; }; + std::unordered_map live; + std::vector> waiting; + std::size_t fixed = 0; + WorkloadResult r; + auto take = [&](std::uint32_t s, int w, int h, std::uint64_t frame, bool evict) { + if (s == kNone) { + r.cleanFailures = r.cleanFailures && p.Evicted().empty() && p.Cleared().empty(); + return false; + } + r.freeEvictsNothing = r.freeEvictsNothing && (evict || p.Evicted().empty()); + for (std::uint32_t v : p.Evicted()) { + auto it = live.find(v); + if (it == live.end()) { r.onlyCold = false; continue; } + r.onlyCold = r.onlyCold && it->second.lastUse + kHold <= frame; + r.covers = r.covers && std::ranges::any_of(p.Cleared(), [&](const Cell& c) { return Contains(c, it->second.cell); }); + live.erase(it); + ++r.evicted; + } + const Cell cell = p.CellOf(s); + r.inside = r.inside && cell.w == w && cell.h == h && cell.x >= 0 && cell.y >= 0 + && cell.x + w <= size && cell.y + h <= size; + for (const auto& [slot, l] : live) r.apart = r.apart && !Overlap(cell, l.cell); + for (const Cell& c : p.Cleared()) { + for (const auto& [slot, l] : live) r.spares = r.spares && !Overlap(c, l.cell); + r.dirty = r.dirty && Dirty(p, c); + } + r.dirty = r.dirty && Dirty(p, cell); + const bool isFixed = frame < 30 && fixed < 20 && rng() % 2 == 0; + fixed += isFixed; + live[s] = { cell, frame, frame, p.Handle(s), isFixed }; + ++r.placed; + return true; + }; + for (std::uint64_t frame = 10; frame < 4010; ++frame) { + for (auto [w, h] : std::exchange(waiting, {})) { + if (take(p.Allocate(w, h, frame, kDepth, /*evict*/ true), w, h, frame, true)) ++r.settled; + else ++r.failed; + } + for (auto& [slot, l] : live) { + if (l.fixed || l.born + 20 > frame) { p.Touch(slot, frame); l.lastUse = frame; } + } + const int maxH = (frame / 500) % 2 == 0 ? 12 : 40; + const int n = static_cast(rng() % 12); + for (int k = 0; k < n; ++k) { + const int w = 4 + static_cast(rng() % 37); + const int h = 4 + static_cast(rng() % static_cast(maxH - 3)); + if (!take(p.Allocate(w, h, frame, kDepth, /*evict*/ false), w, h, frame, false)) waiting.emplace_back(w, h); + } + if (frame % 50 == 0) { + // Every live handle still holds its cell. Touching it in its own + // last frame leaves the workload as it was. + for (const auto& [slot, l] : live) { + r.handles = r.handles && p.Touch(std::span{ &l.handle, 1 }, l.lastUse); + } + Upload(p); + } + } + return r; +} + +} // namespace + +int main() { + // ── 1. Filling: the first shelf tall enough with room, else a new one. ── + { + Packer p(100); + const std::uint32_t a = Place(p, 30, 10, 0); + const std::uint32_t b = Place(p, 30, 10, 0); + const std::uint32_t c = Place(p, 30, 10, 0); + const std::uint32_t d = Place(p, 30, 10, 0); + const std::uint32_t e = Place(p, 10, 8, 0); + const std::uint32_t f = Place(p, 20, 12, 0); + Check(SameCell(p.CellOf(a), {0, 0, 30, 10}) && SameCell(p.CellOf(b), {30, 0, 30, 10}) + && SameCell(p.CellOf(c), {60, 0, 30, 10}), "a shelf fills left to right"); + Check(SameCell(p.CellOf(d), {0, 10, 30, 10}), "a glyph without room on a shelf opens the next one"); + Check(SameCell(p.CellOf(e), {90, 0, 10, 8}), "a shorter glyph takes the first shelf with room"); + Check(SameCell(p.CellOf(f), {0, 20, 20, 12}), "a taller glyph opens a shelf of its own height"); + Check(p.Evicted().empty() && p.Cleared().empty(), "filling evicts nothing"); + Check(Dirty(p, p.CellOf(a)) && Dirty(p, p.CellOf(f)), "every cell handed out is in a dirty span"); + Check(Place(p, 0, 10, 0) == kNone && Place(p, 10, 101, 0) == kNone, + "an empty box, or one bigger than the atlas, gets no cell"); + } + + // ── 2. Full: nothing to give while every glyph is in flight. ─────────── + { + Packer p(64); + const auto cells = Fill(p, 16, 16, 16, 1); + Check(AllPlaced(cells), "sixteen 16x16 cells fill a 64x64 atlas"); + bool held = true; + for (std::uint64_t frame = 1; frame < 1 + kHold; ++frame) { + held = held && Place(p, 16, 16, frame) == kNone && p.Evicted().empty(); + } + Check(held, "for framesInFlight + 1 frames after their use, the glyphs keep their cells"); + Check(p.Allocate(16, 16, 1 + kHold, kDepth, /*evict*/ false) == kNone && p.Evicted().empty(), + "then, without evict, still none gives way"); + const std::uint32_t s = Place(p, 16, 16, 1 + kHold); + Check(s != kNone && p.Evicted().size() == 1, "with it, one gives way, and only one"); + Check(p.Cleared().size() == 1 && SameCell(p.Cleared()[0], p.CellOf(s)) && Dirty(p, p.Cleared()[0]), + "its texels are cleared, and the clearing is in a dirty span"); + Check(Place(p, 8, 8, 1 + kHold) != kNone && p.Evicted().size() == 1, + "a smaller glyph takes one cold cell too"); + } + + // ── 3. The least recently used goes first; a touch keeps a glyph. ────── + { + Packer p(64); + std::vector cells; + for (std::uint64_t frame = 1; frame <= 16; ++frame) cells.push_back(Place(p, 16, 16, frame)); + p.Touch(cells[0], 19); // frame 1's glyph, used again in frame 19 + std::vector order; + for (int i = 0; i < 15; ++i) { + if (Place(p, 16, 16, 20) == kNone || p.Evicted().size() != 1) break; + order.push_back(p.Evicted()[0]); + } + Check(order == std::vector(cells.begin() + 1, cells.end()), + "full, the glyphs give way in the order they were last used"); + Check(Place(p, 16, 16, 20) == kNone, "the glyph touched in a frame still in flight stays"); + } + + // ── 4. A wide glyph takes adjacent cold cells, never one in flight. ──── + { + Packer p(64); + const std::uint32_t a = Place(p, 16, 16, 1); + const std::uint32_t b = Place(p, 16, 16, 9); + const std::uint32_t c = Place(p, 16, 16, 2); + const std::uint32_t d = Place(p, 16, 16, 3); + Check(AllPlaced(Fill(p, 12, 16, 16, 9)), "three more shelves, in flight in frame 10"); + const std::uint32_t s = Place(p, 32, 16, 10); + Check(s != kNone && SameCell(p.CellOf(s), {32, 0, 32, 16}), + "a 32-wide glyph takes the two cold cells beside each other"); + const auto evicted = p.Evicted(); + Check(evicted.size() == 2 && std::ranges::count(evicted, c) == 1 && std::ranges::count(evicted, d) == 1, + "it evicts just those two"); + Check(std::ranges::count(evicted, a) == 0 && std::ranges::count(evicted, b) == 0, + "the colder glyph beside the one in flight stays, as does that one"); + Check(p.Cleared().size() == 2 && Dirty(p, p.Cleared()[0]) && Dirty(p, p.Cleared()[1]), + "both cells are cleared, in a dirty span"); + } + + // ── 5. A glyph taller than every shelf spans several. ────────────────── + { + Packer p(64); + // Eight shelves of eight 8x8 glyphs; shelf 1's are in flight. + for (int shelf = 0; shelf < 8; ++shelf) Fill(p, 8, 8, 8, shelf == 1 ? 9 : 1); + Upload(p); + const std::uint32_t s = Place(p, 16, 24, 10); + Check(s != kNone && SameCell(p.CellOf(s), {0, 16, 16, 24}), + "a 24-tall glyph spans three 8-row shelves, past the one in flight"); + Check(p.Evicted().size() == 6, "it evicts the six glyphs in its columns"); + bool cleared = p.Cleared().size() == 6; + for (const Cell& c : p.Cleared()) cleared = cleared && Contains(p.CellOf(s), c) && Dirty(p, c); + Check(cleared, "their cells are cleared, in a dirty span"); + const std::uint32_t t = Place(p, 40, 20, 10); + Check(t != kNone && SameCell(p.CellOf(t), {16, 16, 40, 20}), + "a glyph spanning shelves blocks its columns on each, and no more"); + } + { + Packer p(64); + // Eight 8-row shelves, each holding one 40x8 glyph, all in flight: + // columns 40 to 63 are free on every shelf. + Check(AllPlaced(Fill(p, 8, 40, 8, 1)), "eight 40-wide glyphs open eight shelves"); + const std::uint32_t s = p.Allocate(16, 16, 1, kDepth, /*evict*/ false); + Check(s != kNone && SameCell(p.CellOf(s), {40, 0, 16, 16}) && p.Evicted().empty(), + "a 16-tall glyph takes free columns across two shelves, without evict too"); + } + { + Packer p(64); + // Eight shelves of eight 8x8 glyphs, and on shelf k the glyph in + // column k is in flight: every shelf holds one, every pair of shelves + // has them in two columns side by side. + std::vector cells; + for (int shelf = 0; shelf < 8; ++shelf) { + for (std::uint32_t c : Fill(p, 8, 8, 8, 1)) cells.push_back(c); + } + for (std::uint32_t c : cells) { + if (p.CellOf(c).x == p.CellOf(c).y) p.Touch(c, 9); + } + const std::uint32_t s = Place(p, 16, 16, 10); + Check(s != kNone && SameCell(p.CellOf(s), {16, 0, 16, 16}) && p.Evicted().size() == 4, + "glyphs in flight on every shelf block only their own columns"); + } + { + Packer p(64); + // Six full shelves of 8x8 glyphs in flight, a seventh holding three + // in columns 0 to 23, and 8 free rows below it: a 12-tall glyph is + // taller than the free rows and than any shelf. + Check(AllPlaced(Fill(p, 51, 8, 8, 9)), "six and a bit 8-row shelves, in flight in frame 10"); + const std::uint32_t s = p.Allocate(10, 12, 10, kDepth, /*evict*/ false); + Check(s != kNone && SameCell(p.CellOf(s), {24, 48, 10, 12}) && p.Evicted().empty(), + "a glyph taller than the free rows spans the last shelf and them"); + const std::uint32_t t = Place(p, 64, 4, 10); + Check(t != kNone && SameCell(p.CellOf(t), {0, 60, 64, 4}), + "the free rows it left below take later glyphs"); + } + + // ── 6. Touch marks every handle that still holds its cell. ───────────── + { + Packer p(32); + const auto cells = Fill(p, 4, 16, 16, 1); + std::vector run; + for (std::uint32_t c : cells) run.push_back(p.Handle(c)); + const std::uint32_t n = Place(p, 16, 16, 1 + kHold); // takes cells[0], the first found + Check(n == cells[0] && p.Evicted().size() == 1, "one of four cold glyphs gives way"); + Check(!p.Touch(run, 2 + kHold), "a run whose first glyph went touches false"); + // Only the new glyph is cold kHold frames on: the run's other three + // were marked although the first handle no longer held its cell. + const std::uint32_t m = Place(p, 16, 16, 1 + 2 * kHold); + Check(m != kNone && p.Evicted().size() == 1 && p.Evicted()[0] == n, + "the run's other glyphs were marked used past the stale handle"); + } + + // ── 7. A handle whose cell went to another glyph touches nothing. ────── + { + Packer p(32); + const std::uint32_t a = Place(p, 32, 32, 1); + const std::array handles{ p.Handle(a) }; + Check(p.Touch(handles, 2), "a handle to a live cell touches its glyph"); + const std::uint32_t b = Place(p, 32, 32, 2 + kHold); + Check(b == a && p.Evicted().size() == 1 && p.Evicted()[0] == a, "the cell goes to another glyph"); + Check(p.Handle(b).generation != handles[0].generation, "with a new generation"); + Check(!p.Touch(handles, 3 + kHold), "the old handle no longer touches it"); + Check(Place(p, 32, 32, 2 + 2 * kHold) != kNone, + "and did not keep the new glyph from being evicted in its turn"); + const std::array foreign{ GlyphHandle{ 999, 0 } }; + Check(!p.Touch(foreign, 9), "a handle to no slot touches nothing"); + } + + // ── 8. A long random workload. ───────────────────────────────────────── + { + const WorkloadResult r = Workload(256, 55); + std::println("random workload: {} placed ({} when a frame began), {} evicted, {} failed", + r.placed, r.settled, r.evicted, r.failed); + Check(r.evicted > 1000 && r.settled > 1000, "the workload evicts, when frames begin"); + Check(r.inside, "every cell is the size asked for and inside the atlas"); + Check(r.apart, "no cell overlaps a live one"); + Check(r.onlyCold, "only glyphs no frame in flight uses are evicted"); + Check(r.covers, "the cleared regions cover every evicted cell"); + Check(r.spares, "and no live one"); + Check(r.dirty, "every cell handed out and every region cleared is in a dirty span"); + Check(r.cleanFailures, "a failure evicts nothing"); + Check(r.freeEvictsNothing, "a placement without evict evicts nothing"); + Check(r.handles, "every live glyph's handle still holds its cell"); + } + + if (failures == 0) std::println("\nAll font atlas packer checks passed."); + else std::println("\n{} font atlas packer check(s) FAILED.", failures); + return failures == 0 ? EXIT_SUCCESS : EXIT_FAILURE; +} diff --git a/tests/ShapeTextCache/main.cpp b/tests/ShapeTextCache/main.cpp index 545425e..6d2df79 100644 --- a/tests/ShapeTextCache/main.cpp +++ b/tests/ShapeTextCache/main.cpp @@ -10,8 +10,9 @@ // // What is asserted: // - A cache HIT produces byte-identical glyphs to the original (MISS) call. -// - The HIT path does NOT touch the atlas (no Ensure → atlas stays clean), -// while the MISS path rasterises new glyphs and dirties it. +// - The HIT path rasterises nothing (it only marks the run's glyphs used, so +// the atlas stays clean), while the MISS path rasterises new glyphs and +// dirties it. // - Translating the same string to a different (x, baselineY) shifts every // glyph by exactly that delta (cache stores origin-relative geometry). // - Center / Right alignment fold the advance-based shift into ShapeText @@ -83,6 +84,28 @@ bool RunsEqual(std::span a, std::span b) { return true; } +// The atlas texels under a glyph's UVs, which are texel / kAtlasSize. +std::vector AtlasPixels(FontAtlas& atlas, const GlyphItem& g) { + constexpr int kSize = FontAtlas::kAtlasSize; + const int x0 = static_cast(g.u0 * kSize), y0 = static_cast(g.v0 * kSize); + const int x1 = static_cast(g.u1 * kSize), y1 = static_cast(g.v1 * kSize); + std::vector out; + for (int y = y0; y < y1; ++y) out.insert(out.end(), atlas.PixelPtr() + y * kSize + x0, atlas.PixelPtr() + y * kSize + x1); + return out; +} + +// The same glyphs in the same places and colours, each drawn from the same +// atlas texels, wherever in the atlas they sit. +bool RunsDrawEqual(FontAtlas& atlas, std::span a, std::span b) { + if (a.size() != b.size()) return false; + for (std::size_t i = 0; i < a.size(); ++i) { + GlyphItem moved = b[i]; + moved.u0 = a[i].u0; moved.v0 = a[i].v0; moved.u1 = a[i].u1; moved.v1 = a[i].v1; + if (!GlyphEq(a[i], moved) || AtlasPixels(atlas, a[i]) != AtlasPixels(atlas, b[i])) return false; + } + return true; +} + std::filesystem::path FindFont() { // The runner's cwd is not guaranteed: cfg.files copies the font next to // the binary, but `crafter-build test` may also run from the project root. @@ -137,7 +160,7 @@ int main() { Check(advHit == advMiss, "HIT reported the same advance as MISS"); Check(RunsEqual({bufMiss.data(), nMiss}, {bufHit.data(), nHit}), "HIT is byte-identical to MISS"); - Check(!atlas.dirty, "HIT did not touch the atlas (no re-rasterise)"); + Check(!atlas.dirty, "HIT left the atlas clean (no re-rasterise)"); // ── 3. Translation: a different origin shifts every glyph by the delta. ─ std::array bufMoved{}; @@ -183,6 +206,10 @@ int main() { Check(advTrunc == advHit, "advance is full even when the buffer truncates"); // ── 6. InvalidateFont drops the run; a re-shape is still correct. ────── + // The atlas forgets the font's glyphs too (#55), so the re-shape puts + // them into new cells: the old ones stay until evicted, as a frame in + // flight may still draw them. Everything but the UVs matches, and the + // texels under the new UVs are those under the old. ui.InvalidateFont(font); std::array bufReshape{}; float advReshape = 0.0f; @@ -190,8 +217,8 @@ int main() { bufReshape.data(), bufReshape.size(), &advReshape); Check(nReshape == nHit && advReshape == advHit - && RunsEqual({bufReshape.data(), nReshape}, {bufHit.data(), nHit}), - "re-shape after InvalidateFont matches the original output"); + && RunsDrawEqual(atlas, {bufHit.data(), nHit}, {bufReshape.data(), nReshape}), + "re-shape after InvalidateFont draws the original output"); // ── 7. Distinct color and pxSize are distinct entries (different runs). ─ std::array bufRed{}; diff --git a/tests/UIDispatchAtlasFlush/main.cpp b/tests/UIDispatchAtlasFlush/main.cpp new file mode 100644 index 0000000..2fbea86 --- /dev/null +++ b/tests/UIDispatchAtlasFlush/main.cpp @@ -0,0 +1,102 @@ +//SPDX-License-Identifier: LGPL-3.0-only +//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® + +// Issue #55: once the font atlas is full, a glyph ShapeText places can take a +// cell that held another glyph, whose SDF the GPU image keeps until the atlas +// is uploaded again. DispatchText and DispatchFused uploaded it before their +// kernels; the generic UIRenderer::Dispatch, the Tier 1 path a custom text +// kernel sampling FontAtlasImageSlot() takes, did not, so for that frame the +// kernel drew the cell's old glyph. Every kernel the renderer dispatches now +// uploads the atlas first. +// +// What is asserted, in one onBuild: ShapeText of new glyphs leaves the atlas +// dirty; a ui.Dispatch of a custom kernel (the standard quads one, given no +// items) leaves it clean, and the frame renders without validation errors +// (which counts only with CRAFTER_GRAPHICS_VALIDATION set). +// +// Needs the Wayland backend and a live compositor (the UIRenderer needs a +// Window), the UI kernels and a font, which project.cpp copies next to the +// binary. + +#include "vulkan/vulkan.h" +#include + +import Crafter.Graphics; +import Crafter.Event; +import std; + +using namespace Crafter; +namespace fs = std::filesystem; + +namespace { + +int failures = 0; + +void Check(bool ok, std::string_view what) { + std::println("{} {}", ok ? "PASS" : "FAIL", what); + if (!ok) ++failures; +} + +fs::path ExeDir() { + return fs::read_symlink("/proc/self/exe").parent_path(); +} + +} // namespace + +int main() { + const fs::path dir = ExeDir(); + + Device::Initialize(); + Window window(320, 200, "UIDispatchAtlasFlush test"); + VkCommandBuffer init = window.StartInit(); + + GraphicsDescriptorHeap heap; + heap.Initialize(/*images*/ 4, /*buffers*/ 4, /*samplers*/ 2); + window.descriptorHeap = &heap; + + Font font(dir / "font.ttf"); + FontAtlas atlas; + atlas.Initialize(init); + + UIRenderer ui; + ui.fontAtlas = &atlas; + ui.Initialize(window, heap, init, + dir / "ui-quads.comp.spv", dir / "ui-circles.comp.spv", + dir / "ui-images.comp.spv", dir / "ui-text.comp.spv", + dir / "ui-fused.comp.spv"); + window.passes.push_back(&ui); + + GraphicsBuffer quads; + quads.Create(VK_BUFFER_USAGE_STORAGE_BUFFER_BIT | VK_BUFFER_USAGE_SHADER_DEVICE_ADDRESS_BIT, + VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT, 1); + BufferSlot quadsSlot = ui.RegisterBuffer(quads); + + bool built = false, dirtyAfterShaping = false, dirtyAfterDispatch = true; + EventListener buildSub(&ui.onBuild, [&](UIBuildArgs a) { + if (built) return; + built = true; + std::array glyphs{}; + ui.ShapeText(font, 24.0f, 10.0f, 40.0f, "Tier 1 kernels", {1, 1, 1, 1}, + glyphs.data(), static_cast(glyphs.size())); + dirtyAfterShaping = atlas.dirty; + const UIDispatchHeader header = ui.FillHeader(quadsSlot, 0); + ui.Dispatch(a.cmd, ui.drawQuads, &header, sizeof(header), + (window.width + 7) / 8, (window.height + 7) / 8); + dirtyAfterDispatch = atlas.dirty; + }); + + window.FinishInit(); + window.Render(); + + Check(built, "onBuild ran"); + Check(dirtyAfterShaping, "shaping new glyphs leaves the atlas dirty"); + Check(!dirtyAfterDispatch, "a custom kernel's ui.Dispatch uploads the atlas before it runs"); + if (std::getenv("CRAFTER_GRAPHICS_VALIDATION")) { + Check(Device::validationErrorCount == 0, + std::format("no validation errors ({} seen)", Device::validationErrorCount)); + } + + if (failures == 0) std::println("\nAll UI dispatch atlas flush checks passed."); + else std::println("\n{} UI dispatch atlas flush check(s) FAILED.", failures); + return failures == 0 ? EXIT_SUCCESS : EXIT_FAILURE; +}