Crafter.Graphics/README.md
Jorijn van der Graaf 234fcce099
fix(window): size the swapchain for its present mode, and key frames in flight by frame instead of image
Window
- CreateSwapchain asked for exactly numFrames (3) images whatever the
  surface said, read them into a three-element array, and keyed the
  command buffers, the fences and the descriptor-heap copy holding "its"
  swapchain image by the acquired image index. A swapchain of another
  size fell back to FIFO (asked for MAILBOX or IMMEDIATE) or threw, and a
  surface whose minimum is above three got a create info below it
  (#160). Mesa's MAILBOX needs four images: Turnip on phoc, llvmpipe on
  any Wayland compositor.
- The present mode is picked first, then the swapchain is sized by
  SwapchainCapabilities: the surface's counts for that mode where the
  instance has surface_maintenance1. With swapchain_maintenance1 the
  swapchain names its mode (VkSwapchainPresentModesCreateInfoKHR), which
  is what makes the mode's own count the one that applies, at creation and
  to the forward progress of a UINT64_MAX acquire; without it the stricter
  of the mode's and the surface's counts for all modes. It asks for
  numFrames or that minimum where more, never past the maximum
  (SwapchainMinImageCount). Turnip's FIFO keeps its three images and
  MAILBOX gets four. The FIFO fallback and presentModeUnfit are gone.
- images, imageViewInfos (was imageViews: they are create-infos), the made
  imageViews that descriptor sets take, imageInitialised and the
  render-finished semaphores are per image, as many as the driver made.
  The views are made once per swapchain. The semaphores only grow, since
  a present of the old swapchain may still wait on one.
- Frames in flight stay numFrames, keyed by currentFrame, frameCounter %
  numFrames: the command buffer, the fence, the acquire semaphore, the
  heap copy (or set), RenderingElement3D::tlases, the frameIdx a pass is
  recorded with. currentImage is the acquired image. currentBuffer is
  gone, so code that used it stops compiling instead of picking the wrong
  one; RenderPass::Record's frameIdx keeps its name and is now the frame
  slot, as RenderPass.cppm says.
- The frame's fence is waited before the acquire. The acquire semaphores
  need no spare then, and a frame waits for exactly the one numFrames
  before it, which the deletion queue and the staging rings count on and
  fences keyed by image did not promise once images come back out of
  order. The swapchain slots are written before the fence is reset.
- From the acquire to the present the window is recording: a Resize or
  SetPresentMode from a listener (a VSync key ticked in onUpdate) takes
  effect after the present, through RebuildSwapchain, together with a
  present that found the swapchain out of date. Rebuilding mid-frame used
  to destroy the image the frame was drawing into; with images sized per
  swapchain it would also index past the new one's end.

DescriptorHeapVulkan
- Frames and images pair up differently from frame to frame, so no heap
  copy can keep one swapchain image. AddSwapchainSlot(slot, window) lists
  an image slot as that window's swapchain image, and Window::Render
  writes the image a frame acquired into its window's listed slots in that
  frame's copy, or its set, before onUpdate, skipping a slot that holds it
  already. A slot is one window's, so two windows can share a heap without
  writing each other's slots under each other's frames in flight. A slot
  listed during a frame its window records is written at once. A slot past
  the heap's image slots, an empty ImageSlot's 0xFFFF among them, is
  refused. Freeing a slot unlists it, as does initialising the heap in
  either binding model. WriteImageSet, the one use of which this
  replaces, is gone, and with it the per-frame dimension of setImageViews.

Device
- VK_KHR_surface_maintenance1 (or EXT) and VK_KHR_get_surface_capabilities2
  are enabled where the loader has them (surfaceMaintenance1Supported,
  vkGetPhysicalDeviceSurfaceCapabilities2KHR), and the matching
  swapchain_maintenance1 with its feature where the device has it
  (swapchainMaintenance1Supported).
- DistinctGpus groups the physical devices by GPU as CRAFTER_GRAPHICS_DEVICE
  numbers them, for ChooseGpu and tests.

UI
- UIRenderer lists its output slot instead of writing every copy itself,
  and again on every resize; the resize listener is gone on both
  backends. The headers' frameIdx is currentFrame.

Examples
- VulkanTriangle, Sponza, SponzaBench and RTVolume list their output slot
  and write only the per-frame TLASes. They wrote imageViews[f] into copy
  f once, which a resize left pointing at destroyed images.

Tests
- SwapchainImageCount: SwapchainMinImageCount on the numbers drivers
  report; every offered mode keeps its mode and gets what the window asks
  for; every frame presents the colour it drew, read back a texel per
  frame; a switch and a resize asked for during a frame wait for its
  present; a slot listed during a frame holds its image at once; two
  windows on one heap draw their own colours. The frame checks run again
  in child processes, through CRAFTER_GRAPHICS_DEVICE, on a GPU whose
  swapchains have more images than frames (this one, else llvmpipe), in
  each binding model the parent didn't cover; a child skips only for a GPU
  that can't run the library, and checks it runs the model it was asked
  for.
- FrameLoopSync checks the frame slot follows the frame counter and counts
  the swapchain's own images. HeapASRead, HeapDynamicIndexHit and
  RTPassPushData list their swapchain slot and drop their resize rewrites.

Docs
- README, AGENTS.md and AGENTS.consumer.md: frames and images, the
  swapchain slots, rebuilds held to the present, the new optional groups.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 18:14:18 +01:00

338 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Crafter.Graphics
Vulkan + WebGPU graphics library built around C++20 modules and
bindless heaps. Provides window management, ray tracing, and a
compute-shader-driven UI on a single, opinionated stack. Native
builds use Vulkan with `VK_EXT_descriptor_heap` (the compute/UI path
also runs without it, see
[below](#drivers-without-vk_ext_descriptor_heap)); `wasm32-*` builds
target the browser via WebGPU and a DOM window backend.
## Backends
Backends are chosen at build time by the target triple:
| Target | Window | Renderer | Shaders |
|---------------------|------------------------|---------------------|---------|
| native Linux | Wayland | Vulkan (heap-bound) | GLSL → SPIR-V |
| native Windows | Win32 | Vulkan (heap-bound) | GLSL → SPIR-V |
| `wasm32-*` (any) | DOM (canvas + JS env) | WebGPU | WGSL (loaded at runtime) |
The two backends share the same C++ surface for the high-level pieces
(`UIRenderer`, `Mesh`, `RenderingElement3D`, `RTPass`, item structs,
`FontAtlas`, `Image2D`, `ComputeShader`). Backend-typed pieces
(`*Vulkan` vs `*WebGPU`) live behind `#ifdef CRAFTER_GRAPHICS_WINDOW_DOM`.
Vulkan ray tracing is hardware (`VK_KHR_ray_tracing_pipeline`); WebGPU
ray tracing is a library-built software path (BVH + traceRay in a
compute pipeline composed from user-supplied WGSL stages). The WebGPU
path supports triangle and AABB (procedural, `VK_GEOMETRY_TYPE_AABBS_KHR`)
geometry, closest-hit / miss / any-hit / intersection shaders — see
[examples/RTVolume](examples/RTVolume/README.md) for procedural spheres
shaded through an intersection shader with an any-hit cut-out.
> **Native RT status:** reading an acceleration structure through
> `VK_EXT_descriptor_heap` aborts with `VK_ERROR_DEVICE_LOST` on NVIDIA
> driver `610.43.02` — a driver-side fault in the brand-new descriptor-heap
> acceleration-structure path, not an engine bug (the setup is correct and
> validation-clean; images/buffers through the same heap work). The engine
> **works around it transparently** (issue #15): on the NVIDIA driver only,
> `VulkanShader` rewrites the compiled SPIR-V so heap AS reads become a
> TLAS-device-address + `OpConvertUToAccelerationStructureKHR` path (which
> reads no descriptor), and `RTPass` supplies the address as push data.
> Shaders and example code are unchanged, and it's a single fenced block
> gated on `Device::workaroundDescriptorHeapAS`, removable once a fixed
> driver ships. **Still present on `615.78.08`**: with the rewrite off,
> `traceRayEXT` still loses the device and an inline ray query renders
> nothing. `tests/HeapASRead` covers the engine path; run
> `HeapASRead --no-workaround` to check a newer driver (it tries both paths
> with the rewrite off). Any app runs with the rewrite off while
> `CRAFTER_GRAPHICS_NO_AS_WORKAROUND` is set, to any value. See
> [examples/VulkanTriangle/README.md](examples/VulkanTriangle/README.md)
> for the full investigation. WebGPU RT is unaffected.
> **Native RT limitation (fixed in NVIDIA `615.78.08`) — dynamic
> `descriptor_heap` indexing in hit shaders:** on NVIDIA `610.43.02`, indexing
> a `descriptor_heap` array with a **runtime (non-constant)** index inside a
> ray-tracing **hit** shader also device-losts (`VK_ERROR_DEVICE_LOST`), for
> plain SSBO **and** sampled-image descriptors. On `615.78.08` it works:
> `tests/HeapDynamicIndexHit` picks a buffer and a texture by a runtime index
> in a closest-hit, including different descriptors in neighbouring lanes of
> a wave. The rest of this note applies to the older drivers. A **constant / spec-constant**
> index is fine (that's why [Sponza](examples/Sponza/README.md)'s
> closest-hit reads `textures[albedoSlot]` and `samplers[samplerSlot]`
> through spec constants), and
> the identical dynamic pattern works in fragment shaders (the UI
> renderer indexes `uiTextures[]` by per-item runtime slots) — so this
> is **RT-stage-specific**, not a general heap problem. Unlike the
> AS-read fault above this **cannot** be worked around transparently:
> sampled images have no device-address escape hatch the way an
> acceleration structure does (`OpConvertUToAccelerationStructureKHR`).
> The recommended pattern for bindless per-mesh geometry/material is to
> **bind one resource and index *within* it dynamically** rather than
> selecting a descriptor dynamically: pack geometry into a single SSBO
> (or reach it via `buffer_reference`) at a spec-constant slot and index
> by element offset, and put materials in one `texture2DArray` indexed
> by layer. Dynamic addressing *inside* a bound resource is ordinary
> memory/layer addressing and is unaffected; only dynamic selection of a
> *descriptor* faults. This is exactly what the WebGPU path already does
> (bucketed texture arrays + a single buffer). Full investigation and
> GLSL in [examples/Sponza/README.md](examples/Sponza/README.md) (issue
> #23). WebGPU RT is unaffected.
## What's in here
- **Window** — Wayland, Win32, and DOM backends, swapchain ring / canvas
framing, input events. Pick a backend at build time via the target
triple. The DOM backend routes every dynamic symbol through
[additional/dom-env.js](additional/dom-env.js) and
[additional/dom-webgpu.js](additional/dom-webgpu.js). On Wayland the
window needs `wl_compositor`, `wl_shm` and `xdg_wm_base`, and uses
`xdg-decoration`, `wp_viewporter` and `wp_fractional_scale_v1` where the
compositor has them. Without xdg-decoration (GNOME) the window has no
title bar, as the library draws no client-side decorations; when either
of the other two is missing it draws at scale 1, and the compositor
scales it up on a HiDPI output. Natively the frame loop keeps three
frames in flight (`Window::numFrames`) over however many images the
swapchain has: as many as its present mode needs. Where the instance has
`surface_maintenance1` and the device `swapchain_maintenance1`, that is
the mode's own count, which on Mesa's Wayland is below the surface's
count for all modes (FIFO three, Mailbox four); without, the stricter
of the two. `window.currentFrame` picks what lives per frame in flight
(the command buffer, the descriptor-heap copy,
`RenderingElement3D::tlases`, the `frameIdx` a pass is recorded with);
`window.currentImage` is the swapchain image the frame draws into,
`window.images[window.currentImage]`. A shader reads that image from a
heap slot listed with `heap.AddSwapchainSlot(slot, window)`, which the
window rewrites with the acquired image every frame, so nothing is
rewritten after a resize, and two windows can share a heap. A `Resize`
or `SetPresentMode` from a listener of the frame being recorded takes
effect after that frame's present.
- **Device** *(Vulkan only)* — single-instance bring-up targeting
`VK_EXT_descriptor_heap`; pipelines are created with
`VK_PIPELINE_CREATE_2_DESCRIPTOR_HEAP_BIT_EXT` so there are no
descriptor-set layouts and push constants travel via
`vkCmdPushDataEXT`. Needs Vulkan 1.3. The heap, ray tracing,
device-fault and swapchain-maintenance extensions are optional groups,
detected at start-up (`Device::descriptorHeapSupported`,
`rayTracingSupported`, `deviceFaultSupported`,
`swapchainMaintenance1Supported`), as is the instance's
`surface_maintenance1` (`surfaceMaintenance1Supported`). Device
features split by who uses them: the ones the binding model and the
library's own shaders need are the library's to enable; arithmetic,
storage and layout features an application's shaders use
(`shaderInt16` for a `uint16_t` spec
constant, `scalarBlockLayout`, fp16, 16-bit storage) are the
application's to ask for, as core feature structs:
`Device::Initialize({.vulkan10 = {.shaderInt16 = VK_TRUE}})`. A second
argument takes features to use where the GPU has them, and
`Device::enabledFeatures` says which it got. A GPU that can't run the
application is skipped. `VulkanShader` refuses a module that declares,
directly or implicitly, a capability whose core feature, subgroup
operation or optional group (ray tracing, the descriptor heap) is off,
naming the field to pass or saying the GPU lacks it. Capabilities only
an extension the library never enables could allow are left to the
driver and the validation layer, as are layout rules
(`scalarBlockLayout`), which have no capability.
- **DescriptorHeapVulkan / DescriptorHeapWebGPU** — bindless slot
allocators. Vulkan side allocates image/buffer/sampler slots in a
`VK_EXT_descriptor_heap`; WebGPU side resolves slots to JS-side
handle-table cookies that the dispatch bridge binds per pass. The
window binds the Vulkan heap in every frame it records; a command
buffer recorded outside the frame loop that dispatches against it
calls `heap.Bind(cmd, frame)`, which binds the frame's heaps, or its
descriptor set on a driver without `VK_EXT_descriptor_heap`.
- **VulkanBuffer\<T, Mapped\> / WebGPUBuffer\<T\>** — typed buffer.
Vulkan variant has optional host mapping and a `FlushDevice` that
issues the right host-write barrier; WebGPU variant goes through
`queue.writeBuffer` over the JS bridge.
- **ImageVulkan\<Pixel\> / Image2D\<Pixel\> / Image2DArray\<Pixel\>** —
image + staging buffer with mip-chain support on Vulkan; on WebGPU,
`rgba8unorm` 2D / 2D-array textures created and written via the
bridge. Atlas (`r8unorm`, sub-region writes) is a separate path.
- **PipelineRTVulkan / PipelineRTWebGPU / ShaderBindingTableVulkan /
ShaderBindingTableWebGPU / RTPass** — ray-tracing pipelines. Vulkan
uses native RT pipelines + SBTs; WebGPU compiles a **wavefront /
streaming** software tracer — five `@compute` kernels
(`GENERATE → PREP → TRACE → SHADE → RESOLVE`) sharing one module,
connected by GPU ray/hit/payload buffers and a GPU-driven indirect
bounce loop (`dispatchWorkgroupsIndirect`). TRACE carries zero user
code (traversal + intersection only); user raygen calls
`rtEmitPrimaryRay`, and closesthit / miss run in SHADE where they
`rtEmitRay` continuation/shadow rays and `rtAccumulate` radiance
(atomic — any number of rays per pixel per bounce, e.g. one shadow
ray per light). An
optional Resolve shader tonemaps the linear accumulator. See
[WAVEFRONT-DESIGN.md](WAVEFRONT-DESIGN.md).
- **ComputeShader / WebGPUComputeShader** — Tier 1 wrapper used by the
UI system. Vulkan loads a `.spv` and dispatches with
`vkCmdPushDataEXT` (or the `.sets.spv` build and `vkCmdPushConstants`
without the heap); WebGPU loads a user-supplied `.wgsl` blob at
runtime via `wgpuLoadCustomShader`. Use it directly for any custom
compute.
- **UI** — three-tier UI system; see below. The standard shaders (four
kernels plus the fused one) ship as `.spv` / `.sets.spv` pairs on
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`).
Backend-agnostic.
- **Mesh / RenderingElement3D / Animation** — BLAS/TLAS construction
and 3D scene plumbing. Vulkan calls `vkCmdBuildAccelerationStructures`;
WebGPU registers BLAS data (verts, idx, BVH nodes, primRemap, optional
per-vertex attribs) into global mesh heaps and builds the TLAS in a
library compute pass.
- **Clipboard / Input / Gamepad / Router / Dom** — input plumbing.
Gamepad uses libudev+libevdev on Linux (libevdev is vendored in
`lib/libevdev`) and WGI on Windows; the DOM
backend exposes the host page DOM (`Dom::HtmlElement`) and a router
for hash-routed wasm apps.
## UI system (three tiers)
The UI is *deliberately* layered to balance no-boilerplate against
no-lock-in:
- **Tier 1 — `ComputeShader`.** Load any `.spv`, dispatch with push
constants, library inserts inter-dispatch barriers. The escape hatch:
if the standard shaders don't fit, write your own compute and
dispatch it next to them.
- **Tier 2 — `UIRenderer` + standard shaders.** Four shipped compute
shaders (`drawQuads`, `drawCircles`, `drawImages`, `drawText`), POD
item structs (`QuadItem`, `CircleItem`, `ImageItem`, `GlyphItem`), a
shared GLSL contract in [shaders/ui-shared.glsl](shaders/ui-shared.glsl),
and helpers (`RegisterBuffer`, `RegisterImage`, `RegisterSampler`,
`FillHeader`, `Dispatch*`, `ShapeText`). You build your own per-shader
SSBOs (manual batching) and call one `Dispatch*` per shader type per
frame. Item array order = draw order.
- **Tier 3 — stateless presentation functions.** `DrawButton`,
`DrawCheckbox`, `DrawSlider`, `DrawProgressBar`. Each is a small
function that *appends* items to your buffers — they don't dispatch.
Colors come in as small inline `*Colors` aggregates, no library
`Theme` type. **The source is the customization API**: if a
component doesn't fit, copy its body and edit it. No virtual hooks,
no extension points.
What's *not* in the UI: widget tree, layout engine (just a `Rect::SubRect`
carving helper), theming, hit-testing, focus management. State for
interactive components (hover, drag, focus) lives in user-owned POD
structs, not the library.
### UI dispatch model
Standard shaders dispatch one workgroup per 8×8 *screen tile* — each
thread iterates every item in the SSBO in array order, accumulating
into a local `dst`, and stores once. Total cost is `O(W·H·N)`; works
well up to a few hundred items at 1080p. Splitting one buffer into
multiple dispatches doesn't help — the same total work plus barrier
overhead. If you need to render thousands of UI items, you want a
different shader (tile binning, per-item-list resolve), not more
dispatches.
### Drivers without `VK_EXT_descriptor_heap`
The compute/UI path also runs on Vulkan drivers that lack
`VK_EXT_descriptor_heap` (Mesa Turnip on Qualcomm Adreno, for one).
`Device::Initialize` detects this and sets
`Device::descriptorHeapSupported = false`. The same slots then live in
one bindless descriptor set per frame instead of the heap, and push
constants go through `vkCmdPushConstants`. Slot handles, item structs,
`UIDispatchHeader` and the `UIRenderer` API are unchanged, so
application code does not branch on it. Ray tracing is heap-only: on
such a driver `Device::rayTracingSupported` is false and the RT entry
points throw.
Each compute shader is shipped in two builds of the same source:
`<name>.spv` for the heap and `<name>.sets.spv` for descriptor sets.
`ComputeShader::Load(path, heap)` takes the `.spv` path and picks the
right file itself. For a custom shader that means three things:
- Declare bindless resources with the `UI_HEAP_*` macros from
[shaders/ui-shared.glsl](shaders/ui-shared.glsl), not with a literal
`descriptor_heap`:
```glsl
layout(UI_HEAP_BUFFERS, std430) readonly buffer MyBuf { MyItem items[]; } myHeap[];
```
- Register the shader twice in `project.cpp`, the second time with the
define and the variant name:
```cpp
cfg.shaders.emplace_back(fs::path("my.comp.glsl"), std::string("main"), ShaderType::Compute);
Shader& sets = cfg.shaders.emplace_back(fs::path("my.comp.glsl"), std::string("main"), ShaderType::Compute);
sets.defines = {"CRAFTER_GRAPHICS_DESCRIPTOR_SETS"};
sets.variant = "sets";
```
- Keep the push-constant block within the device's
`maxPushConstantsSize` (256 bytes on common drivers). Without the
heap a larger payload throws in `ComputeShader::Dispatch`.
Set the environment variable `CRAFTER_GRAPHICS_NO_DESCRIPTOR_HEAP` to
force the descriptor-set path on a driver that does have the heap; the
`UIFallbackParity` test uses it to compare the two paths frame for
frame.
## Build
The repository is built with `crafter-build` (a project-config based
build system; the project description lives in `project.cpp`):
```bash
crafter-build # native: Wayland on Linux, Win32 on Windows
crafter-build --target=wasm32-wasip1 # browser: DOM window + WebGPU renderer
crafter-build -r # build and run (in an example directory)
```
The build picks the window + renderer pair automatically from the
target triple: any `wasm32-*` triple flips to DOM + WebGPU (no Vulkan
loader linked), everything else stays on the native Vulkan path. Each
example with both backends ships GLSL *and* WGSL copies of its shaders
side-by-side (e.g. [raygen.glsl](examples/Sponza/raygen.glsl) +
[raygen.wgsl](examples/Sponza/raygen.wgsl)); `project.cpp` selects the
right set per target.
## Examples
See [examples/](examples/). Quick map:
- [HelloWindow](examples/HelloWindow/) — minimal native window, no rendering.
- [HelloDom](examples/HelloDom/) — wasm-only smoke test of the DOM
partition: page-level events, `HtmlElement::CreateInBody`, and
`Router::PushState`-driven SPA navigation. No GPU work.
- [VulkanTriangle](examples/VulkanTriangle/) — ray-traced triangle on
both Vulkan and WebGPU. The smallest test of the bindless + RT path
on each backend.
- [RTStress](examples/RTStress/) — wavefront RT benchmark: an N×N×N grid
of a cube mesh (instance-count knob `kGrid`, 512 → 8000) shaded with
primary + shadow rays. Prints a GPU timestamp-query per-pass breakdown
each second. WebGPU/DOM only.
- [Sponza](examples/Sponza/) — ray-traced Sponza atrium on both
backends. Exercises `.cmesh` / `.ctex` decompression (GPU
`VK_EXT_memory_decompression` on Vulkan, CPU on WebGPU) and a
textured closest-hit. See [its README](examples/Sponza/README.md)
for asset provenance.
- [HelloUI](examples/HelloUI/) — UI smoke test using all three tiers
(background quad, slider, progress bar, button with text label,
cursor-tracking circle).
- [CustomShader](examples/CustomShader/) — Tier 1 demo: a user-authored
compute shader inverting RGB under a list of item-circles, dispatched
alongside the standard `drawQuads`. Shipped as both
[`.comp.glsl`](examples/CustomShader/inverse-circle.comp.glsl) and
[`.comp.wgsl`](examples/CustomShader/inverse-circle.comp.wgsl).
- [Decompression](examples/Decompression/) — `Crafter::Compression`
CPU round-trip smoke test (used by the WebGPU asset path).
- [InputSystem](examples/InputSystem/) — keyboard / mouse / gamepad
event surface check.
## License
This library is licensed under the GNU Lesser General Public License, version 3 only (LGPL-3.0-only). See [LICENSE](LICENSE) for the license text. The LGPL supplements the GNU General Public License version 3, a copy of which is included as [LICENSE.GPL](LICENSE.GPL).
The example code under [examples/](examples/) is licensed under the MIT license (see [examples/LICENSE](examples/LICENSE)), so it can be freely copied into your own projects.
The [lib/](lib/) directory contains vendored third-party code (stb, Wayland protocol bindings) that remains under its own permissive licenses; see the headers of those files.
## Copyright
Copyright (C) 2026 Catcrafts®
catcrafts.net