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

@ -79,26 +79,32 @@ int main() {
VkCommandBuffer cmd = window.StartInit();
DescriptorHeapVulkan descriptorHeap;
descriptorHeap.Initialize(/*images*/ 2, /*buffers*/ 1, /*samplers*/ 0);
descriptorHeap.Initialize(/*images*/ 2, /*buffers*/ 1, /*samplers*/ 1);
// Two specialization constants: the TLAS slot offset (shared with
// VulkanTriangle pattern) and the albedo slot index for closesthit.
// Specialization constants: the TLAS slot offset for raygen (shared
// with the VulkanTriangle pattern), and the albedo's image and sampler
// slots for closesthit.
VkSpecializationMapEntry raygenEntry = { .constantID = 0, .offset = 0, .size = sizeof(std::uint16_t) };
VkSpecializationInfo raygenSpec = {
.mapEntryCount = 1, .pMapEntries = &raygenEntry,
.dataSize = sizeof(std::uint16_t), .pData = &descriptorHeap.bufferStartElement,
};
// Allocate the albedo slot first so its index is known when we
// Allocate the albedo's slots first so their indices are known when we
// compile closesthit.spv.
auto imgSlots = descriptorHeap.AllocateImageSlots(2);
auto bufSlots = descriptorHeap.AllocateBufferSlots(1);
auto imgSlots = descriptorHeap.AllocateImageSlots(2);
auto bufSlots = descriptorHeap.AllocateBufferSlots(1);
auto samplerSlots = descriptorHeap.AllocateSamplerSlots(1);
std::uint16_t albedoHeapSlot = static_cast<std::uint16_t>(imgSlots.firstElement + 1);
VkSpecializationMapEntry hitEntry = { .constantID = 0, .offset = 0, .size = sizeof(std::uint16_t) };
const std::array<std::uint16_t, 2> hitConstants { albedoHeapSlot, samplerSlots.firstElement };
const std::array<VkSpecializationMapEntry, 2> hitEntries {{
{ .constantID = 0, .offset = 0, .size = sizeof(std::uint16_t) },
{ .constantID = 1, .offset = sizeof(std::uint16_t), .size = sizeof(std::uint16_t) },
}};
VkSpecializationInfo hitSpec = {
.mapEntryCount = 1, .pMapEntries = &hitEntry,
.dataSize = sizeof(std::uint16_t), .pData = &albedoHeapSlot,
.mapEntryCount = static_cast<std::uint32_t>(hitEntries.size()), .pMapEntries = hitEntries.data(),
.dataSize = sizeof(hitConstants), .pData = hitConstants.data(),
};
std::array<VulkanShader, 3> shaders {{
@ -140,7 +146,6 @@ int main() {
VK_IMAGE_USAGE_TRANSFER_DST_BIT | VK_IMAGE_USAGE_SAMPLED_BIT,
VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL);
albedo.Update(loadedTex, cmd, VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL);
SamplerVulkan<RGBA8> sampler;
static RenderingElement3D renderer;
renderer.instance = {
@ -161,13 +166,26 @@ int main() {
window.FinishInit();
// Write descriptors: TLAS at bufSlots[0], output image at imgSlots[0],
// albedo (combined image+sampler) at imgSlots[1]. Per-frame replicated.
// albedo (a sampled image) at imgSlots[1], and its sampler at
// samplerSlots[0] in the sampler heap. Per-frame replicated.
VkDeviceAddressRangeKHR tlasRanges[Window::numFrames];
VkImageDescriptorInfoEXT outImgInfos[Window::numFrames];
VkDescriptorImageInfo albedoInfo {
.sampler = sampler.textureSampler,
.imageView = albedo.imageView,
.imageLayout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL,
// The heap takes an image view's create-info, not a VkImageView.
const VkImageViewCreateInfo albedoView {
.sType = VK_STRUCTURE_TYPE_IMAGE_VIEW_CREATE_INFO,
.image = albedo.image,
.viewType = VK_IMAGE_VIEW_TYPE_2D,
.format = VK_FORMAT_R8G8B8A8_UNORM,
.subresourceRange = {
.aspectMask = VK_IMAGE_ASPECT_COLOR_BIT,
.levelCount = albedo.mipLevels,
.layerCount = 1,
},
};
const VkImageDescriptorInfoEXT albedoInfo {
.sType = VK_STRUCTURE_TYPE_IMAGE_DESCRIPTOR_INFO_EXT,
.pView = &albedoView,
.layout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL,
};
for (std::uint32_t f = 0; f < Window::numFrames; ++f) {
tlasRanges[f] = { .address = RenderingElement3D::tlases[f].address };
@ -205,8 +223,8 @@ int main() {
});
resources.push_back({
.sType = VK_STRUCTURE_TYPE_RESOURCE_DESCRIPTOR_INFO_EXT,
.type = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER,
.data = { .pCombinedImageSampler = &albedoInfo },
.type = VK_DESCRIPTOR_TYPE_SAMPLED_IMAGE,
.data = { .pImage = &albedoInfo },
});
destinations.push_back({
.address = descriptorHeap.resourceHeap[f].value
@ -221,6 +239,34 @@ int main() {
descriptorHeap.resourceHeap[f].FlushDevice();
}
// The sampler heap takes a sampler's create-info, as
// UIRenderer::RegisterSampler writes it.
const VkSamplerCreateInfo albedoSamplerInfo {
.sType = VK_STRUCTURE_TYPE_SAMPLER_CREATE_INFO,
.magFilter = VK_FILTER_LINEAR,
.minFilter = VK_FILTER_LINEAR,
.mipmapMode = VK_SAMPLER_MIPMAP_MODE_LINEAR,
.addressModeU = VK_SAMPLER_ADDRESS_MODE_REPEAT,
.addressModeV = VK_SAMPLER_ADDRESS_MODE_REPEAT,
.addressModeW = VK_SAMPLER_ADDRESS_MODE_REPEAT,
.maxLod = VK_LOD_CLAMP_NONE,
};
VkSamplerCreateInfo samplerInfos[Window::numFrames];
VkHostAddressRangeEXT samplerDestinations[Window::numFrames];
for (std::uint32_t f = 0; f < Window::numFrames; ++f) {
samplerInfos[f] = albedoSamplerInfo;
samplerDestinations[f] = {
.address = descriptorHeap.samplerHeap[f].value
+ descriptorHeap.SamplerByteOffset(samplerSlots.firstElement),
.size = Device::descriptorHeapProperties.samplerDescriptorSize,
};
}
Device::vkWriteSamplerDescriptorsEXT(Device::device, Window::numFrames,
samplerInfos, samplerDestinations);
for (std::uint32_t f = 0; f < Window::numFrames; ++f) {
descriptorHeap.samplerHeap[f].FlushDevice();
}
window.descriptorHeap = &descriptorHeap;
RTPass rtPass(&pipeline);
window.passes.push_back(&rtPass);