diff --git a/project.cpp b/project.cpp index 3ffbf47..c445d25 100644 --- a/project.cpp +++ b/project.cpp @@ -471,6 +471,37 @@ extern "C" Configuration CrafterBuildProject(std::span a heapAsImpls.emplace_back("tests/HeapASRead/main"); ha.GetInterfacesAndImplementations(ifaces, heapAsImpls); cfg.tests.push_back(std::move(heapAsTest)); + + // Dynamic descriptor-heap indexing in a hit shader: a closest-hit + // picks a storage buffer and a sampled image by a runtime index + // (the same across a wave on one instance, different between + // neighbouring lanes on the other), which lost the device on + // NVIDIA 610.43.02; skips on NVIDIA drivers older than 615.78.08. + // Same requirements as HeapASRead. + Test dynIndexTest; + Configuration& di = dynIndexTest.config; + di.path = cfg.path; + di.name = "HeapDynamicIndexHit"; + di.outputName = "HeapDynamicIndexHit"; + di.type = ConfigurationType::Executable; + di.target = cfg.target; + di.march = cfg.march; + di.mtune = cfg.mtune; + di.debug = cfg.debug; + di.sysroot = cfg.sysroot; + di.dependencies = cfg.dependencies; + di.externalDependencies = cfg.externalDependencies; + di.compileFlags = cfg.compileFlags; + di.linkFlags = cfg.linkFlags; + di.defines = cfg.defines; + di.cFiles = cfg.cFiles; + di.shaders.emplace_back(fs::path("tests/HeapDynamicIndexHit/dynidx-raygen.glsl"), std::string("main"), ShaderType::RayGen); + di.shaders.emplace_back(fs::path("tests/HeapDynamicIndexHit/dynidx-closesthit.glsl"), std::string("main"), ShaderType::ClosestHit); + di.shaders.emplace_back(fs::path("tests/HeapDynamicIndexHit/dynidx-miss.glsl"), std::string("main"), ShaderType::Miss); + std::vector dynIndexImpls(impls.begin(), impls.end()); + dynIndexImpls.emplace_back("tests/HeapDynamicIndexHit/main"); + di.GetInterfacesAndImplementations(ifaces, dynIndexImpls); + cfg.tests.push_back(std::move(dynIndexTest)); } // Issue #36: BLAS build options. Drives the real hardware AS-build diff --git a/tests/HeapDynamicIndexHit/dynidx-closesthit.glsl b/tests/HeapDynamicIndexHit/dynidx-closesthit.glsl new file mode 100644 index 0000000..b4d95f3 --- /dev/null +++ b/tests/HeapDynamicIndexHit/dynidx-closesthit.glsl @@ -0,0 +1,35 @@ +//SPDX-License-Identifier: LGPL-3.0-only +//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® + +#version 460 +#extension GL_EXT_ray_tracing : enable +#extension GL_EXT_shader_explicit_arithmetic_types_int16 : enable +#extension GL_EXT_descriptor_heap : enable +#extension GL_EXT_nonuniform_qualifier : enable + +// Picks a storage buffer and a sampled image out of the descriptor heap by a +// runtime index. Instance 0 uses descriptor set 0 everywhere, so the index is +// the same across each wave. Instance 1 alternates between sets 1 and 0 from +// one pixel column to the next, so neighbouring lanes of every wave over it +// pick different descriptors. On NVIDIA 610.43.02 a runtime index here lost the +// device, for both descriptor kinds, which is why the engine's examples select +// hit-shader descriptors through spec constants. +layout(constant_id = 0) const uint16_t bufferStart = 0us; +layout(constant_id = 1) const uint textureBase = 0u; +layout(constant_id = 2) const uint colourBase = 0u; + +layout(descriptor_heap, std430) readonly buffer Colour { vec4 value; } colours[]; +layout(descriptor_heap) uniform texture2D textures[]; +layout(descriptor_heap) uniform sampler samplers[]; + +layout(location = 0) rayPayloadInEXT vec3 hitValue; + +void main() { + uint set = gl_InstanceCustomIndexEXT == 0u ? 0u : 1u - (gl_LaunchIDEXT.x & 1u); + vec4 colour = colours[nonuniformEXT(uint(bufferStart) + colourBase + set)].value; + vec4 texel = textureLod(sampler2D(textures[nonuniformEXT(textureBase + set)], samplers[0]), + vec2(0.5), 0.0); + // Red from the buffer, green from the texture, so each descriptor kind + // is checked separately. + hitValue = vec3(colour.r, texel.g, 0.0); +} diff --git a/tests/HeapDynamicIndexHit/dynidx-miss.glsl b/tests/HeapDynamicIndexHit/dynidx-miss.glsl new file mode 100644 index 0000000..783417e --- /dev/null +++ b/tests/HeapDynamicIndexHit/dynidx-miss.glsl @@ -0,0 +1,11 @@ +//SPDX-License-Identifier: LGPL-3.0-only +//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® + +#version 460 +#extension GL_EXT_ray_tracing : enable + +layout(location = 0) rayPayloadInEXT vec3 hitValue; + +void main() { + hitValue = vec3(0.0, 0.0, 1.0); +} diff --git a/tests/HeapDynamicIndexHit/dynidx-raygen.glsl b/tests/HeapDynamicIndexHit/dynidx-raygen.glsl new file mode 100644 index 0000000..e835e90 --- /dev/null +++ b/tests/HeapDynamicIndexHit/dynidx-raygen.glsl @@ -0,0 +1,31 @@ +//SPDX-License-Identifier: LGPL-3.0-only +//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® + +#version 460 +#extension GL_EXT_ray_tracing : enable +#extension GL_EXT_shader_image_load_formatted : enable +#extension GL_EXT_shader_explicit_arithmetic_types_int16 : enable +#extension GL_EXT_descriptor_heap : enable +#extension GL_EXT_nonuniform_qualifier : enable + +layout(constant_id = 0) const uint16_t bufferStart = 0us; +layout(descriptor_heap) uniform accelerationStructureEXT topLevelAS[]; +layout(descriptor_heap) uniform writeonly image2D image[]; + +layout(location = 0) rayPayloadEXT vec3 hitValue; + +void main() { + uvec2 pixel = gl_LaunchIDEXT.xy; + uvec2 resolution = gl_LaunchSizeEXT.xy; + vec2 ndc = (vec2(pixel) + 0.5) / vec2(resolution) * 2.0 - 1.0; + + // Same pinhole camera as examples/VulkanTriangle, facing +Z. + vec3 origin = vec3(0.0, 0.0, -300.0); + float aspect = float(resolution.x) / float(resolution.y); + float tanHalfFov = tan(radians(60.0) * 0.5); + vec3 direction = normalize(vec3(ndc.x * aspect * tanHalfFov, -ndc.y * tanHalfFov, 1.0)); + + traceRayEXT(topLevelAS[bufferStart], gl_RayFlagsNoneEXT, 0xff, 0, 0, 0, + origin, 0.001, direction, 10000.0, 0); + imageStore(image[0], ivec2(pixel), vec4(hitValue, 1.0)); +} diff --git a/tests/HeapDynamicIndexHit/main.cpp b/tests/HeapDynamicIndexHit/main.cpp new file mode 100644 index 0000000..057d289 --- /dev/null +++ b/tests/HeapDynamicIndexHit/main.cpp @@ -0,0 +1,415 @@ +//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; +}