fix(font-atlas): give glyphs that find no room a cell when the next frame begins, evicting the least recently drawn
The atlas packed glyphs onto shelves and never gave a place back (#55). Once its 1024x1024 image was full, EnsureAndGet returned nullptr for each new glyph before caching anything, so every new string holding one rasterised it again, and ShapeText dropped it together with its advance: the rest of the line closed up over the gap, and stayed that way. FontAtlas - A glyph is measured, not rasterised, until it gets a cell. One that finds no free room keeps its advance and box and draws blank for a frame. EnsureAndGet always returns the glyph, now a reference; Glyph::Resident() says whether it has a cell, Glyph::handle which one (slot and generation). - Evictions wait for the next frame: its first EnsureAndGet, Touch or Update places the glyphs that waited, once every glyph the frame before drew has been marked. Evicting while a frame is still asking for glyphs took ones it drew further down, which then took others in turn: a page switched back to could rasterise most of itself again rather than what it was missing, and on the DOM backend an over-full view could do so every frame. - A glyph keeps its cell for framesInFlight + 1 frames after it was drawn. The frame loop waits frame u's fence at the start of frame u + framesInFlight, but Device::frameCounter reads that from the end of the frame before, and input callbacks and onBeforeUpdate run in between. - FontAtlas::Packer places glyphs. Until the rows run out it fills shelves exactly as before. Then a glyph takes the same columns of one shelf at least as tall, or of several in a row as tall together, the free rows below the last shelf counting as one more: free texels, and, when the frame begins, glyphs nothing in flight draws, the least recently drawn first. A glyph spanning shelves lets in one taller than every shelf, and a few glyphs drawn every frame pin only their columns. Merging whole shelves instead, tried first, left a random workload without room for a glyph while a median 43% of the atlas was in flight; spanning, at 65%. - A box that found no room in a frame is remembered, with every box at least as big, as a small set per mode, until the frame ends or (without eviction) something is evicted: a saturated atlas no longer searches every shelf for each of its missing glyphs. - An evicted glyph is forgotten, and its texels are zeroed and uploaded with the glyph that takes its place: a neighbour's filtering at its edge must read padding or zero, not a stale glyph. - Touch(handles) marks every glyph whose handle still holds its cell, and says whether all did. InvalidateFont(font) forgets the font's glyphs and those waiting for a cell, whose Font* the next frame would read. The glyph index is looked up at rasterisation, so a Font replaced in place never gets one past its glyphs. GlyphsRasterized() counts, for tests. UIRenderer - A shaped run keeps a handle per glyph it draws. A hit touches them all, so the atlas keeps what is drawn every frame, and shapes the run again when one was evicted or one had no room. A glyph without room draws blank but advances the pen. - InvalidateFont clears the atlas's glyphs of the font too, as its comment and AGENTS.md always said it did. - Record calls the atlas's Update before onBuild, every frame, so the waiting glyphs are placed and uploaded first. Dispatch uploads the atlas before any kernel, a Tier 1 one sampling FontAtlasImageSlot() included: a glyph placed earlier in onBuild can sit in a cell another glyph had. DispatchText and DispatchFused no longer do it themselves. Device, Window (DOM) - Device::frameCounter and Device::framesInFlight exist on the DOM backend too, the counter bumped at the end of Window::Render, with one frame in flight: a frame's writeTexture lands before that frame's submit but after every earlier one. HelloUI - One set of item buffers and slots per frame in flight, by frameIdx. A frame still on the GPU read the items the next frame's onBuild was rewriting; with cells changing hands, those can name cells it drew other glyphs in. Tests - FontAtlasPacker: the packer with made-up boxes and no device: the fill order, least recently drawn first, framesInFlight + 1 frames held, nothing without evict, wide glyphs, tall glyphs spanning shelves and the free rows, Touch past a stale handle, stale handles, dirty spans, and the invariants over a random workload placed the way the atlas places. - FontAtlasEviction: fills the atlas through ShapeText frame by frame. The line that no longer fits keeps its advance and its glyphs' places; its missing glyphs aren't rasterised while nothing can go; when only line 0 is cold, only its glyphs give way; a frame's own misses evict nothing in it; a frame later the line and twenty more are whole, pixel for pixel, line 0 losing a larger share than the glyphs drawn after it; a stale cached run is shaped again; no stale texel is left; InvalidateFont reaches the atlas. Against master, with its two calls to the new API stubbed out, an earlier version failed ten checks. - UIDispatchAtlasFlush: a custom ui.Dispatch after ShapeText leaves the atlas uploaded. Needs a compositor. - ShapeTextCache: a hit rasterises nothing; after InvalidateFont the re-shaped run draws the same, from new cells. Docs - README and AGENTS.consumer.md: the atlas's eviction, the frame a glyph waits, shaping text every frame into per-frame item buffers, InvalidateFont, EnsureAndGet's signature and Lookup's lifetime, and that the atlas, like the deletion queue, assumes one rendering window and evicts nothing without one. - AGENTS.md: FontAtlas::Packer among the pure-logic helpers, eviction without a Window, and InvalidateFont. Breaking: FontAtlas::EnsureAndGet returns const Glyph& and never fails; check Glyph::Resident() before drawing from its UVs. FontAtlas::Touch is no longer noexcept. A GlyphItem is good for the frame it was shaped for, once the atlas has filled up, and item buffers must be per frame. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
578a3c6dc4
commit
a4be6f1a9c
17 changed files with 1632 additions and 187 deletions
|
|
@ -32,7 +32,7 @@ window.StartSync(); // the event loop
|
|||
- `Update()` computes `mouseDelta` and calls `Render()`, which waits for the fence of its frame slot (three frames in flight, `Window::numFrames`; the slot is `window.currentFrame`), acquires a swapchain image (`window.currentImage`; the swapchain has as many images as its present mode needs, not necessarily three), writes it into this window's swapchain slots of the heap, reclaims deferred deletions, begins `drawCmdBuffers[currentFrame]`, binds the heap (or the descriptor set), fires `onUpdate(FrameTime)`, records every pass in `passes` in order with a barrier on the swapchain image between them, transitions the image to present, submits and presents. Per-frame GPU work of your own (a TLAS rebuild, a physics dispatch) is recorded from `onUpdate` into `window.drawCmdBuffers[window.currentFrame]`; the heaps are bound by then and the passes come after. A `Resize` or `SetPresentMode` from a listener of the frame (an input action ticked in `onUpdate`, say) takes effect after its present. `onResize` fires after a swapchain rebuild (a new size or present mode); the swapchain slots follow by themselves, and anything else that holds the old images or is sized after the window is made again there.
|
||||
- A `RenderPass` is `Record(cmd, frameIdx, window)` plus `SwapchainStage()`, which must return the stage that touches the swapchain image (`COMPUTE_SHADER`, `RAY_TRACING_SHADER` or `TRANSFER`): the frame's barriers take their stage and access masks from it. `frameIdx` is the frame slot, `window.currentFrame`, not the image: the frame's image is `window.images[window.currentImage]`. The image stays in `VK_IMAGE_LAYOUT_GENERAL` through the whole pass list, with `STORAGE | TRANSFER_SRC` usage and `TRANSFER_DST` where the surface allows it (`window.swapchainUsage`). Nothing clears it: draw a background quad, or clear from a transfer pass where `TRANSFER_DST` is there.
|
||||
- `window.width` and `height` are physical pixels, as is `currentMousePos`. `CaptureNextFrame(path)` saves the next frame as a PNG. `SetTitle`, `SetAppId`, `SetCursorImage`, `SetDefaultCursor`; `SetTextInput(active, x, y, w, h, purpose)` tells the compositor's input method a text field has focus, which opens an on-screen keyboard on a phone.
|
||||
- Several windows can exist (on Wayland `Device::windows` lists them; the other backends keep no list), and may share a heap, each writing only its own swapchain slots, but they share one frame clock: one rendering window is the assumption behind the deletion queue and the staging rings.
|
||||
- Several windows can exist (on Wayland `Device::windows` lists them; the other backends keep no list), and may share a heap, each writing only its own swapchain slots, but they share one frame clock: one rendering window is the assumption behind the deletion queue, the staging rings and the font atlas's evictions. Without a `Window`, nothing advances `Device::frameCounter`: a program that draws text without one steps it itself after each frame's work, or its full atlas never frees a cell and new glyphs stay blank.
|
||||
|
||||
### Device and features
|
||||
|
||||
|
|
@ -51,7 +51,7 @@ Frames are pipelined three deep and nothing waits for the GPU at steady state, s
|
|||
- `Window` is neither movable nor copyable, and its destructor waits for the device, so a window can be closed and another opened.
|
||||
- `ImageVulkan` has no destructor: call `Destroy()` once the GPU is done with it. `FontAtlas` waits for the queue and destroys its own image.
|
||||
- Heap slots (`ImageSlot`, `BufferSlot`, `SamplerSlot`) are RAII handles that return the slot on destruction, and a returned slot is handed out again at once. Free a slot only when no frame in flight reads it (after a `vkQueueWaitIdle`, or at a scene switch), and before its heap goes. In descriptor-set mode a slot write destroys the previous view immediately: same rule.
|
||||
- `RenderingElement3D::elements` holds raw pointers: `Add`/`Remove` elements whose storage does not move (reserve the vector, or make them `static`). `UIRenderer::ShapeText` and `FontAtlas` cache by `Font*`: call `ui.InvalidateFont(font)` before destroying a `Font`.
|
||||
- `RenderingElement3D::elements` holds raw pointers: `Add`/`Remove` elements whose storage does not move (reserve the vector, or make them `static`). `UIRenderer::ShapeText` and `FontAtlas` cache by `Font*`: call `ui.InvalidateFont(font)`, which clears both, before destroying or replacing a `Font` (the atlas keeps the `Font*` of a glyph waiting for a cell until the next frame).
|
||||
|
||||
### The bindless heap
|
||||
|
||||
|
|
@ -77,13 +77,13 @@ Setup: `FontAtlas atlas; atlas.Initialize(init);` then `UIRenderer ui; ui.fontAt
|
|||
|
||||
Per frame, in an `EventListener<UIBuildArgs>` on `ui.onBuild` (fired from the pass's `Record` with `{cmd, frameIdx}`):
|
||||
|
||||
1. Rebuild the item arrays in your mapped buffers, in draw order: later items overdraw earlier ones. Lay out with `Rect::FromWindow(window)`, `SubRect(size, Anchor)` and `Inset(...)`.
|
||||
1. Rebuild the item arrays in your mapped buffers, in draw order: later items overdraw earlier ones. Lay out with `Rect::FromWindow(window)`, `SubRect(size, Anchor)` and `Inset(...)`. Keep one set of item buffers (and slots) per frame in flight, `Window::numFrames` of them, and fill the one at `frameIdx`: a frame still on the GPU reads its items as it runs, and a later frame's items written into the same buffer can name atlas cells that frame drew other glyphs in (`examples/HelloUI`).
|
||||
2. `buf.FlushDevice(cmd, VK_ACCESS_SHADER_READ_BIT, VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT)` for each buffer with items.
|
||||
3. Dispatch: `ui.DispatchQuads(cmd, slot, count, clipRectPx = {0, 0, 1e9, 1e9})`, `DispatchCircles`, `DispatchImages`, `DispatchText`, or `ui.DispatchFused(cmd, {quadsSlot, qc}, {circlesSlot, cc}, {imagesSlot, ic}, {glyphsSlot, gc})`, which composites the four categories in that fixed order (quads, circles, images, text) with one load and one store; an empty category costs nothing. The two forms mix; a custom `ui.Dispatch` between two `DispatchFused` calls is the flush point.
|
||||
|
||||
- The item structs are `std430` PODs mirrored byte for byte in `shaders/ui-shared.glsl`, and frozen: `QuadItem` (64 bytes: `x, y, w, h`, `r, g, b, a`, corner radii `cTL, cTR, cBR, cBL`, `outline, oR, oG, oB`), `CircleItem` (48: `cx, cy, radius, _p0`, colour, outline), `ImageItem` (64: rect, `u0, v0, u1, v1`, tint, `texSlot, sampSlot, _p1, _p2`), `GlyphItem` (48: rect, atlas uv, colour). Colours are straight alpha, blended source-over; coordinates are window pixels.
|
||||
- Cost: every kernel dispatches one workgroup per 8x8 screen tile and each pixel walks every item (behind a per-tile cull), so a dispatch is `O(W*H*N)`, fine up to a few hundred items at 1080p. Splitting one buffer into several dispatches does not help. Thousands of items want a different shader (tile binning), which is Tier 1.
|
||||
- Text: `Font font("font.ttf")` (TrueType through stb_truetype); `font.GetLineWidth(text, px)`, `LineHeight(px)`, `AscentPx(px)`, `NearestCursorByte(text, px, x)`. The atlas is a 1024 by 1024 SDF atlas at a 32 px base size, filled lazily per codepoint and re-uploaded by dirty rectangle; glyphs scale from it, so very large text goes soft. `ui.ShapeText(font, px, x, baselineY, utf8, color, out, capacity, &advance, TextAlign::Left)` writes `GlyphItem`s: single line, no kerning, no wrapping, a shaped-run cache behind it.
|
||||
- Text: `Font font("font.ttf")` (TrueType through stb_truetype); `font.GetLineWidth(text, px)`, `LineHeight(px)`, `AscentPx(px)`, `NearestCursorByte(text, px, x)`. The atlas is a 1024 by 1024 SDF atlas at a 32 px base size, filled lazily per codepoint and re-uploaded by dirty rectangle; glyphs scale from it, so very large text goes soft. `ui.ShapeText(font, px, x, baselineY, utf8, color, out, capacity, &advance, TextAlign::Left)` writes `GlyphItem`s: single line, no kerning, no wrapping, a shaped-run cache behind it. Once the atlas is full, a glyph that finds no free room draws blank for one frame but keeps its advance, and gets a cell when the next frame begins, taken from the glyphs drawn least recently, never one drawn in the last `Device::framesInFlight + 1` frames. So a `GlyphItem` holds for the frame it was shaped for: shape text every frame (the Tier 3 components do, and a cached run costs a copy) rather than drawing a glyph buffer filled in an earlier frame. `atlas.EnsureAndGet(font, codepoint)` returns `const Glyph&` and never fails; its UVs hold only when `Resident()`, and a `Lookup` pointer only until the atlas's next `EnsureAndGet`, `Touch` or `Update`. A Tier 1 kernel that samples `FontAtlasImageSlot()` through `ui.Dispatch` sees the frame's new glyphs: every dispatch uploads the atlas first.
|
||||
- Tier 3: fill a `UIBuffer` (`quads`, `quadCount`, `quadCap`; `glyphs`, `glyphCount`, `glyphCap`; `images`, `imageCount`, `imageCap`; `atlas`; `renderer`; a null pointer makes the components that need it no-ops) and call `DrawButton(buf, rect, label, hovered, pressed, font, px, ButtonColors)`, `DrawCheckbox(buf, rect, checked, hovered, CheckboxColors)`, `DrawSlider(buf, rect, t01, dragging, SliderColors)`, `DrawProgressBar(buf, rect, t01, ProgressColors)`, `DrawText(buf, text, x, baselineY, font, px, color, align)` (returns the advance) and `DrawImage(buf, rect, textureSlot, samplerSlot, tint, uv)`. Colours are small `*Colors` aggregates you hold; there is no `Theme`.
|
||||
- `InputField` is the model for a stateful component: a POD (`value`, `type`, `focused`, `cursorPos`), free functions you call from your own listeners (`InputField_OnText(field, utf8)` from `onTextInput`, `InputField_OnKey(field, code)` from `onRawKeyDown`, `InputField_HitTestCursor(field, rect, clickX, font, px, colors)`), `InputField_TryParse<T>(field)`, and `DrawInputField(buf, field, rect, font, px, colors, caretVisible)`; the blink is yours.
|
||||
- Images in the UI: `auto tex = ui.RegisterImage(image, VK_FORMAT_R8G8B8A8_UNORM);` (an `ImageSlot`; the layout defaults to `SHADER_READ_ONLY_OPTIMAL`), `auto samp = ui.RegisterLinearClampSampler();`, then both go into `ImageItem::texSlot` and `sampSlot`. `ui.OutImageSlot()`, `FontAtlasImageSlot()` and `FontAtlasSamplerSlot()` expose the renderer's own slots.
|
||||
|
|
|
|||
|
|
@ -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/<name>.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<RenderingElement3D>` 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.
|
||||
|
|
|
|||
11
README.md
11
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`;
|
||||
|
|
|
|||
|
|
@ -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<QuadItem, true> quadsBuf;
|
||||
GraphicsBuffer<CircleItem, true> circlesBuf;
|
||||
GraphicsBuffer<GlyphItem, true> 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<QuadItem, true> quads;
|
||||
GraphicsBuffer<CircleItem, true> circles;
|
||||
GraphicsBuffer<GlyphItem, true> glyphs;
|
||||
BufferSlot quadsSlot, circlesSlot, glyphsSlot;
|
||||
};
|
||||
std::array<FrameItems, Window::numFrames> frames;
|
||||
for (FrameItems& f : frames) {
|
||||
#ifndef CRAFTER_GRAPHICS_WINDOW_DOM
|
||||
quadsBuf.Create(
|
||||
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);
|
||||
circlesBuf.Create(
|
||||
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);
|
||||
glyphsBuf.Create(
|
||||
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<UIBuildArgs> buildSub(&ui.onBuild, [&](UIBuildArgs a) {
|
||||
auto cmd = a.cmd;
|
||||
FrameItems& f = frames[a.frameIdx];
|
||||
|
||||
// Update demo progress.
|
||||
progress = std::fmod(progress + 0.005f, 1.0f);
|
||||
|
|
@ -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},
|
||||
{f.quadsSlot, qc},
|
||||
{f.circlesSlot, cc},
|
||||
{}, // no images this frame
|
||||
{glyphsSlot, gc});
|
||||
{f.glyphsSlot, gc});
|
||||
});
|
||||
|
||||
window.FinishInit();
|
||||
|
|
|
|||
|
|
@ -15,9 +15,9 @@ module Crafter.Graphics:FontAtlas_impl;
|
|||
import :FontAtlas;
|
||||
import :Font;
|
||||
import :GraphicsTypes;
|
||||
import :Device;
|
||||
#ifndef CRAFTER_GRAPHICS_WINDOW_DOM
|
||||
import :ImageVulkan;
|
||||
import :Device;
|
||||
#else
|
||||
import :WebGPU;
|
||||
#endif
|
||||
|
|
@ -25,6 +25,241 @@ import std;
|
|||
|
||||
using namespace Crafter;
|
||||
|
||||
// ─── Packer ─────────────────────────────────────────────────────────────
|
||||
|
||||
std::uint32_t FontAtlas::Packer::Allocate(int w, int h, std::uint64_t now, std::uint32_t framesInFlight, bool evict) {
|
||||
evicted_.clear();
|
||||
cleared_.clear();
|
||||
if (w <= 0 || h <= 0 || w > size_ || h > size_) return GlyphHandle::kNone;
|
||||
if (failedFrame_ != now || failedDepth_ != framesInFlight) {
|
||||
failedFrame_ = now;
|
||||
failedDepth_ = framesInFlight;
|
||||
failedEvicting_.clear();
|
||||
failedFree_.clear();
|
||||
}
|
||||
auto refused = [&](const std::vector<Box>& boxes) {
|
||||
return std::ranges::any_of(boxes, [&](const Box& b) { return w >= b.w && h >= b.h; });
|
||||
};
|
||||
// A box no eviction found room for has no free room either.
|
||||
if (refused(failedEvicting_) || (!evict && refused(failedFree_))) return GlyphHandle::kNone;
|
||||
|
||||
// A glyph used in frame u is drawn by that frame's dispatches, which the
|
||||
// frame loop has waited for at the start of frame u + framesInFlight; by
|
||||
// the end of the frame before, and the callbacks that run in between,
|
||||
// Device::frameCounter already reads u + framesInFlight. So a cell goes
|
||||
// from frame u + framesInFlight + 1 on: never sooner than two frames
|
||||
// after its use, even before a Window has set framesInFlight, and on the
|
||||
// DOM backend (one frame in flight) a glyph the last frame drew, the
|
||||
// likeliest to be drawn again, stays.
|
||||
const std::uint64_t depth = std::uint64_t{std::max<std::uint32_t>(framesInFlight, 1)} + 1;
|
||||
auto cold = [&](std::uint32_t slot) { return slots_[slot].lastUse + depth <= now; };
|
||||
auto end = [&](std::uint32_t slot) { return slots_[slot].cell.x + slots_[slot].cell.w; };
|
||||
|
||||
// The first gap wide enough on the first shelf tall enough, else a new
|
||||
// shelf as tall as the glyph. Until something is evicted a shelf's only
|
||||
// gap is its tail, so this fills the atlas exactly as the shelves always
|
||||
// did.
|
||||
for (std::size_t i = 0; i < shelves_.size(); ++i) {
|
||||
const Shelf& s = shelves_[i];
|
||||
if (s.height < h) continue;
|
||||
int x = 0;
|
||||
bool gap = false;
|
||||
for (std::uint32_t slot : s.slots) {
|
||||
if (slots_[slot].cell.x - x >= w) { gap = true; break; }
|
||||
x = end(slot);
|
||||
}
|
||||
if (gap || size_ - x >= w) return Insert(i, i, x, w, h, now);
|
||||
}
|
||||
if (size_ - nextShelfY_ >= h) {
|
||||
OpenShelf(h);
|
||||
return Insert(shelves_.size() - 1, shelves_.size() - 1, 0, w, h, now);
|
||||
}
|
||||
|
||||
// The same w columns of the fewest shelves from some shelf on that are
|
||||
// together at least h tall: one shelf as tall, or several shorter ones,
|
||||
// with the free rows below the last for the rows they are short. The
|
||||
// glyphs in flight there cut the columns into stretches of free texels
|
||||
// and cold glyphs; in each stretch at least w wide, a window starts at
|
||||
// either end and on either side of each cold glyph. The best window's
|
||||
// newest glyph was used longest ago (one over free texels alone has
|
||||
// none, and wins); then it evicts the fewest glyphs, then it leaves the
|
||||
// fewest rows of the run below the glyph. Without `evict` only windows
|
||||
// over free texels count.
|
||||
struct Window {
|
||||
std::size_t first = 0, last = 0; // the shelves it covers
|
||||
int x = 0;
|
||||
int extra = 0; // the free rows it opens as one more shelf
|
||||
std::uint64_t newest = 0;
|
||||
std::size_t count = 0;
|
||||
int waste = 0;
|
||||
bool found = false;
|
||||
} best;
|
||||
for (std::size_t i = 0; i < shelves_.size(); ++i) {
|
||||
std::size_t j = i;
|
||||
int height = shelves_[i].height;
|
||||
while (height < h && j + 1 < shelves_.size()) height += shelves_[++j].height;
|
||||
const int extra = std::max(h - height, 0);
|
||||
if (extra > size_ - nextShelfY_) break; // nor from any shelf further down
|
||||
|
||||
// The run's glyphs by column, each once: one on several shelves is on
|
||||
// each one's list. A single shelf's list is in order already.
|
||||
std::span<const std::uint32_t> glyphs = shelves_[i].slots;
|
||||
if (j > i) {
|
||||
run_.clear();
|
||||
for (std::size_t k = i; k <= j; ++k) run_.insert(run_.end(), shelves_[k].slots.begin(), shelves_[k].slots.end());
|
||||
std::ranges::sort(run_, [&](std::uint32_t a, std::uint32_t b) {
|
||||
return slots_[a].cell.x != slots_[b].cell.x ? slots_[a].cell.x < slots_[b].cell.x : a < b;
|
||||
});
|
||||
run_.erase(std::unique(run_.begin(), run_.end()), run_.end());
|
||||
glyphs = run_;
|
||||
}
|
||||
int widest = 0;
|
||||
for (std::uint32_t slot : glyphs) widest = std::max(widest, slots_[slot].cell.w);
|
||||
|
||||
auto consider = [&](int x) {
|
||||
Window win{ .first = i, .last = j, .x = x, .extra = extra,
|
||||
.waste = height + extra - h, .found = true };
|
||||
// A glyph over [x, x + w) starts before x + w and ends after x,
|
||||
// so it starts at x - widest or later.
|
||||
auto it = std::ranges::lower_bound(glyphs, x - widest, {},
|
||||
[&](std::uint32_t slot) { return slots_[slot].cell.x; });
|
||||
for (; it != glyphs.end() && slots_[*it].cell.x < x + w; ++it) {
|
||||
if (end(*it) <= x) continue;
|
||||
win.newest = std::max(win.newest, slots_[*it].lastUse);
|
||||
++win.count;
|
||||
}
|
||||
if (win.count > 0 && !evict) return;
|
||||
if (!best.found || win.newest < best.newest
|
||||
|| (win.newest == best.newest && (win.count < best.count
|
||||
|| (win.count == best.count && win.waste < best.waste)))) {
|
||||
best = win;
|
||||
}
|
||||
};
|
||||
int left = 0; // where the stretch starts: past every glyph in flight so far
|
||||
auto stretch = [&](int right) {
|
||||
if (right - left < w) return;
|
||||
consider(left);
|
||||
consider(right - w);
|
||||
for (std::uint32_t slot : glyphs) {
|
||||
const Cell& c = slots_[slot].cell;
|
||||
if (!cold(slot) || c.x + c.w <= left || c.x >= right) continue;
|
||||
consider(std::clamp(c.x, left, right - w));
|
||||
consider(std::clamp(c.x + c.w, left, right - w));
|
||||
}
|
||||
};
|
||||
for (std::uint32_t slot : glyphs) {
|
||||
if (cold(slot)) continue;
|
||||
stretch(slots_[slot].cell.x);
|
||||
left = std::max(left, end(slot));
|
||||
}
|
||||
stretch(size_);
|
||||
}
|
||||
if (best.found) {
|
||||
// Collected before evicting: Evict edits the lists.
|
||||
run_.clear();
|
||||
for (std::size_t k = best.first; k <= best.last; ++k) {
|
||||
for (std::uint32_t slot : shelves_[k].slots) {
|
||||
if (slots_[slot].cell.x < best.x + w && end(slot) > best.x) run_.push_back(slot);
|
||||
}
|
||||
}
|
||||
std::ranges::sort(run_);
|
||||
run_.erase(std::unique(run_.begin(), run_.end()), run_.end());
|
||||
for (std::uint32_t slot : run_) Evict(slot);
|
||||
if (best.extra > 0) {
|
||||
OpenShelf(best.extra);
|
||||
best.last = shelves_.size() - 1;
|
||||
}
|
||||
return Insert(best.first, best.last, best.x, w, h, now);
|
||||
}
|
||||
|
||||
// No box this one holds needs remembering any more.
|
||||
std::vector<Box>& failed = evict ? failedEvicting_ : failedFree_;
|
||||
std::erase_if(failed, [&](const Box& b) { return b.w >= w && b.h >= h; });
|
||||
failed.push_back({ w, h });
|
||||
return GlyphHandle::kNone;
|
||||
}
|
||||
|
||||
std::uint32_t FontAtlas::Packer::Insert(std::size_t first, std::size_t last, int x, int w, int h, std::uint64_t now) {
|
||||
std::uint32_t slot;
|
||||
if (!freeSlots_.empty()) {
|
||||
slot = freeSlots_.back();
|
||||
freeSlots_.pop_back();
|
||||
} else {
|
||||
slot = static_cast<std::uint32_t>(slots_.size());
|
||||
slots_.emplace_back();
|
||||
}
|
||||
const int y = shelves_[first].y;
|
||||
slots_[slot].cell = { x, y, w, h };
|
||||
slots_[slot].lastUse = now;
|
||||
for (std::size_t k = first; k <= last; ++k) {
|
||||
std::vector<std::uint32_t>& v = shelves_[k].slots;
|
||||
v.insert(std::ranges::find_if(v, [&](std::uint32_t other) { return slots_[other].cell.x > x; }), slot);
|
||||
}
|
||||
shelves_[first].dirty.Add(x, y, w, h);
|
||||
return slot;
|
||||
}
|
||||
|
||||
void FontAtlas::Packer::Evict(std::uint32_t slot) {
|
||||
const Cell c = slots_[slot].cell;
|
||||
const std::size_t first = ShelfAt(c.y);
|
||||
for (std::size_t k = first; k < shelves_.size() && shelves_[k].y < c.y + c.h; ++k) {
|
||||
std::vector<std::uint32_t>& v = shelves_[k].slots;
|
||||
if (auto it = std::ranges::find(v, slot); it != v.end()) v.erase(it);
|
||||
}
|
||||
cleared_.push_back(c);
|
||||
shelves_[first].dirty.Add(c.x, c.y, c.w, c.h);
|
||||
++slots_[slot].generation;
|
||||
freeSlots_.push_back(slot);
|
||||
evicted_.push_back(slot);
|
||||
failedFree_.clear();
|
||||
}
|
||||
|
||||
void FontAtlas::Packer::OpenShelf(int height) {
|
||||
shelves_.push_back(Shelf{ .y = nextShelfY_, .height = height });
|
||||
nextShelfY_ += height;
|
||||
}
|
||||
|
||||
std::size_t FontAtlas::Packer::ShelfAt(int y) const noexcept {
|
||||
const auto above = std::ranges::upper_bound(shelves_, y, {}, &Shelf::y);
|
||||
return static_cast<std::size_t>(above - shelves_.begin()) - 1;
|
||||
}
|
||||
|
||||
bool FontAtlas::Packer::Touch(std::span<const GlyphHandle> handles, std::uint64_t now) noexcept {
|
||||
bool all = true;
|
||||
for (const GlyphHandle& handle : handles) {
|
||||
if (handle.slot < slots_.size() && slots_[handle.slot].generation == handle.generation) {
|
||||
slots_[handle.slot].lastUse = now;
|
||||
} else {
|
||||
all = false;
|
||||
}
|
||||
}
|
||||
return all;
|
||||
}
|
||||
|
||||
// ─── FontAtlas ──────────────────────────────────────────────────────────
|
||||
|
||||
namespace {
|
||||
// A glyph's advance and box at the atlas's base size, without rasterising
|
||||
// it. The box is the one stbtt_GetGlyphSDF rasterises into, the glyph's
|
||||
// bitmap box grown by the padding: a glyph is laid out, and its cell
|
||||
// found, before its SDF exists, and without one when there is no room.
|
||||
void Measure(const Font& font, std::uint32_t codepoint, Glyph& g) {
|
||||
const float fontScale = stbtt_ScaleForPixelHeight(&font.font, FontAtlas::kBaseSize);
|
||||
const int index = stbtt_FindGlyphIndex(&font.font, static_cast<int>(codepoint));
|
||||
int advanceUnits = 0, lsb = 0;
|
||||
stbtt_GetGlyphHMetrics(&font.font, index, &advanceUnits, &lsb);
|
||||
g.advance = advanceUnits * fontScale;
|
||||
|
||||
int x0 = 0, y0 = 0, x1 = 0, y1 = 0;
|
||||
stbtt_GetGlyphBitmapBoxSubpixel(&font.font, index, fontScale, fontScale, 0.0f, 0.0f, &x0, &y0, &x1, &y1);
|
||||
if (x0 == x1 || y0 == y1) return; // no ink (a space): nothing to place
|
||||
g.xoff = static_cast<float>(x0 - FontAtlas::kPadding);
|
||||
g.yoff = static_cast<float>(y0 - FontAtlas::kPadding);
|
||||
g.w = static_cast<float>(x1 - x0 + 2 * FontAtlas::kPadding);
|
||||
g.h = static_cast<float>(y1 - y0 + 2 * FontAtlas::kPadding);
|
||||
}
|
||||
}
|
||||
|
||||
std::uint8_t* FontAtlas::PixelPtr() noexcept {
|
||||
#ifndef CRAFTER_GRAPHICS_WINDOW_DOM
|
||||
return image.buffer.value;
|
||||
|
|
@ -59,95 +294,128 @@ void FontAtlas::Initialize(GraphicsCommandBuffer cmd) {
|
|||
// first Update clears it once; thereafter only glyph sub-rects flip
|
||||
// dirty, and every untouched texel stays the zero it was uploaded as —
|
||||
// so partial uploads keep the image byte-identical to the staging copy.
|
||||
// An evicted glyph's texels are zeroed again and uploaded with the glyph
|
||||
// that takes its place, which keeps that so.
|
||||
MarkDirty(0, 0, kAtlasSize, kAtlasSize);
|
||||
}
|
||||
|
||||
bool FontAtlas::ShelfPlace(int w, int h, int& outX, int& outY, int& outShelf) {
|
||||
for (std::size_t i = 0; i < shelves_.size(); ++i) {
|
||||
Shelf& s = shelves_[i];
|
||||
if (h <= s.height && s.cursorX + w <= kAtlasSize) {
|
||||
outX = s.cursorX;
|
||||
outY = s.y;
|
||||
outShelf = static_cast<int>(i);
|
||||
s.cursorX += w;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if (nextShelfY_ + h > kAtlasSize) return false;
|
||||
Shelf s{};
|
||||
s.y = nextShelfY_;
|
||||
s.height = h;
|
||||
s.cursorX = w;
|
||||
outX = 0;
|
||||
outY = s.y;
|
||||
outShelf = static_cast<int>(shelves_.size());
|
||||
shelves_.push_back(s);
|
||||
nextShelfY_ += h;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool FontAtlas::Ensure(Font& font, std::uint32_t codepoint) {
|
||||
return EnsureAndGet(font, codepoint) != nullptr;
|
||||
const Glyph& g = EnsureAndGet(font, codepoint);
|
||||
return g.w == 0 || g.Resident();
|
||||
}
|
||||
|
||||
const Glyph* FontAtlas::EnsureAndGet(Font& font, std::uint32_t codepoint) {
|
||||
Key key{&font, codepoint};
|
||||
if (auto it = cache_.find(key); it != cache_.end()) return &it->second;
|
||||
const Glyph& FontAtlas::EnsureAndGet(Font& font, std::uint32_t codepoint) {
|
||||
const std::uint64_t now = Device::frameCounter;
|
||||
if (settled_ != now) Settle(now);
|
||||
const Key key{&font, codepoint};
|
||||
auto [it, inserted] = cache_.try_emplace(key);
|
||||
Entry& e = it->second;
|
||||
if (inserted) Measure(font, codepoint, e.glyph);
|
||||
if (e.glyph.w > 0) {
|
||||
if (e.glyph.Resident()) {
|
||||
packer_.Touch(e.glyph.handle.slot, now);
|
||||
} else if (!Place(font, key, e, now, /*evict*/ false) && e.waiting != now) {
|
||||
e.waiting = now;
|
||||
waiting_.push_back(key);
|
||||
}
|
||||
}
|
||||
return e.glyph;
|
||||
}
|
||||
|
||||
float fontScale = stbtt_ScaleForPixelHeight(&font.font, kBaseSize);
|
||||
void FontAtlas::Settle(std::uint64_t now) {
|
||||
settled_ = now;
|
||||
// Every glyph the last frame drew was marked in it, and the Packer
|
||||
// keeps those: an eviction now takes only glyphs that frame did not draw,
|
||||
// where one made while a frame is still asking for glyphs takes any it
|
||||
// has yet to ask for. The waiting glyphs go in the order first asked for.
|
||||
const std::vector<Key> waiting = std::exchange(waiting_, {});
|
||||
for (const Key& key : waiting) {
|
||||
auto it = cache_.find(key);
|
||||
if (it == cache_.end() || it->second.glyph.Resident()) continue;
|
||||
Place(*key.font, key, it->second, now, /*evict*/ true);
|
||||
}
|
||||
}
|
||||
|
||||
int advanceUnits = 0, lsb = 0;
|
||||
stbtt_GetCodepointHMetrics(&font.font, static_cast<int>(codepoint), &advanceUnits, &lsb);
|
||||
bool FontAtlas::Place(const Font& font, const Key& key, Entry& e, std::uint64_t now, bool evict) {
|
||||
Glyph& g = e.glyph;
|
||||
const std::uint32_t slot = packer_.Allocate(static_cast<int>(g.w), static_cast<int>(g.h), now, Device::framesInFlight, evict);
|
||||
|
||||
// The glyphs whose cells it took are forgotten, so their next use
|
||||
// measures and places them again; `e`, which has no cell yet, is never
|
||||
// among them. A key cached afresh since its glyph got the cell is left.
|
||||
for (std::uint32_t victim : packer_.Evicted()) {
|
||||
auto owner = cache_.find(owners_[victim]);
|
||||
if (owner != cache_.end() && owner->second.glyph.handle.slot == victim) cache_.erase(owner);
|
||||
}
|
||||
std::uint8_t* pixels = PixelPtr();
|
||||
for (const Packer::Cell& c : packer_.Cleared()) {
|
||||
for (int row = c.y; row < c.y + c.h; ++row) {
|
||||
std::memset(pixels + static_cast<std::size_t>(row) * kAtlasSize + c.x, 0, static_cast<std::size_t>(c.w));
|
||||
}
|
||||
}
|
||||
if (slot == GlyphHandle::kNone) return false;
|
||||
|
||||
const Packer::Cell& cell = packer_.CellOf(slot);
|
||||
const float fontScale = stbtt_ScaleForPixelHeight(&font.font, kBaseSize);
|
||||
// The index from the font as it is now: a Font replaced in place keeps
|
||||
// its address, and an index measured in the old one can lie past the
|
||||
// new one's glyphs.
|
||||
const int index = stbtt_FindGlyphIndex(&font.font, static_cast<int>(key.cp));
|
||||
int sw = 0, sh = 0, xoff = 0, yoff = 0;
|
||||
unsigned char* sdf = stbtt_GetCodepointSDF(
|
||||
&font.font, fontScale, static_cast<int>(codepoint),
|
||||
unsigned char* sdf = stbtt_GetGlyphSDF(
|
||||
&font.font, fontScale, index,
|
||||
kPadding, static_cast<unsigned char>(kOnEdgeValue), kPixelDistScale,
|
||||
&sw, &sh, &xoff, &yoff
|
||||
);
|
||||
|
||||
Glyph g{};
|
||||
g.advance = advanceUnits * fontScale;
|
||||
g.xoff = static_cast<float>(xoff);
|
||||
g.yoff = static_cast<float>(yoff);
|
||||
|
||||
if (sdf && sw > 0 && sh > 0) {
|
||||
int px = 0, py = 0, shelf = 0;
|
||||
if (!ShelfPlace(sw, sh, px, py, shelf)) {
|
||||
stbtt_FreeSDF(sdf, nullptr);
|
||||
return nullptr;
|
||||
}
|
||||
std::uint8_t* dst = PixelPtr();
|
||||
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(
|
||||
dst + (py + row) * kAtlasSize + px,
|
||||
pixels + static_cast<std::size_t>(cell.y + row) * kAtlasSize + cell.x,
|
||||
sdf + row * sw,
|
||||
static_cast<std::size_t>(sw)
|
||||
);
|
||||
}
|
||||
stbtt_FreeSDF(sdf, nullptr);
|
||||
|
||||
g.w = static_cast<float>(sw);
|
||||
g.h = static_cast<float>(sh);
|
||||
g.u0 = static_cast<float>(px) / kAtlasSize;
|
||||
g.v0 = static_cast<float>(py) / kAtlasSize;
|
||||
g.u1 = static_cast<float>(px + sw) / kAtlasSize;
|
||||
g.v1 = static_cast<float>(py + sh) / kAtlasSize;
|
||||
// Mark the *shelf's* span, not one global box — keeps each upload
|
||||
// tight when glyphs land on different shelves the same frame (#129).
|
||||
shelves_[shelf].dirty.Add(px, py, sw, sh);
|
||||
dirty = true;
|
||||
}
|
||||
stbtt_FreeSDF(sdf, nullptr);
|
||||
}
|
||||
++rasterized_;
|
||||
|
||||
return &cache_.emplace(key, g).first->second;
|
||||
g.u0 = static_cast<float>(cell.x) / kAtlasSize;
|
||||
g.v0 = static_cast<float>(cell.y) / kAtlasSize;
|
||||
g.u1 = static_cast<float>(cell.x + cell.w) / kAtlasSize;
|
||||
g.v1 = static_cast<float>(cell.y + cell.h) / kAtlasSize;
|
||||
g.handle = packer_.Handle(slot);
|
||||
if (owners_.size() <= slot) owners_.resize(slot + 1);
|
||||
owners_[slot] = key;
|
||||
// The packer has added the cell, and any it cleared, to its shelves'
|
||||
// spans (#129).
|
||||
dirty = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
const Glyph* FontAtlas::Lookup(Font& font, std::uint32_t codepoint) const {
|
||||
auto it = cache_.find(Key{&font, codepoint});
|
||||
return it == cache_.end() ? nullptr : &it->second;
|
||||
return it == cache_.end() ? nullptr : &it->second.glyph;
|
||||
}
|
||||
|
||||
bool FontAtlas::Touch(std::span<const GlyphHandle> handles) {
|
||||
const std::uint64_t now = Device::frameCounter;
|
||||
if (settled_ != now) Settle(now);
|
||||
return packer_.Touch(handles, now);
|
||||
}
|
||||
|
||||
void FontAtlas::InvalidateFont(const Font& font) noexcept {
|
||||
std::erase_if(cache_, [&](const auto& entry) { return entry.first.font == &font; });
|
||||
std::erase_if(waiting_, [&](const Key& key) { return key.font == &font; });
|
||||
}
|
||||
|
||||
void FontAtlas::Update(GraphicsCommandBuffer cmd) {
|
||||
const std::uint64_t now = Device::frameCounter;
|
||||
if (settled_ != now) Settle(now);
|
||||
if (!dirty) return;
|
||||
|
||||
// Issue one tight copy per dirty span and reset it. Clamping to the atlas
|
||||
|
|
@ -178,7 +446,7 @@ void FontAtlas::Update(GraphicsCommandBuffer cmd) {
|
|||
(void)cmd;
|
||||
#endif
|
||||
upload(dirtyRect);
|
||||
for (Shelf& s : shelves_) upload(s.dirty);
|
||||
packer_.ForEachDirty(upload);
|
||||
|
||||
dirty = false;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -84,36 +84,9 @@ std::uint32_t UIRenderer::ShapeText(Font& font, float pxSize,
|
|||
ShapedRunViewKey viewKey{&font, pxSize, color, utf8};
|
||||
auto it = shapedRuns_.find(viewKey);
|
||||
if (it == shapedRuns_.end()) {
|
||||
// Miss: shape from scratch. Ensure() each glyph so brand-new ones
|
||||
// rasterise into the atlas and mark it dirty for the next upload —
|
||||
// a hit skips this, which is safe because atlas entries are never
|
||||
// evicted once placed.
|
||||
const float scale = pxSize / FontAtlas::kBaseSize;
|
||||
// Miss: shape from scratch.
|
||||
ShapedRun run;
|
||||
float cursor = 0.0f;
|
||||
std::size_t i = 0;
|
||||
while (i < utf8.size()) {
|
||||
std::uint32_t cp = DecodeUtf8(utf8, i);
|
||||
if (cp == 0) break;
|
||||
if (cp == '\n') { continue; }
|
||||
|
||||
const Glyph* g = fontAtlas->EnsureAndGet(font, cp);
|
||||
if (g == nullptr) continue;
|
||||
|
||||
if (g->w > 0 && g->h > 0) {
|
||||
GlyphItem gi;
|
||||
gi.x = cursor + g->xoff * scale;
|
||||
gi.y = g->yoff * scale;
|
||||
gi.w = g->w * scale;
|
||||
gi.h = g->h * scale;
|
||||
gi.u0 = g->u0; gi.v0 = g->v0;
|
||||
gi.u1 = g->u1; gi.v1 = g->v1;
|
||||
gi.r = color[0]; gi.g = color[1]; gi.b = color[2]; gi.a = color[3];
|
||||
run.glyphs.push_back(gi);
|
||||
}
|
||||
cursor += g->advance * scale;
|
||||
}
|
||||
run.advance = cursor;
|
||||
ShapeRun(font, pxSize, utf8, color, run);
|
||||
|
||||
// Evict the least-recently-used run (LRU list back) on overflow rather
|
||||
// than clearing the whole cache. The back is whatever has gone longest
|
||||
|
|
@ -134,6 +107,18 @@ std::uint32_t UIRenderer::ShapeText(Font& font, float pxSize,
|
|||
// Hit: promote to most-recently-used. splice is O(1) and keeps lruIt
|
||||
// (and every other iterator) valid.
|
||||
shapedRunsLru_.splice(shapedRunsLru_.begin(), shapedRunsLru_, it->second.lruIt);
|
||||
// The atlas only sees the glyphs it is asked for, so a hit tells it
|
||||
// the run's are in use, or it would take their cells once full (#55).
|
||||
// If it already has taken one, the run samples whatever glyph holds
|
||||
// the cell now: shape it again, as a run with a glyph that had no
|
||||
// room must be until the glyph gets one. Touched first either way, so
|
||||
// the run's glyphs that still hold their cells are marked before the
|
||||
// shaping asks for its missing ones.
|
||||
ShapedRun& hit = it->second;
|
||||
const bool cellsHeld = fontAtlas->Touch(hit.cells);
|
||||
if (!hit.complete || !cellsHeld) {
|
||||
ShapeRun(font, pxSize, utf8, color, hit);
|
||||
}
|
||||
}
|
||||
|
||||
const ShapedRun& run = it->second;
|
||||
|
|
@ -157,6 +142,45 @@ std::uint32_t UIRenderer::ShapeText(Font& font, float pxSize,
|
|||
return written;
|
||||
}
|
||||
|
||||
void UIRenderer::ShapeRun(Font& font, float pxSize, std::string_view utf8,
|
||||
std::array<float,4> color, ShapedRun& run) {
|
||||
// EnsureAndGet each glyph: brand-new ones rasterise into the atlas and
|
||||
// mark it dirty for the next upload, and every one is marked used in this
|
||||
// frame. A glyph that finds no room draws nothing, but still advances
|
||||
// the pen, so the rest of the line stays where it belongs.
|
||||
const float scale = pxSize / FontAtlas::kBaseSize;
|
||||
run.glyphs.clear();
|
||||
run.cells.clear();
|
||||
run.complete = true;
|
||||
float cursor = 0.0f;
|
||||
std::size_t i = 0;
|
||||
while (i < utf8.size()) {
|
||||
std::uint32_t cp = DecodeUtf8(utf8, i);
|
||||
if (cp == 0) break;
|
||||
if (cp == '\n') { continue; }
|
||||
|
||||
const Glyph& g = fontAtlas->EnsureAndGet(font, cp);
|
||||
if (g.w > 0 && g.h > 0) {
|
||||
if (g.Resident()) {
|
||||
GlyphItem gi;
|
||||
gi.x = cursor + g.xoff * scale;
|
||||
gi.y = g.yoff * scale;
|
||||
gi.w = g.w * scale;
|
||||
gi.h = g.h * scale;
|
||||
gi.u0 = g.u0; gi.v0 = g.v0;
|
||||
gi.u1 = g.u1; gi.v1 = g.v1;
|
||||
gi.r = color[0]; gi.g = color[1]; gi.b = color[2]; gi.a = color[3];
|
||||
run.glyphs.push_back(gi);
|
||||
run.cells.push_back(g.handle);
|
||||
} else {
|
||||
run.complete = false;
|
||||
}
|
||||
}
|
||||
cursor += g.advance * scale;
|
||||
}
|
||||
run.advance = cursor;
|
||||
}
|
||||
|
||||
void UIRenderer::InvalidateFont(const Font& font) noexcept {
|
||||
// Drop matching runs from both the map and the LRU order list, keeping the
|
||||
// two in sync (the list points at keys owned by the map).
|
||||
|
|
@ -168,6 +192,10 @@ void UIRenderer::InvalidateFont(const Font& font) noexcept {
|
|||
++it;
|
||||
}
|
||||
}
|
||||
// The atlas's glyphs of the font too: another Font at the same address
|
||||
// would get their metrics and cells, and the glyphs waiting for a cell
|
||||
// keep the Font* until the next frame.
|
||||
if (fontAtlas != nullptr) fontAtlas->InvalidateFont(font);
|
||||
}
|
||||
|
||||
bool UIRenderer::IsShapedRunCached(const Font& font, float pxSize,
|
||||
|
|
|
|||
|
|
@ -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});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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<std::uint64_t> frameCounter = 0;
|
||||
inline static std::uint8_t framesInFlight = 1;
|
||||
#endif
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<const std::uint32_t> Evicted() const noexcept { return evicted_; }
|
||||
std::span<const Cell> 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<const GlyphHandle> 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<typename F>
|
||||
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<std::uint32_t> 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<Shelf> shelves_;
|
||||
std::vector<Slot> slots_;
|
||||
std::vector<std::uint32_t> freeSlots_;
|
||||
std::vector<std::uint32_t> evicted_;
|
||||
std::vector<Cell> cleared_;
|
||||
std::vector<std::uint32_t> 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<Box> failedEvicting_;
|
||||
std::vector<Box> 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<std::uint8_t> 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<const GlyphHandle> 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<Shelf> 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<Key, Glyph, KeyHash> 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<Key, Entry, KeyHash> cache_;
|
||||
// owners_[slot]: the glyph holding a cell, to drop when it is evicted.
|
||||
std::vector<Key> owners_;
|
||||
// The glyphs asked for since the last Settle that found no free room,
|
||||
// in the order they were first asked for.
|
||||
std::vector<Key> 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);
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<GlyphItem> 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<GlyphHandle> 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<const ShapedRunKey*>::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<float,4> 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
|
||||
|
|
|
|||
38
project.cpp
38
project.cpp
|
|
@ -408,6 +408,18 @@ extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> 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<const std::string_view> 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<const std::string_view> 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
|
||||
|
|
|
|||
399
tests/FontAtlasEviction/main.cpp
Normal file
399
tests/FontAtlasEviction/main.cpp
Normal file
|
|
@ -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 <cstdlib>
|
||||
|
||||
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<char>(cp);
|
||||
} else if (cp < 0x800) {
|
||||
s += static_cast<char>(0xC0 | (cp >> 6));
|
||||
s += static_cast<char>(0x80 | (cp & 0x3F));
|
||||
} else {
|
||||
s += static_cast<char>(0xE0 | (cp >> 12));
|
||||
s += static_cast<char>(0x80 | ((cp >> 6) & 0x3F));
|
||||
s += static_cast<char>(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<std::string> InkedLines(Font& font, std::vector<std::vector<std::uint32_t>>& codepoints) {
|
||||
const float scale = stbtt_ScaleForPixelHeight(&font.font, FontAtlas::kBaseSize);
|
||||
std::vector<std::uint32_t> 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<int>(cp)) == 0) continue;
|
||||
int x0 = 0, y0 = 0, x1 = 0, y1 = 0;
|
||||
stbtt_GetCodepointBitmapBox(&font.font, static_cast<int>(cp), scale, scale, &x0, &y0, &x1, &y1);
|
||||
if (x0 != x1 && y0 != y1) pool.push_back(cp);
|
||||
}
|
||||
}
|
||||
std::vector<std::string> 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<GlyphItem> 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<std::uint32_t>(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<int>(u0 * kSize), static_cast<int>(v0 * kSize),
|
||||
static_cast<int>(u1 * kSize), static_cast<int>(v1 * kSize) };
|
||||
}
|
||||
|
||||
std::vector<std::uint8_t> 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<std::uint8_t> 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<const GlyphItem> drawn, std::span<const GlyphItem> 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<std::vector<std::uint8_t>> PixelsOf(FontAtlas& atlas, const Shaped& s) {
|
||||
std::vector<std::vector<std::uint8_t>> 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<std::vector<std::uint32_t>> lineCodepoints;
|
||||
const std::vector<std::string> 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> 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<std::uint8_t> covered(static_cast<std::size_t>(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<std::size_t>(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;
|
||||
}
|
||||
359
tests/FontAtlasPacker/main.cpp
Normal file
359
tests/FontAtlasPacker/main.cpp
Normal file
|
|
@ -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 <cstdlib>
|
||||
|
||||
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<std::uint32_t> Fill(Packer& p, int count, int w, int h, std::uint64_t frame) {
|
||||
std::vector<std::uint32_t> slots;
|
||||
for (int i = 0; i < count; ++i) slots.push_back(Place(p, w, h, frame));
|
||||
return slots;
|
||||
}
|
||||
|
||||
bool AllPlaced(std::span<const std::uint32_t> 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<std::uint32_t, Live> live;
|
||||
std::vector<std::pair<int, int>> 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<int>(rng() % 12);
|
||||
for (int k = 0; k < n; ++k) {
|
||||
const int w = 4 + static_cast<int>(rng() % 37);
|
||||
const int h = 4 + static_cast<int>(rng() % static_cast<std::uint32_t>(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<std::uint32_t> 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<std::uint32_t> 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<std::uint32_t>(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<std::uint32_t> 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<GlyphHandle> 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;
|
||||
}
|
||||
|
|
@ -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<const GlyphItem> a, std::span<const GlyphItem> b) {
|
|||
return true;
|
||||
}
|
||||
|
||||
// The atlas texels under a glyph's UVs, which are texel / kAtlasSize.
|
||||
std::vector<std::uint8_t> AtlasPixels(FontAtlas& atlas, const GlyphItem& g) {
|
||||
constexpr int kSize = FontAtlas::kAtlasSize;
|
||||
const int x0 = static_cast<int>(g.u0 * kSize), y0 = static_cast<int>(g.v0 * kSize);
|
||||
const int x1 = static_cast<int>(g.u1 * kSize), y1 = static_cast<int>(g.v1 * kSize);
|
||||
std::vector<std::uint8_t> 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<const GlyphItem> a, std::span<const GlyphItem> 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<GlyphItem, 64> 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<GlyphItem, 64> 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<GlyphItem, 64> bufRed{};
|
||||
|
|
|
|||
102
tests/UIDispatchAtlasFlush/main.cpp
Normal file
102
tests/UIDispatchAtlasFlush/main.cpp
Normal file
|
|
@ -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 <cstdlib>
|
||||
|
||||
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<QuadItem, true> 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<UIBuildArgs> buildSub(&ui.onBuild, [&](UIBuildArgs a) {
|
||||
if (built) return;
|
||||
built = true;
|
||||
std::array<GlyphItem, 32> glyphs{};
|
||||
ui.ShapeText(font, 24.0f, 10.0f, 40.0f, "Tier 1 kernels", {1, 1, 1, 1},
|
||||
glyphs.data(), static_cast<std::uint32_t>(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;
|
||||
}
|
||||
Loading…
Reference in a new issue