diff --git a/cmake/Renderer.cmake b/cmake/Renderer.cmake index e6745be..3b2a38b 100644 --- a/cmake/Renderer.cmake +++ b/cmake/Renderer.cmake @@ -34,6 +34,21 @@ foreach(FASET_ENTRY gpuVertexMain gpuShadowMain gpuCullMain gpuHzbMain gpuPostCu 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() +foreach(FASET_ENTRY temporalResolveMain temporalCompositeVertexMain temporalCompositeFragmentMain) + if(FASET_ENTRY STREQUAL "temporalResolveMain") + set(FASET_TEMPORAL_DEFINE FASET_TEMPORAL_RESOLVE=1) + else() + set(FASET_TEMPORAL_DEFINE FASET_TEMPORAL_COMPOSITE=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/temporal.slang" + --entry "${FASET_ENTRY}" --define "${FASET_TEMPORAL_DEFINE}" --output "${FASET_SHADER_DIRECTORY}" + BYPRODUCTS "${FASET_SHADER_DIRECTORY}/${FASET_ENTRY}.slang-reflection.json" + DEPENDS "${PROJECT_SOURCE_DIR}/shaders/temporal.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 @@ -57,6 +72,11 @@ if(BUILD_TESTING) target_link_libraries(faset_render_lighting_policy_tests PRIVATE faset_render) add_test(NAME render_lighting_policy COMMAND faset_render_lighting_policy_tests) set_tests_properties(render_lighting_policy PROPERTIES LABELS "p3") + add_executable(faset_render_temporal_shader_contract_tests "${PROJECT_SOURCE_DIR}/tests/render_temporal_shader_contract_tests.cpp") + target_include_directories(faset_render_temporal_shader_contract_tests PRIVATE "${PROJECT_SOURCE_DIR}/src/render") + target_link_libraries(faset_render_temporal_shader_contract_tests PRIVATE faset_render faset_core) + target_compile_definitions(faset_render_temporal_shader_contract_tests PRIVATE FASET_TEST_SHADER_DIRECTORY="${FASET_SHADER_DIRECTORY}") + add_test(NAME render_temporal_shader_contract COMMAND faset_render_temporal_shader_contract_tests) add_executable(faset_render_temporal_reference_tests "${PROJECT_SOURCE_DIR}/tests/render_temporal_reference_tests.cpp") target_link_libraries(faset_render_temporal_reference_tests PRIVATE faset_render) add_test(NAME render_temporal_reference COMMAND faset_render_temporal_reference_tests) diff --git a/shaders/temporal.slang b/shaders/temporal.slang new file mode 100644 index 0000000..2a17f7f --- /dev/null +++ b/shaders/temporal.slang @@ -0,0 +1,146 @@ +// Temporal scene resolve and full-resolution composite. No material/lighting +// descriptors are consumed here; the scene was shaded before this pass. +// Velocity target: xy = current-minus-prior scene-local UV, z = expected prior +// clip depth, w = opaque motion validity (zero for reactive/invalid pixels). +#if defined(FASET_TEMPORAL_RESOLVE) +struct TemporalResolveParameters { + uint4 dimensions; // output width/height, internal width/height + float4 outputSceneRect; // output-pixel x/y/width/height + float4 internalSceneRect; // internal-pixel x/y/width/height + uint4 flags; // x = prior history valid +}; +[[vk::push_constant]] ConstantBuffer temporalParameters; +[[vk::binding(0,0)]] Texture2D currentSceneColor; +[[vk::binding(1,0)]] Texture2D currentSceneDepth; +[[vk::binding(2,0)]] Texture2D currentSceneVelocity; +[[vk::binding(3,0)]] Texture2D previousHistoryColor; +[[vk::binding(4,0)]] Texture2D previousHistoryDepth; +[[vk::binding(5,0)]] [vk::image_format("rgba16f")] +RWTexture2D nextHistoryColor; +[[vk::binding(6,0)]] [vk::image_format("r32f")] +RWTexture2D nextHistoryDepth; + +int2 clampScenePixel(int2 pixel) { + return clamp(pixel, int2(0), int2(temporalParameters.dimensions.zw) - 1); +} +float4 sceneColorAt(int2 pixel) { + return currentSceneColor.Load(int3(clampScenePixel(pixel), 0)); +} +float sceneDepthAt(int2 pixel) { + return currentSceneDepth.Load(int3(clampScenePixel(pixel), 0)); +} +float4 sceneVelocityAt(int2 pixel) { + return currentSceneVelocity.Load(int3(clampScenePixel(pixel), 0)); +} +float4 historyBilinear(float2 uv) { + float2 position = uv * float2(temporalParameters.dimensions.xy) - .5; + int2 base = int2(floor(position)); + float2 fraction = position - float2(base); + int2 limit = int2(temporalParameters.dimensions.xy) - 1; + int2 p00 = clamp(base, int2(0), limit); + int2 p10 = clamp(base + int2(1, 0), int2(0), limit); + int2 p01 = clamp(base + int2(0, 1), int2(0), limit); + int2 p11 = clamp(base + int2(1, 1), int2(0), limit); + float4 top = lerp(previousHistoryColor.Load(int3(p00, 0)), + previousHistoryColor.Load(int3(p10, 0)), fraction.x); + float4 bottom = lerp(previousHistoryColor.Load(int3(p01, 0)), + previousHistoryColor.Load(int3(p11, 0)), fraction.x); + return lerp(top, bottom, fraction.y); +} + +[shader("compute")] +[numthreads(8, 8, 1)] +void temporalResolveMain(uint3 dispatchId : SV_DispatchThreadID) { + const uint2 outputPixel = dispatchId.xy; + const uint2 outputExtent = temporalParameters.dimensions.xy; + const uint2 internalExtent = temporalParameters.dimensions.zw; + if (outputPixel.x >= outputExtent.x || outputPixel.y >= outputExtent.y) return; + + const float2 center = float2(outputPixel) + .5; + const float4 outputRect = temporalParameters.outputSceneRect; + const float4 internalRect = temporalParameters.internalSceneRect; + const bool insideScene = all(center >= outputRect.xy) && + all(center < outputRect.xy + outputRect.zw) && + all(outputRect.zw > 0); + const float2 sceneLocalUV = insideScene + ? (center - outputRect.xy) / outputRect.zw : float2(0); + const float2 internalPosition = insideScene + ? internalRect.xy + sceneLocalUV * internalRect.zw + : center / float2(outputExtent) * float2(internalExtent); + const int2 currentPixel = clampScenePixel(int2(floor(internalPosition))); + const float4 currentColor = sceneColorAt(currentPixel); + const float currentDepth = sceneDepthAt(currentPixel); + float4 resolved = currentColor; + + if (insideScene && temporalParameters.flags.x != 0) { + const float4 centerMotion = sceneVelocityAt(currentPixel); + if (all(isfinite(centerMotion)) && centerMotion.w > 0 && + centerMotion.z >= 0 && centerMotion.z <= 1) { + float4 selectedMotion = centerMotion; + float selectedDepth = currentDepth; + const float currentTolerance = .002 + .01 * currentDepth; + [unroll] for (int dy = -1; dy <= 1; ++dy) + [unroll] for (int dx = -1; dx <= 1; ++dx) { + const int2 neighbor = clampScenePixel(currentPixel + int2(dx, dy)); + const float depth = sceneDepthAt(neighbor); + const float4 motion = sceneVelocityAt(neighbor); + if (all(isfinite(motion)) && motion.w > 0 && motion.z >= 0 && + motion.z <= 1 && abs(depth - currentDepth) <= currentTolerance && + depth < selectedDepth) { + selectedDepth = depth; + selectedMotion = motion; + } + } + const float2 previousLocalUV = sceneLocalUV - selectedMotion.xy; + if (all(isfinite(previousLocalUV)) && all(previousLocalUV >= 0) && + all(previousLocalUV < 1)) { + const float2 previousOutputUV = + (outputRect.xy + previousLocalUV * outputRect.zw) / float2(outputExtent); + if (all(previousOutputUV >= 0) && all(previousOutputUV < 1)) { + const int2 priorPixel = clamp( + int2(floor(previousOutputUV * float2(outputExtent))), int2(0), + int2(outputExtent) - 1); + const float priorDepth = previousHistoryDepth.Load(int3(priorPixel, 0)); + const float depthTolerance = .002 + .01 * selectedMotion.z; + if (isfinite(priorDepth) && + abs(priorDepth - selectedMotion.z) <= depthTolerance) { + float3 low = float3(1e30), high = float3(-1e30); + [unroll] for (int dy = -1; dy <= 1; ++dy) + [unroll] for (int dx = -1; dx <= 1; ++dx) { + const float3 color = sceneColorAt(currentPixel + int2(dx, dy)).rgb; + low = min(low, color); + high = max(high, color); + } + const float2 motionPixels = selectedMotion.xy * outputRect.zw; + const float weight = .9 * saturate(centerMotion.w) / + (1 + .5 * length(motionPixels)); + const float3 priorColor = clamp(historyBilinear(previousOutputUV).rgb, + low, high); + resolved.rgb = lerp(currentColor.rgb, priorColor, weight); + } + } + } + } + } + nextHistoryColor[outputPixel] = resolved; + nextHistoryDepth[outputPixel] = currentDepth; +} + +#elif defined(FASET_TEMPORAL_COMPOSITE) +[[vk::binding(0,0)]] Texture2D resolvedHistoryColor; + +[shader("vertex")] +float4 temporalCompositeVertexMain(uint vertexId : SV_VertexID) : SV_Position { + const float2 position = vertexId == 0 ? float2(-1, -1) + : vertexId == 1 ? float2(3, -1) : float2(-1, 3); + return float4(position, 0, 1); +} + +[shader("fragment")] +float4 temporalCompositeFragmentMain(float4 position : SV_Position) : SV_Target { + // Scene shading is already display-referred. No second tone or gamma pass. + return resolvedHistoryColor.Load(int3(int2(position.xy), 0)); +} +#else +#error Select FASET_TEMPORAL_RESOLVE or FASET_TEMPORAL_COMPOSITE. +#endif diff --git a/src/render/shader_contract.cpp b/src/render/shader_contract.cpp index 7c2e6cc..272c713 100644 --- a/src/render/shader_contract.cpp +++ b/src/render/shader_contract.cpp @@ -158,6 +158,75 @@ void validate_gpu_layout(const Json& layout, std::string_view entry) { locations(layout.at("outputs"), {}, "GPU compute outputs"); } } +void validate_temporal_layout(const Json& layout, std::string_view entry) { + const bool resolve = entry == "temporalResolveMain"; + const bool vertex = entry == "temporalCompositeVertexMain"; + require(resolve || vertex || entry == "temporalCompositeFragmentMain", + "unknown temporal shader entry"); + require(layout.at("stage") == (resolve ? "compute" : vertex ? "vertex" : "fragment"), + "temporal shader stage changed"); + const auto& descriptors = layout.at("descriptors"); + require(descriptors.is_array() && descriptors.size() == (resolve ? 7u : 1u), + "temporal descriptor count changed"); + for (std::size_t i = 0; i < descriptors.size(); ++i) { + const auto& descriptor = descriptors[i]; + require(descriptor.at("set") == 0 && descriptor.at("binding") == i && + descriptor.at("count") == 1, + "temporal descriptor set, binding or count changed"); + require(descriptor.at("type") == + (resolve && i >= 5 ? "storage_image_2d" : "sampled_image_2d"), + "temporal image descriptor type changed"); + require(descriptor.at("used") == (resolve || !vertex), + "temporal entry uses an unexpected image binding"); + } + const auto& constants = layout.at("push_constants"); + const auto& spirv_constants = layout.at("spirv_push_constants"); + require(constants.is_array() && spirv_constants.is_array(), + "temporal push constants are malformed"); + if (resolve) { + require(constants.size() == 1 && constants[0].at("offset") == 0 && + constants[0].at("size") == 64 && spirv_constants.size() == 1, + "temporal resolve push block changed"); + const auto& members = constants[0].at("members"); + const auto& spirv_members = spirv_constants[0].at("members"); + require(members.size() == 4 && spirv_members.size() == 4, + "temporal resolve push members changed"); + const char* types[] = {"uint32x4", "float32x4", "float32x4", "uint32x4"}; + for (std::size_t i = 0; i < 4; ++i) + require(members[i].at("offset") == 16 * i && + members[i].at("size") == 16 && members[i].at("type") == types[i] && + spirv_members[i].at("member") == i && + spirv_members[i].at("offset") == 16 * i, + "temporal resolve push member ABI changed"); + } else + require(constants.empty() && spirv_constants.empty(), + "temporal composite unexpectedly uses push constants"); + if (resolve) { + locations(layout.at("inputs"), {}, "temporal resolve inputs"); + locations(layout.at("outputs"), {}, "temporal resolve outputs"); + } else if (vertex) { + locations(layout.at("inputs"), {}, "temporal composite vertex inputs"); + locations(layout.at("outputs"), {}, "temporal composite vertex outputs"); + } else { + locations(layout.at("inputs"), {}, "temporal composite fragment inputs"); + locations(layout.at("outputs"), {"float32x4"}, + "temporal composite fragment outputs"); + } + const auto& input_builtins = layout.at("input_builtins"); + require(input_builtins.is_array() && input_builtins.size() == 1 && + input_builtins[0].at("semantic") == + (resolve ? "SV_DISPATCHTHREADID" : vertex ? "SV_VERTEXID" : "SV_POSITION") && + input_builtins[0].at("type") == (vertex ? "uint32" : resolve ? "uint32x3" + : "float32x4"), + "temporal entry input builtin changed"); + const auto& output_builtins = layout.at("output_builtins"); + require(output_builtins.is_array() && output_builtins.size() == (vertex ? 1u : 0u), + "temporal entry output builtin count changed"); + if (vertex) + require(output_builtins[0].at("semantic") == "SV_POSITION" && + output_builtins[0].at("type") == "float32x4", + "temporal composite vertex position changed"); +} 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, @@ -183,7 +252,7 @@ void validate_spirv(const std::vector& words, std::uint32_t execu require(entry_found, "SPIR-V main entry point missing"); } detail::ShaderCode load(const std::filesystem::path& directory, const char* entry, - bool gpu = false) { + bool gpu = false, bool temporal = 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( @@ -196,7 +265,9 @@ 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"); - if (gpu) + if (temporal) + validate_temporal_layout(layout, entry); + else if (gpu) validate_gpu_layout(layout, entry); else validate_layout(layout, entry); @@ -204,9 +275,13 @@ detail::ShaderCode load(const std::filesystem::path& directory, const char* entr result.layout_fingerprint = fingerprint; result.words.resize(bytes.size() / 4); std::memcpy(result.words.data(), bytes.data(), bytes.size()); - validate_spirv(result.words, gpu ? ((std::string_view(entry) == "gpuVertexMain" || - std::string_view(entry) == "gpuShadowMain") ? 0u : 5u) - : (std::string_view(entry) == "fragmentMain" ? 4u : 0u)); + const auto stage = temporal + ? (std::string_view(entry) == "temporalResolveMain" ? 5u + : std::string_view(entry) == "temporalCompositeVertexMain" ? 0u : 4u) + : gpu ? ((std::string_view(entry) == "gpuVertexMain" || + std::string_view(entry) == "gpuShadowMain") ? 0u : 5u) + : (std::string_view(entry) == "fragmentMain" ? 4u : 0u); + validate_spirv(result.words, stage); return result; } } // namespace @@ -221,6 +296,12 @@ detail::load_gpu_shader_bundle(const std::filesystem::path& directory) { load(directory, "gpuCullMain", true), load(directory, "gpuHzbMain", true), load(directory, "gpuPostCullMain", true)}; } +std::array +detail::load_temporal_shader_bundle(const std::filesystem::path& directory) { + return {load(directory, "temporalResolveMain", false, true), + load(directory, "temporalCompositeVertexMain", false, true), + load(directory, "temporalCompositeFragmentMain", false, 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 86b136d..3f822e8 100644 --- a/src/render/shader_contract.hpp +++ b/src/render/shader_contract.hpp @@ -13,4 +13,6 @@ struct ShaderCode { 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); +// Order: temporal resolve compute, full-screen composite vertex and fragment. +std::array load_temporal_shader_bundle(const std::filesystem::path& directory); } // namespace faset::render::detail diff --git a/tests/render_temporal_shader_contract_tests.cpp b/tests/render_temporal_shader_contract_tests.cpp new file mode 100644 index 0000000..41043dd --- /dev/null +++ b/tests/render_temporal_shader_contract_tests.cpp @@ -0,0 +1,72 @@ +#include "shader_contract.hpp" +#include +#include + +#include +#include +#include + +namespace fs = std::filesystem; +namespace { +void require(bool value, const char* message) { + if (!value) + 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::path_from_utf8("Faset temporal shaders " + faset::new_id()); + struct Cleanup { + fs::path path; + ~Cleanup() { std::error_code error; fs::remove_all(faset::native_io_path(path), error); } + } cleanup{temporary}; + fs::create_directories(faset::native_io_path(temporary)); + constexpr const char* entries[] = {"temporalResolveMain", "temporalCompositeVertexMain", + "temporalCompositeFragmentMain"}; + for (const auto* entry : entries) + for (const auto* extension : {".spv", ".reflection.json"}) { + const auto name = std::string(entry) + extension; + fs::copy_file(faset::native_io_path(original / name), + faset::native_io_path(temporary / name)); + } + + const auto bundle = faset::render::detail::load_temporal_shader_bundle(temporary); + for (const auto& shader : bundle) + require(!shader.words.empty() && !shader.layout_fingerprint.empty(), + "Every temporal shader entry has valid checked SPIR-V and reflection"); + + auto reflection = temporary / "temporalResolveMain.reflection.json"; + auto metadata = faset::read_json(reflection); + require(metadata["layout"]["stage"] == "compute" && + metadata["layout"]["descriptors"].size() == 7 && + metadata["layout"]["push_constants"][0]["size"] == 64, + "Temporal resolve ABI contains seven images and a 64-byte push block"); + metadata["layout"]["descriptors"][5]["binding"] = 8; + metadata["layout_fingerprint"] = faset::sha256(metadata["layout"].dump()); + faset::atomic_write_json(reflection, metadata); + must_reject([&] { (void)faset::render::detail::load_temporal_shader_bundle(temporary); }, + "A rehashed temporal image binding change must be rejected"); + faset::atomic_write_json( + reflection, faset::read_json(original / "temporalResolveMain.reflection.json")); + + auto fragment = temporary / "temporalCompositeFragmentMain.spv"; + const auto bytes = faset::read_text(fragment); + faset::atomic_write(fragment, "corrupt"); + must_reject([&] { (void)faset::render::detail::load_temporal_shader_bundle(temporary); }, + "A broken composite shader cannot enter the temporal bundle"); + faset::atomic_write(fragment, bytes); + fs::remove(faset::native_io_path(temporary / "temporalCompositeVertexMain.spv")); + must_reject([&] { (void)faset::render::detail::load_temporal_shader_bundle(temporary); }, + "A missing temporal entry cannot enter a complete shader bundle"); +}