diff --git a/cmake/Renderer.cmake b/cmake/Renderer.cmake index 3b2a38b..a1a1002 100644 --- a/cmake/Renderer.cmake +++ b/cmake/Renderer.cmake @@ -72,6 +72,14 @@ 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_graph_tests "${PROJECT_SOURCE_DIR}/tests/render_temporal_graph_tests.cpp") + target_link_libraries(faset_render_temporal_graph_tests PRIVATE faset_render) + add_test(NAME render_temporal_graph COMMAND faset_render_temporal_graph_tests) + set_tests_properties(render_temporal_graph PROPERTIES LABELS "gpu") + add_executable(faset_render_temporal_acceptance_tests "${PROJECT_SOURCE_DIR}/tests/render_temporal_acceptance_tests.cpp") + target_link_libraries(faset_render_temporal_acceptance_tests PRIVATE faset_render) + add_test(NAME render_temporal_acceptance COMMAND faset_render_temporal_acceptance_tests) + set_tests_properties(render_temporal_acceptance PROPERTIES LABELS "gpu") add_executable(faset_render_temporal_shader_contract_tests "${PROJECT_SOURCE_DIR}/tests/render_temporal_shader_contract_tests.cpp") target_include_directories(faset_render_temporal_shader_contract_tests PRIVATE "${PROJECT_SOURCE_DIR}/src/render") target_link_libraries(faset_render_temporal_shader_contract_tests PRIVATE faset_render faset_core) diff --git a/tests/render_temporal_acceptance_tests.cpp b/tests/render_temporal_acceptance_tests.cpp new file mode 100644 index 0000000..bb78117 --- /dev/null +++ b/tests/render_temporal_acceptance_tests.cpp @@ -0,0 +1,94 @@ +#include "render_temporal_fixtures.hpp" +#include + +#include +#include +#include + +using namespace faset::render; +using namespace faset::render::temporal_test; + +namespace { +void moving_reveal_and_camera_resets(VisibilityMode visibility) { + constexpr std::uint32_t width = 160, height = 120; + auto config = headless_config(width, height, visibility); + config.temporal_mode = TemporalMode::TAA; + Renderer taa(config); + config.temporal_mode = TemporalMode::Off; + Renderer off(config); + + auto frame = lit_scene(width, height); + frame.draws.push_back(cube({0, 0, -2}, {.1f, .95f, .2f, 1}, "background")); + frame.draws.push_back(cube({0, 0, 1}, {.95f, .1f, .1f, 1}, "door")); + frame.ui_quads.push_back({2, 2, 25, 12, {.8f, .7f, .25f, 1}}); + taa.render(frame); + require(!taa.stats().temporal_history_valid && + taa.stats().temporal_reset_reason == TemporalResetReason::FirstFrame, + "The first TAA frame must use only current color"); + taa.render(frame); + require(taa.stats().temporal_history_valid, + "An unchanged second frame must accept eligible temporal history"); + + frame.draws[1].model = transform({3, 0, 1}); + taa.render(frame); + off.render(frame); + require(mean_rgb_error(taa.pixels(), off.pixels(), width, height, {75, 55, 10, 10}) <= 8.0, + "Opening a foreground door reveals current background without old-color trail"); + require(taa.stats().validation_errors == 0, + "Moving-disocclusion resolve must pass Vulkan validation"); + require(pixel(taa.pixels(), width, 4, 4) == pixel(off.pixels(), width, 4, 4), + "Moving scene and TAA must leave UI pixel-exact"); + + frame.camera_cut = true; + frame.eye = {1, 0, 6}; + frame.view_projection = multiply(frame.projection, look_at(frame.eye, {0, 0, 0})); + taa.render(frame); + off.render(frame); + require(!taa.stats().temporal_history_valid && + taa.stats().temporal_reset_reason == TemporalResetReason::CameraCut && + mean_rgb_error(taa.pixels(), off.pixels(), width, height, + {75, 55, 10, 10}) <= 8.0, + "Explicit camera cut must discard stale color immediately"); + frame.camera_cut = false; + frame.eye = {7, 0, 6}; + frame.view_projection = multiply(frame.projection, look_at(frame.eye, {0, 0, 0})); + taa.render(frame); + require(!taa.stats().temporal_history_valid && + taa.stats().temporal_reset_reason == + TemporalResetReason::CameraDiscontinuity, + "An unmarked large camera teleport also discards history"); + + frame.eye = {0, 0, 6}; + frame.view_projection = multiply(frame.projection, look_at(frame.eye, {0, 0, 0})); + frame.draws[0].mesh = std::make_shared(*frame.draws[0].mesh); + taa.render(frame); + require(taa.stats().validation_errors == 0, + "Mesh identity change and camera return must keep temporal output valid"); +} + +void lower_resolution_scene_and_output_ui() { + constexpr std::uint32_t width = 320, height = 240; + auto config = headless_config(width, height, VisibilityMode::Direct); + config.temporal_mode = TemporalMode::Upscale; + config.render_scale = .67f; + Renderer upscale(config); + auto frame = lit_scene(width, height); + frame.draws.push_back(cube({0, 0, 0}, {.8f, .6f, .25f, 1}, "thin-scene")); + frame.ui_quads.push_back({2, 2, 25, 12, {.8f, .7f, .25f, 1}}); + upscale.render(frame); + require(upscale.stats().effective_temporal_mode == TemporalMode::Upscale && + upscale.stats().temporal_internal_width == 215 && + upscale.stats().temporal_internal_height == 161 && + upscale.pixels().size() == std::size_t(width) * height * 4, + "0.67 upscale rasterizes 215x161 while capture remains 320x240"); + require(upscale.stats().validation_errors == 0, + "Upscale image resize and composite pass Vulkan validation"); +} +} // namespace + +int main() { + moving_reveal_and_camera_resets(VisibilityMode::Direct); + moving_reveal_and_camera_resets(VisibilityMode::GpuFrustum); + moving_reveal_and_camera_resets(VisibilityMode::GpuOcclusion); + lower_resolution_scene_and_output_ui(); +} diff --git a/tests/render_temporal_fixtures.hpp b/tests/render_temporal_fixtures.hpp new file mode 100644 index 0000000..0b36156 --- /dev/null +++ b/tests/render_temporal_fixtures.hpp @@ -0,0 +1,82 @@ +#pragma once +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace faset::render::temporal_test { +inline void require(bool condition, const char* message) { + if (!condition) + throw std::runtime_error(message); +} + +struct Region { + std::uint32_t x{}, y{}, width{}, height{}; +}; + +inline double mean_rgb_error(const std::vector& actual, + const std::vector& reference, + std::uint32_t image_width, std::uint32_t image_height, + Region region) { + require(actual.size() == reference.size() && + actual.size() == std::size_t(image_width) * image_height * 4 && + region.width && region.height && region.x <= image_width && + region.y <= image_height && region.width <= image_width - region.x && + region.height <= image_height - region.y, + "Temporal image metric requires equal images and an in-bounds ROI"); + std::uint64_t difference{}; + for (auto y = region.y; y < region.y + region.height; ++y) + for (auto x = region.x; x < region.x + region.width; ++x) { + const auto base = (std::size_t(y) * image_width + x) * 4; + for (std::size_t channel = 0; channel < 3; ++channel) + difference += static_cast( + std::abs(int(actual[base + channel]) - int(reference[base + channel]))); + } + return double(difference) / (double(region.width) * region.height * 3); +} + +inline std::array pixel(const std::vector& rgba, + std::uint32_t width, std::uint32_t x, + std::uint32_t y) { + const auto base = (std::size_t(y) * width + x) * 4; + require(base + 3 < rgba.size(), "Requested temporal test pixel is outside the image"); + return {rgba[base], rgba[base + 1], rgba[base + 2], rgba[base + 3]}; +} + +inline DrawItem cube(Vec3 position, Color color, std::string key) { + DrawItem draw; + draw.mesh = cube_mesh(); + draw.model = transform(position); + draw.color = color; + draw.instance_key = std::move(key); + draw.cast_shadow = false; + return draw; +} + +inline Snapshot lit_scene(std::uint32_t width, std::uint32_t height) { + Snapshot frame; + frame.view_id = "temporal-acceptance-main"; + frame.eye = {0, 0, 6}; + frame.projection = perspective(.9f, float(width) / float(height), .1f, 50.f); + frame.view_projection = multiply(frame.projection, look_at(frame.eye, {0, 0, 0})); + frame.authored_lights_present = true; + frame.sun = SunLight{"test-sun", {-.5f, -1.f, -.3f}, {1, 1, 1, 1}, 3.f, false}; + return frame; +} + +inline RendererConfig headless_config(std::uint32_t width, std::uint32_t height, + VisibilityMode visibility) { + RendererConfig config; + config.width = width; + config.height = height; + config.headless = true; + config.validation = true; + config.visibility_mode = visibility; + return config; +} +} // namespace faset::render::temporal_test diff --git a/tests/render_temporal_graph_tests.cpp b/tests/render_temporal_graph_tests.cpp new file mode 100644 index 0000000..61d0a31 --- /dev/null +++ b/tests/render_temporal_graph_tests.cpp @@ -0,0 +1,61 @@ +#include "render_temporal_fixtures.hpp" +#include + +#include +#include +#include + +using namespace faset::render; +using namespace faset::render::temporal_test; + +namespace { +std::size_t position(const std::vector& passes, const char* name) { + const auto found = std::find(passes.begin(), passes.end(), name); + require(found != passes.end(), "Required temporal graph pass is absent"); + return static_cast(found - passes.begin()); +} + +void offset_scene_and_sharp_ui() { + constexpr std::uint32_t width = 191, height = 127; + auto config = headless_config(width, height, VisibilityMode::GpuOcclusion); + config.temporal_mode = TemporalMode::TAA; + Renderer taa(config); + config.temporal_mode = TemporalMode::Off; + Renderer off(config); + + auto frame = lit_scene(141, 103); + frame.scene_rect = {13, 7, 141, 103}; + frame.draws.push_back(cube({0, 0, 0}, {.9f, .55f, .25f, 1}, "opaque")); + frame.draws.push_back(cube({.4f, 0, 1}, {.2f, .6f, .9f, .45f}, "translucent")); + Sprite sprite; + sprite.position = {-.7f, -.4f, .7f}; + sprite.size = {.8f, .8f}; + sprite.color = {.3f, .8f, .3f, .8f}; + frame.sprites.push_back(sprite); + frame.ui_quads.push_back({2, 2, 24, 14, {.95f, .8f, .2f, 1}}); + frame.ui_text.push_back({2, 22, "Temporal UI", {1, 1, 1, 1}, 12}); + + taa.render(frame); + off.render(frame); + const auto& stats = taa.stats(); + require(stats.effective_temporal_mode == TemporalMode::TAA && + stats.effective_visibility_mode == VisibilityMode::GpuOcclusion, + "Graph acceptance must exercise temporal resolve after P2 occlusion"); + const auto& passes = stats.graph_passes; + require(position(passes, "PostRasterScene") < position(passes, "TemporalResolve") && + position(passes, "TemporalResolve") < + position(passes, "TemporalComposite") && + position(passes, "TemporalComposite") < position(passes, "UI"), + "Post-cull scene color/velocity must resolve before final-resolution UI"); + require(pixel(taa.pixels(), width, 4, 4) == pixel(off.pixels(), width, 4, 4), + "Opaque UI remains pixel-exact and unjittered on an offset scene viewport"); + require(pixel(taa.pixels(), width, 180, 120) == pixel(off.pixels(), width, 180, 120), + "Pixels outside the offset scene rectangle retain the Off clear result"); + require(stats.validation_errors == 0, + "Temporal graph attachment store/load and transitions pass Vulkan validation"); +} +} // namespace + +int main() { + offset_scene_and_sharp_ui(); +}