diff --git a/project.cpp b/project.cpp index 9780c23..3ffbf47 100644 --- a/project.cpp +++ b/project.cpp @@ -439,6 +439,38 @@ extern "C" Configuration CrafterBuildProject(std::span a parityImpls.emplace_back("tests/UIFallbackParity/main"); up.GetInterfacesAndImplementations(ifaces, parityImpls); cfg.tests.push_back(std::move(parityTest)); + + // Reading the TLAS from the descriptor heap (#7/#15): renders a + // triangle through traceRayEXT and an inline ray query and checks + // the read-back pixels, through the engine's normal path (so on + // NVIDIA through the SPIR-V rewrite). `HeapASRead --no-workaround` + // is the manual probe for whether a driver still has the fault. + // Needs the Wayland backend, a real compositor and an RT device + // (skips without RT). + Test heapAsTest; + Configuration& ha = heapAsTest.config; + ha.path = cfg.path; + ha.name = "HeapASRead"; + ha.outputName = "HeapASRead"; + ha.type = ConfigurationType::Executable; + ha.target = cfg.target; + ha.march = cfg.march; + ha.mtune = cfg.mtune; + ha.debug = cfg.debug; + ha.sysroot = cfg.sysroot; + ha.dependencies = cfg.dependencies; + ha.externalDependencies = cfg.externalDependencies; + ha.compileFlags = cfg.compileFlags; + ha.linkFlags = cfg.linkFlags; + ha.defines = cfg.defines; + ha.cFiles = cfg.cFiles; + ha.shaders.emplace_back(fs::path("tests/HeapASRead/heapas-raygen.glsl"), std::string("main"), ShaderType::RayGen); + ha.shaders.emplace_back(fs::path("tests/HeapASRead/heapas-closesthit.glsl"), std::string("main"), ShaderType::ClosestHit); + ha.shaders.emplace_back(fs::path("tests/HeapASRead/heapas-miss.glsl"), std::string("main"), ShaderType::Miss); + std::vector heapAsImpls(impls.begin(), impls.end()); + heapAsImpls.emplace_back("tests/HeapASRead/main"); + ha.GetInterfacesAndImplementations(ifaces, heapAsImpls); + cfg.tests.push_back(std::move(heapAsTest)); } // Issue #36: BLAS build options. Drives the real hardware AS-build diff --git a/tests/HeapASRead/heapas-closesthit.glsl b/tests/HeapASRead/heapas-closesthit.glsl new file mode 100644 index 0000000..dd3fa18 --- /dev/null +++ b/tests/HeapASRead/heapas-closesthit.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, 1.0, 0.0); +} diff --git a/tests/HeapASRead/heapas-miss.glsl b/tests/HeapASRead/heapas-miss.glsl new file mode 100644 index 0000000..4a686d4 --- /dev/null +++ b/tests/HeapASRead/heapas-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.5); +} diff --git a/tests/HeapASRead/heapas-raygen.glsl b/tests/HeapASRead/heapas-raygen.glsl new file mode 100644 index 0000000..01c875b --- /dev/null +++ b/tests/HeapASRead/heapas-raygen.glsl @@ -0,0 +1,51 @@ +//SPDX-License-Identifier: LGPL-3.0-only +//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® + +#version 460 +#extension GL_EXT_ray_tracing : enable +#extension GL_EXT_ray_query : 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 + +// Reads the TLAS straight out of the descriptor heap through traceRayEXT and +// through an inline ray query: the two AS-read paths that lost the device on +// NVIDIA 610.43.02 (#7). `path` picks which: 0 traces the left half of the +// frame and ray queries the right half, 1 only traces, 2 only ray queries. +layout(constant_id = 0) const uint16_t bufferStart = 0us; +layout(constant_id = 1) const uint path = 0u; +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)); + + bool trace = path == 1u || (path == 0u && pixel.x < resolution.x / 2); + vec3 colour; + if (trace) { + // Closest-hit writes green, miss writes grey. + traceRayEXT(topLevelAS[bufferStart], gl_RayFlagsNoneEXT, 0xff, 0, 0, 0, + origin, 0.001, direction, 10000.0, 0); + colour = hitValue; + } else { + rayQueryEXT query; + rayQueryInitializeEXT(query, topLevelAS[bufferStart], gl_RayFlagsOpaqueEXT, 0xff, + origin, 0.001, direction, 10000.0); + while (rayQueryProceedEXT(query)) {} + bool hit = rayQueryGetIntersectionTypeEXT(query, true) == gl_RayQueryCommittedIntersectionTriangleEXT; + // White for a hit, the miss shader's grey otherwise. + colour = hit ? vec3(1.0) : vec3(0.5); + } + imageStore(image[0], ivec2(pixel), vec4(colour, 1.0)); +} diff --git a/tests/HeapASRead/main.cpp b/tests/HeapASRead/main.cpp new file mode 100644 index 0000000..68ab572 --- /dev/null +++ b/tests/HeapASRead/main.cpp @@ -0,0 +1,344 @@ +//SPDX-License-Identifier: LGPL-3.0-only +//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® + +// Regression test for reading an acceleration structure out of a +// VK_EXT_descriptor_heap heap (#7, worked around on NVIDIA in #15). It renders +// examples/VulkanTriangle's triangle, tracing the left half of the frame with +// traceRayEXT and ray querying the right half, then reads the frame back and +// checks both halves hit the triangle where it is and miss around it. As run +// by the test runner it goes through the engine's normal path, so on NVIDIA it +// covers the SPIR-V rewrite too; a CRAFTER_GRAPHICS_NO_AS_WORKAROUND inherited +// from the environment is cleared so the rewrite can't be switched off. +// +// `HeapASRead --no-workaround` instead probes whether the driver still has the +// fault: it renders each path on its own in a child process with the rewrite +// off and validation off (GPU-assisted validation instruments the shaders), +// and reports per path whether it works, renders wrong or loses the device. +// It exits 0 only when both work. On 610.43.02 both paths lost the device; on +// 615.78.08 traceRayEXT still does, and the ray query renders nothing. +// +// The validation check only means something with CRAFTER_GRAPHICS_VALIDATION +// 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 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, float fx, float fy, std::array expected, std::string_view what) { + const int x = static_cast(fx * static_cast(frame.width)); + const int y = static_cast(fy * static_cast(frame.height)); + 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])); +} + +// Matches `path` in heapas-raygen.glsl. +enum class Path : std::uint32_t { Both = 0, Trace = 1, Query = 2 }; + +// Spec constants of the raygen, matching heapas-raygen.glsl. +struct RaygenConstants { + std::uint16_t bufferStart; + std::uint16_t pad; + std::uint32_t path; +}; + +constexpr std::array traceHit = {0, 255, 0}; +constexpr std::array queryHit = {255, 255, 255}; +constexpr std::array miss = {128, 128, 128}; + +// Renders the triangle through `path` into `out` and checks the pixels. +// Returns the exit code. +int Render(Path path, const fs::path& out) { + Device::Initialize(); + if (!Device::rayTracingSupported) { + std::println("SKIP no Vulkan ray-tracing device"); + return 77; + } + Device::validationErrorCount = 0; + if (path != Path::Both) { + Check(!Device::workaroundDescriptorHeapAS, "the AS workaround is off for the probe"); + } + + Window window(640, 360, "HeapASRead test"); + VkCommandBuffer cmd = window.StartInit(); + + DescriptorHeapVulkan descriptorHeap; + descriptorHeap.Initialize(1, 1, 0); + + RaygenConstants raygenConstants = { + .bufferStart = descriptorHeap.bufferStartElement, + .pad = 0, + .path = static_cast(path), + }; + std::array raygenEntries {{ + { .constantID = 0, .offset = offsetof(RaygenConstants, bufferStart), .size = sizeof(std::uint16_t) }, + { .constantID = 1, .offset = offsetof(RaygenConstants, path), .size = sizeof(std::uint32_t) }, + }}; + VkSpecializationInfo raygenSpec = { + .mapEntryCount = static_cast(raygenEntries.size()), + .pMapEntries = raygenEntries.data(), + .dataSize = sizeof(RaygenConstants), + .pData = &raygenConstants, + }; + + const fs::path dir = ExeDir(); + std::array shaders{{ + {dir / "heapas-raygen.spv", "main", VK_SHADER_STAGE_RAYGEN_BIT_KHR, &raygenSpec}, + {dir / "heapas-miss.spv", "main", VK_SHADER_STAGE_MISS_BIT_KHR, nullptr}, + {dir / "heapas-closesthit.spv", "main", VK_SHADER_STAGE_CLOSEST_HIT_BIT_KHR, nullptr}, + }}; + 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); + + Mesh triangleMesh; + std::array, 3> verts {{{-150, -150, 100}, {0, 150, 100}, {150, -150, 100}}}; + std::array index {{2, 1, 0}}; + triangleMesh.Build(verts, index, cmd); + + RenderingElement3D renderer; + renderer.instance = { + .instanceCustomIndex = 0, + .mask = 0xFF, + .instanceShaderBindingTableRecordOffset = 0, + .flags = VK_GEOMETRY_INSTANCE_FORCE_OPAQUE_BIT_KHR, + .accelerationStructureReference = triangleMesh.blasAddr, + }; + MatrixRowMajor::Identity().Store(reinterpret_cast(renderer.instance.transform.matrix)); + RenderingElement3D::Add(&renderer); + for (std::uint32_t f = 0; f < Window::numFrames; ++f) RenderingElement3D::BuildTLAS(cmd, f); + + window.FinishInit(); + + // Per frame: the TLAS at buffer slot 0 and that frame's swapchain image at + // image slot 0, each in its own copy of the heap. Written again whenever + // the swapchain is recreated, since the image descriptors point at its + // images. + auto imgSlots = descriptorHeap.AllocateImageSlots(1); + auto bufSlots = descriptorHeap.AllocateBufferSlots(1); + auto writeDescriptors = [&] { + std::array tlasRanges{}; + std::array imageInfos{}; + std::vector resources; + std::vector destinations; + for (std::uint32_t f = 0; f < Window::numFrames; ++f) { + tlasRanges[f] = { .address = RenderingElement3D::tlases[f].address }; + imageInfos[f] = { + .sType = VK_STRUCTURE_TYPE_IMAGE_DESCRIPTOR_INFO_EXT, + .pView = &window.imageViews[f], + .layout = VK_IMAGE_LAYOUT_GENERAL, + }; + resources.push_back({ + .sType = VK_STRUCTURE_TYPE_RESOURCE_DESCRIPTOR_INFO_EXT, + .type = VK_DESCRIPTOR_TYPE_ACCELERATION_STRUCTURE_KHR, + .data = { .pAddressRange = &tlasRanges[f] }, + }); + destinations.push_back({ + .address = descriptorHeap.resourceHeap[f].value + descriptorHeap.BufferByteOffset(bufSlots.firstElement), + .size = Device::descriptorHeapProperties.bufferDescriptorSize, + }); + resources.push_back({ + .sType = VK_STRUCTURE_TYPE_RESOURCE_DESCRIPTOR_INFO_EXT, + .type = VK_DESCRIPTOR_TYPE_STORAGE_IMAGE, + .data = { .pImage = &imageInfos[f] }, + }); + destinations.push_back({ + .address = descriptorHeap.resourceHeap[f].value + descriptorHeap.ImageByteOffset(imgSlots.firstElement), + .size = Device::descriptorHeapProperties.imageDescriptorSize, + }); + } + 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); + + window.descriptorHeap = &descriptorHeap; + RTPass rtPass(&pipeline); + window.passes.push_back(&rtPass); + + // More frames than are in flight, so every per-frame heap copy and TLAS + // 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()) { + switch (path) { + case Path::Both: + // The triangle covers the middle of the frame at any aspect + // ratio; 5% either side of the centre line is inside it. + CheckPixel(frame, 0.45f, 0.5f, traceHit, "traceRayEXT hit"); + CheckPixel(frame, 0.55f, 0.5f, queryHit, "rayQueryEXT hit"); + CheckPixel(frame, 0.05f, 0.1f, miss, "traceRayEXT miss"); + CheckPixel(frame, 0.95f, 0.1f, miss, "rayQueryEXT miss"); + break; + case Path::Trace: + CheckPixel(frame, 0.5f, 0.5f, traceHit, "traceRayEXT hit"); + CheckPixel(frame, 0.05f, 0.1f, miss, "traceRayEXT miss"); + break; + case Path::Query: + CheckPixel(frame, 0.5f, 0.5f, queryHit, "rayQueryEXT hit"); + CheckPixel(frame, 0.05f, 0.1f, miss, "rayQueryEXT miss"); + break; + } + } + RenderingElement3D::Remove(&renderer); + return failures == 0 ? EXIT_SUCCESS : EXIT_FAILURE; +} + +// 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-as-read"; + std::error_code ec; + fs::remove_all(dir, ec); + fs::create_directories(dir); + return dir; +} + +// --no-workaround: renders each path in its own child process, so a device +// loss on one doesn't hide the other. +int Probe() { + const fs::path exe = fs::read_symlink("/proc/self/exe"); + const fs::path dir = OutputDir(); + bool allWork = true; + for (const auto& [name, arg] : {std::pair{"traceRayEXT", "trace"}, std::pair{"rayQueryEXT", "query"}}) { + const int exit = RunCommand(std::format( + "env -u CRAFTER_GRAPHICS_VALIDATION CRAFTER_GRAPHICS_NO_AS_WORKAROUND=1 '{}' --probe {} '{}'", + exe.string(), arg, (dir / std::format("{}.png", arg)).string())); + const std::string verdict = + exit == 0 ? std::string("works") : + exit == 77 ? std::string("skipped (no RT device)") : + exit == 1 ? std::string("renders wrong") : + std::format("loses the device or crashes (exit {})", exit); + std::println("PROBE {} reading the TLAS from the heap: {}", name, verdict); + if (exit != 0) allWork = false; + } + std::println("{}", allWork ? "the driver reads the TLAS from the heap on both paths" + : "the driver still needs the workaround"); + return allWork ? EXIT_SUCCESS : EXIT_FAILURE; +} + +} // namespace + +int main(int argc, char** argv) { + if (argc == 2 && std::string_view(argv[1]) == "--no-workaround") { + return Probe(); + } + if (argc == 4 && std::string_view(argv[1]) == "--probe") { + const Path path = std::string_view(argv[2]) == "trace" ? Path::Trace : Path::Query; + return Render(path, argv[3]); + } + + unsetenv("CRAFTER_GRAPHICS_NO_AS_WORKAROUND"); + const int result = Render(Path::Both, OutputDir() / "both.png"); + if (result == 77) return result; + Check(Device::workaroundDescriptorHeapAS + == (Device::descriptorHeapSupported && Device::driverProperties.driverID == VK_DRIVER_ID_NVIDIA_PROPRIETARY), + "the AS workaround is on exactly where the driver needs it"); + std::println("{}", failures == 0 ? "all checks passed" : "check(s) failed"); + return failures == 0 ? EXIT_SUCCESS : EXIT_FAILURE; +}