From b67cdc1ce840aef916d5a0ea54835069ff3d0fd9 Mon Sep 17 00:00:00 2001 From: Emil <65846814+emil28092005@users.noreply.github.com> Date: Thu, 24 Sep 2026 01:35:06 +0300 Subject: [PATCH] Add temporal history and jitter policy --- cmake/Renderer.cmake | 5 +- include/faset/render/temporal.hpp | 76 ++++++++++++ src/render/temporal.cpp | 132 +++++++++++++++++++++ tests/render_temporal_policy_tests.cpp | 157 +++++++++++++++++++++++++ 4 files changed, 369 insertions(+), 1 deletion(-) create mode 100644 include/faset/render/temporal.hpp create mode 100644 src/render/temporal.cpp create mode 100644 tests/render_temporal_policy_tests.cpp diff --git a/cmake/Renderer.cmake b/cmake/Renderer.cmake index b1f2136..5867043 100644 --- a/cmake/Renderer.cmake +++ b/cmake/Renderer.cmake @@ -40,7 +40,7 @@ add_custom_command(OUTPUT "${FASET_SHADER_DIRECTORY}/compatibility.spv" -o "${FASET_SHADER_DIRECTORY}/compatibility.spv" DEPENDS "${PROJECT_SOURCE_DIR}/shaders/compatibility.hlsl" VERBATIM) add_custom_target(faset_shaders DEPENDS ${FASET_SHADER_OUTPUTS} "${FASET_SHADER_DIRECTORY}/compatibility.spv") -add_library(faset_render "${PROJECT_SOURCE_DIR}/src/render/renderer.cpp" "${PROJECT_SOURCE_DIR}/src/render/math.cpp" "${PROJECT_SOURCE_DIR}/src/render/render_graph.cpp" "${PROJECT_SOURCE_DIR}/src/render/shader_contract.cpp" "${PROJECT_SOURCE_DIR}/src/render/lighting.cpp") +add_library(faset_render "${PROJECT_SOURCE_DIR}/src/render/renderer.cpp" "${PROJECT_SOURCE_DIR}/src/render/math.cpp" "${PROJECT_SOURCE_DIR}/src/render/render_graph.cpp" "${PROJECT_SOURCE_DIR}/src/render/shader_contract.cpp" "${PROJECT_SOURCE_DIR}/src/render/lighting.cpp" "${PROJECT_SOURCE_DIR}/src/render/temporal.cpp") target_include_directories(faset_render PUBLIC "${PROJECT_SOURCE_DIR}/include") target_compile_features(faset_render PUBLIC cxx_std_20) target_link_libraries(faset_render PRIVATE Vulkan::Vulkan SDL3::SDL3 faset_core) @@ -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_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) add_executable(faset_render_tests "${PROJECT_SOURCE_DIR}/tests/render_tests.cpp") target_link_libraries(faset_render_tests PRIVATE faset_render SDL3::SDL3) add_test(NAME render_graph COMMAND faset_render_tests --unit) diff --git a/include/faset/render/temporal.hpp b/include/faset/render/temporal.hpp new file mode 100644 index 0000000..7fa371e --- /dev/null +++ b/include/faset/render/temporal.hpp @@ -0,0 +1,76 @@ +#pragma once +#include +#include +#include +#include + +namespace faset::render { + +enum class TemporalMode { Off, TAA, Upscale }; + +struct TemporalCapabilities { + bool compute{}; + bool formats{}; + bool extent{}; +}; + +enum class TemporalFallbackReason { + None, + ComputeUnavailable, + FormatUnavailable, + ExtentUnsupported +}; + +TemporalFallbackReason temporal_fallback_reason(TemporalMode requested, + TemporalCapabilities available) noexcept; +TemporalMode select_effective_temporal_mode(TemporalMode requested, + TemporalCapabilities available) noexcept; + +enum class TemporalResetReason { + None, + FirstFrame, + CameraCut, + CameraDiscontinuity, + ViewChanged, + ViewportChanged, + ProjectionChanged, + Resize, + ModeChanged, + ScaleChanged, + ShaderReload, + Unsupported +}; + +// Snapshot matrices/rect stay unjittered. This is independent of HZB history: +// Direct rendering and GPU frustum mode can still accumulate temporal color. +// A missing previous key means no completed frame is available to reuse. +struct TemporalHistoryKey { + std::string view_id; + std::uint32_t output_width{}, output_height{}; + std::uint32_t internal_width{}, internal_height{}; + std::array scene_rect{}; + std::array projection{}, view_projection{}; + std::array camera_eye{}; + TemporalMode mode{TemporalMode::Off}; + float render_scale{1}; + std::uint64_t shader_generation{}; + bool camera_cut{}; +}; + +struct TemporalHistoryDecision { + bool valid{}; + TemporalResetReason reason{TemporalResetReason::FirstFrame}; +}; + +TemporalHistoryDecision +evaluate_temporal_history(const std::optional& previous, + const TemporalHistoryKey& current) noexcept; + +// Sixteen-phase Halton(2,3) offset in clip-space units, for the scene raster +// projection only. UI, picking, culling, and history keys use unjittered space. +// A zero viewport extent throws std::invalid_argument. +std::array temporal_jitter(std::uint64_t frame_index, + std::uint32_t viewport_width, + std::uint32_t viewport_height); + +} // namespace faset::render diff --git a/src/render/temporal.cpp b/src/render/temporal.cpp new file mode 100644 index 0000000..c6dba9c --- /dev/null +++ b/src/render/temporal.cpp @@ -0,0 +1,132 @@ +#include +#include +#include + +namespace faset::render { + +TemporalFallbackReason temporal_fallback_reason(TemporalMode requested, + TemporalCapabilities available) noexcept { + if (requested == TemporalMode::Off) + return TemporalFallbackReason::None; + if (!available.compute) + return TemporalFallbackReason::ComputeUnavailable; + if (!available.formats) + return TemporalFallbackReason::FormatUnavailable; + if (!available.extent) + return TemporalFallbackReason::ExtentUnsupported; + return TemporalFallbackReason::None; +} + +TemporalMode select_effective_temporal_mode(TemporalMode requested, + TemporalCapabilities available) noexcept { + return temporal_fallback_reason(requested, available) == TemporalFallbackReason::None + ? requested : TemporalMode::Off; +} + +namespace { +float halton(std::uint32_t index, std::uint32_t base) noexcept { + float sample = 0.f; + float place = 1.f / static_cast(base); + while (index != 0) { + sample += static_cast(index % base) * place; + index /= base; + place /= static_cast(base); + } + return sample; +} + +bool finite_camera(const TemporalHistoryKey& key) noexcept { + for (float value : key.camera_eye) + if (!std::isfinite(value)) + return false; + for (float value : key.view_projection) + if (!std::isfinite(value)) + return false; + return true; +} + +std::array view_direction(const TemporalHistoryKey& key) noexcept { + // A perspective VP encodes view forward in its fourth row. An orthographic + // projection has a constant fourth row; its third row carries direction. + std::array direction{key.view_projection[3], key.view_projection[7], + key.view_projection[11]}; + float length_squared = direction[0] * direction[0] + direction[1] * direction[1] + + direction[2] * direction[2]; + if (length_squared < 1e-12f) { + direction = {key.view_projection[2], key.view_projection[6], key.view_projection[10]}; + length_squared = direction[0] * direction[0] + direction[1] * direction[1] + + direction[2] * direction[2]; + } + if (!std::isfinite(length_squared) || length_squared < 1e-12f) + return {}; + const float reciprocal = 1.f / std::sqrt(length_squared); + for (float& component : direction) + component *= reciprocal; + return direction; +} + +bool camera_discontinuity(const TemporalHistoryKey& previous, + const TemporalHistoryKey& current) noexcept { + if (!finite_camera(previous) || !finite_camera(current)) + return true; + float translation_squared{}; + for (int axis = 0; axis < 3; ++axis) { + const float delta = current.camera_eye[axis] - previous.camera_eye[axis]; + translation_squared += delta * delta; + } + // A conservative world-space threshold catches unmarked teleports. Ordinary + // camera motion and smaller view changes are handled by motion vectors. + if (!std::isfinite(translation_squared) || translation_squared > 25.f) + return true; + const auto a = view_direction(previous), b = view_direction(current); + if (a == std::array{} || b == std::array{}) + return true; + const float dot = a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; + return !std::isfinite(dot) || dot < 0.70710678f; // turn greater than 45 degrees +} +} // namespace + +TemporalHistoryDecision +evaluate_temporal_history(const std::optional& previous, + const TemporalHistoryKey& current) noexcept { + auto reset = [](TemporalResetReason reason) { return TemporalHistoryDecision{false, reason}; }; + if (current.camera_cut) + return reset(TemporalResetReason::CameraCut); + if (!previous) + return reset(TemporalResetReason::FirstFrame); + const auto& before = *previous; + if (before.view_id != current.view_id) + return reset(TemporalResetReason::ViewChanged); + if (before.output_width != current.output_width || + before.output_height != current.output_height) + return reset(TemporalResetReason::Resize); + if (before.internal_width != current.internal_width || + before.internal_height != current.internal_height || + before.render_scale != current.render_scale) + return reset(TemporalResetReason::ScaleChanged); + if (before.mode != current.mode) + return reset(TemporalResetReason::ModeChanged); + if (current.mode == TemporalMode::Off) + return reset(TemporalResetReason::Unsupported); + if (before.scene_rect != current.scene_rect) + return reset(TemporalResetReason::ViewportChanged); + if (before.projection != current.projection) + return reset(TemporalResetReason::ProjectionChanged); + if (before.shader_generation != current.shader_generation) + return reset(TemporalResetReason::ShaderReload); + if (camera_discontinuity(before, current)) + return reset(TemporalResetReason::CameraDiscontinuity); + return {true, TemporalResetReason::None}; +} + +std::array temporal_jitter(std::uint64_t frame_index, + std::uint32_t viewport_width, + std::uint32_t viewport_height) { + if (viewport_width == 0 || viewport_height == 0) + throw std::invalid_argument("temporal jitter requires a nonzero viewport extent"); + const auto phase = static_cast(frame_index % 16) + 1; + return {(halton(phase, 2) - 0.5f) * (2.f / static_cast(viewport_width)), + (halton(phase, 3) - 0.5f) * (2.f / static_cast(viewport_height))}; +} + +} // namespace faset::render diff --git a/tests/render_temporal_policy_tests.cpp b/tests/render_temporal_policy_tests.cpp new file mode 100644 index 0000000..4091a2a --- /dev/null +++ b/tests/render_temporal_policy_tests.cpp @@ -0,0 +1,157 @@ +#include + +#include +#include +#include +#include + +namespace { +using namespace faset::render; + +void require(bool condition, const char* message) { + if (!condition) + throw std::runtime_error(message); +} + +void capability_fallback() { + constexpr TemporalCapabilities full{true, true, true}; + require(select_effective_temporal_mode(TemporalMode::Off, {}) == TemporalMode::Off, + "Off must not require temporal GPU capabilities"); + require(temporal_fallback_reason(TemporalMode::Off, {}) == TemporalFallbackReason::None, + "Explicit Off is not a capability fallback"); + require(select_effective_temporal_mode(TemporalMode::TAA, full) == TemporalMode::TAA, + "Available TAA must remain active"); + require(select_effective_temporal_mode(TemporalMode::Upscale, full) == TemporalMode::Upscale, + "Available upscaling must remain active"); + require(select_effective_temporal_mode(TemporalMode::TAA, {false, true, true}) == + TemporalMode::Off && + temporal_fallback_reason(TemporalMode::TAA, {false, true, true}) == + TemporalFallbackReason::ComputeUnavailable, + "Missing compute must produce a named Off fallback"); + require(temporal_fallback_reason(TemporalMode::TAA, {true, false, true}) == + TemporalFallbackReason::FormatUnavailable, + "Missing sampled/storage formats must identify the format fallback"); + require(temporal_fallback_reason(TemporalMode::Upscale, {true, true, false}) == + TemporalFallbackReason::ExtentUnsupported, + "An unsupported target extent must identify the extent fallback"); +} + +TemporalHistoryKey steady_view() { + TemporalHistoryKey key; + key.view_id = "main-camera"; + key.output_width = key.internal_width = 320; + key.output_height = key.internal_height = 240; + key.scene_rect = {7, 11, 301, 219}; + key.projection = {1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1}; + key.view_projection = key.projection; + key.mode = TemporalMode::TAA; + key.render_scale = 1; + key.shader_generation = 4; + return key; +} + +void rendered_history_and_camera_motion() { + const auto previous = steady_view(); + auto current = previous; + require(evaluate_temporal_history(std::nullopt, current).reason == + TemporalResetReason::FirstFrame, + "A first temporal frame must not claim history"); + current.camera_eye = {0.5f, 0, 0}; + current.view_projection[12] = -0.5f; + const auto ordinary_motion = evaluate_temporal_history(previous, current); + require(ordinary_motion.valid && ordinary_motion.reason == TemporalResetReason::None, + "Ordinary camera motion must retain compatible history"); + current.camera_cut = true; + require(evaluate_temporal_history(previous, current).reason == TemporalResetReason::CameraCut, + "An explicit cut must override otherwise compatible history"); + current.camera_cut = false; + current.camera_eye = {6, 0, 0}; + require(evaluate_temporal_history(previous, current).reason == + TemporalResetReason::CameraDiscontinuity, + "A large unmarked teleport must invalidate history"); + current = previous; + current.view_projection[10] = -1; + require(evaluate_temporal_history(previous, current).reason == + TemporalResetReason::CameraDiscontinuity, + "A large camera turn must invalidate history"); + current = previous; + current.view_projection[0] = std::numeric_limits::quiet_NaN(); + require(evaluate_temporal_history(previous, current).reason == + TemporalResetReason::CameraDiscontinuity, + "Nonfinite camera matrices cannot admit history"); +} + +void incompatible_view_state() { + const auto previous = steady_view(); + auto current = previous; + current.view_id = "other-camera"; + require(evaluate_temporal_history(previous, current).reason == TemporalResetReason::ViewChanged, + "A second view cannot inherit another view's color history"); + current = previous; + ++current.output_width; + require(evaluate_temporal_history(previous, current).reason == TemporalResetReason::Resize, + "Output resize invalidates history"); + current = previous; + current.scene_rect[0] += 1; + require(evaluate_temporal_history(previous, current).reason == + TemporalResetReason::ViewportChanged, + "Moving the editor viewport invalidates history"); + current = previous; + current.projection[0] += 0.1f; + require(evaluate_temporal_history(previous, current).reason == + TemporalResetReason::ProjectionChanged, + "FOV or aspect change invalidates history"); + current = previous; + current.mode = TemporalMode::Upscale; + current.render_scale = 0.67f; + current.internal_width = 215; + current.internal_height = 161; + require(evaluate_temporal_history(previous, current).reason == + TemporalResetReason::ScaleChanged, + "New internal render scale invalidates full-resolution history"); + current = previous; + current.mode = TemporalMode::Off; + require(evaluate_temporal_history(previous, current).reason == + TemporalResetReason::ModeChanged, + "Turning temporal processing off invalidates history"); + current = previous; + ++current.shader_generation; + require(evaluate_temporal_history(previous, current).reason == + TemporalResetReason::ShaderReload, + "A changed shading generation invalidates history"); +} + +void deterministic_jitter() { + const auto first = temporal_jitter(0, 320, 240); + const auto second = temporal_jitter(1, 320, 240); + require(std::abs(first[0]) < 1e-7f && std::abs(first[1] + 1.f / 720.f) < 1e-7f, + "First Halton(2,3) sample must be a clip-space offset"); + require(std::abs(second[0] + 1.f / 640.f) < 1e-7f && + std::abs(second[1] - 1.f / 720.f) < 1e-7f, + "Second Halton sample must visit a different subpixel position"); + require(first == temporal_jitter(16, 320, 240), + "The finite sequence must repeat on frame sixteen"); + require(std::abs(temporal_jitter(0, 640, 480)[1] * 2.f - first[1]) < 1e-7f, + "Clip jitter must scale inversely with viewport extent"); + for (std::uint64_t frame = 0; frame < 16; ++frame) { + const auto sample = temporal_jitter(frame, 319, 241); + require(std::abs(sample[0]) <= 1.f / 319.f && + std::abs(sample[1]) <= 1.f / 241.f, + "Every jitter sample must stay within half an output pixel"); + } + bool rejected_zero_extent = false; + try { + (void)temporal_jitter(0, 0, 240); + } catch (const std::invalid_argument&) { + rejected_zero_extent = true; + } + require(rejected_zero_extent, "Zero viewport extent cannot produce finite clip jitter"); +} +} // namespace + +int main() { + capability_fallback(); + rendered_history_and_camera_motion(); + incompatible_view_state(); + deterministic_jitter(); +}