2026-05-19 00:27:09 +02:00
|
|
|
# Sponza example
|
|
|
|
|
|
|
|
|
|
Loads the Sponza atrium as a `.cmesh` + one albedo `.ctex` and renders
|
|
|
|
|
it via ray tracing on both Vulkan (native) and WebGPU (wasm). Same
|
|
|
|
|
`main.cpp`, `#ifdef CRAFTER_GRAPHICS_WINDOW_DOM` selects the backend.
|
|
|
|
|
|
|
|
|
|
## What this example proves
|
|
|
|
|
|
|
|
|
|
- `.cmesh` and `.ctex` decompression round-trip on both backends
|
|
|
|
|
(GPU via `VK_EXT_memory_decompression` on Vulkan, CPU via
|
|
|
|
|
`Compression::DecompressCPU` on WebGPU).
|
|
|
|
|
- A single texture binding flowing from `Image2D<RGBA8>` through the
|
|
|
|
|
RT pipeline's closest-hit on both backends. The closest-hit samples
|
|
|
|
|
at the barycentric attribs as UVs — proof-of-binding, not visually
|
|
|
|
|
accurate. Per-vertex UV interpolation is the next step.
|
|
|
|
|
|
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
|
|
|
The closest-hit reads its texture and its sampler through **spec
|
|
|
|
|
constants** (`textures[albedoSlot]`, `samplers[samplerSlot]`), not a
|
|
|
|
|
runtime index. That is deliberate — see below.
|
|
|
|
|
|
|
|
|
|
On Vulkan the texture and the sampler are separate descriptors: a sampled
|
|
|
|
|
image in the resource heap and a sampler in the sampler heap, joined where
|
|
|
|
|
the closest-hit samples them,
|
|
|
|
|
`texture(sampler2D(textures[albedoSlot], samplers[samplerSlot]), uv)`.
|
|
|
|
|
The descriptor heap has no combined image samplers, and glslang rejects a
|
|
|
|
|
`descriptor_heap` `sampler2D` (#164).
|
2026-06-03 20:05:12 +00:00
|
|
|
|
|
|
|
|
## Native RT limitation: dynamic `descriptor_heap` indexing in hit shaders
|
|
|
|
|
|
2026-10-19 03:03:17 +02:00
|
|
|
Fixed in NVIDIA `615.78.08`: `tests/HeapDynamicIndexHit` selects buffers and
|
|
|
|
|
textures with a runtime index in a closest-hit, including different
|
|
|
|
|
descriptors in neighbouring lanes of a wave. This example still uses the
|
|
|
|
|
spec-constant pattern below, which also runs on the older drivers.
|
|
|
|
|
|
2026-06-03 20:05:12 +00:00
|
|
|
On NVIDIA driver `610.43.02` (Vulkan 1.4), indexing a
|
|
|
|
|
`layout(descriptor_heap)` array with a **runtime (non-constant)** index
|
|
|
|
|
inside a ray-tracing **hit** shader aborts the device with
|
|
|
|
|
`VK_ERROR_DEVICE_LOST` (an instruction-pointer / `READ_INVALID`
|
|
|
|
|
device-fault) with validation off. GPU-Assisted Validation masks it —
|
|
|
|
|
the scene runs fine under GPU-AV — which is why a validated run doesn't
|
|
|
|
|
catch it. It is a **driver-side fault**, the same family as the
|
|
|
|
|
descriptor-heap AS-read fault (#7 / #15) and the RT recursion / compute
|
|
|
|
|
TLAS-push issues (#21 / #22), but here for plain **SSBO and
|
|
|
|
|
sampled-image** descriptors read with a non-constant heap index
|
|
|
|
|
(issue #23).
|
|
|
|
|
|
|
|
|
|
### What was isolated (NVIDIA RTX 4090, driver `610.43.02`)
|
|
|
|
|
|
|
|
|
|
Driving a native bindless RT scene headlessly and bisecting the
|
|
|
|
|
closest-hit:
|
|
|
|
|
|
|
|
|
|
- A closest-hit that reads only `lightHeap[lightSlot]` where `lightSlot`
|
|
|
|
|
is a **spec constant** survives indefinitely. ✅ (This example'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
|
|
|
`textures[albedoSlot]` and `samplers[samplerSlot]` are exactly this
|
|
|
|
|
case.)
|
2026-06-03 20:05:12 +00:00
|
|
|
- Reading `indexHeap[assetIndexStart + gl_InstanceCustomIndexEXT]` /
|
|
|
|
|
`vertexHeap[...]` — a heap index offset by a **runtime** value —
|
|
|
|
|
device-losts on the first geometry hit. ❌
|
|
|
|
|
- Reading a **texture** dynamically,
|
|
|
|
|
`textureHeap[assetColorStart + gl_InstanceCustomIndexEXT]`, also
|
|
|
|
|
device-losts. ❌ So it is SSBO *and* sampled-image descriptors.
|
|
|
|
|
- `nonuniformEXT()` on the dynamic index does **not** help.
|
|
|
|
|
- The identical dynamic-heap-index pattern works fine in **fragment**
|
|
|
|
|
shaders (the UI renderer indexes `uiTextures[]` / `ui*Heap[]` by
|
|
|
|
|
per-item runtime slots), so this is **RT-stage-specific**, not a
|
|
|
|
|
general `descriptor_heap` problem.
|
|
|
|
|
- Reading a spec-constant-indexed SSBO in **raygen** works; only the
|
|
|
|
|
*dynamic* index in the hit stage faults.
|
|
|
|
|
|
|
|
|
|
### Why there is no transparent engine workaround
|
|
|
|
|
|
|
|
|
|
The AS-read fault (#15) is worked around transparently because an
|
|
|
|
|
acceleration structure can be reached two ways: through a descriptor, or
|
|
|
|
|
through its **device address** via
|
|
|
|
|
`OpConvertUToAccelerationStructureKHR` (which reads no descriptor). There
|
|
|
|
|
is exactly one TLAS, so the engine rewrites the heap AS read into an
|
|
|
|
|
address load and feeds the address in as push data.
|
|
|
|
|
|
|
|
|
|
Neither half of that applies here:
|
|
|
|
|
|
|
|
|
|
- **Sampled images have no device-address path.** A texture *must* be
|
|
|
|
|
reached through a descriptor; there is no `OpConvertUToImage`. A
|
|
|
|
|
dynamic heap texture index cannot be rewritten into anything that
|
|
|
|
|
avoids dynamic descriptor selection.
|
|
|
|
|
- **There are many buffers, dynamically selected.** SSBOs *can* be
|
|
|
|
|
reached by address (`buffer_reference` / `OpConvertUToPtr`), but a
|
|
|
|
|
per-mesh array selected by `gl_InstanceCustomIndexEXT` would need the
|
|
|
|
|
engine to maintain and bind an address-table buffer and a SPIR-V
|
|
|
|
|
rewrite far larger than the single-TLAS AS case — and it would still
|
|
|
|
|
leave the texture half broken.
|
|
|
|
|
|
|
|
|
|
So the engine cannot paper over this the way it does the AS read. The
|
|
|
|
|
fix is on the **consumer** side: avoid dynamically selecting a
|
|
|
|
|
*descriptor* in a hit shader.
|
|
|
|
|
|
|
|
|
|
### Recommended pattern
|
|
|
|
|
|
|
|
|
|
The fault is dynamic selection of a **descriptor**. Indexing *within* a
|
|
|
|
|
single bound resource — an element offset into one SSBO, a layer into
|
|
|
|
|
one array texture — is ordinary memory / layer addressing and is
|
|
|
|
|
**unaffected**. So bind one resource and index inside it, rather than
|
|
|
|
|
indexing the heap:
|
|
|
|
|
|
|
|
|
|
- **Geometry** — pack all meshes' vertices/indices into a single SSBO
|
|
|
|
|
bound at a **spec-constant** slot and index it by a runtime element
|
|
|
|
|
offset, or reach each mesh's buffer via `buffer_reference` (a device
|
|
|
|
|
address loaded from one bound table). Either way the *descriptor* is
|
|
|
|
|
constant; only the offset/address is dynamic.
|
|
|
|
|
|
|
|
|
|
```glsl
|
|
|
|
|
// ❌ faults in a hit shader on NVIDIA: dynamic descriptor selection
|
|
|
|
|
layout(descriptor_heap) buffer Verts { Vertex v[]; } vertexHeap[];
|
|
|
|
|
Vertex vtx = vertexHeap[assetVertexStart + gl_InstanceCustomIndexEXT].v[i];
|
|
|
|
|
|
|
|
|
|
// ✅ one descriptor (spec-constant slot), dynamic element offset
|
|
|
|
|
layout(constant_id = 0) const uint16_t vertexSlot = 0us;
|
|
|
|
|
layout(descriptor_heap) buffer Verts { Vertex v[]; } vertexHeap[];
|
|
|
|
|
uint base = assetVertexStart[gl_InstanceCustomIndexEXT]; // from a bound SSBO
|
|
|
|
|
Vertex vtx = vertexHeap[vertexSlot].v[base + i];
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
- **Materials / textures** — put them in one `texture2DArray` (or a small
|
|
|
|
|
number of arrays bucketed by format/size) bound at a spec-constant
|
|
|
|
|
slot and index by **layer**:
|
|
|
|
|
|
|
|
|
|
```glsl
|
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
|
|
|
// ✅ one array texture (spec-constant slots), dynamic layer index
|
2026-06-03 20:05:12 +00:00
|
|
|
layout(constant_id = 1) const uint16_t materialArraySlot = 0us;
|
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
|
|
|
layout(constant_id = 2) const uint16_t samplerSlot = 0us;
|
|
|
|
|
layout(descriptor_heap) uniform texture2DArray materials[];
|
|
|
|
|
layout(descriptor_heap) uniform sampler samplers[];
|
2026-06-03 20:05:12 +00:00
|
|
|
uint layer = materialLayer[gl_InstanceCustomIndexEXT]; // from a bound SSBO
|
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
|
|
|
vec3 albedo = texture(sampler2DArray(materials[materialArraySlot], samplers[samplerSlot]),
|
|
|
|
|
vec3(uv, layer)).rgb;
|
2026-06-03 20:05:12 +00:00
|
|
|
```
|
|
|
|
|
|
|
|
|
|
This is precisely what the WebGPU path already does — bucketed texture
|
|
|
|
|
arrays plus a single geometry buffer — so it is a proven, cross-backend
|
|
|
|
|
pattern, and it sidesteps the NVIDIA RT fault on the native path.
|
|
|
|
|
|
|
|
|
|
|
2026-05-19 00:27:09 +02:00
|
|
|
## Asset fetch
|
|
|
|
|
|
|
|
|
|
`project.cpp` calls `Crafter::GitFetch(...)` on
|
|
|
|
|
[https://github.com/jimmiebergmann/Sponza](https://github.com/jimmiebergmann/Sponza)
|
|
|
|
|
(pinned to commit `222338979d32f4f4818466291bdbc29f192b86ba`). The
|
|
|
|
|
clone lands in the per-user crafter-build cache; first build pulls
|
|
|
|
|
~280 MB once, subsequent builds reuse it.
|
|
|
|
|
|
|
|
|
|
`cfg.assets` then picks two files out of that clone:
|
|
|
|
|
|
|
|
|
|
| Source | Compressed output |
|
|
|
|
|
|-----------------------------------------|-------------------------|
|
|
|
|
|
| `sponza.obj` | `sponza.cmesh` |
|
|
|
|
|
| `textures/sponza_arch_diff.tga` | `sponza_arch_diff.ctex` |
|
|
|
|
|
|
|
|
|
|
Both land flat in the example's bin directory.
|
|
|
|
|
|
|
|
|
|
## Building
|
|
|
|
|
|
|
|
|
|
```
|
|
|
|
|
crafter build # native Vulkan
|
|
|
|
|
crafter build --target=wasm32-wasip1 # WebGPU / wasm
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
## License & attribution
|
|
|
|
|
|
|
|
|
|
Sponza geometry, materials, and textures are licensed under
|
|
|
|
|
[CC BY 3.0](https://creativecommons.org/licenses/by/3.0/).
|
|
|
|
|
|
|
|
|
|
- **Original model:** Frank Meinl, Crytek (2010).
|
|
|
|
|
- **OBJ packaging / cleanup:** Morgan McGuire, McGuire Computer
|
|
|
|
|
Graphics Archive — https://casual-effects.com/data.
|
|
|
|
|
- **GitHub mirror used here:** Jimmie Bergmann's roof-material fixup —
|
|
|
|
|
https://github.com/jimmiebergmann/Sponza.
|
|
|
|
|
|
|
|
|
|
When redistributing builds of this example that bundle the compressed
|
|
|
|
|
Sponza outputs (`*.cmesh`, `*.ctex`), the CC BY 3.0 attribution
|
|
|
|
|
requirement applies. Quoting the original credit somewhere visible to
|
|
|
|
|
end users (about-screen, credits page, etc.) is enough.
|
|
|
|
|
|
|
|
|
|
The Crafter.Graphics library code itself is LGPL-3.0; the two
|
|
|
|
|
licenses are compatible for data + code distribution.
|