Expose and package temporal rendering with guarded edge history
Native and manual checks / native (ubuntu-24.04) (push) Failing after 37s
Native and manual checks / manual (push) Successful in 27s
Windows editor and software Vulkan / windows-graphics (push) Canceled after 0s
Native and manual checks / native (windows-2025) (push) Canceled after 0s
Native and manual checks / native (ubuntu-24.04) (push) Failing after 37s
Native and manual checks / manual (push) Successful in 27s
Windows editor and software Vulkan / windows-graphics (push) Canceled after 0s
Native and manual checks / native (windows-2025) (push) Canceled after 0s
This commit is contained in:
@@ -10,6 +10,7 @@
|
||||
#include <faset/scripting/project.hpp>
|
||||
#include <iostream>
|
||||
#include <thread>
|
||||
#include <utility>
|
||||
#ifndef _WIN32
|
||||
#include <csignal>
|
||||
#endif
|
||||
@@ -211,6 +212,14 @@ int integration(const fs::path& root) {
|
||||
"Export has a separate CMake directory");
|
||||
require(read_json(directory / "manifest.json").at("configuration") == "Release",
|
||||
"Export manifest records the actual profile");
|
||||
for (const auto* entry : {"temporalResolveMain", "temporalCompositeVertexMain",
|
||||
"temporalCompositeFragmentMain", "temporalVertexMain",
|
||||
"temporalFragmentMain", "gpuTemporalVertexMain"}) {
|
||||
for (const auto* suffix : {".spv", ".reflection.json"})
|
||||
require(fs::is_regular_file(directory / "shaders" /
|
||||
(std::string(entry) + suffix)),
|
||||
"Export contains compiled and reflected temporal shader");
|
||||
}
|
||||
Process player(
|
||||
{{result.result.at("executable").get<std::string>(), "--headless", "--frames", "3",
|
||||
"--capture", path_to_utf8(directory / "verification.ppm")},
|
||||
@@ -219,6 +228,22 @@ int integration(const fs::path& root) {
|
||||
std::cout << collect(player);
|
||||
require(fs::file_size(directory / "verification.ppm") > 1000,
|
||||
"Exported game rendered a frame");
|
||||
for (const auto& [mode, scale] :
|
||||
{std::pair{"taa", "1.0"}, std::pair{"upscale", "0.67"}}) {
|
||||
const auto profile = directory / (std::string("profile-") + mode + ".json");
|
||||
Process temporalPlayer(
|
||||
{{result.result.at("executable").get<std::string>(), "--headless", "--frames",
|
||||
"2", "--temporal", mode, "--render-scale", scale, "--profile",
|
||||
path_to_utf8(profile)},
|
||||
directory,
|
||||
{}});
|
||||
std::cout << collect(temporalPlayer);
|
||||
const auto report = read_json(profile);
|
||||
require(report.at("temporal_mode") == mode &&
|
||||
report.at("effective_temporal_mode") == mode &&
|
||||
report.at("samples").size() == 2,
|
||||
"Relocated Player selects and profiles temporal rendering");
|
||||
}
|
||||
atomic_write_json(root / ("result-" + std::to_string(dimension) + ".json"), result.result);
|
||||
}
|
||||
auto source = read_text(config.project_root / "Scripts" / "Gameplay.cpp");
|
||||
|
||||
@@ -44,6 +44,21 @@ int main(int argc, char** argv) {
|
||||
overlay.append(scene, renderer, 1.f / 60.f);
|
||||
require(renderer.visibility_mode() == render::VisibilityMode::GpuFrustum,
|
||||
"Clicking GPU frustum switches the live renderer");
|
||||
render::Event temporal_click = mode_click;
|
||||
temporal_click.x = 205;
|
||||
temporal_click.y = 154;
|
||||
temporal_click.type = render::Event::Type::MouseDown;
|
||||
require(overlay.process_events(std::span(&temporal_click, 1)).empty(),
|
||||
"Temporal mode button captures pointer down");
|
||||
scene.ui_triangles.clear();
|
||||
overlay.append(scene, renderer, 1.f / 60.f);
|
||||
temporal_click.type = render::Event::Type::MouseUp;
|
||||
require(overlay.process_events(std::span(&temporal_click, 1)).empty(),
|
||||
"Temporal mode button captures pointer up");
|
||||
scene.ui_triangles.clear();
|
||||
overlay.append(scene, renderer, 1.f / 60.f);
|
||||
require(renderer.temporal_mode() == render::TemporalMode::TAA,
|
||||
"Clicking TAA switches the live Editor renderer");
|
||||
scene.ui_triangles.clear();
|
||||
overlay.append(scene, renderer, 1.f / 60.f);
|
||||
renderer.render(scene);
|
||||
@@ -125,7 +140,7 @@ int main(int argc, char** argv) {
|
||||
render::Event preview_click;
|
||||
preview_click.button = 1;
|
||||
preview_click.x = 34;
|
||||
preview_click.y = 405;
|
||||
preview_click.y = 463;
|
||||
const auto click_preview = [&] {
|
||||
preview_click.type = render::Event::Type::MouseDown;
|
||||
require(hzb_overlay.process_events(std::span(&preview_click, 1)).empty(),
|
||||
|
||||
@@ -45,6 +45,44 @@ with tempfile.TemporaryDirectory(prefix="faset-player-diagnostics-") as temporar
|
||||
lighting["effective_sun_cascades"] == 0 and \
|
||||
lighting["local_shadow_faces"] == 0, lighting
|
||||
|
||||
for temporal_mode, scale in [("off", None), ("taa", None), ("upscale", "0.67")]:
|
||||
temporal_profile = root / f"temporal-{temporal_mode}.json"
|
||||
arguments = [sys.argv[1], "--scene", str(scene), "--headless", "--frames", "2",
|
||||
"--temporal", temporal_mode, "--profile", str(temporal_profile)]
|
||||
if scale is not None:
|
||||
arguments += ["--render-scale", scale]
|
||||
selected = subprocess.run(arguments, capture_output=True, text=True,
|
||||
encoding="utf-8", timeout=30)
|
||||
assert selected.returncode == 0, (temporal_mode, selected.stdout, selected.stderr)
|
||||
temporal_report = json.loads(temporal_profile.read_text(encoding="utf-8"))
|
||||
assert temporal_report["temporal_mode"] == temporal_mode, temporal_report
|
||||
assert abs(temporal_report["render_scale"] -
|
||||
(float(scale) if scale else 1.0)) < 0.000001
|
||||
assert temporal_report["effective_temporal_mode"] == temporal_mode, temporal_report
|
||||
for sample in temporal_report["samples"]:
|
||||
for key in ["requested_temporal_mode", "effective_temporal_mode",
|
||||
"temporal_fallback_reason", "temporal_reset_reason",
|
||||
"temporal_history_valid", "temporal_internal_width",
|
||||
"temporal_internal_height", "temporal_jitter",
|
||||
"gpu_temporal_resolve_ms", "gpu_temporal_composite_ms", "gpu_ui_ms"]:
|
||||
assert key in sample, (key, sample)
|
||||
assert sample["effective_temporal_mode"] == temporal_mode, sample
|
||||
if temporal_mode != "off":
|
||||
assert sample["temporal_internal_width"] > 0, sample
|
||||
assert sample["temporal_internal_height"] > 0, sample
|
||||
assert sample["gpu_temporal_resolve_ms"] is not None, sample
|
||||
if temporal_mode != "off":
|
||||
assert not temporal_report["samples"][0]["temporal_history_valid"]
|
||||
assert temporal_report["samples"][1]["temporal_history_valid"]
|
||||
|
||||
for invalid in [["--temporal", "missing"], ["--render-scale", "NaN"],
|
||||
["--temporal", "taa", "--render-scale", "0.67"],
|
||||
["--temporal", "upscale", "--render-scale", "1.0"]]:
|
||||
result = subprocess.run([sys.argv[1], *invalid], capture_output=True,
|
||||
text=True, encoding="utf-8", timeout=20)
|
||||
assert result.returncode != 0 and ("temporal" in result.stderr.lower() or
|
||||
"scale" in result.stderr.lower()), result
|
||||
|
||||
# The same linked v2 schema must validate without registering or invoking behavior.
|
||||
validated = subprocess.run([sys.argv[1], "--scene", str(scene), "--validate"],
|
||||
capture_output=True, text=True, encoding="utf-8", timeout=20)
|
||||
|
||||
@@ -48,7 +48,10 @@ int main() {
|
||||
fs::create_directories(bundle);
|
||||
for (const auto* entry : {"vertexMain", "fragmentMain", "shadowMain",
|
||||
"gpuVertexMain", "gpuShadowMain", "gpuCullMain",
|
||||
"gpuHzbMain", "gpuPostCullMain"})
|
||||
"gpuHzbMain", "gpuPostCullMain", "temporalResolveMain",
|
||||
"temporalCompositeVertexMain", "temporalCompositeFragmentMain",
|
||||
"temporalVertexMain", "temporalFragmentMain",
|
||||
"gpuTemporalVertexMain"})
|
||||
for (const auto* extension : {".spv", ".reflection.json"}) {
|
||||
const auto name = std::string(entry) + extension;
|
||||
fs::copy_file(path_from_utf8(FASET_TEST_SHADER_DIRECTORY) / name, bundle / name);
|
||||
@@ -88,7 +91,10 @@ int main() {
|
||||
render::Renderer renderer(configuration);
|
||||
const auto baseline_only = temporary / "baseline-only";
|
||||
fs::create_directories(baseline_only);
|
||||
for (const auto* entry : {"vertexMain", "fragmentMain", "shadowMain"})
|
||||
for (const auto* entry : {"vertexMain", "fragmentMain", "shadowMain",
|
||||
"temporalResolveMain", "temporalCompositeVertexMain",
|
||||
"temporalCompositeFragmentMain", "temporalVertexMain",
|
||||
"temporalFragmentMain", "gpuTemporalVertexMain"})
|
||||
for (const auto* extension : {".spv", ".reflection.json"}) {
|
||||
const auto name = std::string(entry) + extension;
|
||||
fs::copy_file(bundle / name, baseline_only / name);
|
||||
@@ -124,6 +130,13 @@ int main() {
|
||||
opaque_cube.instance_key = "shader-reload-cube";
|
||||
opaque_cube.cast_shadow = false;
|
||||
opaque_scene.draws.push_back(opaque_cube);
|
||||
auto temporal_configuration = configuration;
|
||||
temporal_configuration.temporal_mode = render::TemporalMode::TAA;
|
||||
render::Renderer temporal_renderer(temporal_configuration);
|
||||
temporal_renderer.render(opaque_scene);
|
||||
temporal_renderer.render(opaque_scene);
|
||||
require(temporal_renderer.stats().temporal_history_valid,
|
||||
"Temporal reload fixture has a completed color history");
|
||||
gpu_renderer.render(opaque_scene);
|
||||
const auto gpu_expected = gpu_renderer.pixels();
|
||||
render::Snapshot scene;
|
||||
@@ -141,7 +154,10 @@ int main() {
|
||||
fs::create_directories(native_io_path(deep_bundle));
|
||||
for (const auto* entry : {"vertexMain", "fragmentMain", "shadowMain",
|
||||
"gpuVertexMain", "gpuShadowMain", "gpuCullMain",
|
||||
"gpuHzbMain", "gpuPostCullMain"})
|
||||
"gpuHzbMain", "gpuPostCullMain", "temporalResolveMain",
|
||||
"temporalCompositeVertexMain", "temporalCompositeFragmentMain",
|
||||
"temporalVertexMain", "temporalFragmentMain",
|
||||
"gpuTemporalVertexMain"})
|
||||
for (const auto* extension : {".spv", ".reflection.json"}) {
|
||||
const auto name = std::string(entry) + extension;
|
||||
atomic_write(deep_bundle / name, read_text(bundle / name));
|
||||
@@ -179,6 +195,17 @@ int main() {
|
||||
require(renderer.stats().validation_errors == 0,
|
||||
"Rejected bytecode must not reach Vulkan validation");
|
||||
};
|
||||
const auto temporal_resolve = read_text(bundle / "temporalResolveMain.spv");
|
||||
fs::remove(native_io_path(bundle / "temporalResolveMain.spv"));
|
||||
std::string temporal_error;
|
||||
require(!temporal_renderer.reload_shaders(temporal_error) && !temporal_error.empty(),
|
||||
"A partial temporal package must reject reload atomically");
|
||||
temporal_renderer.render(opaque_scene);
|
||||
require(temporal_renderer.stats().effective_temporal_mode == render::TemporalMode::TAA &&
|
||||
temporal_renderer.stats().temporal_history_valid &&
|
||||
temporal_renderer.stats().validation_errors == 0,
|
||||
"Rejected temporal reload retains active mode, pixels and history");
|
||||
atomic_write(bundle / "temporalResolveMain.spv", temporal_resolve);
|
||||
atomic_write(bundle / "fragmentMain.spv", "damaged bytecode");
|
||||
retained();
|
||||
restore();
|
||||
@@ -236,6 +263,13 @@ int main() {
|
||||
require(renderer.reload_shaders(error), "Compatible shader edit reloads successfully");
|
||||
require(gpu_renderer.reload_shaders(error),
|
||||
"Compatible fragment edit reloads GPU scene pipeline");
|
||||
require(temporal_renderer.reload_shaders(error),
|
||||
"Complete compatible package reloads temporal pipelines");
|
||||
temporal_renderer.render(opaque_scene);
|
||||
require(!temporal_renderer.stats().temporal_history_valid &&
|
||||
temporal_renderer.stats().temporal_reset_reason ==
|
||||
render::TemporalResetReason::ShaderReload,
|
||||
"Successful temporal shader reload rejects stale color history");
|
||||
gpu_renderer.render(opaque_scene);
|
||||
const auto gpu_changed = gpu_renderer.pixels();
|
||||
require(gpu_changed != gpu_expected,
|
||||
|
||||
@@ -2,8 +2,11 @@
|
||||
#include <faset/render/temporal.hpp>
|
||||
|
||||
#include <array>
|
||||
#include <cmath>
|
||||
#include <cstdint>
|
||||
#include <iostream>
|
||||
#include <memory>
|
||||
#include <vector>
|
||||
|
||||
using namespace faset::render;
|
||||
using namespace faset::render::temporal_test;
|
||||
@@ -75,6 +78,7 @@ void lower_resolution_scene_and_output_ui() {
|
||||
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}});
|
||||
frame.scene_rect = {13, 17, 287, 199};
|
||||
upscale.render(frame);
|
||||
require(upscale.stats().effective_temporal_mode == TemporalMode::Upscale &&
|
||||
upscale.stats().temporal_internal_width == 215 &&
|
||||
@@ -83,6 +87,82 @@ void lower_resolution_scene_and_output_ui() {
|
||||
"0.67 upscale rasterizes 215x161 while capture remains 320x240");
|
||||
require(upscale.stats().validation_errors == 0,
|
||||
"Upscale image resize and composite pass Vulkan validation");
|
||||
const auto output_ui = pixel(upscale.pixels(), width, 4, 4);
|
||||
upscale.render(frame);
|
||||
require(upscale.stats().temporal_history_valid,
|
||||
"A compatible upscaled second frame has output-resolution history");
|
||||
upscale.set_temporal_mode(TemporalMode::Upscale, .5f);
|
||||
upscale.render(frame);
|
||||
require(upscale.stats().temporal_internal_width == 160 &&
|
||||
upscale.stats().temporal_internal_height == 120 &&
|
||||
!upscale.stats().temporal_history_valid &&
|
||||
upscale.stats().temporal_reset_reason == TemporalResetReason::ScaleChanged &&
|
||||
pixel(upscale.pixels(), width, 4, 4) == output_ui,
|
||||
"Changing upscale factor resets history without changing sharp output UI");
|
||||
upscale.set_temporal_mode(TemporalMode::TAA);
|
||||
upscale.render(frame);
|
||||
require(upscale.stats().temporal_reset_reason == TemporalResetReason::ScaleChanged &&
|
||||
upscale.stats().temporal_internal_width == width &&
|
||||
upscale.stats().temporal_internal_height == height,
|
||||
"Switching from upscale to 1:1 TAA resets the changed internal scale");
|
||||
upscale.set_temporal_mode(TemporalMode::Off);
|
||||
upscale.render(frame);
|
||||
require(upscale.stats().effective_temporal_mode == TemporalMode::Off &&
|
||||
pixel(upscale.pixels(), width, 4, 4) == output_ui,
|
||||
"Switching Off restores the full-resolution baseline and sharp UI");
|
||||
|
||||
upscale.resize(319, 241);
|
||||
frame = lit_scene(319, 241);
|
||||
frame.scene_rect = {13, 17, 285, 199};
|
||||
upscale.set_temporal_mode(TemporalMode::Upscale, .5f);
|
||||
upscale.render(frame);
|
||||
require(upscale.stats().temporal_internal_width == 160 &&
|
||||
upscale.stats().temporal_internal_height == 121 &&
|
||||
upscale.pixels().size() == std::size_t(319) * 241 * 4 &&
|
||||
upscale.stats().validation_errors == 0,
|
||||
"Odd output and offset scene rectangle preserve ceil-rounded internal extent");
|
||||
}
|
||||
|
||||
double frame_variation(const std::vector<std::vector<std::uint8_t>>& frames,
|
||||
std::uint32_t width, Region region) {
|
||||
require(frames.size() >= 2, "Temporal variation metric needs multiple frames");
|
||||
double sum{};
|
||||
for (std::size_t i = 1; i < frames.size(); ++i)
|
||||
sum += mean_rgb_error(frames[i], frames[i - 1], width,
|
||||
static_cast<std::uint32_t>(frames[i].size() / (width * 4)),
|
||||
region);
|
||||
return sum / double(frames.size() - 1);
|
||||
}
|
||||
|
||||
void static_edge_reduces_jitter_variation() {
|
||||
constexpr std::uint32_t width = 160, height = 120;
|
||||
auto config = headless_config(width, height, VisibilityMode::Direct);
|
||||
config.temporal_mode = TemporalMode::TAA;
|
||||
Renderer accumulated(config), spatial(config);
|
||||
auto frame = lit_scene(width, height);
|
||||
frame.clear_color = {0, 0, 0, 1};
|
||||
frame.draws.push_back(cube({0, 0, 0}, {1, 1, 1, 1}, "static-edge"));
|
||||
std::vector<std::vector<std::uint8_t>> resolved, unaccumulated;
|
||||
for (unsigned phase = 0; phase < 16; ++phase) {
|
||||
accumulated.render(frame);
|
||||
if (phase >= 4)
|
||||
resolved.push_back(accumulated.pixels());
|
||||
frame.camera_cut = true; // Same jitter phase, but no prior color may be read.
|
||||
spatial.render(frame);
|
||||
if (phase >= 4)
|
||||
unaccumulated.push_back(spatial.pixels());
|
||||
frame.camera_cut = false;
|
||||
}
|
||||
const Region edge_area{25, 12, 110, 95};
|
||||
const double raw = frame_variation(unaccumulated, width, edge_area);
|
||||
const double temporal = frame_variation(resolved, width, edge_area);
|
||||
std::cerr << "Static-edge mean frame variation: unaccumulated=" << raw
|
||||
<< " TAA=" << temporal << '\n';
|
||||
require(raw > .05 && temporal < raw * .98,
|
||||
"After warm-up TAA must reduce static edge shimmer across Halton phases");
|
||||
require(accumulated.stats().validation_errors == 0 &&
|
||||
spatial.stats().validation_errors == 0,
|
||||
"Static-edge temporal sequence must not raise Vulkan validation errors");
|
||||
}
|
||||
} // namespace
|
||||
|
||||
@@ -91,4 +171,5 @@ int main() {
|
||||
moving_reveal_and_camera_resets(VisibilityMode::GpuFrustum);
|
||||
moving_reveal_and_camera_resets(VisibilityMode::GpuOcclusion);
|
||||
lower_resolution_scene_and_output_ui();
|
||||
static_edge_reduces_jitter_variation();
|
||||
}
|
||||
|
||||
@@ -53,6 +53,12 @@ void offset_scene_and_sharp_ui() {
|
||||
"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");
|
||||
if (stats.gpu_ms > 0)
|
||||
require(stats.gpu_temporal_resolve_ms > 0 &&
|
||||
stats.gpu_temporal_composite_ms > 0,
|
||||
"Temporal resolve and composite expose independent GPU pass timings");
|
||||
require(stats.gpu_allocated_bytes > off.stats().gpu_allocated_bytes,
|
||||
"Temporal scene, velocity and output histories count toward live GPU memory");
|
||||
}
|
||||
} // namespace
|
||||
|
||||
|
||||
@@ -50,8 +50,8 @@ int main() {
|
||||
auto metadata = faset::read_json(reflection);
|
||||
require(metadata["layout"]["stage"] == "compute" &&
|
||||
metadata["layout"]["descriptors"].size() == 7 &&
|
||||
metadata["layout"]["push_constants"][0]["size"] == 64,
|
||||
"Temporal resolve ABI contains seven images and a 64-byte push block");
|
||||
metadata["layout"]["push_constants"][0]["size"] == 80,
|
||||
"Temporal resolve ABI contains seven images and an 80-byte push block");
|
||||
metadata["layout"]["descriptors"][5]["binding"] = 8;
|
||||
metadata["layout_fingerprint"] = faset::sha256(metadata["layout"].dump());
|
||||
faset::atomic_write_json(reflection, metadata);
|
||||
|
||||
Reference in New Issue
Block a user