examples/Sponza no longer built natively (#164). Its closest-hit declared a descriptor_heap sampler2D, which glslang has rejected since c8253623 ("Reject descriptor_heap combined image samplers"), and main.cpp wrote it through VkResourceDescriptorDataEXT::pCombinedImageSampler, which that union no longer has: the descriptor heap has no combined image samplers. Both went unnoticed because nothing builds the examples. Sponza - closesthit.glsl declares texture2D and sampler heap arrays and joins them where it samples, as ui-shared.glsl and HeapDynamicIndexHit do. The sampler slot is a second spec constant beside the image slot, so the shader assumes nothing about where the host put it, and a constant index keeps the example running on NVIDIA 610.43.02 (see its README). The comment no longer says the image slot is offset by bufferStartElement; only buffer slots are. - main.cpp gives the heap a sampler slot, writes the albedo as a SAMPLED_IMAGE from a view create-info, and writes the sampler into the sampler heap from a VkSamplerCreateInfo with vkWriteSamplerDescriptorsEXT, as UIRenderer::RegisterSampler does. SamplerVulkan's VkSampler had no use left. The sampler keeps its linear filtering and repeat addressing and drops anisotropy, which a hit shader cannot use: it samples at LOD 0, without derivatives. Docs - README.md and examples/Sponza/README.md quote the new declarations, and the Sponza README says why the texture and the sampler are two descriptors. Its texture2DArray materials pattern declared a descriptor_heap sampler2DArray, which glslang rejects as well; it now joins a texture2DArray and a sampler. - examples/SponzaBench/README.md drops its caveat that Sponza does not compile. Tests - ExampleShaders compiles every GLSL shader under examples/ with glslang as crafter-build does for the example (Vulkan rules, SPIR-V 1.4, shaders/ on the include path, and the descriptor-set build where the example registers one), so a glslang that starts rejecting one fails a test rather than an example build nobody runs. It fails on Sponza's old closest-hit and needs glslang only. Closes #164. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
310 lines
17 KiB
Markdown
310 lines
17 KiB
Markdown
# 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).
|
||
- **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 and
|
||
device-fault extensions are optional groups, detected at start-up
|
||
(`Device::descriptorHeapSupported`, `rayTracingSupported`,
|
||
`deviceFaultSupported`). 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.
|
||
- **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
|