diff --git a/cmake/Renderer.cmake b/cmake/Renderer.cmake index 5867043..e6c3e96 100644 --- a/cmake/Renderer.cmake +++ b/cmake/Renderer.cmake @@ -57,6 +57,9 @@ 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_motion_tests "${PROJECT_SOURCE_DIR}/tests/render_temporal_motion_tests.cpp") + target_link_libraries(faset_render_temporal_motion_tests PRIVATE faset_render) + add_test(NAME render_temporal_motion COMMAND faset_render_temporal_motion_tests) add_executable(faset_render_temporal_policy_tests "${PROJECT_SOURCE_DIR}/tests/render_temporal_policy_tests.cpp") target_link_libraries(faset_render_temporal_policy_tests PRIVATE faset_render) add_test(NAME render_temporal_policy COMMAND faset_render_temporal_policy_tests) diff --git a/include/faset/render/temporal.hpp b/include/faset/render/temporal.hpp index 82c63cc..6ed7187 100644 --- a/include/faset/render/temporal.hpp +++ b/include/faset/render/temporal.hpp @@ -74,4 +74,11 @@ std::array temporal_jitter(std::uint64_t frame_index, std::uint32_t viewport_width, std::uint32_t viewport_height); +// Scene-local normalized UV motion, current minus previous. Both clips use +// their own jittered scene VP and the same local vertex. Invalid/behind-eye +// clips have no usable history; the shader writes velocity validity zero. +std::optional> +project_motion(const std::array& current_clip, + const std::array& previous_clip) noexcept; + } // namespace faset::render diff --git a/include/faset/render/visibility.hpp b/include/faset/render/visibility.hpp index 4637659..bb1866c 100644 --- a/include/faset/render/visibility.hpp +++ b/include/faset/render/visibility.hpp @@ -37,11 +37,19 @@ struct InstanceUpdate { bool previous_valid{}; }; -// GPU instance metadata: history valid, stable slot, then generation low/high. +// GPU instance metadata: x bits 0/1 separately mark previous HZB bounds and +// previous temporal transform; the other lanes carry stable slot/generation. // A zero generation identifies an anonymous, untracked draw. +inline constexpr std::uint32_t gpu_hzb_history_bit = 1U; +inline constexpr std::uint32_t gpu_temporal_history_bit = 2U; constexpr std::array -gpu_instance_metadata(const InstanceUpdate& update, bool history_compatible) noexcept { - return {update.previous_valid && history_compatible ? 1U : 0U, update.slot, +gpu_instance_metadata(const InstanceUpdate& update, bool hzb_compatible, + bool temporal_compatible = false) noexcept { + return {update.previous_valid + ? (hzb_compatible ? gpu_hzb_history_bit : 0U) | + (temporal_compatible ? gpu_temporal_history_bit : 0U) + : 0U, + update.slot, static_cast(update.generation), static_cast(update.generation >> 32)}; } diff --git a/shaders/gpu_scene.slang b/shaders/gpu_scene.slang index 8e8283d..807da23 100644 --- a/shaders/gpu_scene.slang +++ b/shaders/gpu_scene.slang @@ -26,7 +26,7 @@ struct InstanceRecord { float4 currentExtent; // 160..175: world AABB half extents float4 previousCenter; // 176..191 float4 previousExtent; // 192..207 - uint4 metadata; // 208..223: x=previousValid, y=stableSlot, + uint4 metadata; // 208..223: x bits 0=HZB, 1=temporal prior valid; y=stableSlot, // z=generation low 32, w=generation high 32 (zero = untracked) }; struct ViewRecord { @@ -200,7 +200,7 @@ void gpuCullMain(uint3 dispatchId : SV_DispatchThreadID) { ViewRecord view = cullViews[0]; if (!inFrustum(instance.currentCenter, instance.currentExtent, view.currentViewProjection)) return; - bool guessedHidden = view.flags.x != 0 && instance.metadata.x != 0 && + bool guessedHidden = view.flags.x != 0 && (instance.metadata.x & 1u) != 0 && occluded(instance.previousCenter, instance.previousExtent, view.previousViewProjection, view.previousViewport, view.previousHzbSize, previousHzb); diff --git a/src/render/temporal.cpp b/src/render/temporal.cpp index f8c3b46..ecba009 100644 --- a/src/render/temporal.cpp +++ b/src/render/temporal.cpp @@ -141,4 +141,26 @@ std::array temporal_jitter(std::uint64_t frame_index, (halton(phase, 3) - 0.5f) * (2.f / static_cast(viewport_height))}; } +std::optional> +project_motion(const std::array& current_clip, + const std::array& previous_clip) noexcept { + for (float coordinate : current_clip) + if (!std::isfinite(coordinate)) + return std::nullopt; + for (float coordinate : previous_clip) + if (!std::isfinite(coordinate)) + return std::nullopt; + if (current_clip[3] <= 0.f || previous_clip[3] <= 0.f) + return std::nullopt; + std::array motion{}; + for (std::size_t axis = 0; axis < 2; ++axis) { + const float current_uv = current_clip[axis] / current_clip[3] * 0.5f + 0.5f; + const float previous_uv = previous_clip[axis] / previous_clip[3] * 0.5f + 0.5f; + motion[axis] = current_uv - previous_uv; + if (!std::isfinite(motion[axis])) + return std::nullopt; + } + return motion; +} + } // namespace faset::render diff --git a/tests/render_temporal_motion_tests.cpp b/tests/render_temporal_motion_tests.cpp new file mode 100644 index 0000000..6bfb20b --- /dev/null +++ b/tests/render_temporal_motion_tests.cpp @@ -0,0 +1,71 @@ +#include +#include +#include + +#include +#include +#include +#include + +using namespace faset::render; + +namespace { +void require(bool value, const char* message) { + if (!value) + throw std::runtime_error(message); +} + +void motion_uses_current_minus_previous_scene_uv() { + const auto motion = project_motion(std::array{0.2f, -0.2f, 0.6f, 1.f}, + std::array{0.f, 0.f, 0.4f, 1.f}); + require(motion && std::abs((*motion)[0] - 0.1f) < 1e-6f && + std::abs((*motion)[1] + 0.1f) < 1e-6f, + "Motion sign and units must be current minus previous normalized scene UV"); + const auto perspective = project_motion(std::array{0.6f, 0.f, 0.8f, 2.f}, + std::array{0.2f, 0.f, 0.5f, 1.f}); + require(perspective && std::abs((*perspective)[0] - 0.05f) < 1e-6f, + "Motion must divide each frame's clip coordinates by its own W"); + require(!project_motion({0, 0, 0, 1}, {0, 0, 0, 0}), + "A previous vertex on the eye plane cannot carry valid motion"); + require(!project_motion({0, 0, 0, 1}, {0, 0, 0, -1}), + "A previous vertex behind the camera cannot carry valid motion"); + require(!project_motion({INFINITY, 0, 0, 1}, {0, 0, 0, 1}), + "Nonfinite clip coordinates cannot enter temporal history"); +} + +void hzb_and_color_history_have_independent_bits() { + auto mesh = cube_mesh(); + auto replacement = std::make_shared(*mesh); + const Bounds bounds{{-1, -1, -1}, {1, 1, 1}}; + InstanceTracker tracker; + const auto model_a = transform({0, 0, 0}); + const auto model_b = transform({1, 0, 0}); + const auto first = tracker.update("cube", mesh, model_a, bounds, "main"); + require(gpu_instance_metadata(first, true, true)[0] == 0, + "A first-frame tracked instance has neither prior HZB nor color history"); + tracker.finish_frame(); + const auto moved = tracker.update("cube", mesh, model_b, bounds, "main"); + require(moved.previous_valid && moved.previous_model == model_a, + "A stable instance must retain its prior model in Direct or GPU mode"); + require(gpu_instance_metadata(moved, true, false)[0] == 1, + "Only prior HZB eligibility sets bit zero"); + require(gpu_instance_metadata(moved, false, true)[0] == 2, + "Temporal transform eligibility must not require HZB history"); + require(gpu_instance_metadata(moved, true, true)[0] == 3, + "Both independent history bits may be valid in GPU occlusion mode"); + require(gpu_instance_metadata(moved, false, false)[0] == 0, + "Neither history bit may leak after an incompatible frame"); + + tracker.finish_frame(); + const auto replaced = tracker.update("cube", replacement, model_b, bounds, "main"); + require(!replaced.previous_valid && gpu_instance_metadata(replaced, true, true)[0] == 0, + "A mesh identity change must reject both previous bounds and motion"); + require(gpu_instance_metadata({}, true, true)[0] == 0, + "An anonymous draw cannot inherit another draw's transform"); +} +} // namespace + +int main() { + motion_uses_current_minus_previous_scene_uv(); + hzb_and_color_history_have_independent_bits(); +}