Add bounded CPU reference for temporal resolve
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
#pragma once
|
||||
#include <array>
|
||||
#include <cstdint>
|
||||
#include <vector>
|
||||
|
||||
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<float, 4> color{0, 0, 0, 1};
|
||||
float depth{1};
|
||||
std::array<float, 2> motion{};
|
||||
float previous_depth{1};
|
||||
bool motion_valid{};
|
||||
float reactive{};
|
||||
};
|
||||
|
||||
struct TemporalReferenceFrame {
|
||||
std::uint32_t width{}, height{};
|
||||
std::vector<TemporalReferencePixel> pixels;
|
||||
};
|
||||
|
||||
struct TemporalReferenceHistory {
|
||||
std::uint32_t width{}, height{};
|
||||
std::vector<std::array<float, 4>> color;
|
||||
std::vector<float> depth;
|
||||
};
|
||||
|
||||
struct TemporalReferenceResult {
|
||||
TemporalReferenceHistory history;
|
||||
std::vector<std::uint8_t> 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
|
||||
@@ -0,0 +1,141 @@
|
||||
#include <faset/render/temporal_reference.hpp>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <limits>
|
||||
#include <stdexcept>
|
||||
|
||||
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<std::size_t>(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<int>(std::floor(x));
|
||||
const auto y0 = static_cast<int>(std::floor(y));
|
||||
const float tx = x - static_cast<float>(x0);
|
||||
const float ty = y - static_cast<float>(y0);
|
||||
const auto sample = [&](int sx, int sy) {
|
||||
const auto px = std::clamp(sx, 0, static_cast<int>(history.width) - 1);
|
||||
const auto py = std::clamp(sy, 0, static_cast<int>(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<float>(x) + .5f) / current.width - selected->motion[0];
|
||||
const float v = (static_cast<float>(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<std::uint32_t>(u * previous->width),
|
||||
previous->width - 1);
|
||||
const auto py = std::min(static_cast<std::uint32_t>(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<float>::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
|
||||
@@ -0,0 +1,160 @@
|
||||
#include <faset/render/temporal_reference.hpp>
|
||||
|
||||
#include <array>
|
||||
#include <cmath>
|
||||
#include <cstdint>
|
||||
#include <stdexcept>
|
||||
#include <vector>
|
||||
|
||||
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();
|
||||
}
|
||||
Reference in New Issue
Block a user