From 0d2cae18fb30a5f8e89540b6353b17497c72a6be Mon Sep 17 00:00:00 2001 From: Emil <65846814+emil28092005@users.noreply.github.com> Date: Wed, 23 Sep 2026 22:20:34 +0300 Subject: [PATCH] Add checked Slang shaders for GPU visibility and HZB --- cmake/Renderer.cmake | 25 ++ shaders/gpu_scene.slang | 256 +++++++++++++++++++++ src/editor/build_service.cpp | 12 +- src/render/shader_contract.cpp | 87 ++++++- src/render/shader_contract.hpp | 2 + tests/build_schema_tests.cpp | 6 + tests/build_schema_tool.cpp | 4 +- tests/render_gpu_shader_contract_tests.cpp | 71 ++++++ tests/render_reload_tests.cpp | 8 +- tests/test_shader_reflection.py | 81 +++++++ tools/compile_shader.py | 21 +- 11 files changed, 560 insertions(+), 13 deletions(-) create mode 100644 shaders/gpu_scene.slang create mode 100644 tests/render_gpu_shader_contract_tests.cpp create mode 100644 tests/test_shader_reflection.py diff --git a/cmake/Renderer.cmake b/cmake/Renderer.cmake index 2309205..5ead206 100644 --- a/cmake/Renderer.cmake +++ b/cmake/Renderer.cmake @@ -17,6 +17,23 @@ foreach(FASET_ENTRY vertexMain fragmentMain shadowMain) DEPENDS "${PROJECT_SOURCE_DIR}/shaders/baseline.slang" "${PROJECT_SOURCE_DIR}/tools/compile_shader.py" VERBATIM) list(APPEND FASET_SHADER_OUTPUTS "${FASET_SHADER_OUTPUT}" "${FASET_SHADER_DIRECTORY}/${FASET_ENTRY}.reflection.json") endforeach() +foreach(FASET_ENTRY gpuVertexMain gpuShadowMain gpuCullMain gpuHzbMain gpuPostCullMain) + if(FASET_ENTRY STREQUAL "gpuVertexMain" OR FASET_ENTRY STREQUAL "gpuShadowMain") + set(FASET_GPU_DEFINE FASET_GPU_GRAPHICS=1) + elseif(FASET_ENTRY STREQUAL "gpuHzbMain") + set(FASET_GPU_DEFINE FASET_GPU_HZB=1) + else() + set(FASET_GPU_DEFINE FASET_GPU_CULL=1) + endif() + set(FASET_SHADER_OUTPUT "${FASET_SHADER_DIRECTORY}/${FASET_ENTRY}.spv") + add_custom_command(OUTPUT "${FASET_SHADER_OUTPUT}" "${FASET_SHADER_DIRECTORY}/${FASET_ENTRY}.reflection.json" + COMMAND "${Python3_EXECUTABLE}" "${PROJECT_SOURCE_DIR}/tools/compile_shader.py" + --compiler "${SLANGC_EXECUTABLE}" --source "${PROJECT_SOURCE_DIR}/shaders/gpu_scene.slang" + --entry "${FASET_ENTRY}" --define "${FASET_GPU_DEFINE}" --output "${FASET_SHADER_DIRECTORY}" + BYPRODUCTS "${FASET_SHADER_DIRECTORY}/${FASET_ENTRY}.slang-reflection.json" + DEPENDS "${PROJECT_SOURCE_DIR}/shaders/gpu_scene.slang" "${PROJECT_SOURCE_DIR}/tools/compile_shader.py" VERBATIM) + list(APPEND FASET_SHADER_OUTPUTS "${FASET_SHADER_OUTPUT}" "${FASET_SHADER_DIRECTORY}/${FASET_ENTRY}.reflection.json") +endforeach() add_custom_command(OUTPUT "${FASET_SHADER_DIRECTORY}/compatibility.spv" COMMAND "${SLANGC_EXECUTABLE}" "${PROJECT_SOURCE_DIR}/shaders/compatibility.hlsl" -entry compatibilityMain -stage compute -target spirv -profile spirv_1_6 @@ -49,6 +66,14 @@ if(BUILD_TESTING) FASET_PYTHON_EXECUTABLE="${Python3_EXECUTABLE}") add_test(NAME render_shader_reload COMMAND faset_render_reload_tests) set_tests_properties(render_shader_reload PROPERTIES LABELS "gpu") + add_executable(faset_render_gpu_shader_contract_tests "${PROJECT_SOURCE_DIR}/tests/render_gpu_shader_contract_tests.cpp") + target_include_directories(faset_render_gpu_shader_contract_tests PRIVATE "${PROJECT_SOURCE_DIR}/src/render") + target_link_libraries(faset_render_gpu_shader_contract_tests PRIVATE faset_render faset_core) + target_compile_definitions(faset_render_gpu_shader_contract_tests PRIVATE FASET_TEST_SHADER_DIRECTORY="${FASET_SHADER_DIRECTORY}") + add_test(NAME render_gpu_shader_contract COMMAND faset_render_gpu_shader_contract_tests) + add_test(NAME render_shader_reflection COMMAND "${CMAKE_COMMAND}" -E env + "FASET_TEST_SLANGC=${SLANGC_EXECUTABLE}" + "${Python3_EXECUTABLE}" "${PROJECT_SOURCE_DIR}/tests/test_shader_reflection.py") add_executable(faset_render_window_tests "${PROJECT_SOURCE_DIR}/tests/render_window_tests.cpp") target_link_libraries(faset_render_window_tests PRIVATE faset_render SDL3::SDL3) add_test(NAME render_window_lifecycle COMMAND faset_render_window_tests "${CMAKE_BINARY_DIR}/window-test") diff --git a/shaders/gpu_scene.slang b/shaders/gpu_scene.slang new file mode 100644 index 0000000..3b07c01 --- /dev/null +++ b/shaders/gpu_scene.slang @@ -0,0 +1,256 @@ +// GPU-visible opaque scene. All host records use 16-byte lanes; reflected strides +// are validated before pipelines are created. Sprites/UI and shadow caster selection +// remain independent of camera culling. +struct GpuSceneVertex { + float3 position : POSITION; + float3 normal : NORMAL; + float4 color : COLOR0; + float2 uv : TEXCOORD0; +}; +struct GpuSceneOutput { + float4 position : SV_Position; + float3 world : TEXCOORD0; + float3 normal : NORMAL; + float4 color : COLOR0; + float2 material : TEXCOORD1; + float2 uv : TEXCOORD2; +}; +struct InstanceRecord { + column_major float4x4 model; // 0..63 + float4 normalRow0; // 64..79, inverse-transpose 3x3 + float4 normalRow1; // 80..95 + float4 normalRow2; // 96..111 + float4 color; // 112..127 + float4 material; // 128..143: roughness, metallic, texture flags + float4 currentCenter; // 144..159: world AABB center + float4 currentExtent; // 160..175: world AABB half extents + float4 previousCenter; // 176..191 + float4 previousExtent; // 192..207 + uint4 metadata; // 208..223: x=previousValid, others reserved +}; +struct ViewRecord { + column_major float4x4 currentViewProjection; // 0..63 + column_major float4x4 previousViewProjection; // 64..127 + float4 currentViewport; // 128..143: x/y/width/height in target pixels + float4 previousViewport; // 144..159 + uint4 currentHzbSize; // 160..175: width/height/mipCount/reserved + uint4 previousHzbSize; // 176..191 + uint4 flags; // 192..207: x=historyValid +}; +struct BinRecord { + uint candidateFirst, candidateCount, visibleBase, capacity; +}; +struct Candidate { + uint instanceId, binIndex, flags, reserved; +}; +// Exactly VkDrawIndirectCommand: vertexCount, instanceCount, firstVertex, firstInstance. +struct IndirectArgs { + uint vertexCount, instanceCount, firstVertex, firstInstance; +}; + +#if defined(FASET_GPU_GRAPHICS) +struct GpuFrameParameters { + column_major float4x4 lightViewProjection; // same first 96 bytes as baseline fragment + float4 lightDirection; + float4 eye; + uint4 drawInfo; // x=visible ID range base; firstInstance is always zero +}; +[[vk::push_constant]] ConstantBuffer gpuFrame; +[[vk::binding(0,1)]] StructuredBuffer gfxInstances; +[[vk::binding(1,1)]] StructuredBuffer gfxVisibleIds; +[[vk::binding(2,1)]] StructuredBuffer gfxViews; + +[shader("vertex")] +GpuSceneOutput gpuVertexMain(GpuSceneVertex vertex, uint drawInstance : SV_InstanceID) { + InstanceRecord instance = gfxInstances[gfxVisibleIds[gpuFrame.drawInfo.x + drawInstance]]; + float4 world = mul(instance.model, float4(vertex.position, 1)); + GpuSceneOutput output; + output.position = mul(gfxViews[0].currentViewProjection, world); + output.world = world.xyz; + float3 normal = float3(dot(instance.normalRow0.xyz, vertex.normal), + dot(instance.normalRow1.xyz, vertex.normal), + dot(instance.normalRow2.xyz, vertex.normal)); + float normalLength = length(normal); + output.normal = normalLength > 1e-8 ? normal / normalLength : float3(0, 0, 0); + output.color = vertex.color * instance.color; + output.material = instance.material.xy; + output.uv = vertex.uv; + return output; +} + +[shader("vertex")] +float4 gpuShadowMain(GpuSceneVertex vertex, uint drawInstance : SV_InstanceID) : SV_Position { + InstanceRecord instance = gfxInstances[gfxVisibleIds[gpuFrame.drawInfo.x + drawInstance]]; + return mul(gpuFrame.lightViewProjection, mul(instance.model, float4(vertex.position, 1))); +} + +#elif defined(FASET_GPU_CULL) +struct CullParameters { + uint candidateCount; + uint deferredCapacity; + uint reserved0; + uint reserved1; +}; +[[vk::push_constant]] ConstantBuffer cullParameters; +[[vk::binding(0,0)]] StructuredBuffer cullInstances; +[[vk::binding(1,0)]] StructuredBuffer candidates; +[[vk::binding(2,0)]] StructuredBuffer bins; +[[vk::binding(3,0)]] RWStructuredBuffer visibleIds; +[[vk::binding(4,0)]] RWStructuredBuffer args; +[[vk::binding(5,0)]] RWStructuredBuffer deferredIds; +[[vk::binding(6,0)]] RWStructuredBuffer deferredCount; +[[vk::binding(7,0)]] Texture2D previousHzb; +[[vk::binding(8,0)]] Texture2D currentHzb; +[[vk::binding(9,0)]] StructuredBuffer cullViews; + +float4 boundsCorner(float4 center, float4 extent, uint corner) { + return float4(center.xyz + extent.xyz * float3((corner & 1) != 0 ? 1 : -1, + (corner & 2) != 0 ? 1 : -1, + (corner & 4) != 0 ? 1 : -1), 1); +} +bool finiteClip(float4 clip) { + return all(isfinite(clip)); +} +// A plane may reject an AABB only when all eight corners are strictly outside. +// Nonfinite arithmetic fails open so malformed data cannot cause disappearing meshes. +bool inFrustum(float4 center, float4 extent, float4x4 viewProjection) { + uint rejected[6] = {0, 0, 0, 0, 0, 0}; + [unroll] for (uint corner = 0; corner < 8; ++corner) { + float4 clip = mul(viewProjection, boundsCorner(center, extent, corner)); + if (!finiteClip(clip)) return true; + float planes[6] = {clip.x + clip.w, clip.w - clip.x, + clip.y + clip.w, clip.w - clip.y, + clip.z, clip.w - clip.z}; + [unroll] for (uint plane = 0; plane < 6; ++plane) + rejected[plane] += planes[plane] < 0 ? 1 : 0; + } + [unroll] for (uint plane = 0; plane < 6; ++plane) + if (rejected[plane] == 8) return false; + return true; +} +// Ordinary Z: the HZB contains the furthest depth (maximum) in every footprint. +// The nearest candidate depth must be farther than *all* those samples to occlude. +bool occluded(float4 center, float4 extent, float4x4 viewProjection, + float4 viewport, uint4 pyramidSize, Texture2D pyramid) { + if (pyramidSize.x == 0 || pyramidSize.y == 0 || pyramidSize.z == 0 || + viewport.z <= 0 || viewport.w <= 0) return false; + float2 minPixel = float2(1e30, 1e30), maxPixel = float2(-1e30, -1e30); + float nearestDepth = 1; + [unroll] for (uint corner = 0; corner < 8; ++corner) { + float4 clip = mul(viewProjection, boundsCorner(center, extent, corner)); + // Near-plane crossings and perspective singularities are always visible. + if (!finiteClip(clip) || clip.w <= 0 || clip.z <= 0 || clip.z >= clip.w) return false; + float3 projected = clip.xyz / clip.w; + float2 pixel = viewport.xy + (projected.xy * 0.5 + 0.5) * viewport.zw; + if (!all(isfinite(pixel)) || !isfinite(projected.z)) return false; + minPixel = min(minPixel, pixel); + maxPixel = max(maxPixel, pixel); + nearestDepth = min(nearestDepth, projected.z); + } + if (minPixel.x < 0 || minPixel.y < 0 || maxPixel.x >= pyramidSize.x || + maxPixel.y >= pyramidSize.y) return false; + float extentPixels = max(maxPixel.x - minPixel.x, maxPixel.y - minPixel.y); + uint mip = min((uint)ceil(log2(max(extentPixels, 1.0))), pyramidSize.z - 1); + uint2 size = max(uint2(1, 1), (pyramidSize.xy + ((1u << mip) - 1)) >> mip); + uint2 first = min((uint2)floor(minPixel / (1u << mip)), size - 1); + uint2 last = min((uint2)floor(maxPixel / (1u << mip)), size - 1); + float furthest = 0; + for (uint y = first.y; y <= last.y; ++y) + for (uint x = first.x; x <= last.x; ++x) + furthest = max(furthest, pyramid.Load(int3(x, y, mip))); + return nearestDepth > furthest + 0.0001; +} +bool appendVisible(uint binIndex, uint instanceId) { + BinRecord bin = bins[binIndex]; + uint observed = args[binIndex].instanceCount; + while (observed < bin.capacity) { + uint previous; + InterlockedCompareExchange(args[binIndex].instanceCount, observed, observed + 1, previous); + if (previous == observed) { + visibleIds[bin.visibleBase + observed] = instanceId; + return true; + } + observed = previous; + } + return false; +} +bool appendDeferred(uint candidateIndex) { + uint observed = deferredCount[0]; + while (observed < cullParameters.deferredCapacity) { + uint previous; + InterlockedCompareExchange(deferredCount[0], observed, observed + 1, previous); + if (previous == observed) { + deferredIds[observed] = candidateIndex; + return true; + } + observed = previous; + } + return false; +} +[shader("compute")] +[numthreads(64, 1, 1)] +void gpuCullMain(uint3 dispatchId : SV_DispatchThreadID) { + uint index = dispatchId.x; + if (index >= cullParameters.candidateCount) return; + Candidate candidate = candidates[index]; + InstanceRecord instance = cullInstances[candidate.instanceId]; + ViewRecord view = cullViews[0]; + if (!inFrustum(instance.currentCenter, instance.currentExtent, + view.currentViewProjection)) return; + bool guessedHidden = view.flags.x != 0 && instance.metadata.x != 0 && + occluded(instance.previousCenter, instance.previousExtent, + view.previousViewProjection, view.previousViewport, + view.previousHzbSize, previousHzb); + if (guessedHidden && appendDeferred(index)) return; + appendVisible(candidate.binIndex, candidate.instanceId); +} +[shader("compute")] +[numthreads(64, 1, 1)] +void gpuPostCullMain(uint3 dispatchId : SV_DispatchThreadID) { + uint index = dispatchId.x; + if (index >= cullParameters.deferredCapacity || index >= deferredCount[0]) return; + Candidate candidate = candidates[deferredIds[index]]; + InstanceRecord instance = cullInstances[candidate.instanceId]; + ViewRecord view = cullViews[0]; + if (!occluded(instance.currentCenter, instance.currentExtent, + view.currentViewProjection, view.currentViewport, + view.currentHzbSize, currentHzb)) + appendVisible(candidate.binIndex, candidate.instanceId); +} + +#elif defined(FASET_GPU_HZB) +struct HzbParameters { + uint sourceWidth, sourceHeight, outputWidth, outputHeight; +}; +[[vk::push_constant]] ConstantBuffer hzbParameters; +[[vk::binding(0,0)]] Texture2D hzbSource; +[[vk::binding(1,0)]] RWTexture2D hzbOutput; +[shader("compute")] +[numthreads(8, 8, 1)] +void gpuHzbMain(uint3 dispatchId : SV_DispatchThreadID) { + uint2 pixel = dispatchId.xy; + if (pixel.x >= hzbParameters.outputWidth || pixel.y >= hzbParameters.outputHeight) return; + if (hzbParameters.outputWidth >= hzbParameters.sourceWidth && + hzbParameters.outputHeight >= hzbParameters.sourceHeight) { + // Mip 0 copies depth into a power-of-two base. Missing edge texels are + // ordinary-Z far depth, so a padded region can never hide geometry. + hzbOutput[pixel] = pixel.x < hzbParameters.sourceWidth && + pixel.y < hzbParameters.sourceHeight + ? hzbSource.Load(int3(pixel, 0)) : 1.0; + return; + } + float furthest = 0; + [unroll] for (uint y = 0; y < 2; ++y) + [unroll] for (uint x = 0; x < 2; ++x) { + uint2 child = pixel * 2 + uint2(x, y); + // Ordinary-Z clear/far depth is 1. Padding therefore cannot occlude. + float depth = child.x < hzbParameters.sourceWidth && + child.y < hzbParameters.sourceHeight + ? hzbSource.Load(int3(child, 0)) : 1.0; + furthest = max(furthest, depth); + } + hzbOutput[pixel] = furthest; +} +#else +#error Select FASET_GPU_GRAPHICS, FASET_GPU_CULL, or FASET_GPU_HZB. +#endif diff --git a/src/editor/build_service.cpp b/src/editor/build_service.cpp index 6723b65..5ad1c1c 100644 --- a/src/editor/build_service.cpp +++ b/src/editor/build_service.cpp @@ -334,7 +334,11 @@ struct BuildService::Impl { copy_required_file(exporter, staging / ("faset_schema_exporter" + executable_suffix())); for (const auto* file : {"vertexMain.spv", "fragmentMain.spv", "shadowMain.spv", "vertexMain.reflection.json", "fragmentMain.reflection.json", - "shadowMain.reflection.json"}) + "shadowMain.reflection.json", "gpuVertexMain.spv", + "gpuShadowMain.spv", "gpuCullMain.spv", "gpuHzbMain.spv", + "gpuPostCullMain.spv", "gpuVertexMain.reflection.json", + "gpuShadowMain.reflection.json", "gpuCullMain.reflection.json", + "gpuHzbMain.reflection.json", "gpuPostCullMain.reflection.json"}) copy_required_file(native_directory / "shaders" / file, staging / "shaders" / file); copy_runtime_libraries(job, player, staging, native_directory, configuration); Json manifest{{"format", "faset.build"}, @@ -582,7 +586,11 @@ struct BuildService::Impl { staging / ("faset_player" + executable_suffix())); for (const auto* shader : {"vertexMain.spv", "fragmentMain.spv", "shadowMain.spv", "vertexMain.reflection.json", "fragmentMain.reflection.json", - "shadowMain.reflection.json"}) + "shadowMain.reflection.json", "gpuVertexMain.spv", + "gpuShadowMain.spv", "gpuCullMain.spv", "gpuHzbMain.spv", + "gpuPostCullMain.spv", "gpuVertexMain.reflection.json", + "gpuShadowMain.reflection.json", "gpuCullMain.reflection.json", + "gpuHzbMain.reflection.json", "gpuPostCullMain.reflection.json"}) copy_required_file(build_directory / "shaders" / shader, staging / "shaders" / shader); for (const auto& entry : fs::directory_iterator(build_directory)) { diff --git a/src/render/shader_contract.cpp b/src/render/shader_contract.cpp index 839335d..0dde05f 100644 --- a/src/render/shader_contract.cpp +++ b/src/render/shader_contract.cpp @@ -85,7 +85,72 @@ void validate_layout(const Json& layout, std::string_view entry) { locations(layout.at("outputs"), {}, "shadow outputs"); } } -void validate_spirv(const std::vector& words, bool fragment) { +void validate_gpu_layout(const Json& layout, std::string_view entry) { + const bool graphics = entry == "gpuVertexMain" || entry == "gpuShadowMain"; + const bool hzb = entry == "gpuHzbMain"; + const bool compute = !graphics; + require(layout.at("stage") == (compute ? "compute" : "vertex"), "GPU shader stage changed"); + const auto& descriptors = layout.at("descriptors"); + const std::size_t expected_count = graphics ? 3 : hzb ? 2 : 10; + require(descriptors.is_array() && descriptors.size() == expected_count, + "GPU descriptor count changed"); + const std::array compute_strides{224, 16, 16, 4, 16, 4, 4, 0, 0, 208}; + const std::array graphics_strides{224, 4, 208}; + for (std::size_t i = 0; i < expected_count; ++i) { + const auto& binding = descriptors[i]; + require(binding.at("set") == (graphics ? 1 : 0) && binding.at("binding") == i && + binding.at("count") == 1, + "GPU descriptor set, binding or count changed"); + const int stride = graphics ? graphics_strides[i] : hzb ? 0 : compute_strides[i]; + const char* type = hzb ? (i == 0 ? "sampled_image_2d" : "storage_image_2d") + : stride > 0 ? "storage_buffer" : "sampled_image_2d"; + require(binding.at("type") == type, "GPU descriptor type changed"); + if (stride > 0) + require(binding.at("element_stride") == stride, "GPU storage record stride changed"); + } + const auto& constants = layout.at("push_constants"); + require(constants.is_array() && constants.size() == 1 && + constants[0].at("offset") == 0 && + constants[0].at("size") == (graphics ? 112 : 16), + "GPU push-constant block changed"); + const auto& members = constants[0].at("members"); + require(members.is_array() && members.size() == 4, "GPU push-constant fields changed"); + const int graphics_offsets[] = {0, 64, 80, 96}; + const int graphics_sizes[] = {64, 16, 16, 16}; + const char* graphics_types[] = {"float32x4x4", "float32x4", "float32x4", "uint32x4"}; + for (std::size_t i = 0; i < 4; ++i) { + require(members[i].at("offset") == (graphics ? graphics_offsets[i] : int(i) * 4) && + members[i].at("size") == (graphics ? graphics_sizes[i] : 4) && + members[i].at("type") == (graphics ? graphics_types[i] : "uint32"), + "GPU push-constant layout changed"); + } + const auto& blocks = layout.at("spirv_push_constants"); + require(blocks.is_array() && blocks.size() == 1, "GPU SPIR-V push block changed"); + const auto& actual = blocks[0].at("members"); + require(actual.is_array() && actual.size() == 4, "GPU SPIR-V push members changed"); + for (std::size_t i = 0; i < 4; ++i) + require(actual[i].at("member") == i && + actual[i].at("offset") == (graphics ? graphics_offsets[i] : int(i) * 4), + "GPU SPIR-V push offsets changed"); + if (graphics) { + require(actual[0].at("matrix_layout") == "row-major" && + actual[0].at("matrix_stride") == 16, + "GPU SPIR-V matrix storage convention changed"); + locations(layout.at("inputs"), + {"float32x3", "float32x3", "float32x4", "float32x2"}, + "GPU vertex inputs"); + if (entry == "gpuVertexMain") + locations(layout.at("outputs"), + {"float32x3", "float32x3", "float32x4", "float32x2", "float32x2"}, + "GPU vertex outputs"); + else + locations(layout.at("outputs"), {}, "GPU shadow outputs"); + } else { + locations(layout.at("inputs"), {}, "GPU compute inputs"); + locations(layout.at("outputs"), {}, "GPU compute outputs"); + } +} +void validate_spirv(const std::vector& words, std::uint32_t execution_model) { require(words.size() >= 5 && words[0] == 0x07230203 && words[1] >= 0x00010000 && words[1] <= 0x00010600 && words[3] > 0 && words[3] < (1u << 20) && words[4] == 0, "invalid SPIR-V header"); @@ -101,7 +166,7 @@ void validate_spirv(const std::vector& words, bool fragment) { const auto* terminator = static_cast(std::memchr(name, 0, available)); require(terminator != nullptr, "unterminated SPIR-V entry name"); if (std::string_view(name, terminator - name) == "main") { - require(words[offset + 1] == (fragment ? 4u : 0u), "SPIR-V entry stage changed"); + require(words[offset + 1] == execution_model, "SPIR-V entry stage changed"); entry_found = true; } } @@ -109,7 +174,8 @@ void validate_spirv(const std::vector& words, bool fragment) { } require(entry_found, "SPIR-V main entry point missing"); } -detail::ShaderCode load(const std::filesystem::path& directory, const char* entry) { +detail::ShaderCode load(const std::filesystem::path& directory, const char* entry, + bool gpu = false) { const auto bytes = read_bounded(directory / (std::string(entry) + ".spv"), 16 * 1024 * 1024); require(bytes.size() >= 20 && bytes.size() % 4 == 0, "invalid SPIR-V byte length"); const auto metadata = Json::parse( @@ -122,12 +188,17 @@ detail::ShaderCode load(const std::filesystem::path& directory, const char* entr const auto& layout = metadata.at("layout"); const auto fingerprint = faset::sha256(layout.dump()); require(metadata.at("layout_fingerprint") == fingerprint, "layout fingerprint mismatch"); - validate_layout(layout, entry); + if (gpu) + validate_gpu_layout(layout, entry); + else + validate_layout(layout, entry); detail::ShaderCode result; result.layout_fingerprint = fingerprint; result.words.resize(bytes.size() / 4); std::memcpy(result.words.data(), bytes.data(), bytes.size()); - validate_spirv(result.words, std::string_view(entry) == "fragmentMain"); + validate_spirv(result.words, gpu ? ((std::string_view(entry) == "gpuVertexMain" || + std::string_view(entry) == "gpuShadowMain") ? 0u : 5u) + : (std::string_view(entry) == "fragmentMain" ? 4u : 0u)); return result; } } // namespace @@ -136,6 +207,12 @@ detail::load_shader_bundle(const std::filesystem::path& directory) { return {load(directory, "vertexMain"), load(directory, "fragmentMain"), load(directory, "shadowMain")}; } +std::array +detail::load_gpu_shader_bundle(const std::filesystem::path& directory) { + return {load(directory, "gpuVertexMain", true), load(directory, "gpuShadowMain", true), + load(directory, "gpuCullMain", true), load(directory, "gpuHzbMain", true), + load(directory, "gpuPostCullMain", true)}; +} void validate_shader_bundle(const std::filesystem::path& directory) { (void)detail::load_shader_bundle(directory); } diff --git a/src/render/shader_contract.hpp b/src/render/shader_contract.hpp index c0154ea..86b136d 100644 --- a/src/render/shader_contract.hpp +++ b/src/render/shader_contract.hpp @@ -11,4 +11,6 @@ struct ShaderCode { std::string layout_fingerprint; }; std::array load_shader_bundle(const std::filesystem::path& directory); +// Order: opaque vertex, optional instanced shadow vertex, main cull, HZB, post cull. +std::array load_gpu_shader_bundle(const std::filesystem::path& directory); } // namespace faset::render::detail diff --git a/tests/build_schema_tests.cpp b/tests/build_schema_tests.cpp index 19d2252..ecb3acf 100644 --- a/tests/build_schema_tests.cpp +++ b/tests/build_schema_tests.cpp @@ -148,6 +148,12 @@ int test_main(int argc, char** argv) { const auto first = builds.wait(builds.start_build()); check(first.state == "succeeded", "Valid custom schema v2 publishes: " + first.error); const auto directory = path_from_utf8(first.result.at("directory").get()); + for (const auto* entry : {"gpuVertexMain", "gpuShadowMain", "gpuCullMain", + "gpuHzbMain", "gpuPostCullMain"}) + for (const auto* extension : {".spv", ".reflection.json"}) + check(fs::is_regular_file(directory / "shaders" / + (std::string(entry) + extension)), + "Published Player contains every checked P2 shader artifact"); const auto player = path_from_utf8(first.result.at("player").get()); const auto schema = path_from_utf8(first.result.at("schema").get()); const auto last_build = builds.config().cache_root / "last_build.json"; diff --git a/tests/build_schema_tool.cpp b/tests/build_schema_tool.cpp index a1631f3..b6d80ab 100644 --- a/tests/build_schema_tool.cpp +++ b/tests/build_schema_tool.cpp @@ -60,7 +60,9 @@ int tool_main(int argc, char** argv) { for (const auto* target : {"faset_player", "faset_schema_exporter"}) fs::copy_file(self, build / (std::string(target) + suffix), fs::copy_options::overwrite_existing); - for (const auto* entry : {"vertexMain", "fragmentMain", "shadowMain"}) + for (const auto* entry : {"vertexMain", "fragmentMain", "shadowMain", + "gpuVertexMain", "gpuShadowMain", "gpuCullMain", + "gpuHzbMain", "gpuPostCullMain"}) for (const auto* extension : {".spv", ".reflection.json"}) atomic_write(build / "shaders" / (std::string(entry) + extension), "fixture\n"); return 0; diff --git a/tests/render_gpu_shader_contract_tests.cpp b/tests/render_gpu_shader_contract_tests.cpp new file mode 100644 index 0000000..423dc93 --- /dev/null +++ b/tests/render_gpu_shader_contract_tests.cpp @@ -0,0 +1,71 @@ +#include "shader_contract.hpp" +#include +#include +#include +#include +#include +#include + +namespace fs = std::filesystem; +namespace { +void require(bool valid, const char* message) { + if (!valid) + throw std::runtime_error(message); +} +template void must_reject(Function&& function, const char* message) { + try { + function(); + } catch (const std::exception&) { + return; + } + throw std::runtime_error(message); +} +} // namespace +int main() { + const auto original = fs::path(FASET_TEST_SHADER_DIRECTORY); + const auto temporary = fs::temp_directory_path() / "faset-gpu-shader-contract-test"; + struct Cleanup { + fs::path path; + ~Cleanup() { std::error_code ignored; fs::remove_all(path, ignored); } + } cleanup{temporary}; + try { + fs::create_directories(temporary); + constexpr const char* entries[] = {"gpuVertexMain", "gpuShadowMain", "gpuCullMain", + "gpuHzbMain", "gpuPostCullMain"}; + for (const auto* entry : entries) + for (const auto* extension : {".spv", ".reflection.json"}) { + const auto file = std::string(entry) + extension; + fs::copy_file(original / file, temporary / file, fs::copy_options::overwrite_existing); + } + auto shaders = faset::render::detail::load_gpu_shader_bundle(temporary); + for (const auto& shader : shaders) + require(!shader.words.empty() && !shader.layout_fingerprint.empty(), + "Every P2 entry is valid SPIR-V with checked metadata"); + auto shader_file = temporary / "gpuCullMain.spv"; + const auto shader_bytes = faset::read_text(shader_file); + faset::atomic_write(shader_file, "corrupt"); + must_reject([&] { (void)faset::render::detail::load_gpu_shader_bundle(temporary); }, + "Corrupt P2 SPIR-V must be rejected"); + faset::atomic_write(shader_file, shader_bytes); + auto reflection_file = temporary / "gpuPostCullMain.reflection.json"; + auto metadata = faset::read_json(reflection_file); + metadata["layout_fingerprint"] = "tampered"; + faset::atomic_write_json(reflection_file, metadata); + must_reject([&] { (void)faset::render::detail::load_gpu_shader_bundle(temporary); }, + "Tampered P2 layout fingerprint must be rejected"); + metadata = faset::read_json(original / "gpuPostCullMain.reflection.json"); + metadata["layout"]["descriptors"][0]["element_stride"] = 208; + metadata["layout_fingerprint"] = faset::sha256(metadata["layout"].dump()); + faset::atomic_write_json(reflection_file, metadata); + must_reject([&] { (void)faset::render::detail::load_gpu_shader_bundle(temporary); }, + "A consistently rehashed but incompatible GPU record stride must be rejected"); + fs::remove(temporary / "gpuHzbMain.spv"); + must_reject([&] { (void)faset::render::detail::load_gpu_shader_bundle(temporary); }, + "Missing P2 entry must be rejected"); + std::cout << "GPU shader bundle validates every entry and rejects corrupt or missing artifacts\n"; + return 0; + } catch (const std::exception& error) { + std::cerr << error.what() << '\n'; + return 1; + } +} diff --git a/tests/render_reload_tests.cpp b/tests/render_reload_tests.cpp index 91471b6..e18c664 100644 --- a/tests/render_reload_tests.cpp +++ b/tests/render_reload_tests.cpp @@ -46,7 +46,9 @@ int main() { try { const auto bundle = temporary / "shaders"; fs::create_directories(bundle); - for (const auto* entry : {"vertexMain", "fragmentMain", "shadowMain"}) + for (const auto* entry : {"vertexMain", "fragmentMain", "shadowMain", + "gpuVertexMain", "gpuShadowMain", "gpuCullMain", + "gpuHzbMain", "gpuPostCullMain"}) for (const auto* extension : {".spv", ".reflection.json"}) { const auto name = std::string(entry) + extension; fs::copy_file(path_from_utf8(FASET_TEST_SHADER_DIRECTORY) / name, bundle / name); @@ -76,7 +78,9 @@ int main() { require(deep_bundle.native().size() > 300, "Shader file fixture must exceed the legacy Windows path limit"); fs::create_directories(native_io_path(deep_bundle)); - for (const auto* entry : {"vertexMain", "fragmentMain", "shadowMain"}) + for (const auto* entry : {"vertexMain", "fragmentMain", "shadowMain", + "gpuVertexMain", "gpuShadowMain", "gpuCullMain", + "gpuHzbMain", "gpuPostCullMain"}) for (const auto* extension : {".spv", ".reflection.json"}) { const auto name = std::string(entry) + extension; atomic_write(deep_bundle / name, read_text(bundle / name)); diff --git a/tests/test_shader_reflection.py b/tests/test_shader_reflection.py new file mode 100644 index 0000000..d2caa1c --- /dev/null +++ b/tests/test_shader_reflection.py @@ -0,0 +1,81 @@ +"""The cooked shader interface must preserve GPU resource kinds and element strides.""" + +import importlib.util +import json +import os +from pathlib import Path +import struct +import subprocess +import sys +import tempfile +import unittest + + +SCRIPT = Path(__file__).resolve().parents[1] / "tools" / "compile_shader.py" +SPEC = importlib.util.spec_from_file_location("faset_compile_shader", SCRIPT) +assert SPEC and SPEC.loader +shader = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(shader) + + +def parameter(name: str, index: int, shape: str, access: str, stride: int | None = None): + result = {"kind": "scalar", "scalarType": "uint32", "sizes": []} + if stride is not None: + result["sizes"] = [{"kind": "uniform", "value": stride, "alignment": 16}] + return { + "name": name, + "binding": {"kind": "descriptorTableSlot", "index": index, "space": 0}, + "type": { + "kind": "resource", + "baseShape": shape, + "access": access, + "resultType": result, + "sizes": [{"kind": "descriptorTableSlot", "value": 1}], + }, + } + + +class ReflectionTests(unittest.TestCase): + def test_gpu_storage_resources_keep_kind_and_stride(self): + parameters = [ + parameter("instances", 0, "structuredBuffer", "read", 224), + parameter("visibleIds", 1, "structuredBuffer", "readWrite", 4), + parameter("depthOutput", 2, "texture2D", "readWrite"), + parameter("depthInput", 3, "texture2D", "read"), + ] + raw = { + "parameters": parameters, + "entryPoints": [{ + "name": "gpuCullMain", "stage": "compute", + "bindings": [{"name": p["name"], "binding": {"used": 1}} for p in parameters], + }], + } + spirv = struct.pack("<5I", 0x07230203, 0x00010600, 0, 1, 0) + layout = shader.normalize(raw, spirv, "gpuCullMain")["layout"] + descriptors = layout["descriptors"] + self.assertEqual( + [(d["type"], d.get("element_stride")) for d in descriptors], + [("storage_buffer", 224), ("storage_buffer", 4), + ("storage_image_2d", None), ("sampled_image_2d", None)], + ) + + def test_real_slang_compute_interface(self): + compiler = os.environ["FASET_TEST_SLANGC"] + with tempfile.TemporaryDirectory(prefix="faset-shader-reflection-") as directory: + process = subprocess.run( + [sys.executable, str(SCRIPT), "--compiler", compiler, "--source", + str(SCRIPT.parents[1] / "shaders" / "gpu_scene.slang"), "--entry", + "gpuCullMain", "--define", "FASET_GPU_CULL=1", "--output", directory], + capture_output=True, text=True, + ) + self.assertEqual(process.returncode, 0, process.stderr) + metadata = json.loads((Path(directory) / "gpuCullMain.reflection.json").read_text()) + bindings = {item["binding"]: item for item in metadata["layout"]["descriptors"]} + self.assertEqual([bindings[n]["element_stride"] for n in (0, 1, 2, 3, 4, 5, 6, 9)], + [224, 16, 16, 4, 16, 4, 4, 208]) + self.assertEqual([bindings[n]["type"] for n in (7, 8)], + ["sampled_image_2d", "sampled_image_2d"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/compile_shader.py b/tools/compile_shader.py index 20eb7b8..74d0510 100644 --- a/tools/compile_shader.py +++ b/tools/compile_shader.py @@ -109,13 +109,22 @@ def normalize(raw: dict, bytecode: bytes, entry_name: str) -> dict: if ty["kind"] == "array": count = ty["elementCount"] ty = ty["elementType"] + element_stride = None if ty["kind"] == "samplerState": descriptor_type = "sampler" elif ty["kind"] == "resource" and ty.get("baseShape") == "texture2D": - descriptor_type = "sampled_image_2d" + descriptor_type = "storage_image_2d" if ty.get("access") == "readWrite" else "sampled_image_2d" + elif ty["kind"] == "resource" and ty.get("baseShape") == "structuredBuffer": + descriptor_type = "storage_buffer" + element_stride = next((item["value"] for item in ty["resultType"].get("sizes", []) if item["kind"] == "uniform"), None) + if not isinstance(element_stride, int) or element_stride <= 0: + raise ValueError(f"Structured buffer lacks a valid element stride: {parameter['name']}") else: raise ValueError(f"Unsupported descriptor kind: {ty}") - descriptors.append({"name": parameter["name"], "set": binding.get("space", 0), "binding": binding["index"], "type": descriptor_type, "count": count, "used": used.get(parameter["name"], True)}) + descriptor = {"name": parameter["name"], "set": binding.get("space", 0), "binding": binding["index"], "type": descriptor_type, "count": count, "used": used.get(parameter["name"], True)} + if element_stride is not None: + descriptor["element_stride"] = element_stride + descriptors.append(descriptor) else: raise ValueError(f"Unsupported global shader binding: {binding['kind']}") inputs, input_builtins = interface(entry.get("parameters", []), "varyingInput") @@ -130,13 +139,19 @@ def main() -> int: parser.add_argument("--source", required=True, type=Path) parser.add_argument("--entry", required=True) parser.add_argument("--output", required=True, type=Path) + parser.add_argument("--define", action="append", default=[], + help="Slang preprocessor definition, NAME or NAME=VALUE") args = parser.parse_args() args.output.mkdir(parents=True, exist_ok=True) with tempfile.TemporaryDirectory(prefix=".shader-", dir=args.output) as temporary: directory = Path(temporary) spirv = directory / f"{args.entry}.spv" raw = directory / f"{args.entry}.slang-reflection.json" - process = subprocess.run([args.compiler, str(args.source), "-entry", args.entry, "-target", "spirv", "-profile", "spirv_1_6", "-matrix-layout-column-major", "-o", str(spirv), "-reflection-json", str(raw)]) + command = [args.compiler, str(args.source), "-entry", args.entry, "-target", "spirv", + "-profile", "spirv_1_6", "-matrix-layout-column-major"] + command += [f"-D{definition}" for definition in args.define] + command += ["-o", str(spirv), "-reflection-json", str(raw)] + process = subprocess.run(command) if process.returncode: return process.returncode normalized = normalize(json.loads(raw.read_text(encoding="utf-8")), spirv.read_bytes(), args.entry)