Add temporal history and jitter policy
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")
|
||||
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)
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
#pragma once
|
||||
#include <array>
|
||||
#include <cstdint>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
|
||||
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<float, 4> scene_rect{};
|
||||
std::array<float, 16> projection{}, view_projection{};
|
||||
std::array<float, 3> 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<TemporalHistoryKey>& 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<float, 2> temporal_jitter(std::uint64_t frame_index,
|
||||
std::uint32_t viewport_width,
|
||||
std::uint32_t viewport_height);
|
||||
|
||||
} // namespace faset::render
|
||||
@@ -0,0 +1,132 @@
|
||||
#include <faset/render/temporal.hpp>
|
||||
#include <cmath>
|
||||
#include <stdexcept>
|
||||
|
||||
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<float>(base);
|
||||
while (index != 0) {
|
||||
sample += static_cast<float>(index % base) * place;
|
||||
index /= base;
|
||||
place /= static_cast<float>(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<float, 3> 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<float, 3> 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<float, 3>{} || b == std::array<float, 3>{})
|
||||
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<TemporalHistoryKey>& 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<float, 2> 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<std::uint32_t>(frame_index % 16) + 1;
|
||||
return {(halton(phase, 2) - 0.5f) * (2.f / static_cast<float>(viewport_width)),
|
||||
(halton(phase, 3) - 0.5f) * (2.f / static_cast<float>(viewport_height))};
|
||||
}
|
||||
|
||||
} // namespace faset::render
|
||||
@@ -0,0 +1,157 @@
|
||||
#include <faset/render/temporal.hpp>
|
||||
|
||||
#include <cmath>
|
||||
#include <limits>
|
||||
#include <optional>
|
||||
#include <stdexcept>
|
||||
|
||||
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<float>::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();
|
||||
}
|
||||
Reference in New Issue
Block a user