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:
@@ -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" /
|
||||
|
||||
@@ -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"})
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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"])
|
||||
|
||||
|
||||
Reference in New Issue
Block a user