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>
42 KiB
Crafter.Graphics
The Crafter engine's graphics library: a C++ module, import Crafter.Graphics;, that opens a window, drives a Vulkan 1.3+ device through VK_EXT_descriptor_heap (bindless: no descriptor-set layouts, push data through vkCmdPushDataEXT), traces rays through VK_KHR_ray_tracing_pipeline, draws UI from compute shaders, and reads keyboards, mice, touch, gamepads and the clipboard. A wasm32-* build of the same surface runs in the browser on WebGPU with a software ray tracer. Everything is in namespace Crafter. The vectors and matrices are Crafter.Math's (import Crafter.Math;) and the compressed asset formats Crafter.Asset's (import Crafter.Asset;); both ship notes of their own. Window callbacks are Crafter::Event<T> from Crafter.Event (import Crafter.Event;): EventListener<void> sub(&window.onMouseMove, [&] { ... }); subscribes, and the listener's destructor unsubscribes. Source, examples and issues: https://forgejo.catcrafts.net/Catcrafts/Crafter.Graphics (LGPL-3.0-only; the examples under examples/ are MIT and meant to be copied). These notes are its AGENTS.consumer.md: a fix to them goes there, not into the copy crafter-build writes.
Building against it
- The target picks the backend, in the library and in your code. Any
wasm32-*target builds the DOM + WebGPU library; everything else builds Vulkan, with Wayland on Linux (there is no X11 backend) or Win32 onx86_64-w64-mingw32. The library definesCRAFTER_GRAPHICS_WINDOW_WAYLAND,_WIN32or_DOMfor its own units only, so a wasm consumer setscfg.target = "wasm32-wasip1",cfg.defines.push_back({"CRAFTER_GRAPHICS_WINDOW_DOM", ""}),cfg.compileFlags.push_back("-msimd128")(the dependency PCMs were compiled with it) andEnableWasiBrowserRuntime(cfg), asexamples/HelloUI/project.cppdoes. Portable code#ifdef CRAFTER_GRAPHICS_WINDOW_DOMs at the few sites where the backends differ; the library has no shared signatures that are no-ops on one side. - The library's link flags (the Vulkan loader,
wayland-client,xkbcommon,udev, or the Win32 libraries) come with the dependency. Its shaders (ui-*.comp.spvandui-*.comp.sets.spv) and, for wasm,dom-env.jsanddom-webgpu.jsare copied into your executable's bin directory, as are yourcfg.shadersoutputs (<name>.spvfrom<name>.glsl) andcfg.files. Paths are resolved against the working directory at runtime, so run from the bin directory, ascrafter-build -rdoes. - Your own shaders:
cfg.shaders.emplace_back(fs::path("raygen.glsl"), std::string("main"), ShaderType::RayGen), withCompute,RayGen,Miss,ClosestHit,AnyHit,IntersectandCallable. A compute shader for the UI heap is registered twice, see "Custom compute shaders". WGSL for the DOM build ships throughcfg.filesand is loaded at runtime. - Project args the library understands, passed through your
args:--local(sibling checkouts of Crafter.Event, Crafter.Math and Crafter.Asset instead of clones),--timing(meant to add frame timing counters; the build does not compile with it at the moment, issue #178),--no-webgpu(wasm: a DOM-only page without the WebGPU bridge, which otherwise requests a GPU adapter on every load and replaces the page body with an error where WebGPU is missing). - Needs at runtime: Vulkan 1.3 (1.4 is used where the GPU has it), a presentable surface, and the features the library's own shaders use.
VK_EXT_descriptor_heapand ray tracing are optional groups, see "Device and features". Mesa's llvmpipe runs the compute/UI path, and recent Mesa advertises the heap.
A program, and what the loop does
Device::Initialize({.vulkan10 = {.shaderInt16 = VK_TRUE}}); // the features your shaders use; on the DOM build Initialize() takes none
Window window(1280, 720, "Title"); // `static` on the DOM build
VkCommandBuffer init = window.StartInit(); // a one-time setup command buffer
DescriptorHeapVulkan heap;
heap.Initialize(/*images*/ 16, /*buffers*/ 16, /*samplers*/ 4);
window.descriptorHeap = &heap; // bound at the start of every frame
// create buffers, images, meshes, pipelines and passes, recording into `init` ...
window.passes.push_back(&pass); // RenderPass*: a UIRenderer, an RTPass, your own
window.FinishInit(); // submits `init` and waits for it
window.Render(); // one frame
window.StartUpdate(); // frames on the compositor's clock from here
window.StartSync(); // the event loop; never returns
- Include
vulkan/vulkan.hyourself, before the imports (the examples wrap it in#ifndef CRAFTER_GRAPHICS_WINDOW_DOM): the library includes it only in its own global module fragments, and neither Vulkan's types nor its macros (VK_TRUE, theVK_*flags) cross animport. On the DOM buildDevice::Initialize()takes no arguments andDeviceFeaturesdoes not exist, which is why the dual-target examples keep a separate DOMmain. StartSyncruns the platform loop untilopenis false (onClosefires first). Each pass of the loop dispatches platform events into theWindowevents, runsGamepad::Tick()andonBeforeUpdate, and runsUpdate()for a window that is due. On Wayland a window on the compositor's clock updates from its frame callback during that event dispatch, before the pass'sonBeforeUpdate, and the loop wakes for every event, soonBeforeUpdatefires several times per frame; on Win32 and in the browseronBeforeUpdateprecedes the same pass'sUpdate.updateInterval, set beforeStartUpdate, makes a window update every so often whether visible or not; pair it withSetPresentMode(PresentMode::Mailbox)so a present never waits for the display.AddPollFd(fd, callback)(Wayland) wakes the loop for a socket or an eventfd.Update()computesmouseDeltaand callsRender(), which waits for the fence of its frame slot (three frames in flight,Window::numFrames; the slot iswindow.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, beginsdrawCmdBuffers[currentFrame], binds the heap (or the descriptor set), firesonUpdate(FrameTime), records every pass inpassesin 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 fromonUpdateintowindow.drawCmdBuffers[window.currentFrame]; the heaps are bound by then and the passes come after. AResizeorSetPresentModefrom a listener of the frame (an input action ticked inonUpdate, say) takes effect after its present.onResizefires 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
RenderPassisRecord(cmd, frameIdx, window)plusSwapchainStage(), which must return the stage that touches the swapchain image (COMPUTE_SHADER,RAY_TRACING_SHADERorTRANSFER): the frame's barriers take their stage and access masks from it.frameIdxis the frame slot,window.currentFrame, not the image: the frame's image iswindow.images[window.currentImage]. The image stays inVK_IMAGE_LAYOUT_GENERALthrough the whole pass list, withSTORAGE | TRANSFER_SRCusage andTRANSFER_DSTwhere the surface allows it (window.swapchainUsage). Nothing clears it: draw a background quad, or clear from a transfer pass whereTRANSFER_DSTis there. window.widthandheightare physical pixels, as iscurrentMousePos.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::windowslists 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 aWindow, nothing advancesDevice::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
Device::Initialize(required = {}, optional = {})creates the instance, weighs every GPU and creates the device.DeviceFeaturesholds the five core feature structs,vulkan10tovulkan14; only theirVkBool32s are read, so{.vulkan12 = {.shaderFloat16 = VK_TRUE}}is a complete argument. The library enables what its own shaders use and nothing more: auint16_tspec constant needs.vulkan10.shaderInt16, fp16 arithmetic.vulkan12.shaderFloat16, 16-bit storage.vulkan11.storageBuffer16BitAccess, scalar layout.vulkan12.scalarBlockLayout. A GPU lacking a required feature is skipped; when none is left the call throws naming each GPU's gaps, and may be called again asking for less. Optional features are enabled where the GPU has them;Device::enabledFeaturessays which,supportedFeatureswhat the GPU has,apiVersionthe version in use (vulkan14features need a 1.4 GPU;maintenance5is always on).CRAFTER_GRAPHICS_DEVICE=<n>picks GPUn.VulkanShaderchecks every module's SPIR-V capabilities against what was enabled and throws naming the file and the field to pass (vulkan10.shaderInt16), or saying the GPU lacks it. Most drivers accept such a module silently, so this is the error you get here instead of a wrong frame on another machine.- Optional groups, decided once at
Initialize:Device::descriptorHeapSupported(VK_EXT_descriptor_heap),rayTracingSupported(needs the heap too),memoryDecompressionSupported(VK_EXT_memory_decompression:.cmeshand.ctexdecompress on the GPU; otherwise the library decodes them on the CPU),deviceFaultSupported,swapchainMaintenance1Supported(VK_KHR_swapchain_maintenance1, with the instance'ssurface_maintenance1,surfaceMaintenance1Supported: a swapchain names its present mode and gets that mode's own image count; otherwise the stricter of the mode's and the surface's). The RT entry points throw throughDevice::RequireRayTracingwithout ray tracing; the compute/UI path runs everywhere. Device::device,queue,commandPool,physDevice,instance,memoryProperties,deviceProperties,rayTracingPropertiesanddescriptorHeapPropertiesare public statics for raw Vulkan.Device::GetMemoryType(typeBits, required, preferred = 0)andPreferDirectDeviceWrite(bytes)are the allocation helpers.Device::pipelineCacheis shared by every pipeline and written toDevice::pipelineCachePath(pipeline_cache.binin the working directory) at exit. Set the path beforeInitialize, and gitignore the file.
Lifetimes
Frames are pipelined three deep and nothing waits for the GPU at steady state, so:
- Every library object that owns a Vulkan handle (
VulkanBuffer,Mesh,TlasWithBuffer,VulkanShader,ComputeShader,PipelineRTVulkan, andImageVulkan's staging buffer) is move-only and releases the handle throughDevice::EnqueueDeletion; the render loop frees itDevice::framesInFlightframes later. Destroying one mid-frame is safe, and so is destroying it from another thread.VulkanBuffer::Clear()frees at once and is only for a GPU known to be idle;DeferredClear()is the queued form. A program with noWindowreclaims nothing on its own: callDevice::DrainDeletions()aftervkQueueWaitIdle(Device::queue). - Static owners (a
static Mesh, astatic VulkanBuffer) are fine: their destructors run after the driver has unloaded, and the queue allows for that by never being destroyed itself.RenderingElement3D::tlasesis leaked for the same reason. Windowis neither movable nor copyable, and its destructor waits for the device, so a window can be closed and another opened.ImageVulkanhas no destructor: callDestroy()once the GPU is done with it.FontAtlaswaits 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 avkQueueWaitIdle, 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::elementsholds raw pointers:Add/Removeelements whose storage does not move (reserve the vector, or make themstatic).UIRenderer::ShapeTextandFontAtlascache byFont*: callui.InvalidateFont(font), which clears both, before destroying or replacing aFont(the atlas keeps theFont*of a glyph waiting for a cell until the next frame).
The bindless heap
DescriptorHeapVulkan::Initialize(images, buffers, samplers) sizes one resource heap and one sampler heap per frame in flight (resourceHeap[Window::numFrames], samplerHeap[...], mapped VulkanBuffers) and the slot allocators. AllocateImageSlots(n), AllocateBufferSlots(n) and AllocateSamplerSlots(n) return {firstElement, count} ranges (contiguous; freed runs are reused), Free*Slots returns them, and running out throws naming the pool and the counts. Initialize throws too when its buffer slots would need shader-visible indices past 16 bits.
- Buffer slot indices are relative.
AllocateBufferSlotsindexes the heap's buffer region, which starts after the image region, while a GLSLdescriptor_heapSSBO array indexes buffer descriptors from heap byte 0. The shader-visible index isheap.bufferStartElement + raw. The RAIIBufferSlotconverts to exactly that (itsoperator std::uint16_t()),UIRenderer::RegisterBufferreturns one, and raw code passesheap.bufferStartElementas a spec constant: the RT examples declarelayout(constant_id = 0) const uint16_t bufferStart = 0us;and indextopLevelAS[bufferStart], which is why they ask forshaderInt16. Image slots index the heap as they are, in image-descriptor units; samplers live in their own heap. A wrong buffer index reads zeros, which looks like every field of every item being zero. In descriptor-set modebufferStartElementis 0, so code that goes through the conversion is right in both. - Writing a descriptor by hand:
Device::vkWriteResourceDescriptorsEXTintoheap.resourceHeap[f].value + heap.ImageByteOffset(imageRange.firstElement)orheap.BufferByteOffset(bufferRange.firstElement),Device::descriptorHeapProperties.imageDescriptorSizeorbufferDescriptorSizebytes long, for each framef, thenheap.resourceHeap[f].FlushDevice().BufferByteOffsettakes the raw indexAllocateBufferSlotsreturned and adds the region start itself; handing it aBufferSlot, which converts to the absolute index, adds the start twice and puts the descriptor in the wrong place.ImageByteOffsettakes an image slot as it is. Per-frame resources (tlases[f]) are written into framef's heap; a shared resource into every frame's. The swapchain image is neither, since frames and images pair up differently from frame to frame: list its image slot once withheap.AddSwapchainSlot(slot, window), and the window writes the image each frame draws into there, a storage image inGENERAL, in both binding models and across resizes (a slot listed during a frame is written for that frame).examples/VulkanTriangle/main.cppis the pattern. TheUIRenderer::Register*helpers do this for you in both binding models. - Binding the heap outside the frame loop:
Window::Renderbindswindow.descriptorHeapin every frame it records. A command buffer it does not record that dispatches against the heap (window.StartInit()'s, say) callsheap.Bind(cmd, frame), which binds that frame's heaps with their reserved ranges, or in descriptor-set mode its descriptor set. Don't build theVkBindHeapInfoEXTby hand: where the reserved range sits is the heap's to decide. - Descriptor-set fallback. On a driver without
VK_EXT_descriptor_heap(Mesa Turnip on Adreno)Device::descriptorHeapSupportedis false and the same slots live in one bindless descriptor set per frame; slot handles, item structs,UIDispatchHeaderand theUIRendererAPI are unchanged, so application code does not branch. Ray tracing is unavailable there.CRAFTER_GRAPHICS_NO_DESCRIPTOR_HEAPforces this path on a driver that has the heap, to test it. RawvkWriteResourceDescriptorsEXTcode is heap-only; a consumer that must run on such a driver stays on theUIRenderer::Register*helpers.
Buffers, images, samplers
VulkanBuffer<T, Mapped>:Create(usage, memoryFlags, count, preferredFlags = 0);Resize(...)reuses the allocation when the request fits and the usage matches, otherwise queues the old one and creates;size(bytes),buffer,address(non-zero only withVK_BUFFER_USAGE_SHADER_DEVICE_ADDRESS_BIT),memoryPropertyFlagsChosen. WithMapped,valueis theT*of a persistent map: after writing, callFlushDevice()(whole buffer),FlushDevice(offset, bytes)(a range, rounded tononCoherentAtomSize) orFlushDevice(cmd, dstAccess, dstStage), which flushes and records the host-write barrier; all three are no-ops on coherent memory.FlushHost()is the invalidate for readback. A UI item buffer isGraphicsBuffer<QuadItem, true>withSTORAGE_BUFFER | SHADER_DEVICE_ADDRESSusage andHOST_VISIBLEmemory withDEVICE_LOCALas the preferred fourth argument (the examples pass both as required, which throws on a GPU without a host-visible device-local type), flushed each frame before the dispatch that reads it.- Static geometry:
VulkanBuffer<T, false>::UploadDeviceLocal(usage, src, count, cmd, dstAccess, dstStage)places the buffer in device memory, written directly on ReBAR and UMA and staged through a per-frame staging ring on a small BAR, and records the barrier todstStage.UploadDeviceLocalRange(src, offset, count, cmd, dstAccess, dstStage)re-uploads the dirty window[offset, offset + count);srcpoints at the first dirty element (vertices.data() + offset), not at the array's start. A re-upload into a buffer a frame may still read is ordered after those reads on the GPU. ImageVulkan<Pixel>(Image2D<Pixel>is the portable alias):Create(width, height, mipLevels, cmd, format, usage, layout, consumerStage = RAY_TRACING_SHADER, streamed = false)makes the image and a mapped staging bufferimage.buffer; fillimage.buffer.valueandUpdate(cmd, layout)copies it in, builds the mip chain by blits and transitions tolayoutforconsumerStage.Update(const CompressedTextureAsset&, cmd, layout)decompresses a.ctexon the GPU, or the CPU. A static image releases its staging after the first upload;streamed = truekeeps it (the font atlas, throughUpdateRegion). PassVK_PIPELINE_STAGE_COMPUTE_SHADER_BITasconsumerStagefor an image a compute shader samples; the RT default is corrected to compute on a device without ray tracing.image,imageViewanddescriptorare public for raw descriptor writes;Destroy()releases.SamplerVulkan<Pixel>makes one linear, repeat, 16x anisotropic sampler at construction (textureSampler). The UI hasUIRenderer::RegisterSampler(const VkSamplerCreateInfo&)andRegisterLinearClampSampler().
UI
Three tiers, on purpose: Tier 1 is any compute shader you write (ComputeShader, UIRenderer::Dispatch); Tier 2 the four standard kernels, their item structs and the UIRenderer helpers; Tier 3 stateless functions that append items to your buffers. The library has no widget tree, layout engine, theme, hit-testing or focus management: state lives in your PODs, hit-testing is Rect::Contains against the rects you drew, and a Tier 3 component that does not fit is copied out of implementations/Crafter.Graphics-UIComponents.cpp and edited, not subclassed.
Setup: FontAtlas atlas; atlas.Initialize(init); then UIRenderer ui; ui.fontAtlas = &atlas; ui.Initialize(window, heap, init); window.passes.push_back(&ui);. Initialize loads the five kernels by file name from the working directory (ui-quads.comp.spv and so on; the .sets.spv builds must sit beside them) and registers the swapchain images and the atlas. Then, per item buffer, auto slot = ui.RegisterBuffer(buf); gives a BufferSlot ready for a shader.
Per frame, in an EventListener<UIBuildArgs> on ui.onBuild (fired from the pass's Record with {cmd, frameIdx}):
- 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)andInset(...). Keep one set of item buffers (and slots) per frame in flight,Window::numFramesof them, and fill the one atframeIdx: 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). buf.FlushDevice(cmd, VK_ACCESS_SHADER_READ_BIT, VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT)for each buffer with items.- Dispatch:
ui.DispatchQuads(cmd, slot, count, clipRectPx = {0, 0, 1e9, 1e9}),DispatchCircles,DispatchImages,DispatchText, orui.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 customui.Dispatchbetween twoDispatchFusedcalls is the flush point.
- The item structs are
std430PODs mirrored byte for byte inshaders/ui-shared.glsl, and frozen:QuadItem(64 bytes:x, y, w, h,r, g, b, a, corner radiicTL, 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)writesGlyphItems: 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 lastDevice::framesInFlight + 1frames. So aGlyphItemholds 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)returnsconst Glyph&and never fails; its UVs hold only whenResident(), and aLookuppointer only until the atlas's nextEnsureAndGet,TouchorUpdate. A Tier 1 kernel that samplesFontAtlasImageSlot()throughui.Dispatchsees 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 callDrawButton(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) andDrawImage(buf, rect, textureSlot, samplerSlot, tint, uv). Colours are small*Colorsaggregates you hold; there is noTheme. InputFieldis the model for a stateful component: a POD (value,type,focused,cursorPos), free functions you call from your own listeners (InputField_OnText(field, utf8)fromonTextInput,InputField_OnKey(field, code)fromonRawKeyDown,InputField_HitTestCursor(field, rect, clickX, font, px, colors)),InputField_TryParse<T>(field), andDrawInputField(buf, field, rect, font, px, colors, caretVisible); the blink is yours.- Images in the UI:
auto tex = ui.RegisterImage(image, VK_FORMAT_R8G8B8A8_UNORM);(anImageSlot; the layout defaults toSHADER_READ_ONLY_OPTIMAL),auto samp = ui.RegisterLinearClampSampler();, then both go intoImageItem::texSlotandsampSlot.ui.OutImageSlot(),FontAtlasImageSlot()andFontAtlasSamplerSlot()expose the renderer's own slots.
Custom compute shaders (Tier 1)
A GLSL compute shader that dispatches beside the standard kernels follows shaders/ui-shared.glsl, which the build puts on the include path:
#version 460
#extension GL_GOOGLE_include_directive : enable
#include "ui-shared.glsl"
struct MyItem { vec4 a; }; // mirrored in C++ with a static_assert on its size
layout(UI_HEAP_BUFFERS, std430) readonly buffer MyBuf { MyItem items[]; } myHeap[];
layout(push_constant) uniform PC { UIDispatchHeader hdr; /* your fields after it */ } pc;
layout(local_size_x = 8, local_size_y = 8, local_size_z = 1) in;
void main() {
ivec2 px;
if (!uiResolveScreenPixel(pc.hdr, px)) return;
// read myHeap[pc.hdr.itemBuffer].items[i]; imageLoad/imageStore uiImages[pc.hdr.outImage]
}
- Declare bindless resources with
UI_HEAP_IMAGES,UI_HEAP_TEXTURES,UI_HEAP_SAMPLERSandUI_HEAP_BUFFERS, never a literaldescriptor_heapor a set and binding, and register the source twice inproject.cpp: once plain, once withsets.defines = {"CRAFTER_GRAPHICS_DESCRIPTOR_SETS"}; sets.variant = "sets";, which yields<name>.spvand<name>.sets.spv.ComputeShader::Load("<name>.spv", heap)picks the build the device needs and throws naming a missing file.examples/CustomShader/project.cpphas the lines. UIDispatchHeader(48 bytes; in C++outImage, itemBuffer, surfaceWidth, surfaceHeight, clipX, clipY, clipW, clipH, itemCount, frameIdx, flags, _pad, and in GLSL the same bytes asuint outImage, itemBuffer; uvec2 surfaceSize; vec4 clipRectPx; uint itemCount, frameIdx, flags, _pad) must be the first member of the push block;ui.FillHeader(slot, count, clipRectPx, flags)fills it. The high bit offlags(kUIFlagClip) is the renderer's, the rest are yours. Keep the push block a multiple of 4 bytes and within 256; the descriptor-set path throws past the device'smaxPushConstantsSize.ui.Dispatch(cmd, shader, &pc, sizeof(pc), ceil(W / 8), ceil(H / 8))binds, pushes and dispatches, with aSHADER_WRITE -> SHADER_READ | SHADER_WRITEbarrier on the swapchain image before every dispatch but the frame's first, so a custom shader sees what the dispatch before it drew.ComputeShader::Dispatch(cmd, push, bytes, gx, gy, gz, tlasAddress = 0)is the same without the barrier; a compute shader that ray-queries the TLAS passesRenderingElement3D::tlases[frameIdx].address, for the NVIDIA workaround below.- Keep the one-workgroup-per-screen-tile shape for anything that composites. A workgroup per item races on overlapping pixels: Vulkan orders nothing between the workgroups of one dispatch.
- A
ComputeShadercan also run outside the UI pass, dispatched fromonUpdateinto the frame's command buffer with barriers of your own.
Ray tracing (Vulkan)
Heap-only: check Device::rayTracingSupported, or expect std::runtime_error from the first RT call. examples/VulkanTriangle/main.cpp is the whole setup.
- Shaders:
VulkanShader shader("raygen.spv", "main", VK_SHADER_STAGE_RAYGEN_BIT_KHR, &specInfo);(nullptrfor no specialisation);ShaderBindingTableVulkan table; table.Init(shaders);;PipelineRTVulkan pipeline; pipeline.Init(cmd, raygenGroups, missGroups, hitGroups, table, maxRecursionDepth = 1);with spans ofVkRayTracingShaderGroupCreateInfoKHRindexing the shader array.maxRecursionDepthcounts the raygen as 1: a closest-hit that traces a shadow ray needs 2, and tracing past the pipeline's depth loses the device. - Geometry:
Mesh mesh; mesh.Build(std::span<Vector<float, 3, 3>> vertices, std::span<std::uint32_t> indices, cmd, RTBuildOptions{});builds a BLAS and setsmesh.blasAddr;Build(const CompressedMeshAsset&, cmd)builds from a.cmesh, decompressed on the GPU where supported;BuildProcedural(aabbs, opaque, cmd)builds fromRTAabbboxes for aVK_RAY_TRACING_SHADER_GROUP_TYPE_PROCEDURAL_HIT_GROUP_KHRwith an intersection shader, and has an overload taking a device address a compute pass wrote.RTBuildOptionsis.preference(FastTrace,FastBuild),.allowUpdateand.noDuplicateAnyHit; withallowUpdate,Refit(vertices, indices, cmd)and its dirty-range overload update in place and keepblasAddr. A rebuild into storage that still fits keepsblasAddrtoo; read it again after a rebuild that grows. - Instances: a
RenderingElement3Dholds aVkAccelerationStructureInstanceKHR instance(transform,mask,instanceCustomIndex,instanceShaderBindingTableRecordOffset,flags,accelerationStructureReference = mesh.blasAddr; Crafter.Math'sMatrixRowMajor<float, 4, 3, 1>::Store(reinterpret_cast<float*>(instance.transform.matrix))writes the transform),userMetadata(copied per instance id intotlases[f].metadataBuffer) andtransformOwnedByGpu.RenderingElement3D::Add(&e)andRemove(&e)register it.RenderingElement3D::BuildTLAS(cmd, frameIndex, preference = FastTrace)builds or refitstlases[frameIndex]from every registered element: once per frame slot at setup (0,1,2), and per frame fromonUpdateforwindow.currentFramewhen instances move.e.MarkHostDataDirty()after a change to an element's host fields lets the per-frame copy skip unchanged slots.tlases[f].addressgoes into aVK_DESCRIPTOR_TYPE_ACCELERATION_STRUCTURE_KHRdescriptor of framef(see "The bindless heap") and changes only when the TLAS outgrows its storage. - The pass:
RTPass rtPass(&pipeline); rtPass.pushPtr = &camera; rtPass.pushBytes = sizeof(camera);pushes your data at offset 0 of the shaders' push block before every trace (the bytes are read at record time, so updatingcamerabefore the frame is enough);widthandheighttrace at a size other than the window's.window.passes.push_back(&rtPass);. - In GLSL, with the extensions the examples enable (
GL_EXT_ray_tracing,GL_EXT_descriptor_heap,GL_EXT_nonuniform_qualifierfor a non-constant heap index,GL_EXT_shader_explicit_arithmetic_types_int16for auint16_tspec constant, andGL_EXT_shader_image_load_formattedor awriteonlyimage):layout(descriptor_heap) uniform accelerationStructureEXT topLevelAS[];,layout(descriptor_heap) uniform writeonly image2D image[];, indices from spec constants or push data. The output image is the swapchain image at an image slot listed withAddSwapchainSlot, inVK_IMAGE_LAYOUT_GENERAL. Start fromexamples/VulkanTriangle/raygen.glsl. - NVIDIA. On the proprietary driver (still on
615.78.08) reading an acceleration structure through the heap loses the device. The library works around it transparently:VulkanShaderrewrites the SPIR-V so the heap AS read becomes a TLAS-address load from the push block plusOpConvertUToAccelerationStructureKHR, andRTPassandComputeShaderpush the address. Nothing changes in your shaders or code, except that the rewrite appends an 8-byte member after your push block, and that it turnsshaderInt64on for itself only: a shader of yours that declaresInt64still has to ask forshaderInt64, or it runs on NVIDIA alone.CRAFTER_GRAPHICS_NO_AS_WORKAROUNDdisables the rewrite, to probe a new driver. Second note: on610.43.02, indexing adescriptor_heaparray with a runtime value inside a hit shader faulted (fixed in615.78.08). The portable pattern, which the Sponza example uses, is one descriptor at a spec-constant slot and dynamic indexing inside it: all geometry in one SSBO indexed by element offset, materials in onetexture2DArrayindexed by layer.examples/Sponza/README.mdhas the GLSL.
Input
Windowevents,Event<void>unless noted:onMouseMove(thencurrentMousePos,lastMousePos,mouseDelta),onMouseEnterandonMouseLeavewithmouseInside(native only: the DOM build declares the two events but never fires them, and has nomouseInside),onMouseLeftClick,onMouseLeftHold,onMouseLeftReleaseand the right-button trio (mouseLeftHeld,mouseRightHeld),onMouseScroll(Event<std::uint32_t>: whole detents, reinterpret the payload asint32, positive is wheel down, every backend normalised to one per notch),onRawKeyDown,onRawKeyHold,onRawKeyUp(Event<KeyCode>, raw platform codes;heldKeys),onTextInput(Event<const std::string_view>, UTF-8; repeats are synthesised on Wayland),onPreeditandonTextInputDelete(input methods),onTouchDown,onTouchMove,onTouchUp(Event<TouchPoint>; the first finger also drives the pointer events),onClose,onResize,onBeforeUpdate,onUpdate(Event<FrameTime>:now,delta).- Keys:
Key(CrafterKeys::Space)isconstevaland folds to the platform's raw code (a Win32 scancode, a Linux keycode), so bindings stay portable in source and compare againstonRawKeyDown'sKeyCodeat runtime. Bindings serialised withInput::BindingToStringare platform-specific. Input::Map:auto& jump = map.AddAction("Jump", Input::ActionType::Button);(Button,Axis1D,Vector2),jump.bindings = { Input::KeyBind{Key(CrafterKeys::Space)}, Input::GamepadButtonBind{id, Gamepad::Button::South} };(alsoMouseButtonBind,MouseScrollBind,MouseDeltaBind{scale},GamepadAxisBind,GamepadStickBind{id, Gamepad::Stick::Left},WASDBind{up, down, left, right}),map.Attach(window);, andmap.Tick();once per frame, afterGamepad::Tick(), which the loop runs beforeonBeforeUpdate. EveryTickrecomputes the mouse delta and drains the scroll accumulator, and on WaylandonBeforeUpdatefires on every loop wake, so either tick fromonUpdate, or tick inonBeforeUpdateand consume the delta and scroll actions in that same listener, as the Sponza example does. Readaction.pressed,valueorvector2, or subscribe toonPerformed,onCanceled,onValueChanged,onVector2Changed.StartRebind(action, CaptureMask, callback)captures the next input.Gamepad::connected,onConnected,onDisconnected, per-devicebuttons[],axes[]and events,Rumble(dev, low, high, duration),FindById(id); buttons are named by position (South,East,West,North).Clipboard::SetText(text)andGetText()(std::optional<std::string>).
WebGPU and the DOM build
The same C++ surface, with these differences:
- The
Windowis the page, andDevice::Initialize()takes no arguments. Make the windowstatic:StartSynchands control torequestAnimationFrameand_Exit(0)s without running static destructors, so nothing after it runs on any target.StayAliveisStartSyncwithout the animation loop, for pages that only touch the DOM, and isStartSyncnatively.Window::numFramesis 1, sotlases[0]andBuildTLAS(cmd, 0).GraphicsCommandBufferis an opaquestd::uint32_t. GraphicsBuffer<T, true>::Create(count),FlushDevice()without a command buffer (queue.writeBuffer),FlushDeviceRange,FlushDeviceSlot, andEnqueueReadbackwithPollReadbackfor GPU to CPU.Image2D<T>::Create(w, h)isrgba8unorm,Update(const CompressedTextureAsset&)decompresses on the CPU,AllocateSlot(heap)registers it;Image2DArray<T>stacks layers with a CPU mip chain;StorageImage2Dis a GPU-written float texture.AllocateLinearClampSampler(heap)andAllocateLinearRepeatSampler(heap).DispatchImageshas a six-argument overload with one texture and one sampler for the whole dispatch (WebGPU bind groups); the four-argument overload binds the font atlas instead.DispatchFusedissues the four kernels in order through the four-argument overloads, so quads, circles and text match the Vulkan result but an images category does not: draw images with the six-argumentDispatchImageson this backend.- Custom compute in the UI pass:
WebGPUComputeShader::Load(wgslPath, bindings, rayQuery = false), where eachUICustomBindingis{group >= 2, binding, kind, format, pushOffset}withkindone ofBuffer,SampledTexture,Sampler,SampledTextureArray,BufferReadWrite,StorageTexture; the renderer reads the slot atpushOffsetout of your push data and binds that handle. Group 0 binding 0 is the header uniform and group 1 the ping-pong images, both declared by your WGSL; withrayQuerytrue the library injects its own group 1 (the RT heap and therayQuery*API) and your shader must not declare one. Standalone compute:PlainComputeShader::Load(wgsl, pushUniformSize, bindings, rayQuery)andDispatch(push, bytes, handles, gx, gy, gz)run anywhere in the frame,handlesparallel tobindings. It has no library bind groups: group 0 binding 0 is your optional push uniform (pushUniformSizebytes, 0 for none) and your bindings start at group 1, or at group 2 withrayQuery, which reserves group 1 the same way. - Ray tracing is a library-built wavefront tracer composed from your WGSL stages:
WebGPUShader(fs::path("raygen.wgsl"), entryFn, WebGPURTStage::Raygen | Miss | ClosestHit | AnyHit | Intersection | Resolve)(wrap the path: a bare string literal is ambiguous with the constructor that takes the source itself), with the entry signatures documented ininterfaces/Crafter.Graphics-ShaderBindingTableWebGPU.cppm;ShaderBindingTableWebGPU::Init,RTShaderGroup{.type = RTShaderGroupType::General | TrianglesHitGroup | ProceduralHitGroup, ...},PipelineRTWebGPU::Init(cmd, raygen, miss, hit, sbt, bindings, hdrOutputFormat). Raygen callsrtEmitPrimaryRay; closest-hit and miss run in the SHADE stage and callrtEmitRay(continuation and shadow rays) andrtAccumulate(atomic, any number of rays per pixel).RTPassaddsmaxDepth(bounces),raysPerPixel(the ray buffer budget),handlesPtrandhandlesCount(your bindings' handles) andoutTexHandle(HDR output).Mesh::Build,BuildProceduralandRenderingElement3Dkeep their API, butRefitand the host-spanRefitProceduralrebuild the host BVH under a newblasAddr: setinstance.accelerationStructureReferenceagain before the nextBuildTLAS, or portable deforming-mesh code keeps tracing the old geometry here. Only the device-bufferRefitProceduralkeeps the handle. Crafter::Dom::HtmlElementandHtmlElementPtr(move-only DOM handles with typed listeners),Crafter::Router(history, location, query parsing) andCrafter::Dom::*Eventexist only here.WebGPU::SetCanvasMount(elementId)renders into an element instead of the page.- Examples with a DOM build ship
.glsland.wgslside by side;RTStress,RayQueryPick,HDRBloomandRTMultiShadoware WebGPU-only.
Environment variables
All are "set to any value", read once at Device::Initialize or swapchain creation.
| Variable | Effect |
|---|---|
CRAFTER_GRAPHICS_VALIDATION |
The Khronos validation layer with GPU-assisted validation, plus info messages; off by default. Device::validationErrorCount counts the errors. |
CRAFTER_GRAPHICS_DEVICE=<n> |
Use GPU number n as Vulkan lists them, never another; an error when it can't run the application. |
CRAFTER_GRAPHICS_NO_DESCRIPTOR_HEAP |
Take the descriptor-set path on a driver that has the heap (and lose ray tracing), as a heap-less driver would. |
CRAFTER_GRAPHICS_NO_AS_WORKAROUND |
Turn the NVIDIA SPIR-V rewrite off, to check a new driver. |
CRAFTER_GRAPHICS_NO_VULKAN_1_4 |
Create a Vulkan 1.3 instance on a 1.4 GPU. |
CRAFTER_PRESENT_IMMEDIATE |
Present immediately, uncapped, for benchmarks. |
Testing a consumer
Device::Initialize on Linux connects to Wayland right after creating the instance, so even a windowless test needs a compositor (WAYLAND_DISPLAY). Window::CaptureNextFrame(png) followed by Render() writes a frame to read back and compare; decode it with a private copy of stb_image.h (STB_IMAGE_STATIC), since the library compiles only the writer. Assert Device::validationErrorCount == 0 only when CRAFTER_GRAPHICS_VALIDATION is set, as the count stays zero without the layer. Return 77 (crafter-build's skip) when !Device::rayTracingSupported and the test needs it. A test with GPU work and no Window calls vkQueueWaitIdle(Device::queue) and Device::DrainDeletions() before it ends. The library's own tests/ has one of each kind to copy.
Examples to copy from
HelloWindow (a window), HelloUI (all three UI tiers, text, hit-testing), CustomShader (a Tier 1 shader in GLSL and WGSL, registered twice), VulkanTriangle (the smallest RT setup, raw descriptor writes), Sponza (.cmesh and .ctex through Mesh::Build and Image2D, texture arrays, a free camera on Input::Map), RTVolume (procedural geometry with intersection and any-hit shaders), InputSystem (actions, rebinding, gamepads), HelloDom (DOM and router, no GPU), Decompression (the asset round trip). All MIT.