2026-05-02 00:03:24 +02:00
# Crafter.Graphics
2026-05-19 01:43:46 +02:00
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
feat(vulkan): run the compute/UI path without VK_EXT_descriptor_heap
Device::Initialize required twelve device extensions with no capability
check. Mesa Turnip (Adreno) lacks six of them: VK_EXT_descriptor_heap,
VK_KHR_shader_untyped_pointers, VK_EXT_device_fault and the three ray
tracing extensions. vkCreateDevice failed there, so not even a UI-only
program could start, although the UI path is compute-only and needs
nothing else that Turnip is missing.
The descriptor heap, ray tracing and device fault are now optional
groups, detected at start-up from the extension list and the feature
bits (Device::descriptorHeapSupported, rayTracingSupported,
deviceFaultSupported). Ray tracing needs the heap; on a device without
it the ray tracing entry points throw a message naming the cause.
Without the heap the same bindless slots live in one update-after-bind
descriptor set per frame, owned by DescriptorHeapVulkan: binding 0
storage images, 1 sampled images, 2 samplers, 3 storage buffers. Slot
allocation, the slot handles, UIDispatchHeader and the UIRenderer API
are unchanged; buffer slots simply stop being offset. Window::Render
binds that frame's set where it binds the heap, every compute pipeline
shares the heap object's pipeline layout, and push data travels as push
constants.
Each UI kernel is built twice from one source. ui-shared.glsl declares
its resources through UI_HEAP_* macros that expand to descriptor_heap
or, with CRAFTER_GRAPHICS_DESCRIPTOR_SETS defined, to set/binding.
<name>.comp.spv and <name>.comp.sets.spv ship side by side and
ComputeShader::Load picks one. The heap build of every shader is
byte-identical to before.
Breaking:
- ComputeShader::Load takes the heap as a second argument.
- DescriptorHeapVulkan is no longer copyable or movable.
- Custom UI shaders declare heap resources with the UI_HEAP_* macros
and are registered twice in project.cpp (see the README).
- project.cpp uses Shader::defines and Shader::variant, so it needs a
crafter-build that has them (Crafter.Build master, fe38cc1 or later).
Also:
- maintenance5 is enabled as a feature, not only as an extension:
every VulkanBuffer already relied on VkBufferUsageFlags2CreateInfo.
- Barrier stage masks drop RAY_TRACING_SHADER on devices without ray
tracing, and ImageVulkan::Create resolves its default consumer stage
to COMPUTE_SHADER there.
- Window refuses a swapchain whose image count is not numFrames
instead of writing past images[]. The limitation itself is #160.
- CRAFTER_GRAPHICS_NO_DESCRIPTOR_HEAP forces the fallback on a driver
that has the heap, so it can be exercised on a desktop GPU.
Verified on an RTX 4090 in both modes under the validation layer, and
on a Fairphone 6 (Turnip, Adreno 810, postmarketOS), where the fallback
is the only path.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-06 17:28:46 +02:00
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
2026-05-19 01:43:46 +02:00
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
2026-06-02 22:09:30 +00:00
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.
2026-05-02 21:08:20 +02:00
2026-05-31 22:21:57 +00:00
> **Native RT status:** reading an acceleration structure through
2026-06-03 01:59:54 +00:00
> `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
2026-10-19 03:03:17 +02:00
> 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
2026-05-31 22:21:57 +00:00
> [examples/VulkanTriangle/README.md](examples/VulkanTriangle/README.md)
> for the full investigation. WebGPU RT is unaffected.
2026-10-19 03:03:17 +02:00
> **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**
2026-06-03 20:05:12 +00:00
> index is fine (that's why [Sponza](examples/Sponza/README.md)'s
fix(rt): split Sponza's combined image sampler into a heap texture and sampler
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>
2026-10-31 08:25:52 +01:00
> closest-hit reads `textures[albedoSlot]` and `samplers[samplerSlot]`
> through spec constants), and
2026-06-03 20:05:12 +00:00
> 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.
2026-05-02 21:08:20 +02:00
## What's in here
2026-05-19 01:43:46 +02:00
- **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
fix(wayland): open windows on compositors without xdg-decoration, viewporter or fractional scale
Window
- The constructor made a fractional scale, a toplevel decoration and a
viewport from Device::fractionalScaleManager, Device::manager and
Device::wpViewporter without looking at them. Device::handle_global
binds those globals only when the compositor advertises them, and
Initialize requires only wl_shm, wl_compositor and xdg_wm_base, so on a
compositor without one of the three (GNOME's Mutter has no
xdg-decoration) the constructor handed libwayland a null proxy and
crashed before the window existed (#162). Each object is now made only
when its global is there, which Resize and the destructor already
assumed.
- The fractional scale needs the viewporter too: the preferred scale is
applied through the viewport, which shows the buffer's physical pixels at
width/scale x height/scale. A surface without a viewport is as large as
its buffer, so there the scale stays 1, which keeps pointer and touch
positions, configure sizes and the text-input caret, all converted by
it, in physical pixels; the compositor scales the window up on a HiDPI
output. A scale that changes after the constructor is still applied only
by the next resize, which is #165.
- Without xdg-decoration the window has no title bar, since the library
draws no client-side decorations.
Device
- The optional globals say they are optional, and that a request on a
null proxy crashes.
Tests
- WindowOptionalGlobals: hides xdg-decoration, wp_viewporter and
wp_fractional_scale_v1 from the Window one at a time and all at once,
by nulling the Device statics after Initialize, and renders, resizes
and closes a window each time without a protocol error. Before the fix
it crashed on the window without xdg-decoration. A global the compositor
does not offer is reported as a SKIP line, since the path that uses it
goes untested there.
README
- Which Wayland protocols the window needs, and what it does without the
optional ones.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 08:28:48 +01:00
[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
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 08:51:18 +01:00
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.
2026-05-19 01:43:46 +02:00
- **Device** * (Vulkan only) * — single-instance bring-up targeting
`VK_EXT_descriptor_heap` ; pipelines are created with
2026-05-02 21:08:20 +02:00
`VK_PIPELINE_CREATE_2_DESCRIPTOR_HEAP_BIT_EXT` so there are no
descriptor-set layouts and push constants travel via
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 08:51:18 +01:00
`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
feat(device): applications ask for the device features their shaders use
Device
- Device::Initialize(required, optional) takes DeviceFeatures, the five
core feature structs vulkan10 to vulkan14. The library enables only what
its own shaders and code use. Device::enabledFeatures says what was
enabled, supportedFeatures what the GPU has, apiVersion the version in
use.
- shaderInt16, shaderFloat16, storageBuffer16BitAccess, scalarBlockLayout
and the uniform-buffer, storage-buffer and storage-image non-uniform
indexing bits are no longer enabled for everyone. An application whose
shaders take uint16_t spec constants passes
{.vulkan10 = {.shaderInt16 = VK_TRUE}}. scalarBlockLayout stays on with
the NVIDIA workaround, whose TLAS address may sit in a struct's tail
padding.
- Every GPU is weighed before one is chosen: Vulkan 1.3, the swapchain and,
below 1.4, maintenance5 extensions, the library's needs, the required
features. One that can't run the application is skipped, saying why when
it ranks higher. A CPU device runs it only when nothing else is listed,
and CRAFTER_GRAPHICS_DEVICE never falls back. A GPU two drivers both list
is tried through each.
- When no GPU qualifies, Initialize throws naming each GPU's gaps. It
destroys the instance it made, before any Wayland connection, so it can
be called again asking for less.
- maintenance5 is vulkan14's on a 1.4 GPU and VK_KHR_maintenance5's below.
CRAFTER_GRAPHICS_NO_VULKAN_1_4 runs as a 1.3 application, to exercise
that path on a 1.4 GPU.
- VK_KHR_spirv_1_4 and VK_KHR_shader_float_controls, core since 1.2, are no
longer enabled, nor VK_EXT_shader_atomic_float, which was enabled without
its feature struct. memoryDecompression's feature bit is queried before
it is enabled.
Shaders
- VulkanShader checks a module's capabilities, declared directly or
implicitly, against the enabled core features, the GPU's subgroup
operations and the optional groups, and throws naming the field to pass,
or saying the GPU lacks it. The tables are generated from vk.xml and
SPIR-V's grammar.
- WorkaroundNvidiaAS::Patch sized a row-major matrix push member by its
columns, so the TLAS address could land inside it.
Window
- The swapchain format is one whose own feature flags allow storage reads
and writes without a format, which since Vulkan 1.3 each format says for
itself; shaderStorageImageRead/WriteWithoutFormat are no longer required.
Tests
- DeviceFeatures: ResolveFeatures, ChooseDevice and CheckShaderCapabilities,
without a GPU.
- FeatureRequests: required and optional features end to end, the 1.3
path, and a retry after a refusal.
- PushConstantRewrite: a row-major mat3x4 push member.
- HeapASRead, HeapDynamicIndexHit, RTPassPushData and the ray-tracing
examples ask for the features their shaders use.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 06:40:02 +01:00
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.
2026-05-19 01:43:46 +02:00
- **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
fix(descriptor-heap): round the buffer region up, align the reserved range for buffers too, and never size a heap at zero
DescriptorHeapVulkan
- The buffer region started at images * imageDescriptorSize /
bufferDescriptorSize, rounded down. A descriptor_heap array indexes
the heap from byte 0 in strides of its descriptor size, so buffer slot
0 has to sit on a whole buffer descriptor past the images. Where the
image descriptors were not a whole number of buffer descriptors, the
first buffer descriptors overwrote the last image descriptors. Where
they were less than one, the start was bumped to descriptor 1, but the
heap was still sized as if the buffers began right after the images,
so the last buffer descriptors went into the implementation's reserved
range. Every descriptor size is a power of two, so both cases need
image descriptors smaller than buffer descriptors, which NVIDIA (32
and 16 bytes) and llvmpipe (64 and 16) do not have. The start now
rounds up and the heap is sized from it (#163).
- The reserved range sat at a multiple of imageDescriptorAlignment only,
where vkCmdBindResourceHeapEXT also wants bufferDescriptorAlignment
(VUID-vkCmdBindResourceHeapEXT-pBindInfo-11435 and -11436). It now
starts on a multiple of the larger. The buffer descriptors need no more
than the rounding: each alignment is at most its descriptor size, so a
whole number of descriptors is aligned.
- bufferStartElement is 16 bits and took the division unchecked, so an
image region past 65535 buffer descriptors (16384 images on llvmpipe)
wrapped the buffer region around onto the images. Slot counts whose
buffer indices do not fit 16 bits, 0xFFFF being the empty slot, now
throw, naming the counts. A heap without buffer slots has no buffer
region, which starts at 0 as in descriptor-set mode, so image-only
heaps have no index to fit.
- On a driver that reserves nothing (llvmpipe), a heap without sampler
slots was a zero-size buffer, which every Initialize(1, 1, 0) made,
and Window::Render bound its address each frame. Each heap now holds at
least one descriptor, as each binding does in descriptor-set mode. That
grows NVIDIA's sampler heap by one descriptor when no sampler slots are
asked for; the resource heaps of the slot counts in the tree keep their
layouts byte for byte.
- ComputeLayout holds the arithmetic, pure over the descriptor-heap
properties, and Initialize goes through it. GetBufferOffset and
GetBufferOffsetElement, which had their own copies of the old
arithmetic and never had a caller, are gone.
- Bind(cmd, frame) binds a frame's heaps with the reserved ranges
Initialize stored, or in descriptor-set mode the frame's descriptor
set. Window::Render and the RTVolume example worked the reserved-range
offset out from the heap's size with the image-alignment mask, and
call Bind now. The window's per-heap cache of bind structs goes with
it: keyed on the heap pointer, it kept binding the old buffers after a
re-Initialize that reallocated them. The heap still ends with its
reserved range, so code that derives the offset from the size keeps
getting the right one.
Tests
- DescriptorHeapLayout: the issue's two layouts, the reserved range where
buffer descriptors are aligned more strictly than image ones, every
legal combination of descriptor sizes and alignments up to 256 bytes,
no heap empty, the NVIDIA and llvmpipe layouts, the 16-bit limit and
image-only heaps. Pure CPU, no device. Against the old arithmetic it
fails 12 of its 18 checks.
Docs
- README.md's heap bullet and AGENTS.consumer.md say how to bind the
heap outside the frame loop, and the consumer notes that Initialize
throws when buffer indices would pass 16 bits. AGENTS.md lists
ComputeLayout among the functions the pure-logic tests drive.
Closes #163.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 16:02:58 +01:00
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` .
2026-05-19 01:43:46 +02:00
- **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
2026-05-31 20:29:12 +00:00
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
2026-06-09 22:45:25 +00:00
`rtEmitRay` continuation/shadow rays and `rtAccumulate` radiance
(atomic — any number of rays per pixel per bounce, e.g. one shadow
ray per light). An
2026-05-31 20:29:12 +00:00
optional Resolve shader tonemaps the linear accumulator. See
[WAVEFRONT-DESIGN.md ](WAVEFRONT-DESIGN.md ).
2026-05-19 01:43:46 +02:00
- **ComputeShader / WebGPUComputeShader** — Tier 1 wrapper used by the
UI system. Vulkan loads a `.spv` and dispatches with
feat(vulkan): run the compute/UI path without VK_EXT_descriptor_heap
Device::Initialize required twelve device extensions with no capability
check. Mesa Turnip (Adreno) lacks six of them: VK_EXT_descriptor_heap,
VK_KHR_shader_untyped_pointers, VK_EXT_device_fault and the three ray
tracing extensions. vkCreateDevice failed there, so not even a UI-only
program could start, although the UI path is compute-only and needs
nothing else that Turnip is missing.
The descriptor heap, ray tracing and device fault are now optional
groups, detected at start-up from the extension list and the feature
bits (Device::descriptorHeapSupported, rayTracingSupported,
deviceFaultSupported). Ray tracing needs the heap; on a device without
it the ray tracing entry points throw a message naming the cause.
Without the heap the same bindless slots live in one update-after-bind
descriptor set per frame, owned by DescriptorHeapVulkan: binding 0
storage images, 1 sampled images, 2 samplers, 3 storage buffers. Slot
allocation, the slot handles, UIDispatchHeader and the UIRenderer API
are unchanged; buffer slots simply stop being offset. Window::Render
binds that frame's set where it binds the heap, every compute pipeline
shares the heap object's pipeline layout, and push data travels as push
constants.
Each UI kernel is built twice from one source. ui-shared.glsl declares
its resources through UI_HEAP_* macros that expand to descriptor_heap
or, with CRAFTER_GRAPHICS_DESCRIPTOR_SETS defined, to set/binding.
<name>.comp.spv and <name>.comp.sets.spv ship side by side and
ComputeShader::Load picks one. The heap build of every shader is
byte-identical to before.
Breaking:
- ComputeShader::Load takes the heap as a second argument.
- DescriptorHeapVulkan is no longer copyable or movable.
- Custom UI shaders declare heap resources with the UI_HEAP_* macros
and are registered twice in project.cpp (see the README).
- project.cpp uses Shader::defines and Shader::variant, so it needs a
crafter-build that has them (Crafter.Build master, fe38cc1 or later).
Also:
- maintenance5 is enabled as a feature, not only as an extension:
every VulkanBuffer already relied on VkBufferUsageFlags2CreateInfo.
- Barrier stage masks drop RAY_TRACING_SHADER on devices without ray
tracing, and ImageVulkan::Create resolves its default consumer stage
to COMPUTE_SHADER there.
- Window refuses a swapchain whose image count is not numFrames
instead of writing past images[]. The limitation itself is #160.
- CRAFTER_GRAPHICS_NO_DESCRIPTOR_HEAP forces the fallback on a driver
that has the heap, so it can be exercised on a desktop GPU.
Verified on an RTX 4090 in both modes under the validation layer, and
on a Fairphone 6 (Turnip, Adreno 810, postmarketOS), where the fallback
is the only path.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-06 17:28:46 +02:00
`vkCmdPushDataEXT` (or the `.sets.spv` build and `vkCmdPushConstants`
without the heap); WebGPU loads a user-supplied `.wgsl` blob at
2026-05-19 01:43:46 +02:00
runtime via `wgpuLoadCustomShader` . Use it directly for any custom
compute.
feat(vulkan): run the compute/UI path without VK_EXT_descriptor_heap
Device::Initialize required twelve device extensions with no capability
check. Mesa Turnip (Adreno) lacks six of them: VK_EXT_descriptor_heap,
VK_KHR_shader_untyped_pointers, VK_EXT_device_fault and the three ray
tracing extensions. vkCreateDevice failed there, so not even a UI-only
program could start, although the UI path is compute-only and needs
nothing else that Turnip is missing.
The descriptor heap, ray tracing and device fault are now optional
groups, detected at start-up from the extension list and the feature
bits (Device::descriptorHeapSupported, rayTracingSupported,
deviceFaultSupported). Ray tracing needs the heap; on a device without
it the ray tracing entry points throw a message naming the cause.
Without the heap the same bindless slots live in one update-after-bind
descriptor set per frame, owned by DescriptorHeapVulkan: binding 0
storage images, 1 sampled images, 2 samplers, 3 storage buffers. Slot
allocation, the slot handles, UIDispatchHeader and the UIRenderer API
are unchanged; buffer slots simply stop being offset. Window::Render
binds that frame's set where it binds the heap, every compute pipeline
shares the heap object's pipeline layout, and push data travels as push
constants.
Each UI kernel is built twice from one source. ui-shared.glsl declares
its resources through UI_HEAP_* macros that expand to descriptor_heap
or, with CRAFTER_GRAPHICS_DESCRIPTOR_SETS defined, to set/binding.
<name>.comp.spv and <name>.comp.sets.spv ship side by side and
ComputeShader::Load picks one. The heap build of every shader is
byte-identical to before.
Breaking:
- ComputeShader::Load takes the heap as a second argument.
- DescriptorHeapVulkan is no longer copyable or movable.
- Custom UI shaders declare heap resources with the UI_HEAP_* macros
and are registered twice in project.cpp (see the README).
- project.cpp uses Shader::defines and Shader::variant, so it needs a
crafter-build that has them (Crafter.Build master, fe38cc1 or later).
Also:
- maintenance5 is enabled as a feature, not only as an extension:
every VulkanBuffer already relied on VkBufferUsageFlags2CreateInfo.
- Barrier stage masks drop RAY_TRACING_SHADER on devices without ray
tracing, and ImageVulkan::Create resolves its default consumer stage
to COMPUTE_SHADER there.
- Window refuses a swapchain whose image count is not numFrames
instead of writing past images[]. The limitation itself is #160.
- CRAFTER_GRAPHICS_NO_DESCRIPTOR_HEAP forces the fallback on a driver
that has the heap, so it can be exercised on a desktop GPU.
Verified on an RTX 4090 in both modes under the validation layer, and
on a Fairphone 6 (Turnip, Adreno 810, postmarketOS), where the fallback
is the only path.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-06 17:28:46 +02:00
- **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.
2026-05-02 21:08:20 +02:00
- **FontAtlas** — single-channel SDF atlas (1024× 1024, 32pt base,
shelf-packed, lazy `Ensure` per codepoint, dirty-flush via `Update` ).
2026-05-19 01:43:46 +02:00
Backend-agnostic.
2026-05-02 21:08:20 +02:00
- **Mesh / RenderingElement3D / Animation** — BLAS/TLAS construction
2026-05-19 01:43:46 +02:00
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.
2026-10-19 03:03:16 +02:00
Gamepad uses libudev+libevdev on Linux (libevdev is vendored in
`lib/libevdev` ) and WGI on Windows; the DOM
2026-05-19 01:43:46 +02:00
backend exposes the host page DOM (`Dom::HtmlElement` ) and a router
for hash-routed wasm apps.
2026-05-02 21:08:20 +02:00
## 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.
feat(vulkan): run the compute/UI path without VK_EXT_descriptor_heap
Device::Initialize required twelve device extensions with no capability
check. Mesa Turnip (Adreno) lacks six of them: VK_EXT_descriptor_heap,
VK_KHR_shader_untyped_pointers, VK_EXT_device_fault and the three ray
tracing extensions. vkCreateDevice failed there, so not even a UI-only
program could start, although the UI path is compute-only and needs
nothing else that Turnip is missing.
The descriptor heap, ray tracing and device fault are now optional
groups, detected at start-up from the extension list and the feature
bits (Device::descriptorHeapSupported, rayTracingSupported,
deviceFaultSupported). Ray tracing needs the heap; on a device without
it the ray tracing entry points throw a message naming the cause.
Without the heap the same bindless slots live in one update-after-bind
descriptor set per frame, owned by DescriptorHeapVulkan: binding 0
storage images, 1 sampled images, 2 samplers, 3 storage buffers. Slot
allocation, the slot handles, UIDispatchHeader and the UIRenderer API
are unchanged; buffer slots simply stop being offset. Window::Render
binds that frame's set where it binds the heap, every compute pipeline
shares the heap object's pipeline layout, and push data travels as push
constants.
Each UI kernel is built twice from one source. ui-shared.glsl declares
its resources through UI_HEAP_* macros that expand to descriptor_heap
or, with CRAFTER_GRAPHICS_DESCRIPTOR_SETS defined, to set/binding.
<name>.comp.spv and <name>.comp.sets.spv ship side by side and
ComputeShader::Load picks one. The heap build of every shader is
byte-identical to before.
Breaking:
- ComputeShader::Load takes the heap as a second argument.
- DescriptorHeapVulkan is no longer copyable or movable.
- Custom UI shaders declare heap resources with the UI_HEAP_* macros
and are registered twice in project.cpp (see the README).
- project.cpp uses Shader::defines and Shader::variant, so it needs a
crafter-build that has them (Crafter.Build master, fe38cc1 or later).
Also:
- maintenance5 is enabled as a feature, not only as an extension:
every VulkanBuffer already relied on VkBufferUsageFlags2CreateInfo.
- Barrier stage masks drop RAY_TRACING_SHADER on devices without ray
tracing, and ImageVulkan::Create resolves its default consumer stage
to COMPUTE_SHADER there.
- Window refuses a swapchain whose image count is not numFrames
instead of writing past images[]. The limitation itself is #160.
- CRAFTER_GRAPHICS_NO_DESCRIPTOR_HEAP forces the fallback on a driver
that has the heap, so it can be exercised on a desktop GPU.
Verified on an RTX 4090 in both modes under the validation layer, and
on a Fairphone 6 (Turnip, Adreno 810, postmarketOS), where the fallback
is the only path.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-06 17:28:46 +02:00
### 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.
2026-05-02 21:08:20 +02:00
## Build
The repository is built with `crafter-build` (a project-config based
build system; the project description lives in `project.cpp` ):
2026-05-02 00:03:24 +02:00
```bash
2026-05-19 01:43:46 +02:00
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)
2026-05-02 00:03:24 +02:00
```
2026-05-19 01:43:46 +02:00
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.
2026-05-02 00:03:24 +02:00
## Examples
2026-05-02 21:08:20 +02:00
See [examples/ ](examples/ ). Quick map:
2026-05-19 01:43:46 +02:00
- [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.
2026-05-31 20:29:12 +00:00
- [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.
2026-05-19 01:43:46 +02:00
- [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.
2026-05-02 21:08:20 +02:00
- [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
2026-05-19 01:43:46 +02:00
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.
2026-05-02 00:03:24 +02:00
## License
2026-07-22 18:09:06 +02:00
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