From 9f97ca7a7d8cf69817ce41cf60918a49455b32a2 Mon Sep 17 00:00:00 2001 From: Emil <65846814+emil28092005@users.noreply.github.com> Date: Thu, 24 Sep 2026 05:25:53 +0300 Subject: [PATCH] Expose opt-in temporal pixel diagnostics --- apps/player_main.cpp | 8 ++ cmake/Renderer.cmake | 5 + include/faset/render/renderer.hpp | 5 + shaders/temporal.slang | 11 ++- src/editor/debug_overlay.cpp | 19 +++- src/render/renderer.cpp | 94 ++++++++++++++++--- src/render/shader_contract.cpp | 10 +- tests/player_diagnostics_test.py | 5 + tests/render_temporal_diagnostics_tests.cpp | 87 +++++++++++++++++ .../render_temporal_shader_contract_tests.cpp | 14 ++- 10 files changed, 240 insertions(+), 18 deletions(-) create mode 100644 tests/render_temporal_diagnostics_tests.cpp diff --git a/apps/player_main.cpp b/apps/player_main.cpp index 553fa66..eb94dbe 100644 --- a/apps/player_main.cpp +++ b/apps/player_main.cpp @@ -188,6 +188,14 @@ Json profileFrames(const std::vector& samples) { {"temporal_internal_width", sample.lighting.temporal_internal_width}, {"temporal_internal_height", sample.lighting.temporal_internal_height}, {"temporal_jitter", sample.lighting.temporal_jitter}, + {"temporal_counters_valid", + sample.lighting.temporal_counters_valid}, + {"temporal_accepted_pixels", + sample.lighting.temporal_counters_valid + ? Json(sample.lighting.temporal_accepted_pixels) : Json(nullptr)}, + {"temporal_rejected_pixels", + sample.lighting.temporal_counters_valid + ? Json(sample.lighting.temporal_rejected_pixels) : Json(nullptr)}, {"gpu_temporal_resolve_ms", gpuMeasured ? Json(sample.lighting.gpu_temporal_resolve_ms) : Json(nullptr)}, {"gpu_temporal_composite_ms", diff --git a/cmake/Renderer.cmake b/cmake/Renderer.cmake index a05e55c..4b9561f 100644 --- a/cmake/Renderer.cmake +++ b/cmake/Renderer.cmake @@ -169,5 +169,10 @@ if(BUILD_TESTING) "${Python3_EXECUTABLE}" "${PROJECT_SOURCE_DIR}/tests/test_p3_lighting_benchmark.py" --real-executable "$") set_tests_properties(render_lighting_benchmark_smoke PROPERTIES LABELS "gpu;p3" TIMEOUT 90) + add_executable(faset_render_temporal_diagnostics_tests + "${PROJECT_SOURCE_DIR}/tests/render_temporal_diagnostics_tests.cpp") + target_link_libraries(faset_render_temporal_diagnostics_tests PRIVATE faset_render) + add_test(NAME render_temporal_diagnostics COMMAND faset_render_temporal_diagnostics_tests) + set_tests_properties(render_temporal_diagnostics PROPERTIES LABELS "gpu;p3") endif() install(FILES ${FASET_SHADER_OUTPUTS} DESTINATION shaders) diff --git a/include/faset/render/renderer.hpp b/include/faset/render/renderer.hpp index ec5c7a5..0e474e9 100644 --- a/include/faset/render/renderer.hpp +++ b/include/faset/render/renderer.hpp @@ -155,6 +155,8 @@ struct RendererConfig { LightingMode lighting_mode{LightingMode::Auto}; // GPU counter readback is diagnostic-only; normal visibility uses no CPU feedback. bool visibility_diagnostics{false}; + // Exact temporal pixel counters are opt-in; normal TAA avoids GPU readback. + bool temporal_diagnostics{false}; // Optional isolated shader bundle, useful for editor preview and shader reload tests. std::filesystem::path shader_directory{}; }; @@ -219,6 +221,8 @@ struct FrameStats { std::uint32_t temporal_valid_motion_instances{}; std::uint32_t temporal_internal_width{}, temporal_internal_height{}; std::array temporal_jitter{}; + bool temporal_counters_valid{}; + std::uint32_t temporal_accepted_pixels{}, temporal_rejected_pixels{}; double gpu_temporal_resolve_ms{}, gpu_temporal_composite_ms{}, gpu_ui_ms{}; std::vector graph_passes; double gpu_light_tiles_ms{}; @@ -250,6 +254,7 @@ class Renderer { TemporalMode temporal_mode() const; float render_scale() const; void set_visibility_diagnostics(bool enabled); + void set_temporal_diagnostics(bool enabled); // Reads the most recently completed HZB mip for editor diagnostics only. // Normal visibility decisions remain entirely on the GPU. std::optional hzb_debug_image(std::uint32_t mip = 0); diff --git a/shaders/temporal.slang b/shaders/temporal.slang index 6911905..a25b023 100644 --- a/shaders/temporal.slang +++ b/shaders/temporal.slang @@ -7,7 +7,7 @@ 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 + uint4 flags; // x = prior history valid; y = count pixel decisions float4 jitterMotion; // xy = current-minus-prior local UV; z = static camera }; [[vk::push_constant]] ConstantBuffer temporalParameters; @@ -20,6 +20,9 @@ struct TemporalResolveParameters { RWTexture2D nextHistoryColor; [[vk::binding(6,0)]] [vk::image_format("r32f")] RWTexture2D nextHistoryDepth; +// A tiny GPU counter buffer is bound for the normal pipeline too, but touched +// only when explicit Editor diagnostics requests exact per-pixel statistics. +[[vk::binding(7,0)]] RWStructuredBuffer historyDecisionCounts; int2 clampScenePixel(int2 pixel) { return clamp(pixel, int2(0), int2(temporalParameters.dimensions.zw) - 1); @@ -90,6 +93,7 @@ void temporalResolveMain(uint3 dispatchId : SV_DispatchThreadID) { const float4 currentColor = sceneColorAt(currentPixel); const float currentDepth = sceneDepthAt(currentPixel); float4 resolved = currentColor; + bool acceptedHistory = false; if (insideScene && temporalParameters.flags.x != 0) { const float4 centerMotion = sceneVelocityAt(currentPixel); @@ -190,6 +194,7 @@ void temporalResolveMain(uint3 dispatchId : SV_DispatchThreadID) { (!matchingFar || !nearerOccluder)) { const float3 priorColor = clamp(sampled, low, high); resolved.rgb = lerp(currentColor.rgb, priorColor, weight); + acceptedHistory = true; } } } @@ -198,6 +203,10 @@ void temporalResolveMain(uint3 dispatchId : SV_DispatchThreadID) { } nextHistoryColor[outputPixel] = resolved; nextHistoryDepth[outputPixel] = currentDepth; + if (insideScene && temporalParameters.flags.y != 0) { + uint oldCount; + InterlockedAdd(historyDecisionCounts[acceptedHistory ? 0 : 1], 1, oldCount); + } } #elif defined(FASET_TEMPORAL_COMPOSITE) diff --git a/src/editor/debug_overlay.cpp b/src/editor/debug_overlay.cpp index 044c623..862f4b3 100644 --- a/src/editor/debug_overlay.cpp +++ b/src/editor/debug_overlay.cpp @@ -86,7 +86,7 @@ ImGuiKey key(std::string_view name) { } // namespace struct DebugOverlay::Impl { ImGuiContext* context{}; - bool visible{}, freeze{}, show_hzb{}; + bool visible{}, freeze{}, show_hzb{}, count_temporal_pixels{}; bool hzb_sampled{}, hzb_available{}; int hzb_mip{3}, hzb_last_mip{-1}; std::uint64_t hzb_frame{}; @@ -464,6 +464,21 @@ void DebugOverlay::append(render::Snapshot& output, render::Renderer& renderer, ImGui::Text("History: %s Reset: %s", stats.temporal_history_valid ? "valid" : "invalid", temporal_reset_label(stats.temporal_reset_reason)); + ImGui::Text("Jitter (clip): %+.5f, %+.5f", + stats.temporal_jitter[0], stats.temporal_jitter[1]); + ImGui::TextDisabled("Internal pixel offset: %+.3f, %+.3f", + stats.temporal_jitter[0] * stats.temporal_internal_width * .5f, + stats.temporal_jitter[1] * stats.temporal_internal_height * .5f); + ImGui::Checkbox("Count temporal pixels (GPU readback)", + &state.count_temporal_pixels); + if (stats.temporal_counters_valid) + ImGui::Text("History pixels: %u accepted, %u rejected", + stats.temporal_accepted_pixels, + stats.temporal_rejected_pixels); + else if (state.count_temporal_pixels) + ImGui::TextDisabled("History pixel counts unavailable for this frame"); + else + ImGui::TextDisabled("History pixel counts off"); if (stats.gpu_ms > 0) ImGui::Text("GPU: resolve %.2f composite %.2f UI %.2f ms", stats.gpu_temporal_resolve_ms, @@ -520,6 +535,8 @@ void DebugOverlay::append(render::Snapshot& output, render::Renderer& renderer, } // GPU counters are a diagnostics readback, never a normal renderer dependency. renderer.set_visibility_diagnostics(state.visible); + renderer.set_temporal_diagnostics(state.visible && state.count_temporal_pixels && + renderer.temporal_mode() != render::TemporalMode::Off); ImGui::Render(); const auto* data = ImGui::GetDrawData(); if (!data || !data->Valid) diff --git a/src/render/renderer.cpp b/src/render/renderer.cpp index a57c9e7..25edd5a 100644 --- a/src/render/renderer.cpp +++ b/src/render/renderer.cpp @@ -213,6 +213,7 @@ struct SceneResources { }; struct TemporalResources { Image scene_color, velocity, history_color[2], history_depth[2]; + Buffer pixel_counts, pixel_counts_stage; VkDescriptorSetLayout resolve_layout{}, composite_layout{}; VkDescriptorPool descriptor_pool{}; VkDescriptorSet resolve_sets[2]{}, composite_sets[2]{}; @@ -429,6 +430,8 @@ struct Renderer::Impl { destroy(image); for (auto& image : temporal.history_depth) destroy(image); + destroy(temporal.pixel_counts); + destroy(temporal.pixel_counts_stage); destroy(vertices); destroy(readback); destroy(lighting_header); @@ -963,6 +966,8 @@ struct Renderer::Impl { destroy(image); for (auto& image : temporal.history_depth) destroy(image); + destroy(temporal.pixel_counts); + destroy(temporal.pixel_counts_stage); temporal.has_completed_image = false; scene.hzb_history_valid = false; temporal.capabilities.extent = width <= max_image_dimension && @@ -998,6 +1003,10 @@ struct Renderer::Impl { image = make_image(width, height, VK_FORMAT_R32_SFLOAT, VK_IMAGE_USAGE_SAMPLED_BIT | VK_IMAGE_USAGE_STORAGE_BIT, VK_IMAGE_ASPECT_COLOR_BIT); + temporal.pixel_counts = make_buffer(2 * sizeof(std::uint32_t), + VK_BUFFER_USAGE_STORAGE_BUFFER_BIT | VK_BUFFER_USAGE_TRANSFER_DST_BIT | + VK_BUFFER_USAGE_TRANSFER_SRC_BIT, + VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT); temporal.completed_index = 0; } const auto padded_width = std::bit_ceil(internal[0]); @@ -1036,9 +1045,10 @@ struct Renderer::Impl { if (temporal.descriptor_pool) vkDestroyDescriptorPool(device, temporal.descriptor_pool, nullptr); temporal.descriptor_pool = {}; - const std::array sizes{{ + const std::array sizes{{ {VK_DESCRIPTOR_TYPE_SAMPLED_IMAGE, 12}, - {VK_DESCRIPTOR_TYPE_STORAGE_IMAGE, 4}}}; + {VK_DESCRIPTOR_TYPE_STORAGE_IMAGE, 4}, + {VK_DESCRIPTOR_TYPE_STORAGE_BUFFER, 2}}}; VkDescriptorPoolCreateInfo pool_info{}; pool_info.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_POOL_CREATE_INFO; pool_info.maxSets = 4; @@ -1072,7 +1082,7 @@ struct Renderer::Impl { VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL}, {VK_NULL_HANDLE, temporal.history_color[next].view, VK_IMAGE_LAYOUT_GENERAL}, {VK_NULL_HANDLE, temporal.history_depth[next].view, VK_IMAGE_LAYOUT_GENERAL}}}; - std::array writes{}; + std::array writes{}; for (std::uint32_t binding = 0; binding < 7; ++binding) { writes[binding].sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET; writes[binding].dstSet = temporal.resolve_sets[next]; @@ -1082,15 +1092,23 @@ struct Renderer::Impl { ? VK_DESCRIPTOR_TYPE_SAMPLED_IMAGE : VK_DESCRIPTOR_TYPE_STORAGE_IMAGE; writes[binding].pImageInfo = &images[binding]; } + const VkDescriptorBufferInfo count_buffer{temporal.pixel_counts.handle, 0, + 2 * sizeof(std::uint32_t)}; writes[7].sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET; - writes[7].dstSet = temporal.composite_sets[next]; - writes[7].dstBinding = 0; + writes[7].dstSet = temporal.resolve_sets[next]; + writes[7].dstBinding = 7; writes[7].descriptorCount = 1; - writes[7].descriptorType = VK_DESCRIPTOR_TYPE_SAMPLED_IMAGE; + writes[7].descriptorType = VK_DESCRIPTOR_TYPE_STORAGE_BUFFER; + writes[7].pBufferInfo = &count_buffer; + writes[8].sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET; + writes[8].dstSet = temporal.composite_sets[next]; + writes[8].dstBinding = 0; + writes[8].descriptorCount = 1; + writes[8].descriptorType = VK_DESCRIPTOR_TYPE_SAMPLED_IMAGE; const VkDescriptorImageInfo composite_image{ VK_NULL_HANDLE, temporal.history_color[next].view, VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL}; - writes[7].pImageInfo = &composite_image; + writes[8].pImageInfo = &composite_image; vkUpdateDescriptorSets(device, static_cast(writes.size()), writes.data(), 0, nullptr); } @@ -1802,10 +1820,11 @@ struct Renderer::Impl { if (!temporal.capabilities.compute || !temporal.capabilities.formats) return; if (!temporal.resolve_layout) { - std::array bindings{}; + std::array bindings{}; for (std::uint32_t i = 0; i < bindings.size(); ++i) bindings[i] = {i, i < 5 ? VK_DESCRIPTOR_TYPE_SAMPLED_IMAGE - : VK_DESCRIPTOR_TYPE_STORAGE_IMAGE, + : i < 7 ? VK_DESCRIPTOR_TYPE_STORAGE_IMAGE + : VK_DESCRIPTOR_TYPE_STORAGE_BUFFER, 1, VK_SHADER_STAGE_COMPUTE_BIT, nullptr}; VkDescriptorSetLayoutCreateInfo descriptor_info{}; descriptor_info.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_SET_LAYOUT_CREATE_INFO; @@ -2328,6 +2347,8 @@ struct Renderer::Impl { statistics.temporal_internal_width = temporal.internal_width; statistics.temporal_internal_height = temporal.internal_height; statistics.temporal_jitter = {}; + statistics.temporal_counters_valid = false; + statistics.temporal_accepted_pixels = statistics.temporal_rejected_pixels = 0; statistics.gpu_temporal_resolve_ms = statistics.gpu_temporal_composite_ms = statistics.gpu_ui_ms = 0; statistics.graph_passes.clear(); @@ -2342,6 +2363,17 @@ struct Renderer::Impl { const bool occlusion = statistics.effective_visibility_mode == VisibilityMode::GpuOcclusion; const bool temporal_active = statistics.effective_temporal_mode != TemporalMode::Off; + bool collect_temporal_counts = temporal_active && config.temporal_diagnostics; + if (collect_temporal_counts && !temporal.pixel_counts_stage.handle) { + try { + temporal.pixel_counts_stage = make_buffer(2 * sizeof(std::uint32_t), + VK_BUFFER_USAGE_TRANSFER_DST_BIT, + VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | VK_MEMORY_PROPERTY_HOST_COHERENT_BIT, + VK_MEMORY_PROPERTY_HOST_CACHED_BIT); + } catch (const std::exception&) { + collect_temporal_counts = false; + } + } statistics.gpu_visibility_active = gpu_active; statistics.hzb_valid = false; bool can_present = surface != VK_NULL_HANDLE; @@ -3627,6 +3659,15 @@ struct Renderer::Impl { add_pass("TemporalResolve", {"scene_color", "depth", "scene_velocity", "history_previous"}, {"resolved_color", "resolved_depth"}, [&] { + if (collect_temporal_counts) { + vkCmdFillBuffer(command, temporal.pixel_counts.handle, 0, + 2 * sizeof(std::uint32_t), 0); + scene_barrier(VK_PIPELINE_STAGE_2_TRANSFER_BIT, + VK_ACCESS_2_TRANSFER_WRITE_BIT, + VK_PIPELINE_STAGE_2_COMPUTE_SHADER_BIT, + VK_ACCESS_2_SHADER_STORAGE_READ_BIT | + VK_ACCESS_2_SHADER_STORAGE_WRITE_BIT); + } transition(command, temporal.scene_color, VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL, VK_IMAGE_ASPECT_COLOR_BIT); @@ -3656,7 +3697,8 @@ struct Renderer::Impl { temporal.previous_unjittered_vp == snapshot.view_projection; ResolvePush parameters{{width, height, raster_width, raster_height}, output_scene_viewport, scene_viewport, - {statistics.temporal_history_valid ? 1u : 0u, 0, 0, 0}, + {statistics.temporal_history_valid ? 1u : 0u, + collect_temporal_counts ? 1u : 0u, 0, 0}, {(statistics.temporal_jitter[0] - temporal.previous_jitter[0]) * .5f, (statistics.temporal_jitter[1] - @@ -3777,6 +3819,19 @@ struct Renderer::Impl { VK_PIPELINE_STAGE_2_HOST_BIT, VK_ACCESS_2_HOST_READ_BIT); } + if (collect_temporal_counts) { + scene_barrier(VK_PIPELINE_STAGE_2_COMPUTE_SHADER_BIT, + VK_ACCESS_2_SHADER_STORAGE_WRITE_BIT, + VK_PIPELINE_STAGE_2_TRANSFER_BIT, + VK_ACCESS_2_TRANSFER_READ_BIT); + const VkBufferCopy count_copy{0, 0, 2 * sizeof(std::uint32_t)}; + vkCmdCopyBuffer(command, temporal.pixel_counts.handle, + temporal.pixel_counts_stage.handle, 1, &count_copy); + scene_barrier(VK_PIPELINE_STAGE_2_TRANSFER_BIT, + VK_ACCESS_2_TRANSFER_WRITE_BIT, + VK_PIPELINE_STAGE_2_HOST_BIT, + VK_ACCESS_2_HOST_READ_BIT); + } }); if (swap_index) add_pass("Presentation", {"color"}, {"swapchain"}, [&] { @@ -3913,6 +3968,17 @@ struct Renderer::Impl { vkUnmapMemory(device, light_tile_readback.memory); statistics.light_tile_counts_valid = true; } + if (collect_temporal_counts) { + void* mapped_counts{}; + check(vkMapMemory(device, temporal.pixel_counts_stage.memory, 0, + temporal.pixel_counts_stage.size, 0, &mapped_counts), + "Read temporal pixel diagnostics"); + const auto* words = static_cast(mapped_counts); + statistics.temporal_accepted_pixels = words[0]; + statistics.temporal_rejected_pixels = words[1]; + vkUnmapMemory(device, temporal.pixel_counts_stage.memory); + statistics.temporal_counters_valid = true; + } instance_tracker.finish_frame(); scene.previous_vp = raster_vp; scene.previous_projection = snapshot.projection; @@ -3955,7 +4021,8 @@ struct Renderer::Impl { statistics.gpu_allocated_bytes += image.allocation_size; } statistics.gpu_allocated_bytes += temporal.scene_color.allocation_size + - temporal.velocity.allocation_size; + temporal.velocity.allocation_size + temporal.pixel_counts.allocation_size + + temporal.pixel_counts_stage.allocation_size; for (const auto& image : temporal.history_color) statistics.gpu_allocated_bytes += image.allocation_size; for (const auto& image : temporal.history_depth) @@ -4136,6 +4203,11 @@ float Renderer::render_scale() const { void Renderer::set_visibility_diagnostics(bool enabled) { impl_->config.visibility_diagnostics = enabled; } +void Renderer::set_temporal_diagnostics(bool enabled) { + impl_->config.temporal_diagnostics = enabled; + if (!enabled) + impl_->destroy(impl_->temporal.pixel_counts_stage); +} std::optional Renderer::hzb_debug_image(std::uint32_t mip) { auto& renderer = *impl_; if (!renderer.scene.hzb_history_valid || !renderer.scene.hzb_supported) diff --git a/src/render/shader_contract.cpp b/src/render/shader_contract.cpp index 30c4687..f15db29 100644 --- a/src/render/shader_contract.cpp +++ b/src/render/shader_contract.cpp @@ -231,7 +231,7 @@ void validate_temporal_layout(const Json& layout, std::string_view 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), + require(descriptors.is_array() && descriptors.size() == (resolve ? 8u : 1u), "temporal descriptor count changed"); for (std::size_t i = 0; i < descriptors.size(); ++i) { const auto& descriptor = descriptors[i]; @@ -239,8 +239,12 @@ void validate_temporal_layout(const Json& layout, std::string_view entry) { 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"); + (resolve && i == 7 ? "storage_buffer" + : resolve && i >= 5 ? "storage_image_2d" : "sampled_image_2d"), + "temporal descriptor type changed"); + if (resolve && i == 7) + require(descriptor.at("element_stride") == 4, + "temporal counter element stride changed"); require(descriptor.at("used") == (resolve || !vertex), "temporal entry uses an unexpected image binding"); } diff --git a/tests/player_diagnostics_test.py b/tests/player_diagnostics_test.py index 75ba49d..baf9d79 100644 --- a/tests/player_diagnostics_test.py +++ b/tests/player_diagnostics_test.py @@ -66,9 +66,14 @@ with tempfile.TemporaryDirectory(prefix="faset-player-diagnostics-") as temporar "temporal_fallback_reason", "temporal_reset_reason", "temporal_history_valid", "temporal_internal_width", "temporal_internal_height", "temporal_jitter", + "temporal_counters_valid", "temporal_accepted_pixels", + "temporal_rejected_pixels", "gpu_temporal_resolve_ms", "gpu_temporal_composite_ms", "gpu_ui_ms"]: assert key in sample, (key, sample) assert sample["effective_temporal_mode"] == temporal_mode, sample + assert sample["temporal_counters_valid"] is False, sample + assert sample["temporal_accepted_pixels"] is None and \ + sample["temporal_rejected_pixels"] is None, sample if temporal_mode != "off": assert sample["temporal_internal_width"] > 0, sample assert sample["temporal_internal_height"] > 0, sample diff --git a/tests/render_temporal_diagnostics_tests.cpp b/tests/render_temporal_diagnostics_tests.cpp new file mode 100644 index 0000000..4ac8fbd --- /dev/null +++ b/tests/render_temporal_diagnostics_tests.cpp @@ -0,0 +1,87 @@ +#include "render_temporal_fixtures.hpp" + +#include +#include +#include + +using namespace faset::render; +using namespace faset::render::temporal_test; + +namespace { +void pixel_decisions_are_diagnostic_only(VisibilityMode visibility) { + constexpr std::uint32_t width = 96, height = 72; + auto config = headless_config(width, height, visibility); + config.temporal_mode = TemporalMode::TAA; + Renderer renderer(config); + Renderer reference(config); + auto frame = lit_scene(width, height); + frame.draws.push_back(cube({0, 0, 0}, {.9f, .4f, .2f, 1}, "diagnostic-cube")); + + renderer.render(frame); + reference.render(frame); + require(!renderer.stats().temporal_counters_valid && + renderer.stats().temporal_accepted_pixels == 0 && + renderer.stats().temporal_rejected_pixels == 0, + "Normal TAA must not read back per-pixel history diagnostics"); + + renderer.set_temporal_diagnostics(true); + renderer.render(frame); + reference.render(frame); + const auto accepted = renderer.stats().temporal_accepted_pixels; + const auto rejected = renderer.stats().temporal_rejected_pixels; + require(renderer.stats().temporal_counters_valid && accepted > 0 && + accepted + rejected == width * height, + "Requested TAA diagnostics count each scene pixel and reuse eligible history"); + require(renderer.pixels() == reference.pixels(), + "Counting pixels must not alter the TAA image"); + + frame.camera_cut = true; + renderer.render(frame); + reference.render(frame); + require(renderer.stats().temporal_counters_valid && + renderer.stats().temporal_accepted_pixels == 0 && + renderer.stats().temporal_rejected_pixels == width * height, + "A camera cut rejects every per-pixel history sample"); + + frame.camera_cut = false; + frame.scene_rect = {7, 9, 80, 50}; + renderer.render(frame); + reference.render(frame); + require(renderer.stats().temporal_counters_valid && + renderer.stats().temporal_accepted_pixels == 0 && + renderer.stats().temporal_rejected_pixels == 80 * 50, + "The counter covers the scene rectangle, excluding output chrome pixels"); + + renderer.set_temporal_diagnostics(false); + renderer.render(frame); + reference.render(frame); + require(!renderer.stats().temporal_counters_valid && + renderer.stats().temporal_accepted_pixels == 0 && + renderer.stats().temporal_rejected_pixels == 0, + "Disabled diagnostics expose no stale values and perform no readback"); + + renderer.set_temporal_mode(TemporalMode::Off); + reference.set_temporal_mode(TemporalMode::Off); + renderer.set_temporal_diagnostics(true); + renderer.render(frame); + reference.render(frame); + require(!renderer.stats().temporal_counters_valid, + "Off mode does not claim temporal pixel diagnostics"); + require(renderer.stats().validation_errors == 0, + "Diagnostic counter transitions pass Vulkan validation"); + require(renderer.pixels() == reference.pixels(), + "Diagnostic toggles preserve the Off image"); +} +} // namespace + +int main() { + try { + pixel_decisions_are_diagnostic_only(VisibilityMode::Direct); + pixel_decisions_are_diagnostic_only(VisibilityMode::GpuFrustum); + std::cout << "Temporal pixel diagnostics are opt-in and exact\n"; + return 0; + } catch (const std::exception& error) { + std::cerr << error.what() << '\n'; + return 1; + } +} diff --git a/tests/render_temporal_shader_contract_tests.cpp b/tests/render_temporal_shader_contract_tests.cpp index 5c5bdce..9b3ba5e 100644 --- a/tests/render_temporal_shader_contract_tests.cpp +++ b/tests/render_temporal_shader_contract_tests.cpp @@ -49,9 +49,11 @@ int main() { auto reflection = temporary / "temporalResolveMain.reflection.json"; auto metadata = faset::read_json(reflection); require(metadata["layout"]["stage"] == "compute" && - metadata["layout"]["descriptors"].size() == 7 && + metadata["layout"]["descriptors"].size() == 8 && + metadata["layout"]["descriptors"][7]["type"] == "storage_buffer" && + metadata["layout"]["descriptors"][7]["element_stride"] == 4 && metadata["layout"]["push_constants"][0]["size"] == 80, - "Temporal resolve ABI contains seven images and an 80-byte push block"); + "Temporal resolve ABI contains seven images, counters and an 80-byte push block"); metadata["layout"]["descriptors"][5]["binding"] = 8; metadata["layout_fingerprint"] = faset::sha256(metadata["layout"].dump()); faset::atomic_write_json(reflection, metadata); @@ -59,6 +61,14 @@ int main() { "A rehashed temporal image binding change must be rejected"); faset::atomic_write_json( reflection, faset::read_json(original / "temporalResolveMain.reflection.json")); + metadata = faset::read_json(reflection); + metadata["layout"]["descriptors"][7]["element_stride"] = 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 counter stride 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);