//SPDX-License-Identifier: LGPL-3.0-only //SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® // Regression test for dynamic descriptor-heap indexing in ray-tracing hit // shaders. On NVIDIA 610.43.02, selecting a VK_EXT_descriptor_heap descriptor // with a runtime index inside a hit shader lost the device, for storage // buffers and sampled images alike (README, "Native RT limitation"), so the // examples select hit-shader descriptors through spec constants. This renders // two instances of one triangle whose closest-hit picks a storage buffer and a // texture by a runtime index: instance 0 the same set everywhere, instance 1 a // different set in every other pixel column, so neighbouring lanes of a wave // select different descriptors. It reads the frame back and checks every // pixel got its own set's buffer red and texture green. // // The fault fixed in NVIDIA 615.78.08, so on older NVIDIA drivers the test // skips instead of aborting the suite. GPU-assisted validation hides the // fault (examples/Sponza/README.md), so with CRAFTER_GRAPHICS_VALIDATION set // the scene is also rendered in a child process with validation off; the // validation check only means something with it set. Needs a live Wayland // compositor and a Vulkan RT device; without RT it skips. #include "vulkan/vulkan.h" #include #include #include // PNG decode for the read-back frame. The library only compiles the writer // (Window::SaveFrame); STB_IMAGE_STATIC keeps the reader private to this file. #define STB_IMAGE_IMPLEMENTATION #define STB_IMAGE_STATIC #define STBI_ONLY_PNG #include "../../lib/stb_image.h" import Crafter.Graphics; import Crafter.Event; import Crafter.Math; import std; using namespace Crafter; namespace fs = std::filesystem; namespace { int failures = 0; void Check(bool ok, std::string_view what) { std::println("{} {}", ok ? "PASS" : "FAIL", what); // Flushed per line: a driver with the fault aborts the process from // inside Render(), and the checks before it should still show. std::fflush(stdout); if (!ok) ++failures; } // The build places the compiled shaders next to the test binary, and the // test runner's working directory is not that directory. fs::path ExeDir() { return fs::read_symlink("/proc/self/exe").parent_path(); } int RunCommand(const std::string& cmd) { int status = std::system(cmd.c_str()); if (status == -1) return -1; return (status & 0x7f) == 0 ? ((status >> 8) & 0xff) : 128 + (status & 0x7f); } struct RGBA8 { std::uint8_t r, g, b, a; }; struct Frame { int width = 0; int height = 0; std::vector rgba; std::array At(int x, int y) const { const std::size_t i = (static_cast(y) * width + x) * 4; return { rgba[i], rgba[i + 1], rgba[i + 2] }; } }; Frame LoadFrame(const fs::path& path) { Frame frame; int channels = 0; stbi_uc* pixels = stbi_load(path.string().c_str(), &frame.width, &frame.height, &channels, STBI_rgb_alpha); if (pixels != nullptr) { frame.rgba.assign(pixels, pixels + static_cast(frame.width) * frame.height * 4); stbi_image_free(pixels); } return frame; } bool Near(std::array pixel, std::array expected) { for (std::size_t c = 0; c < 3; ++c) { if (std::abs(static_cast(pixel[c]) - static_cast(expected[c])) > 8) return false; } return true; } void CheckPixel(const Frame& frame, int x, int y, std::array expected, std::string_view what) { const std::array pixel = frame.At(x, y); Check(Near(pixel, expected), std::format("{} at ({}, {}) is ({}, {}, {}), expected ({}, {}, {})", what, x, y, pixel[0], pixel[1], pixel[2], expected[0], expected[1], expected[2])); } // The pixel column world x on the triangles' plane (z = 100, 400 in front of // the camera) lands in, for the raygen's 60° vertical field of view. int FrameX(float worldX, const Frame& frame) { const float aspect = static_cast(frame.width) / static_cast(frame.height); const float ndc = worldX / (400.0f * aspect * std::tan(std::numbers::pi_v / 6.0f)); return static_cast((0.5f + ndc * 0.5f) * static_cast(frame.width)); } // Spec constants of the closest-hit, matching dynidx-closesthit.glsl. struct HitConstants { std::uint16_t bufferStart; std::uint16_t pad; std::uint32_t textureBase; std::uint32_t colourBase; }; // What each descriptor set holds: the red its buffer gives and the green its // texture gives. constexpr std::array bufferRed = {1.0f, 0.5f}; constexpr std::array textureGreen = {64, 191}; constexpr std::array, 2> setColour = {{{255, 64, 0}, {128, 191, 0}}}; // NVIDIA's proprietary driver packs its version into 10.8.8.6 bits. std::tuple NvidiaVersion() { const std::uint32_t v = Device::deviceProperties.driverVersion; return {(v >> 22) & 0x3ff, (v >> 14) & 0xff, (v >> 6) & 0xff}; } // Renders the scene into `out` and checks the pixels. Device::Initialize has // already run. Returns the number of failed checks. int RenderScene(const fs::path& out) { const int failuresBefore = failures; Device::validationErrorCount = 0; Window window(640, 360, "HeapDynamicIndexHit test"); VkCommandBuffer cmd = window.StartInit(); // Image slots: 0 = the frame's swapchain image, 1-2 = the textures. // Buffer slots: 0 = the TLAS, 1-2 = the colour buffers. Sampler slot 0. DescriptorHeapVulkan descriptorHeap; descriptorHeap.Initialize(3, 3, 1); 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, }; HitConstants hitConstants = { .bufferStart = descriptorHeap.bufferStartElement, .pad = 0, .textureBase = 1, .colourBase = 1, }; std::array hitEntries {{ { .constantID = 0, .offset = offsetof(HitConstants, bufferStart), .size = sizeof(std::uint16_t) }, { .constantID = 1, .offset = offsetof(HitConstants, textureBase), .size = sizeof(std::uint32_t) }, { .constantID = 2, .offset = offsetof(HitConstants, colourBase), .size = sizeof(std::uint32_t) }, }}; VkSpecializationInfo hitSpec = { .mapEntryCount = static_cast(hitEntries.size()), .pMapEntries = hitEntries.data(), .dataSize = sizeof(HitConstants), .pData = &hitConstants, }; const fs::path dir = ExeDir(); std::array shaders{{ {dir / "dynidx-raygen.spv", "main", VK_SHADER_STAGE_RAYGEN_BIT_KHR, &raygenSpec}, {dir / "dynidx-miss.spv", "main", VK_SHADER_STAGE_MISS_BIT_KHR, nullptr}, {dir / "dynidx-closesthit.spv", "main", VK_SHADER_STAGE_CLOSEST_HIT_BIT_KHR, &hitSpec}, }}; ShaderBindingTableVulkan shaderTable; shaderTable.Init(shaders); std::array raygenGroups {{ { .sType = VK_STRUCTURE_TYPE_RAY_TRACING_SHADER_GROUP_CREATE_INFO_KHR, .type = VK_RAY_TRACING_SHADER_GROUP_TYPE_GENERAL_KHR, .generalShader = 0, .closestHitShader = VK_SHADER_UNUSED_KHR, .anyHitShader = VK_SHADER_UNUSED_KHR, .intersectionShader = VK_SHADER_UNUSED_KHR, } }}; std::array missGroups {{ { .sType = VK_STRUCTURE_TYPE_RAY_TRACING_SHADER_GROUP_CREATE_INFO_KHR, .type = VK_RAY_TRACING_SHADER_GROUP_TYPE_GENERAL_KHR, .generalShader = 1, .closestHitShader = VK_SHADER_UNUSED_KHR, .anyHitShader = VK_SHADER_UNUSED_KHR, .intersectionShader = VK_SHADER_UNUSED_KHR, } }}; std::array hitGroups {{ { .sType = VK_STRUCTURE_TYPE_RAY_TRACING_SHADER_GROUP_CREATE_INFO_KHR, .type = VK_RAY_TRACING_SHADER_GROUP_TYPE_TRIANGLES_HIT_GROUP_KHR, .generalShader = VK_SHADER_UNUSED_KHR, .closestHitShader = 2, .anyHitShader = VK_SHADER_UNUSED_KHR, .intersectionShader = VK_SHADER_UNUSED_KHR, } }}; PipelineRTVulkan pipeline; pipeline.Init(cmd, raygenGroups, missGroups, hitGroups, shaderTable); // One triangle, two instances 100 units either side of the centre line. Mesh triangleMesh; std::array, 3> verts {{{-75, -75, 100}, {0, 75, 100}, {75, -75, 100}}}; std::array index {{2, 1, 0}}; triangleMesh.Build(verts, index, cmd); std::array renderers{}; for (std::uint32_t i = 0; i < renderers.size(); ++i) { renderers[i].instance = { .instanceCustomIndex = i, .mask = 0xFF, .instanceShaderBindingTableRecordOffset = 0, .flags = VK_GEOMETRY_INSTANCE_FORCE_OPAQUE_BIT_KHR, .accelerationStructureReference = triangleMesh.blasAddr, }; MatrixRowMajor::Translation(i == 0 ? -100.0f : 100.0f, 0.0f, 0.0f) .Store(reinterpret_cast(renderers[i].instance.transform.matrix)); RenderingElement3D::Add(&renderers[i]); } for (std::uint32_t f = 0; f < Window::numFrames; ++f) RenderingElement3D::BuildTLAS(cmd, f); // Per descriptor set: a 2x2 texture whose green the hit shader reads, and // a one-vec4 buffer whose red it reads. std::array, 2> textures; std::array, 2> colourBuffers; for (std::size_t i = 0; i < textures.size(); ++i) { textures[i].Create(2, 2, 1, cmd, VK_FORMAT_R8G8B8A8_UNORM, VK_IMAGE_USAGE_TRANSFER_DST_BIT | VK_IMAGE_USAGE_SAMPLED_BIT, VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL); for (std::size_t p = 0; p < 4; ++p) textures[i].buffer.value[p] = {0, textureGreen[i], 0, 255}; textures[i].Update(cmd, VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL); colourBuffers[i].Create(VK_BUFFER_USAGE_STORAGE_BUFFER_BIT | VK_BUFFER_USAGE_SHADER_DEVICE_ADDRESS_BIT, VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT, 4); colourBuffers[i].value[0] = bufferRed[i]; colourBuffers[i].value[1] = 0.0f; colourBuffers[i].value[2] = 0.0f; colourBuffers[i].value[3] = 1.0f; colourBuffers[i].FlushDevice(); } window.FinishInit(); auto imgSlots = descriptorHeap.AllocateImageSlots(3); auto bufSlots = descriptorHeap.AllocateBufferSlots(3); auto samplerSlots = descriptorHeap.AllocateSamplerSlots(1); std::array textureViews{}; std::array textureInfos{}; std::array colourRanges{}; for (std::size_t i = 0; i < 2; ++i) { textureViews[i] = { .sType = VK_STRUCTURE_TYPE_IMAGE_VIEW_CREATE_INFO, .image = textures[i].image, .viewType = VK_IMAGE_VIEW_TYPE_2D, .format = VK_FORMAT_R8G8B8A8_UNORM, .subresourceRange = { .aspectMask = VK_IMAGE_ASPECT_COLOR_BIT, .baseMipLevel = 0, .levelCount = 1, .baseArrayLayer = 0, .layerCount = 1 }, }; textureInfos[i] = { .sType = VK_STRUCTURE_TYPE_IMAGE_DESCRIPTOR_INFO_EXT, .pView = &textureViews[i], .layout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL, }; colourRanges[i] = { .address = colourBuffers[i].address, .size = 4 * sizeof(float) }; } // Written again whenever the swapchain is recreated, since the output // image descriptors point at its images. auto writeDescriptors = [&] { std::array tlasRanges{}; std::array outInfos{}; std::vector resources; std::vector destinations; auto add = [&](std::uint32_t f, VkResourceDescriptorInfoEXT resource, std::uint32_t offset, std::uint32_t size) { resources.push_back(resource); destinations.push_back({ .address = descriptorHeap.resourceHeap[f].value + offset, .size = size }); }; const std::uint32_t imageSize = Device::descriptorHeapProperties.imageDescriptorSize; const std::uint32_t bufferSize = Device::descriptorHeapProperties.bufferDescriptorSize; for (std::uint32_t f = 0; f < Window::numFrames; ++f) { tlasRanges[f] = { .address = RenderingElement3D::tlases[f].address }; outInfos[f] = { .sType = VK_STRUCTURE_TYPE_IMAGE_DESCRIPTOR_INFO_EXT, .pView = &window.imageViews[f], .layout = VK_IMAGE_LAYOUT_GENERAL, }; add(f, { .sType = VK_STRUCTURE_TYPE_RESOURCE_DESCRIPTOR_INFO_EXT, .type = VK_DESCRIPTOR_TYPE_ACCELERATION_STRUCTURE_KHR, .data = { .pAddressRange = &tlasRanges[f] } }, descriptorHeap.BufferByteOffset(bufSlots.firstElement), bufferSize); add(f, { .sType = VK_STRUCTURE_TYPE_RESOURCE_DESCRIPTOR_INFO_EXT, .type = VK_DESCRIPTOR_TYPE_STORAGE_IMAGE, .data = { .pImage = &outInfos[f] } }, descriptorHeap.ImageByteOffset(imgSlots.firstElement), imageSize); for (std::uint16_t i = 0; i < 2; ++i) { add(f, { .sType = VK_STRUCTURE_TYPE_RESOURCE_DESCRIPTOR_INFO_EXT, .type = VK_DESCRIPTOR_TYPE_SAMPLED_IMAGE, .data = { .pImage = &textureInfos[i] } }, descriptorHeap.ImageByteOffset(imgSlots.firstElement + 1 + i), imageSize); add(f, { .sType = VK_STRUCTURE_TYPE_RESOURCE_DESCRIPTOR_INFO_EXT, .type = VK_DESCRIPTOR_TYPE_STORAGE_BUFFER, .data = { .pAddressRange = &colourRanges[i] } }, descriptorHeap.BufferByteOffset(bufSlots.firstElement + 1 + i), bufferSize); } } Device::vkWriteResourceDescriptorsEXT(Device::device, static_cast(resources.size()), resources.data(), destinations.data()); for (auto& heap : descriptorHeap.resourceHeap) heap.FlushDevice(); }; writeDescriptors(); EventListener resizeListener(&window.onResize, writeDescriptors); VkSamplerCreateInfo samplerInfo = { .sType = VK_STRUCTURE_TYPE_SAMPLER_CREATE_INFO, .magFilter = VK_FILTER_NEAREST, .minFilter = VK_FILTER_NEAREST, .mipmapMode = VK_SAMPLER_MIPMAP_MODE_NEAREST, .addressModeU = VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE, .addressModeV = VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE, .addressModeW = VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE, .maxLod = VK_LOD_CLAMP_NONE, }; std::array samplerInfos{}; std::array samplerDestinations{}; for (std::uint32_t f = 0; f < Window::numFrames; ++f) { samplerInfos[f] = samplerInfo; samplerDestinations[f] = { .address = descriptorHeap.samplerHeap[f].value + descriptorHeap.SamplerByteOffset(samplerSlots.firstElement), .size = Device::descriptorHeapProperties.samplerDescriptorSize, }; } Device::vkWriteSamplerDescriptorsEXT(Device::device, Window::numFrames, samplerInfos.data(), samplerDestinations.data()); for (auto& heap : descriptorHeap.samplerHeap) heap.FlushDevice(); window.descriptorHeap = &descriptorHeap; RTPass rtPass(&pipeline); window.passes.push_back(&rtPass); // More frames than are in flight, so every per-frame heap copy is read at // least once. A device loss here throws out of Render(). for (int i = 0; i < Window::numFrames * 2; ++i) window.Render(); Device::CheckVkResult(vkQueueWaitIdle(Device::queue)); // Counted before the capture: SaveFrame reads back an image that has // already been handed to the presentation engine, which the validation // layer reports on its own. if (std::getenv("CRAFTER_GRAPHICS_VALIDATION") != nullptr) { Check(Device::validationErrorCount == 0, std::format("no Vulkan validation errors while rendering ({} seen)", Device::validationErrorCount)); } window.SaveFrame(out); Device::CheckVkResult(vkQueueWaitIdle(Device::queue)); const Frame frame = LoadFrame(out); Check(!frame.rgba.empty(), "the frame was read back"); if (!frame.rgba.empty()) { const int y = frame.height / 2; // An even column then the odd one next to it, in each triangle. const int left = FrameX(-100.0f, frame) & ~1; const int right = FrameX(100.0f, frame) & ~1; CheckPixel(frame, left, y, setColour[0], "instance 0, even column (set 0)"); CheckPixel(frame, left + 1, y, setColour[0], "instance 0, odd column (set 0)"); CheckPixel(frame, right, y, setColour[1], "instance 1, even column (set 1)"); CheckPixel(frame, right + 1, y, setColour[0], "instance 1, odd column (set 0)"); CheckPixel(frame, frame.width / 2, y, {0, 0, 255}, "miss between the triangles"); CheckPixel(frame, frame.width / 20, frame.height / 10, {0, 0, 255}, "miss in the corner"); } for (RenderingElement3D& renderer : renderers) RenderingElement3D::Remove(&renderer); return failures - failuresBefore; } // A fresh directory for the read-back frames, so a frame left by an earlier // run can't stand in for one this run failed to write. fs::path OutputDir() { const fs::path dir = fs::temp_directory_path() / "crafter-heap-dynamic-index-hit"; std::error_code ec; fs::remove_all(dir, ec); fs::create_directories(dir); return dir; } } // namespace int main(int argc, char** argv) { Device::Initialize(); if (!Device::rayTracingSupported) { std::println("SKIP no Vulkan ray-tracing device"); return 77; } if (Device::driverProperties.driverID == VK_DRIVER_ID_NVIDIA_PROPRIETARY && NvidiaVersion() < std::tuple(615u, 78u, 8u)) { const auto [major, minor, patch] = NvidiaVersion(); std::println("SKIP NVIDIA {}.{:02}.{:02} loses the device on this (fixed in 615.78.08)", major, minor, patch); return 77; } // `--render `: the child run, rendering as configured. if (argc == 3 && std::string_view(argv[1]) == "--render") { RenderScene(argv[2]); return failures == 0 ? EXIT_SUCCESS : EXIT_FAILURE; } const fs::path dir = OutputDir(); if (std::getenv("CRAFTER_GRAPHICS_VALIDATION") != nullptr) { const fs::path exe = fs::read_symlink("/proc/self/exe"); const int exit = RunCommand(std::format("env -u CRAFTER_GRAPHICS_VALIDATION '{}' --render '{}'", exe.string(), (dir / "unvalidated.png").string())); Check(exit == 0, std::format("the scene renders with validation off (exit {})", exit)); } RenderScene(dir / "frame.png"); std::println("{}", failures == 0 ? "all checks passed" : "check(s) failed"); return failures == 0 ? EXIT_SUCCESS : EXIT_FAILURE; }