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>
This commit is contained in:
Jorijn van der Graaf 2026-10-31 08:25:52 +01:00
commit 6e94cc6209
Signed by: jorijnvdgraaf
GPG key ID: 2937E59CDCC1BCFB
7 changed files with 230 additions and 40 deletions

View file

@ -14,9 +14,16 @@ it via ray tracing on both Vulkan (native) and WebGPU (wasm). Same
at the barycentric attribs as UVs — proof-of-binding, not visually
accurate. Per-vertex UV interpolation is the next step.
The closest-hit reads its texture through a **spec constant**
(`albedo[albedoSlot]`), not a runtime index. That is deliberate — see
below.
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).
## Native RT limitation: dynamic `descriptor_heap` indexing in hit shaders
@ -44,7 +51,8 @@ closest-hit:
- A closest-hit that reads only `lightHeap[lightSlot]` where `lightSlot`
is a **spec constant** survives indefinitely. ✅ (This example's
`albedo[albedoSlot]` is exactly this case.)
`textures[albedoSlot]` and `samplers[samplerSlot]` are exactly this
case.)
- Reading `indexHeap[assetIndexStart + gl_InstanceCustomIndexEXT]` /
`vertexHeap[...]` — a heap index offset by a **runtime** value —
device-losts on the first geometry hit. ❌
@ -116,11 +124,14 @@ indexing the heap:
slot and index by **layer**:
```glsl
// ✅ one array texture (spec-constant slot), dynamic layer index
// ✅ one array texture (spec-constant slots), dynamic layer index
layout(constant_id = 1) const uint16_t materialArraySlot = 0us;
layout(descriptor_heap) uniform sampler2DArray materials[];
layout(constant_id = 2) const uint16_t samplerSlot = 0us;
layout(descriptor_heap) uniform texture2DArray materials[];
layout(descriptor_heap) uniform sampler samplers[];
uint layer = materialLayer[gl_InstanceCustomIndexEXT]; // from a bound SSBO
vec3 albedo = texture(materials[materialArraySlot], vec3(uv, layer)).rgb;
vec3 albedo = texture(sampler2DArray(materials[materialArraySlot], samplers[samplerSlot]),
vec3(uv, layer)).rgb;
```
This is precisely what the WebGPU path already does — bucketed texture