diff --git a/project.cpp b/project.cpp index c5d39a1..62df28d 100644 --- a/project.cpp +++ b/project.cpp @@ -205,6 +205,38 @@ extern "C" Configuration CrafterBuildProject(std::span a cfg.shaders.emplace_back(fs::path("shaders/ui-images.comp.glsl"), std::string("main"), ShaderType::Compute); cfg.shaders.emplace_back(fs::path("shaders/ui-text.comp.glsl"), std::string("main"), ShaderType::Compute); cfg.buildFiles.emplace_back(fs::path("shaders/ui-shared.glsl")); + + // Regression test for issue #18: drive the NVIDIA descriptor-heap + // AS-read workaround's SPIR-V rewrite over real compiled shaders and + // check the result with spirv-val (one push-constant block, correct + // TLAS offset). The test executable recompiles the whole module plus + // tests/PushConstantRewrite/main.cpp; Configuration isn't copyable + // (it owns the parsed module list), so the shared build settings are + // mirrored field by field. glslang and spirv-val are invoked at + // runtime, so the test declares them as required tools. Remove with + // the rest of the workaround. + Test pcTest; + Configuration& tc = pcTest.config; + tc.path = cfg.path; + tc.name = "PushConstantRewrite"; + tc.outputName = "PushConstantRewrite"; + tc.type = ConfigurationType::Executable; + tc.target = cfg.target; + tc.march = cfg.march; + tc.mtune = cfg.mtune; + tc.debug = cfg.debug; + tc.sysroot = cfg.sysroot; + tc.dependencies = cfg.dependencies; + tc.externalDependencies = cfg.externalDependencies; + tc.compileFlags = cfg.compileFlags; + tc.linkFlags = cfg.linkFlags; + tc.defines = cfg.defines; + tc.cFiles = cfg.cFiles; + std::vector testImpls(impls.begin(), impls.end()); + testImpls.emplace_back("tests/PushConstantRewrite/main"); + tc.GetInterfacesAndImplementations(ifaces, testImpls); + pcTest.requires_ = { "tool:glslang", "tool:spirv-val" }; + cfg.tests.push_back(std::move(pcTest)); } return cfg; diff --git a/tests/PushConstantRewrite/main.cpp b/tests/PushConstantRewrite/main.cpp new file mode 100644 index 0000000..d55ef9f --- /dev/null +++ b/tests/PushConstantRewrite/main.cpp @@ -0,0 +1,228 @@ +/* +Crafter®.Graphics +Copyright (C) 2026 Catcrafts® +catcrafts.net + +This library is free software; you can redistribute it and/or +modify it under the terms of the GNU Lesser General Public +License version 3.0 as published by the Free Software Foundation; + +This library is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +Lesser General Public License for more details. + +You should have received a copy of the GNU Lesser General Public +License along with this library; if not, write to the Free Software +Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA +*/ + +// Regression test for issue #18: the NVIDIA descriptor-heap AS-read workaround +// (WorkaroundNvidiaAS::Patch) used to bolt a brand-new push-constant block onto +// every patched ray-tracing shader. SPIR-V allows at most one push-constant +// block statically used per entry point, so any shader that already declared +// one ended up with two and failed spirv-val: +// +// Entry point id '4' uses more than one PushConstant interface. +// +// This test compiles representative ray-generation shaders with glslang, runs +// them through the real Patch(), and asserts with spirv-val that the result is +// valid and contains exactly one push-constant variable — both for shaders +// that already have a push constant (merge path) and for those that don't +// (synthesize path). It also checks the published TLAS push-constant offset. +// +// Delete this test together with the rest of the workaround once a fixed NVIDIA +// driver ships. + +#include "vulkan/vulkan.h" +#include + +import Crafter.Graphics; +import std; +using namespace Crafter; + +namespace { + +namespace fs = std::filesystem; + +int RunCommand(const std::string& cmd) { + int status = std::system(cmd.c_str()); + if (status == -1) return -1; + // Mirror WEXITSTATUS without pulling in : glibc encodes the + // exit code in bits 8..15 of the wait status when the low byte is zero. + return (status & 0x7f) == 0 ? ((status >> 8) & 0xff) : 128 + (status & 0x7f); +} + +std::vector ReadSpirv(const fs::path& p) { + std::ifstream f(p, std::ios::binary | std::ios::ate); + if (!f) return {}; + std::streamsize size = f.tellg(); + f.seekg(0); + std::vector words(static_cast(size) / sizeof(std::uint32_t)); + f.read(reinterpret_cast(words.data()), size); + return words; +} + +void WriteSpirv(const fs::path& p, const std::vector& words) { + std::ofstream f(p, std::ios::binary); + f.write(reinterpret_cast(words.data()), + static_cast(words.size() * sizeof(std::uint32_t))); +} + +// Count OpVariable instructions in the PushConstant storage class (SC == 9). +int CountPushConstantVariables(const std::vector& words) { + constexpr std::uint32_t OpVariable = 59; + constexpr std::uint32_t StorageClassPushConstant = 9; + int count = 0; + for (std::size_t i = 5; i < words.size();) { + std::uint32_t len = words[i] >> 16; + if (len == 0 || i + len > words.size()) break; + if ((words[i] & 0xFFFFu) == OpVariable && len >= 4 && words[i + 3] == StorageClassPushConstant) + ++count; + i += len; + } + return count; +} + +struct Case { + std::string_view name; + std::string_view glsl; + bool readsAccelStruct; // whether Patch should rewrite anything + bool hasExistingPushConst; // whether the source already declares a push block + std::uint32_t expectedOffset; // expected Device::workaroundTlasPushOffset (only checked when readsAccelStruct) +}; + +// Shared raygen scaffolding: a heap AS + heap image, traced and stored to. +constexpr std::string_view kHeader = + "#version 460\n" + "#extension GL_EXT_ray_tracing : enable\n" + "#extension GL_EXT_shader_image_load_formatted : enable\n" + "#extension GL_EXT_descriptor_heap : enable\n" + "#extension GL_EXT_nonuniform_qualifier : enable\n" + "layout(descriptor_heap) uniform accelerationStructureEXT topLevelAS[];\n" + "layout(descriptor_heap) uniform writeonly image2D image[];\n" + "layout(location = 0) rayPayloadEXT vec3 hitValue;\n"; + +const std::array kCases = {{ + // No push constant at all → Patch synthesizes a fresh single-member block at offset 0. + { "no-push-constant", std::string_view{ + "" + }, true, false, 0 }, + + // Existing block {mat4 @0, vec3 @64, uint @76}; ends at 80, already 8-aligned. + { "merge-mat4-vec3-uint", std::string_view{ + "layout(push_constant) uniform PC { mat4 m; vec3 l; uint f; } pc;\n" + }, true, true, 80 }, + + // Existing block {uint @0}; ends at 4, TLAS rounds up to the next 8. + { "merge-uint", std::string_view{ + "layout(push_constant) uniform PC { uint f; } pc;\n" + }, true, true, 8 }, + + // Existing block {vec4 v[2] @0 (32 bytes), uint @32}; ends at 36, rounds to 40. + { "merge-array", std::string_view{ + "layout(push_constant) uniform PC { vec4 v[2]; uint f; } pc;\n" + }, true, true, 40 }, + + // Push constant but NO acceleration-structure read → Patch is a no-op; the + // single user block must survive untouched and still validate. + { "push-constant-no-as", std::string_view{ + "layout(push_constant) uniform PC { vec4 tint; } pc;\n" + }, false, true, 0 }, +}}; + +std::string BuildSource(const Case& c) { + std::string s(kHeader); + s += c.glsl; + s += "void main() {\n"; + s += " uvec2 pixel = gl_LaunchIDEXT.xy;\n"; + s += " vec3 origin = vec3(0.0, 0.0, -300.0);\n"; + s += " vec3 dir = normalize(vec3(0.0, 0.0, 1.0));\n"; + if (c.readsAccelStruct) + s += " traceRayEXT(topLevelAS[0], gl_RayFlagsNoneEXT, 0xff, 0,0,0, origin, 0.001, dir, 10000.0, 0);\n"; + // Reference the push constant so glslang keeps the block in the module. + std::string_view g = c.glsl; + std::string extra = "vec4(hitValue, 1.0)"; + if (g.find("mat4 m;") != std::string_view::npos) + extra = "pc.m * vec4(hitValue, 1.0) + vec4(pc.l, float(pc.f))"; + else if (g.find("uint f; } pc;") != std::string_view::npos && g.find("vec4 v[2]") != std::string_view::npos) + extra = "vec4(hitValue, 1.0) + pc.v[0] + pc.v[1] + vec4(float(pc.f))"; + else if (g.find("uint f; } pc;") != std::string_view::npos) + extra = "vec4(hitValue, float(pc.f))"; + else if (g.find("vec4 tint;") != std::string_view::npos) + extra = "vec4(hitValue, 1.0) + pc.tint"; + s += " imageStore(image[0], ivec2(pixel), " + extra + ");\n"; + s += "}\n"; + return s; +} + +} // namespace + +int main() { + const fs::path dir = fs::temp_directory_path() / "crafter-pcrewrite-test"; + std::error_code ec; + fs::create_directories(dir, ec); + + int failures = 0; + for (const Case& c : kCases) { + const fs::path glslPath = dir / (std::string(c.name) + ".rgen.glsl"); + const fs::path spvPath = dir / (std::string(c.name) + ".spv"); + const fs::path patched = dir / (std::string(c.name) + ".patched.spv"); + + { std::ofstream f(glslPath); f << BuildSource(c); } + + std::string compile = "glslang --target-env vulkan1.4 -V -S rgen \"" + + glslPath.string() + "\" -o \"" + spvPath.string() + "\" > /dev/null"; + if (RunCommand(compile) != 0) { + std::println(std::cerr, "[{}] glslang failed to compile the source shader", c.name); + ++failures; + continue; + } + + std::vector words = ReadSpirv(spvPath); + if (words.size() < 5) { + std::println(std::cerr, "[{}] could not read compiled SPIR-V", c.name); + ++failures; + continue; + } + + Device::workaroundTlasPushOffset = 0xDEADBEEFu; // poison so we know Patch set it + WorkaroundNvidiaAS::Patch(words); + WriteSpirv(patched, words); + + // 1. The patched module must pass spirv-val under the engine's flags. + std::string validate = "spirv-val \"" + patched.string() + + "\" --relax-block-layout --scalar-block-layout --target-env vulkan1.4"; + if (RunCommand(validate) != 0) { + std::println(std::cerr, "[{}] spirv-val rejected the patched module", c.name); + ++failures; + continue; + } + + // 2. Exactly one push-constant variable — the whole point of issue #18. + int pcVars = CountPushConstantVariables(words); + if (pcVars != 1) { + std::println(std::cerr, "[{}] expected exactly 1 push-constant variable, found {}", c.name, pcVars); + ++failures; + continue; + } + + // 3. The TLAS offset Patch published must match the expected layout end. + if (c.readsAccelStruct && Device::workaroundTlasPushOffset != c.expectedOffset) { + std::println(std::cerr, "[{}] expected TLAS push offset {}, got {}", + c.name, c.expectedOffset, Device::workaroundTlasPushOffset); + ++failures; + continue; + } + + std::println(std::cout, "[{}] ok (push-constant vars: {}, tlas offset: {})", + c.name, pcVars, c.readsAccelStruct ? Device::workaroundTlasPushOffset : 0u); + } + + if (failures != 0) { + std::println(std::cerr, "{} case(s) failed", failures); + return 1; + } + std::println(std::cout, "all push-constant rewrite cases passed"); + return 0; +}