Merge commit '0c96ce5cdfde8503e1ffc3fd65fdc9d7f3793d35' into feat/p1-p3-integration

# Conflicts:
#	PLAN.md
#	docs/IMPLEMENTATION.md
#	docs/manual/editor/diagnostics.md
#	docs/manual/editor/lighting.md
#	docs/manual/editor/profiling.md
#	docs/validation/README.md
#	docs/validation/p3-lighting-2026-09-24/README.md
This commit is contained in:
Emil
2026-09-24 04:08:01 +03:00
43 changed files with 3963 additions and 139 deletions
+1 -1
View File
@@ -209,7 +209,7 @@ int test_main(int argc, char** argv) {
read_text(config.project_root / "schema-export-count.txt") == "3",
"Corrupt shader cannot be a cache hit");
const auto directory = path_from_utf8(first.result.at("directory").get<std::string>());
for (const auto* entry : {"gpuVertexMain", "gpuShadowMain", "gpuCullMain",
for (const auto* entry : {"lightTileMain", "gpuVertexMain", "gpuShadowMain", "gpuCullMain",
"gpuHzbMain", "gpuPostCullMain"})
for (const auto* extension : {".spv", ".reflection.json"})
check(fs::is_regular_file(directory / "shaders" /
+1 -1
View File
@@ -90,7 +90,7 @@ int tool_main(int argc, char** argv) {
for (const auto* target : {"faset_player", "faset_schema_exporter"})
fs::copy_file(self, build / (std::string(target) + suffix),
fs::copy_options::overwrite_existing);
for (const auto* entry : {"vertexMain", "fragmentMain", "shadowMain",
for (const auto* entry : {"vertexMain", "fragmentMain", "shadowMain", "lightTileMain",
"gpuVertexMain", "gpuShadowMain", "gpuCullMain",
"gpuHzbMain", "gpuPostCullMain"})
for (const auto* extension : {".spv", ".reflection.json"})
+25
View File
@@ -14,6 +14,7 @@
#include <faset/scripting/project.hpp>
#include <iostream>
#include <thread>
#include <utility>
#ifndef _WIN32
#include <csignal>
#endif
@@ -311,6 +312,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")},
@@ -319,6 +328,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(),
+41 -1
View File
@@ -39,12 +39,52 @@ with tempfile.TemporaryDirectory(prefix="faset-player-diagnostics-") as temporar
"shadow_caster_budget_drops", "shadow_unavailable_drops",
"shadow_caster_draws", "sun_shadow_atlas_bytes",
"local_shadow_atlas_bytes", "gpu_main_raster_ms",
"gpu_sun_shadow_ms", "gpu_local_shadow_ms"]:
"gpu_sun_shadow_ms", "gpu_local_shadow_ms",
"gpu_light_tiles_ms", "light_tile_count", "light_tile_counts_valid",
"light_tile_candidate_count", "light_tile_overflow_count"]:
assert field in lighting, (field, lighting)
assert lighting["submitted_local_lights"] == 0 and \
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)
+101 -3
View File
@@ -21,13 +21,14 @@ Frame capture(Renderer& renderer, const Snapshot& scene) {
renderer.render(scene);
return {renderer.pixels(), renderer.stats()};
}
Renderer make_renderer(VisibilityMode mode) {
Renderer make_renderer(VisibilityMode mode, LightingMode lighting = LightingMode::Auto) {
RendererConfig config;
config.width = 320;
config.height = 240;
config.headless = true;
config.validation = true;
config.visibility_mode = mode;
config.lighting_mode = lighting;
config.visibility_diagnostics = true;
return Renderer(config);
}
@@ -286,17 +287,114 @@ void local() {
require(brightened > 20,
"Point light with dropped atlas faces still illuminates unshadowed");
}
void tiled() {
for (auto visibility : {VisibilityMode::Direct, VisibilityMode::GpuFrustum,
VisibilityMode::GpuOcclusion}) {
auto forward = make_renderer(visibility, LightingMode::Forward);
auto tiles = make_renderer(visibility, LightingMode::Tiled);
auto fixture = local_scene(LocalLight::Kind::Point, false);
auto no_lights = fixture;
no_lights.local_lights.clear();
const auto empty_tiled = capture(tiles, no_lights);
require(empty_tiled.stats.effective_lighting_path == "forward" &&
empty_tiled.stats.light_tile_count == 0,
"Forced tiles correctly fall back when no local lights are submitted");
fixture.scene_rect = {32, 24, 256, 192};
fixture.local_lights.front().casts_shadow = false;
auto spot = fixture.local_lights.front();
spot.kind = LocalLight::Kind::Spot;
spot.stable_id = "second-spot";
spot.position = {1.5f, 2, 0};
spot.direction = {0, -1, 0};
spot.intensity = 7;
spot.range = 4;
fixture.local_lights.push_back(spot);
auto outside = spot;
outside.stable_id = "offscreen-light";
outside.position = {100, 100, 100};
outside.range = 2;
fixture.local_lights.push_back(outside);
const auto expected = capture(forward, fixture);
const auto actual = capture(tiles, fixture);
require(expected.stats.effective_lighting_path == "forward" &&
actual.stats.effective_lighting_path == "tiled" &&
actual.stats.gpu_light_tiles_ms > 0 &&
actual.stats.light_tile_count > 0,
"Forced 16x16 tile construction reports its actual GPU work");
require(actual.stats.validation_errors == 0,
"Forward+ tile build and fragment reads pass Vulkan validation");
require(actual.stats.light_tile_overflow_count == 0 &&
actual.stats.light_tile_candidate_count <
actual.stats.light_tile_count * 3,
"Depth-free tile lists exclude an offscreen light without overflow");
compare_frames(expected, actual);
auto near_plane = local_scene(LocalLight::Kind::Point, true);
near_plane.local_lights.front().position = {0, 5, 7.95f};
near_plane.local_lights.front().range = 15;
const auto near_forward = capture(forward, near_plane);
const auto near_tiled = capture(tiles, near_plane);
require(near_tiled.stats.effective_lighting_path == "tiled" &&
near_tiled.stats.light_tile_counts_valid,
"Near-plane crossing light and its shadow use actual tile lists");
compare_frames(near_forward, near_tiled);
forward.resize(336, 256);
tiles.resize(336, 256);
fixture.scene_rect = {40, 32, 248, 176};
const auto resized_forward = capture(forward, fixture);
const auto resized_tiled = capture(tiles, fixture);
require(resized_tiled.stats.light_tile_count == 21 * 16,
"Forward+ rebuilds its grid after a drawable resize");
compare_frames(resized_forward, resized_tiled);
// Eighty coincident lights cover the same central tiles. A 64-index tile
// must evaluate the entire submitted list instead of losing late lights.
fixture.local_lights.clear();
for (int i = 0; i < 80; ++i) {
auto light = point_face_scene({0, 0, 1}, false).local_lights.front();
light.stable_id = "overflow-" + std::to_string(i);
light.position = {0, 3, 0};
light.intensity = .45f;
light.range = 8;
light.casts_shadow = false;
fixture.local_lights.push_back(light);
}
const auto all_forward = capture(forward, fixture);
const auto all_tiled = capture(tiles, fixture);
auto automatic = make_renderer(visibility, LightingMode::Auto);
const auto dense_auto = capture(automatic, fixture);
require(dense_auto.stats.effective_lighting_path == "forward" &&
dense_auto.stats.light_tile_count == 0,
"Auto avoids tile construction for unmeasured dense overlap");
require(all_tiled.stats.submitted_local_lights == 80 &&
all_tiled.stats.effective_lighting_path == "tiled" &&
all_tiled.stats.light_tile_overflow_count > 0,
"Overflow fixture submits all eighty lights through Forward+");
compare_frames(all_forward, all_tiled);
fixture.local_lights.resize(64);
const auto first_sixty_four = capture(forward, fixture);
std::size_t extra_light_pixels{};
for (std::size_t i = 0; i < all_forward.pixels.size(); i += 4)
extra_light_pixels += int(all_forward.pixels[i]) >
int(first_sixty_four.pixels[i]) + 2;
require(extra_light_pixels > 20,
"Overflow fixture visibly depends on lights past index 63");
}
}
} // namespace
int main(int argc, char** argv) {
try {
if (argc != 2)
throw std::invalid_argument("Expected --sun or --local");
throw std::invalid_argument("Expected --sun, --local, or --tiled");
if (std::string(argv[1]) == "--sun")
sun();
else if (std::string(argv[1]) == "--local")
local();
else if (std::string(argv[1]) == "--tiled")
tiled();
else
throw std::invalid_argument("Expected --sun or --local");
throw std::invalid_argument("Expected --sun, --local, or --tiled");
std::cout << "Shadow atlas and Direct/GPU lighting parity passed\n";
} catch (const std::exception& error) {
std::cerr << error.what() << '\n';
+73 -5
View File
@@ -46,9 +46,12 @@ int main() {
try {
const auto bundle = temporary / "shaders";
fs::create_directories(bundle);
for (const auto* entry : {"vertexMain", "fragmentMain", "shadowMain",
for (const auto* entry : {"vertexMain", "fragmentMain", "shadowMain", "lightTileMain",
"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", "lightTileMain",
"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,8 +130,49 @@ 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();
auto tiled_configuration = configuration;
tiled_configuration.lighting_mode = render::LightingMode::Tiled;
render::Renderer tiled_renderer(tiled_configuration);
auto lit_scene = opaque_scene;
render::LocalLight point;
point.stable_id = "reload-point";
point.position = {1, 1, 3};
point.intensity = 5;
point.range = 8;
point.casts_shadow = false;
lit_scene.local_lights.push_back(point);
tiled_renderer.render(lit_scene);
require(tiled_renderer.stats().effective_lighting_path == "tiled" &&
tiled_renderer.stats().validation_errors == 0,
"Tiled lighting is active before shader reload");
const auto tiled_expected = tiled_renderer.pixels();
const auto original_tile_spirv = read_text(bundle / "lightTileMain.spv");
atomic_write(bundle / "lightTileMain.spv", "damaged tile bytecode");
std::string tile_error;
require(!tiled_renderer.reload_shaders(tile_error) && !tile_error.empty(),
"Rejected light tile shader preserves the working pipeline");
tiled_renderer.render(lit_scene);
require(tiled_renderer.stats().effective_lighting_path == "tiled" &&
tiled_renderer.pixels() == tiled_expected &&
tiled_renderer.stats().validation_errors == 0,
"Rejected light tile shader retains tiled lighting and pixels");
atomic_write(bundle / "lightTileMain.spv", original_tile_spirv);
require(tiled_renderer.reload_shaders(tile_error),
"Compatible light tile shader reloads successfully");
tiled_renderer.render(lit_scene);
require(tiled_renderer.stats().effective_lighting_path == "tiled" &&
tiled_renderer.pixels() == tiled_expected &&
tiled_renderer.stats().validation_errors == 0,
"Compatible light tile reload preserves tiled pixels");
render::Snapshot scene;
scene.ui_quads.push_back({0, 0, 32, 64, {1, .8f, .4f, 1}});
scene.sprites.push_back({{.5f, 0, .5f}, {1, 2}, {.2f, 1, .4f, 1}});
@@ -139,9 +186,12 @@ int main() {
require(deep_bundle.native().size() > 300,
"Shader file fixture must exceed the legacy Windows path limit");
fs::create_directories(native_io_path(deep_bundle));
for (const auto* entry : {"vertexMain", "fragmentMain", "shadowMain",
for (const auto* entry : {"vertexMain", "fragmentMain", "shadowMain", "lightTileMain",
"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 +229,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 +297,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,
+175
View File
@@ -0,0 +1,175 @@
#include "render_temporal_fixtures.hpp"
#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;
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<Mesh>(*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}});
frame.scene_rect = {13, 17, 287, 199};
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");
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
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();
static_edge_reduces_jitter_variation();
}
+82
View File
@@ -0,0 +1,82 @@
#pragma once
#include <faset/render/renderer.hpp>
#include <cmath>
#include <cstddef>
#include <cstdint>
#include <memory>
#include <stdexcept>
#include <string>
#include <vector>
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<std::uint8_t>& actual,
const std::vector<std::uint8_t>& 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::uint64_t>(
std::abs(int(actual[base + channel]) - int(reference[base + channel])));
}
return double(difference) / (double(region.width) * region.height * 3);
}
inline std::array<std::uint8_t, 4> pixel(const std::vector<std::uint8_t>& 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
+67
View File
@@ -0,0 +1,67 @@
#include "render_temporal_fixtures.hpp"
#include <faset/render/temporal.hpp>
#include <algorithm>
#include <string>
#include <vector>
using namespace faset::render;
using namespace faset::render::temporal_test;
namespace {
std::size_t position(const std::vector<std::string>& 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<std::size_t>(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");
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
int main() {
offset_scene_and_sharp_ui();
}
+101
View File
@@ -0,0 +1,101 @@
#include <faset/render/renderer.hpp>
#include <faset/render/temporal.hpp>
#include <memory>
#include <stdexcept>
#include <string>
using namespace faset::render;
namespace {
void require(bool value, const char* message) {
if (!value)
throw std::runtime_error(message);
}
Snapshot scene(std::shared_ptr<const Mesh> mesh) {
Snapshot frame;
frame.view_id = "temporal-lifecycle-main";
frame.eye = {0, 0, 6};
frame.projection = perspective(.9f, 1.f, .1f, 50.f);
frame.view_projection = multiply(frame.projection, look_at(frame.eye, {0, 0, 0}));
DrawItem cube;
cube.mesh = std::move(mesh);
cube.instance_key = "rigid-cube";
cube.cast_shadow = false;
frame.draws.push_back(std::move(cube));
return frame;
}
void previous_transform_does_not_depend_on_hzb(VisibilityMode visibility) {
RendererConfig config;
config.width = config.height = 128;
config.headless = true;
config.validation = true;
config.visibility_mode = visibility;
config.temporal_mode = TemporalMode::TAA;
config.render_scale = 1.f;
Renderer renderer(config);
auto frame = scene(cube_mesh());
renderer.render(frame);
require(!renderer.stats().temporal_history_valid &&
renderer.stats().temporal_valid_motion_instances == 0,
"First frame must reject temporal color and previous transforms");
frame.draws[0].model = transform({0.25f, 0, 0});
renderer.render(frame);
require(!renderer.stats().hzb_valid,
"Direct and GPU frustum paths have no previous HZB in this fixture");
require(renderer.stats().temporal_history_valid &&
renderer.stats().temporal_valid_motion_instances == 1,
"A stable opaque draw retains its previous model without HZB history");
frame.draws[0].mesh = std::make_shared<Mesh>(*frame.draws[0].mesh);
renderer.render(frame);
require(renderer.stats().temporal_valid_motion_instances == 0,
"Changing mesh identity invalidates prior geometry motion");
require(renderer.stats().validation_errors == 0,
"Temporal lifecycle must not trigger Vulkan validation errors");
}
void aborted_frame_cannot_advance_history() {
RendererConfig config;
config.width = config.height = 128;
config.headless = true;
config.visibility_mode = VisibilityMode::Direct;
config.temporal_mode = TemporalMode::TAA;
Renderer renderer(config);
auto frame = scene(cube_mesh());
renderer.render(frame);
renderer.render(frame);
require(renderer.stats().temporal_history_valid,
"Fixture must have committed history before the aborted frame");
const auto completed_frame = renderer.stats().frame;
auto invalid = frame;
invalid.draws[0].model = transform({1, 0, 0});
invalid.local_lights.push_back(LocalLight{});
invalid.local_lights.back().range = -1.f;
bool rejected = false;
try {
renderer.render(invalid);
} catch (const std::invalid_argument&) {
rejected = true;
}
require(rejected, "Late light validation must abort before command submission");
require(renderer.stats().frame == completed_frame,
"An aborted frame must not count as completed");
frame.draws[0].model = transform({0.25f, 0, 0});
renderer.render(frame);
require(renderer.stats().temporal_history_valid &&
renderer.stats().temporal_valid_motion_instances == 1,
"The next successful frame must reuse the last committed transform/history");
}
} // namespace
int main() {
previous_transform_does_not_depend_on_hzb(VisibilityMode::Direct);
previous_transform_does_not_depend_on_hzb(VisibilityMode::GpuFrustum);
aborted_frame_cannot_advance_history();
}
+71
View File
@@ -0,0 +1,71 @@
#include <faset/render/renderer.hpp>
#include <faset/render/temporal.hpp>
#include <faset/render/visibility.hpp>
#include <array>
#include <cmath>
#include <memory>
#include <stdexcept>
using namespace faset::render;
namespace {
void require(bool value, const char* message) {
if (!value)
throw std::runtime_error(message);
}
void motion_uses_current_minus_previous_scene_uv() {
const auto motion = project_motion(std::array<float, 4>{0.2f, -0.2f, 0.6f, 1.f},
std::array<float, 4>{0.f, 0.f, 0.4f, 1.f});
require(motion && std::abs((*motion)[0] - 0.1f) < 1e-6f &&
std::abs((*motion)[1] + 0.1f) < 1e-6f,
"Motion sign and units must be current minus previous normalized scene UV");
const auto perspective = project_motion(std::array<float, 4>{0.6f, 0.f, 0.8f, 2.f},
std::array<float, 4>{0.2f, 0.f, 0.5f, 1.f});
require(perspective && std::abs((*perspective)[0] - 0.05f) < 1e-6f,
"Motion must divide each frame's clip coordinates by its own W");
require(!project_motion({0, 0, 0, 1}, {0, 0, 0, 0}),
"A previous vertex on the eye plane cannot carry valid motion");
require(!project_motion({0, 0, 0, 1}, {0, 0, 0, -1}),
"A previous vertex behind the camera cannot carry valid motion");
require(!project_motion({INFINITY, 0, 0, 1}, {0, 0, 0, 1}),
"Nonfinite clip coordinates cannot enter temporal history");
}
void hzb_and_color_history_have_independent_bits() {
auto mesh = cube_mesh();
auto replacement = std::make_shared<Mesh>(*mesh);
const Bounds bounds{{-1, -1, -1}, {1, 1, 1}};
InstanceTracker tracker;
const auto model_a = transform({0, 0, 0});
const auto model_b = transform({1, 0, 0});
const auto first = tracker.update("cube", mesh, model_a, bounds, "main");
require(gpu_instance_metadata(first, true, true)[0] == 0,
"A first-frame tracked instance has neither prior HZB nor color history");
tracker.finish_frame();
const auto moved = tracker.update("cube", mesh, model_b, bounds, "main");
require(moved.previous_valid && moved.previous_model == model_a,
"A stable instance must retain its prior model in Direct or GPU mode");
require(gpu_instance_metadata(moved, true, false)[0] == 1,
"Only prior HZB eligibility sets bit zero");
require(gpu_instance_metadata(moved, false, true)[0] == 2,
"Temporal transform eligibility must not require HZB history");
require(gpu_instance_metadata(moved, true, true)[0] == 3,
"Both independent history bits may be valid in GPU occlusion mode");
require(gpu_instance_metadata(moved, false, false)[0] == 0,
"Neither history bit may leak after an incompatible frame");
tracker.finish_frame();
const auto replaced = tracker.update("cube", replacement, model_b, bounds, "main");
require(!replaced.previous_valid && gpu_instance_metadata(replaced, true, true)[0] == 0,
"A mesh identity change must reject both previous bounds and motion");
require(gpu_instance_metadata({}, true, true)[0] == 0,
"An anonymous draw cannot inherit another draw's transform");
}
} // namespace
int main() {
motion_uses_current_minus_previous_scene_uv();
hzb_and_color_history_have_independent_bits();
}
+250
View File
@@ -0,0 +1,250 @@
#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] = -1;
current.view_projection[5] = -1;
require(evaluate_temporal_history(previous, current).reason ==
TemporalResetReason::CameraDiscontinuity,
"A 180-degree roll must invalidate history even when forward is unchanged");
current = previous;
current.view_projection[0] = 0.98480775f;
current.view_projection[1] = 0.17364818f;
current.view_projection[4] = -0.17364818f;
current.view_projection[5] = 0.98480775f;
require(evaluate_temporal_history(previous, current).valid,
"An ordinary small camera roll must retain compatible 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 intentionally_disabled_temporal_mode() {
auto off = steady_view();
off.mode = TemporalMode::Off;
const auto first = evaluate_temporal_history(std::nullopt, off);
require(!first.valid && first.reason == TemporalResetReason::None,
"Explicit Off has no temporal history to reset or unsupported fallback to report");
const auto later = evaluate_temporal_history(off, off);
require(!later.valid && later.reason == TemporalResetReason::None,
"Continuing in Off must remain a deliberate non-temporal mode");
}
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");
}
void render_scale_policy() {
const auto full = temporal_internal_extent(320, 240, TemporalMode::Off, 1.f);
require(full == std::array<std::uint32_t, 2>{320, 240},
"Off keeps scene and output at the same extent");
require(temporal_internal_extent(319, 241, TemporalMode::TAA, 1.f) ==
std::array<std::uint32_t, 2>{319, 241},
"TAA is a one-to-one reconstruction mode");
require(temporal_internal_extent(320, 240, TemporalMode::Upscale, .67f) ==
std::array<std::uint32_t, 2>{215, 161},
"Upscale uses deterministic ceil dimensions for odd pixel products");
require(temporal_internal_extent(1, 1, TemporalMode::Upscale, .5f) ==
std::array<std::uint32_t, 2>{1, 1},
"A supported output always has at least one internal pixel");
auto rejected = [](TemporalMode mode, float scale) {
try {
(void)temporal_internal_extent(320, 240, mode, scale);
return false;
} catch (const std::invalid_argument&) {
return true;
}
};
require(rejected(TemporalMode::TAA, .75f) && rejected(TemporalMode::Upscale, 1.f) &&
rejected(TemporalMode::Upscale, .49f) &&
rejected(TemporalMode::Upscale, std::numeric_limits<float>::quiet_NaN()) &&
rejected(TemporalMode::Off, .67f) &&
rejected(static_cast<TemporalMode>(42), 1.f),
"Invalid mode/scale combinations must fail before target allocation");
bool zero_rejected = false;
try {
(void)temporal_internal_extent(0, 240, TemporalMode::TAA, 1.f);
} catch (const std::invalid_argument&) {
zero_rejected = true;
}
require(zero_rejected, "Zero output width is not a valid temporal target");
}
void completed_frames_only_become_history() {
TemporalHistoryState history;
auto frame = steady_view();
require(history.prepare(frame).reason == TemporalResetReason::FirstFrame,
"A prepared first frame has no committed history");
history.complete(frame);
require(history.prepare(frame).valid,
"A successfully completed frame becomes reusable history");
auto failed_frame = frame;
failed_frame.view_id = "failed-submit-view";
require(history.prepare(failed_frame).reason == TemporalResetReason::ViewChanged,
"A candidate view switch is detected before submission");
try {
throw std::runtime_error("synthetic queue submit failure");
} catch (const std::runtime_error&) {
// The caller never invokes complete() on a failed submission.
}
require(history.prepare(frame).valid,
"A failed submission must not replace the last completed history key");
require(history.prepare(failed_frame).reason == TemporalResetReason::ViewChanged,
"An uncommitted frame must not become the next frame's predecessor");
frame.mode = TemporalMode::Off;
history.complete(frame);
frame.mode = TemporalMode::TAA;
require(history.prepare(frame).reason == TemporalResetReason::FirstFrame,
"Completing an Off frame discards temporal history");
}
} // namespace
int main() {
capability_fallback();
intentionally_disabled_temporal_mode();
rendered_history_and_camera_motion();
incompatible_view_state();
deterministic_jitter();
render_scale_policy();
completed_frames_only_become_history();
}
+166
View File
@@ -0,0 +1,166 @@
#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 exposed_background = temporal_reference_resolve(frame, &prior);
require(!exposed_background.accepted[4] &&
exposed_background.history.color[4] == frame.pixels[4].color,
"A newly exposed deep background pixel must not borrow foreground motion");
frame.pixels[4].depth = .203f;
frame.pixels[4].previous_depth = .203f;
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();
}
@@ -0,0 +1,72 @@
#include "shader_contract.hpp"
#include <faset/core/hash.hpp>
#include <faset/core/io.hpp>
#include <filesystem>
#include <stdexcept>
#include <string>
namespace fs = std::filesystem;
namespace {
void require(bool value, const char* message) {
if (!value)
throw std::runtime_error(message);
}
template <class Function> void must_reject(Function&& function, const char* message) {
try {
function();
} catch (const std::exception&) {
return;
}
throw std::runtime_error(message);
}
} // namespace
int main() {
const auto original = fs::path(FASET_TEST_SHADER_DIRECTORY);
const auto temporary = fs::temp_directory_path() /
faset::path_from_utf8("Faset temporal shaders " + faset::new_id());
struct Cleanup {
fs::path path;
~Cleanup() { std::error_code error; fs::remove_all(faset::native_io_path(path), error); }
} cleanup{temporary};
fs::create_directories(faset::native_io_path(temporary));
constexpr const char* entries[] = {"temporalResolveMain", "temporalCompositeVertexMain",
"temporalCompositeFragmentMain"};
for (const auto* entry : entries)
for (const auto* extension : {".spv", ".reflection.json"}) {
const auto name = std::string(entry) + extension;
fs::copy_file(faset::native_io_path(original / name),
faset::native_io_path(temporary / name));
}
const auto bundle = faset::render::detail::load_temporal_shader_bundle(temporary);
for (const auto& shader : bundle)
require(!shader.words.empty() && !shader.layout_fingerprint.empty(),
"Every temporal shader entry has valid checked SPIR-V and reflection");
auto reflection = temporary / "temporalResolveMain.reflection.json";
auto metadata = faset::read_json(reflection);
require(metadata["layout"]["stage"] == "compute" &&
metadata["layout"]["descriptors"].size() == 7 &&
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);
must_reject([&] { (void)faset::render::detail::load_temporal_shader_bundle(temporary); },
"A rehashed temporal image binding change must be rejected");
faset::atomic_write_json(
reflection, faset::read_json(original / "temporalResolveMain.reflection.json"));
auto fragment = temporary / "temporalCompositeFragmentMain.spv";
const auto bytes = faset::read_text(fragment);
faset::atomic_write(fragment, "corrupt");
must_reject([&] { (void)faset::render::detail::load_temporal_shader_bundle(temporary); },
"A broken composite shader cannot enter the temporal bundle");
faset::atomic_write(fragment, bytes);
fs::remove(faset::native_io_path(temporary / "temporalCompositeVertexMain.spv"));
must_reject([&] { (void)faset::render::detail::load_temporal_shader_bundle(temporary); },
"A missing temporal entry cannot enter a complete shader bundle");
}
+48 -6
View File
@@ -56,14 +56,34 @@ class ReflectionTests(unittest.TestCase):
(d["set"], d["binding"]): (d["type"], d.get("element_stride"))
for d in fragment["layout"]["descriptors"]
}
self.assertEqual([lighting[1, i] for i in range(4)],
self.assertEqual([lighting[1, i] for i in range(5)],
[("storage_buffer", 80), ("storage_buffer", 80),
("storage_buffer", 112), ("sampled_image_2d", None)])
("storage_buffer", 112), ("sampled_image_2d", None),
("storage_buffer", 4)])
graphics = {
(d["set"], d["binding"]): d["element_stride"]
for d in gpu_vertex["layout"]["descriptors"]
}
self.assertEqual([graphics[2, i] for i in range(3)], [224, 4, 208])
self.assertEqual([graphics[2, i] for i in range(3)], [288, 4, 208])
def test_light_tile_compute_reflection(self):
compiler = os.environ["FASET_TEST_SLANGC"]
with tempfile.TemporaryDirectory(prefix="faset-light-tiles-abi-") as directory:
process = subprocess.run(
[sys.executable, str(SCRIPT), "--compiler", compiler, "--source",
str(SCRIPT.parents[1] / "shaders" / "light_tiles.slang"), "--entry",
"lightTileMain", "--output", directory],
capture_output=True, text=True,
)
self.assertEqual(process.returncode, 0, process.stderr)
layout = json.loads((Path(directory) / "lightTileMain.reflection.json").read_text())["layout"]
self.assertEqual(layout["stage"], "compute")
self.assertEqual(
[(item["set"], item["binding"], item["type"], item.get("element_stride"))
for item in layout["descriptors"]],
[(0, 0, "storage_buffer", 80), (0, 1, "storage_buffer", 4)],
)
self.assertEqual(layout["push_constants"][0]["size"], 96)
def test_gpu_vertex_paths_do_not_require_shader_draw_parameters(self):
# SV_InstanceID makes Slang subtract BaseInstance and emit DrawParameters.
@@ -92,9 +112,31 @@ class ReflectionTests(unittest.TestCase):
self.assertIn(1, capabilities) # Shader
self.assertNotIn(4427, capabilities) # DrawParameters
def test_temporal_composite_does_not_require_optional_draw_parameters(self):
compiler = os.environ["FASET_TEST_SLANGC"]
with tempfile.TemporaryDirectory(prefix="faset-temporal-composite-") as directory:
process = subprocess.run(
[sys.executable, str(SCRIPT), "--compiler", compiler, "--source",
str(SCRIPT.parents[1] / "shaders" / "temporal.slang"), "--entry",
"temporalCompositeVertexMain", "--define", "FASET_TEMPORAL_COMPOSITE=1",
"--output", directory], capture_output=True, text=True,
)
self.assertEqual(process.returncode, 0, process.stderr)
bytecode = (Path(directory) / "temporalCompositeVertexMain.spv").read_bytes()
words = struct.unpack(f"<{len(bytecode) // 4}I", bytecode)
capabilities = set()
offset = 5
while offset < len(words):
count, opcode = words[offset] >> 16, words[offset] & 0xffff
self.assertGreater(count, 0)
if opcode == 17:
capabilities.add(words[offset + 1])
offset += count
self.assertNotIn(4427, capabilities) # DrawParameters
def test_gpu_storage_resources_keep_kind_and_stride(self):
parameters = [
parameter("instances", 0, "structuredBuffer", "read", 224),
parameter("instances", 0, "structuredBuffer", "read", 288),
parameter("visibleIds", 1, "structuredBuffer", "readWrite", 4),
parameter("depthOutput", 2, "texture2D", "readWrite"),
parameter("depthInput", 3, "texture2D", "read"),
@@ -111,7 +153,7 @@ class ReflectionTests(unittest.TestCase):
descriptors = layout["descriptors"]
self.assertEqual(
[(d["type"], d.get("element_stride")) for d in descriptors],
[("storage_buffer", 224), ("storage_buffer", 4),
[("storage_buffer", 288), ("storage_buffer", 4),
("storage_image_2d", None), ("sampled_image_2d", None)],
)
@@ -128,7 +170,7 @@ class ReflectionTests(unittest.TestCase):
metadata = json.loads((Path(directory) / "gpuCullMain.reflection.json").read_text())
bindings = {item["binding"]: item for item in metadata["layout"]["descriptors"]}
self.assertEqual([bindings[n]["element_stride"] for n in (0, 1, 2, 3, 4, 5, 6, 9)],
[224, 16, 16, 4, 16, 4, 4, 208])
[288, 16, 16, 4, 16, 4, 4, 208])
self.assertEqual([bindings[n]["type"] for n in (7, 8)],
["sampled_image_2d", "sampled_image_2d"])