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

This commit is contained in:
Emil
2026-09-24 03:59:22 +03:00
parent 0dcc8790a0
commit 7297d01436
19 changed files with 633 additions and 29 deletions
+25
View File
@@ -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");
+16 -1
View File
@@ -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(),
+38
View File
@@ -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)
+37 -3
View File
@@ -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();
}
+6
View File
@@ -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);