From 3a4a2627501ec6dc78343110b89bd891651ad3ab Mon Sep 17 00:00:00 2001 From: Emil <65846814+emil28092005@users.noreply.github.com> Date: Thu, 24 Sep 2026 02:12:13 +0300 Subject: [PATCH] Add bounded CPU reference for temporal resolve --- cmake/Renderer.cmake | 5 +- include/faset/render/temporal_reference.hpp | 45 ++++++ src/render/temporal_reference.cpp | 141 +++++++++++++++++ tests/render_temporal_reference_tests.cpp | 160 ++++++++++++++++++++ 4 files changed, 350 insertions(+), 1 deletion(-) create mode 100644 include/faset/render/temporal_reference.hpp create mode 100644 src/render/temporal_reference.cpp create mode 100644 tests/render_temporal_reference_tests.cpp diff --git a/cmake/Renderer.cmake b/cmake/Renderer.cmake index ce9784f..e6745be 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" "${PROJECT_SOURCE_DIR}/src/render/temporal.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" "${PROJECT_SOURCE_DIR}/src/render/temporal_reference.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_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) add_executable(faset_render_temporal_lifecycle_tests "${PROJECT_SOURCE_DIR}/tests/render_temporal_lifecycle_tests.cpp") target_link_libraries(faset_render_temporal_lifecycle_tests PRIVATE faset_render) add_test(NAME render_temporal_lifecycle COMMAND faset_render_temporal_lifecycle_tests) diff --git a/include/faset/render/temporal_reference.hpp b/include/faset/render/temporal_reference.hpp new file mode 100644 index 0000000..eaf2c00 --- /dev/null +++ b/include/faset/render/temporal_reference.hpp @@ -0,0 +1,45 @@ +#pragma once +#include +#include +#include + +namespace faset::render { + +// CPU-only 1:1 oracle for the temporal resolve shader. Color is already +// display-referred; motion is current-minus-previous normalized scene UV. +// The renderer does not call this per-pixel path in production. +struct TemporalReferencePixel { + std::array color{0, 0, 0, 1}; + float depth{1}; + std::array motion{}; + float previous_depth{1}; + bool motion_valid{}; + float reactive{}; +}; + +struct TemporalReferenceFrame { + std::uint32_t width{}, height{}; + std::vector pixels; +}; + +struct TemporalReferenceHistory { + std::uint32_t width{}, height{}; + std::vector> color; + std::vector depth; +}; + +struct TemporalReferenceResult { + TemporalReferenceHistory history; + std::vector accepted; + std::uint32_t accepted_count{}; +}; + +// A null previous history is the first-frame/cut fallback. Extents must agree +// when history is supplied; reset it instead of reprojecting stale dimensions. +// Input is bounded to four million pixels so synthetic tests cannot consume +// unbounded memory. Invalid motion rejects history for that pixel. +TemporalReferenceResult temporal_reference_resolve( + const TemporalReferenceFrame& current, + const TemporalReferenceHistory* previous = nullptr); + +} // namespace faset::render diff --git a/src/render/temporal_reference.cpp b/src/render/temporal_reference.cpp new file mode 100644 index 0000000..bbc5987 --- /dev/null +++ b/src/render/temporal_reference.cpp @@ -0,0 +1,141 @@ +#include + +#include +#include +#include +#include + +namespace faset::render { +namespace { +constexpr std::uint64_t max_reference_pixels = 4'000'000; + +std::size_t checked_pixel_count(std::uint32_t width, std::uint32_t height) { + const auto count = std::uint64_t(width) * height; + if (!width || !height || count > max_reference_pixels) + throw std::invalid_argument("Temporal reference image extent is unsupported"); + return static_cast(count); +} + +bool valid_motion(const TemporalReferencePixel& pixel) noexcept { + return pixel.motion_valid && pixel.reactive < 1.f && + std::isfinite(pixel.motion[0]) && std::isfinite(pixel.motion[1]) && + std::isfinite(pixel.previous_depth) && pixel.previous_depth >= 0.f && + pixel.previous_depth <= 1.f; +} + +float bilinear_channel(const TemporalReferenceHistory& history, float u, float v, + std::size_t channel) noexcept { + const float x = u * history.width - .5f; + const float y = v * history.height - .5f; + const auto x0 = static_cast(std::floor(x)); + const auto y0 = static_cast(std::floor(y)); + const float tx = x - static_cast(x0); + const float ty = y - static_cast(y0); + const auto sample = [&](int sx, int sy) { + const auto px = std::clamp(sx, 0, static_cast(history.width) - 1); + const auto py = std::clamp(sy, 0, static_cast(history.height) - 1); + return history.color[std::size_t(py) * history.width + px][channel]; + }; + const float top = std::lerp(sample(x0, y0), sample(x0 + 1, y0), tx); + const float bottom = std::lerp(sample(x0, y0 + 1), sample(x0 + 1, y0 + 1), tx); + return std::lerp(top, bottom, ty); +} +} // namespace + +TemporalReferenceResult temporal_reference_resolve(const TemporalReferenceFrame& current, + const TemporalReferenceHistory* previous) { + const auto count = checked_pixel_count(current.width, current.height); + if (current.pixels.size() != count) + throw std::invalid_argument("Temporal reference current pixel count differs from extent"); + if (previous && + (previous->width != current.width || previous->height != current.height || + previous->color.size() != count || previous->depth.size() != count)) + throw std::invalid_argument("Temporal reference history extent or record count differs"); + for (const auto& pixel : current.pixels) { + if (!std::isfinite(pixel.depth) || pixel.depth < 0.f || pixel.depth > 1.f || + !std::isfinite(pixel.reactive) || pixel.reactive < 0.f || pixel.reactive > 1.f) + throw std::invalid_argument("Temporal reference current depth/reactivity is invalid"); + for (float channel : pixel.color) + if (!std::isfinite(channel)) + throw std::invalid_argument("Temporal reference current color is nonfinite"); + } + if (previous) + for (std::size_t i = 0; i < count; ++i) { + if (!std::isfinite(previous->depth[i]) || previous->depth[i] < 0.f || + previous->depth[i] > 1.f) + throw std::invalid_argument("Temporal reference history depth is invalid"); + for (float channel : previous->color[i]) + if (!std::isfinite(channel)) + throw std::invalid_argument("Temporal reference history color is nonfinite"); + } + + TemporalReferenceResult result; + result.history.width = current.width; + result.history.height = current.height; + result.history.color.resize(count); + result.history.depth.resize(count); + result.accepted.resize(count); + + for (std::uint32_t y = 0; y < current.height; ++y) + for (std::uint32_t x = 0; x < current.width; ++x) { + const auto i = std::size_t(y) * current.width + x; + const auto& center = current.pixels[i]; + auto& output = result.history.color[i]; + output = center.color; + result.history.depth[i] = center.depth; + if (!previous || !valid_motion(center)) + continue; + + // Dilate the nearest opaque depth's motion at a silhouette, but + // never resurrect a center pixel with no previous transform. + const TemporalReferencePixel* selected = ¢er; + const auto min_y = y ? y - 1 : y; + const auto min_x = x ? x - 1 : x; + const auto max_y = std::min(y + 1, current.height - 1); + const auto max_x = std::min(x + 1, current.width - 1); + for (auto sy = min_y; sy <= max_y; ++sy) + for (auto sx = min_x; sx <= max_x; ++sx) { + const auto& candidate = current.pixels[std::size_t(sy) * current.width + sx]; + if (valid_motion(candidate) && candidate.depth < selected->depth) + selected = &candidate; + } + const float u = (static_cast(x) + .5f) / current.width - selected->motion[0]; + const float v = (static_cast(y) + .5f) / current.height - selected->motion[1]; + if (!std::isfinite(u) || !std::isfinite(v) || u < 0.f || v < 0.f || + u >= 1.f || v >= 1.f) + continue; + const auto px = std::min(static_cast(u * previous->width), + previous->width - 1); + const auto py = std::min(static_cast(v * previous->height), + previous->height - 1); + const float sampled_depth = previous->depth[std::size_t(py) * previous->width + px]; + const float depth_tolerance = .002f + .01f * selected->previous_depth; + if (std::abs(sampled_depth - selected->previous_depth) > depth_tolerance) + continue; + + const float motion_pixels = std::hypot(selected->motion[0] * current.width, + selected->motion[1] * current.height); + const float weight = .9f * (1.f - center.reactive) / (1.f + .5f * motion_pixels); + if (!std::isfinite(weight) || weight <= 0.f) + continue; + for (std::size_t channel = 0; channel < 3; ++channel) { + float neighborhood_min = std::numeric_limits::infinity(); + float neighborhood_max = -neighborhood_min; + for (auto sy = min_y; sy <= max_y; ++sy) + for (auto sx = min_x; sx <= max_x; ++sx) { + const auto value = current.pixels[std::size_t(sy) * current.width + sx] + .color[channel]; + neighborhood_min = std::min(neighborhood_min, value); + neighborhood_max = std::max(neighborhood_max, value); + } + const float prior = std::clamp(bilinear_channel(*previous, u, v, channel), + neighborhood_min, neighborhood_max); + output[channel] = std::lerp(center.color[channel], prior, weight); + } + result.accepted[i] = 1; + ++result.accepted_count; + } + return result; +} + +} // namespace faset::render diff --git a/tests/render_temporal_reference_tests.cpp b/tests/render_temporal_reference_tests.cpp new file mode 100644 index 0000000..919a9f8 --- /dev/null +++ b/tests/render_temporal_reference_tests.cpp @@ -0,0 +1,160 @@ +#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); +} + +TemporalReferenceFrame solid(std::uint32_t width, std::uint32_t height, + float intensity = .5f, float depth = .5f) { + TemporalReferenceFrame frame; + frame.width = width; + frame.height = height; + frame.pixels.resize(std::size_t(width) * height); + for (auto& pixel : frame.pixels) { + pixel.color = {intensity, intensity, intensity, 1.f}; + pixel.depth = depth; + pixel.previous_depth = depth; + pixel.motion_valid = true; + } + return frame; +} + +TemporalReferenceHistory history_from(const TemporalReferenceFrame& frame) { + TemporalReferenceHistory history; + history.width = frame.width; + history.height = frame.height; + for (const auto& pixel : frame.pixels) { + history.color.push_back(pixel.color); + history.depth.push_back(pixel.depth); + } + return history; +} + +void first_frame_and_malformed_inputs() { + auto frame = solid(2, 2, .4f); + const auto first = temporal_reference_resolve(frame, nullptr); + require(first.accepted_count == 0 && first.accepted.size() == 4, + "A first frame cannot accept previous color"); + for (std::size_t i = 0; i < frame.pixels.size(); ++i) + require(first.history.color[i] == frame.pixels[i].color && + first.history.depth[i] == frame.pixels[i].depth && !first.accepted[i], + "First-frame fallback writes current color and depth exactly"); + auto malformed = frame; + malformed.pixels.pop_back(); + bool rejected = false; + try { + (void)temporal_reference_resolve(malformed, nullptr); + } catch (const std::invalid_argument&) { + rejected = true; + } + require(rejected, "Image extent and pixel count must agree"); + auto wrong_extent = first.history; + wrong_extent.width = 3; + rejected = false; + try { + (void)temporal_reference_resolve(frame, &wrong_extent); + } catch (const std::invalid_argument&) { + rejected = true; + } + require(rejected, "Incompatible history extent must be reset by the caller"); +} + +void reprojection_sign_and_depth_rejection() { + auto frame = solid(4, 1); + frame.pixels[0].color = {0, 0, 0, 1}; + frame.pixels[1].color = {0, 0, 0, 1}; + frame.pixels[3].color = {1, 1, 1, 1}; + frame.pixels[2].motion = {.25f, 0}; + auto prior = history_from(frame); + prior.color[1] = {.2f, .2f, .2f, 1}; + prior.color[3] = {.8f, .8f, .8f, 1}; + const auto moved = temporal_reference_resolve(frame, &prior); + require(moved.accepted[2] && moved.history.color[2][0] < .5f, + "Positive current-minus-prior motion must sample the pixel to the left"); + + prior.depth[1] = .2f; + const auto revealed = temporal_reference_resolve(frame, &prior); + require(!revealed.accepted[2] && revealed.history.color[2] == frame.pixels[2].color, + "A previous-depth disagreement rejects newly exposed background color"); + frame.pixels[2].motion = {2, 0}; + const auto outside = temporal_reference_resolve(frame, &prior); + require(!outside.accepted[2] && outside.history.color[2] == frame.pixels[2].color, + "Reprojection outside the old scene rejects history"); +} + +void neighborhood_clamp_and_reactive_pixels() { + auto frame = solid(3, 3); + for (std::size_t i = 0; i < frame.pixels.size(); ++i) { + const float tone = i % 2 ? .4f : .6f; + frame.pixels[i].color = {tone, tone, tone, 1}; + } + frame.pixels[4].color = {.5f, .5f, .5f, 1}; + auto prior = history_from(frame); + prior.color[4] = {10, 10, 10, 1}; + const auto clipped = temporal_reference_resolve(frame, &prior); + require(clipped.accepted[4] && clipped.history.color[4][0] > .5f && + clipped.history.color[4][0] <= .6f, + "Neighborhood clamp bounds a bright stale history sample"); + + frame.pixels[4].reactive = .5f; + const auto softened = temporal_reference_resolve(frame, &prior); + require(softened.accepted[4] && softened.history.color[4][0] > .5f && + softened.history.color[4][0] < clipped.history.color[4][0], + "Partial reactivity reduces the accepted history weight"); + + frame.pixels[4].reactive = 1.f; + const auto reactive = temporal_reference_resolve(frame, &prior); + require(!reactive.accepted[4] && reactive.history.color[4] == frame.pixels[4].color, + "Reactive transparency/sprite pixels must use current color"); + frame.pixels[4].reactive = 0; + frame.pixels[4].motion_valid = false; + const auto anonymous = temporal_reference_resolve(frame, &prior); + require(!anonymous.accepted[4] && anonymous.history.color[4] == frame.pixels[4].color, + "An anonymous or replaced object cannot borrow neighboring history"); + frame.pixels[4].motion_valid = true; + frame.pixels[4].motion[0] = INFINITY; + const auto invalid_motion = temporal_reference_resolve(frame, &prior); + require(!invalid_motion.accepted[4] && + invalid_motion.history.color[4] == frame.pixels[4].color, + "Nonfinite motion rejects history without contaminating output"); +} + +void nearest_depth_motion_dilation() { + auto frame = solid(3, 3, .5f, .9f); + frame.pixels[4].depth = .8f; + frame.pixels[4].previous_depth = .8f; + frame.pixels[3].depth = .2f; + frame.pixels[3].previous_depth = .2f; + frame.pixels[3].motion = {1.f / 3.f, 0}; + frame.pixels[1].color = {0, 0, 0, 1}; + frame.pixels[5].color = {1, 1, 1, 1}; + auto prior = history_from(frame); + prior.depth[4] = .1f; // Undilated center motion would fail this depth check. + prior.depth[3] = .2f; + prior.color[3] = {.1f, .1f, .1f, 1}; + const auto dilated = temporal_reference_resolve(frame, &prior); + require(dilated.accepted[4] && dilated.history.color[4][0] < .5f, + "Nearest-depth foreground motion should fill a valid silhouette pixel"); + frame.pixels[4].motion_valid = false; + const auto invalid_center = temporal_reference_resolve(frame, &prior); + require(!invalid_center.accepted[4], + "Dilation must not resurrect missing per-object previous transforms"); +} +} // namespace + +int main() { + first_frame_and_malformed_inputs(); + reprojection_sign_and_depth_rejection(); + neighborhood_clamp_and_reactive_pixels(); + nearest_depth_motion_dilation(); +}