diff --git a/apps/player_main.cpp b/apps/player_main.cpp index 3ac7f5d..d8634c7 100644 --- a/apps/player_main.cpp +++ b/apps/player_main.cpp @@ -39,6 +39,54 @@ const char* visibility_mode_name(faset::render::VisibilityMode mode) { } return "unknown"; } +const char* temporal_mode_name(faset::render::TemporalMode mode) { + switch (mode) { + case faset::render::TemporalMode::Off: return "off"; + case faset::render::TemporalMode::TAA: return "taa"; + case faset::render::TemporalMode::Upscale: return "upscale"; + } + return "unknown"; +} +const char* temporal_fallback_name(faset::render::TemporalFallbackReason reason) { + using Reason = faset::render::TemporalFallbackReason; + switch (reason) { + case Reason::None: return "none"; + case Reason::ComputeUnavailable: return "compute-unavailable"; + case Reason::FormatUnavailable: return "format-unavailable"; + case Reason::ExtentUnsupported: return "extent-unsupported"; + } + return "unknown"; +} +const char* temporal_reset_name(faset::render::TemporalResetReason reason) { + using Reason = faset::render::TemporalResetReason; + switch (reason) { + case Reason::None: return "none"; + case Reason::FirstFrame: return "first-frame"; + case Reason::CameraCut: return "camera-cut"; + case Reason::CameraDiscontinuity: return "camera-discontinuity"; + case Reason::ViewChanged: return "view-changed"; + case Reason::ViewportChanged: return "viewport-changed"; + case Reason::ProjectionChanged: return "projection-changed"; + case Reason::Resize: return "resize"; + case Reason::ModeChanged: return "mode-changed"; + case Reason::ScaleChanged: return "scale-changed"; + case Reason::ShaderReload: return "shader-reload"; + case Reason::Unsupported: return "unsupported"; + } + return "unknown"; +} +float render_scale_value(const std::string& value) { + std::size_t consumed{}; + float scale{}; + try { + scale = std::stof(value, &consumed); + } catch (const std::exception&) { + throw std::invalid_argument("--render-scale must be a finite number"); + } + if (consumed != value.size() || !std::isfinite(scale)) + throw std::invalid_argument("--render-scale must be a finite number"); + return scale; +} struct ProfileSample { double wall{}, simulation{}, snapshot{}, render{}, rendererCpu{}, gpu{}, readbackCpu{}; faset::runtime::FrameStats runtime; @@ -125,7 +173,27 @@ Json profileFrames(const std::vector& samples) { {"gpu_sun_shadow_ms", gpuMeasured ? Json(sample.lighting.gpu_sun_shadow_ms) : Json(nullptr)}, {"gpu_local_shadow_ms", - gpuMeasured ? Json(sample.lighting.gpu_local_shadow_ms) : Json(nullptr)}}); + gpuMeasured ? Json(sample.lighting.gpu_local_shadow_ms) : Json(nullptr)}, + {"requested_temporal_mode", + temporal_mode_name(sample.lighting.requested_temporal_mode)}, + {"effective_temporal_mode", + temporal_mode_name(sample.lighting.effective_temporal_mode)}, + {"temporal_fallback_reason", + temporal_fallback_name(sample.lighting.temporal_fallback_reason)}, + {"temporal_reset_reason", + temporal_reset_name(sample.lighting.temporal_reset_reason)}, + {"temporal_history_valid", sample.lighting.temporal_history_valid}, + {"temporal_valid_motion_instances", + sample.lighting.temporal_valid_motion_instances}, + {"temporal_internal_width", sample.lighting.temporal_internal_width}, + {"temporal_internal_height", sample.lighting.temporal_internal_height}, + {"temporal_jitter", sample.lighting.temporal_jitter}, + {"gpu_temporal_resolve_ms", + gpuMeasured ? Json(sample.lighting.gpu_temporal_resolve_ms) : Json(nullptr)}, + {"gpu_temporal_composite_ms", + gpuMeasured ? Json(sample.lighting.gpu_temporal_composite_ms) : Json(nullptr)}, + {"gpu_ui_ms", + gpuMeasured ? Json(sample.lighting.gpu_ui_ms) : Json(nullptr)}}); } return {{"samples", std::move(frames)}, {"summary_ms", @@ -234,6 +302,8 @@ int player_main(int argc, char** argv) { projectRoot; bool headless = false, validateOnly = false, debugPhysics = false, watchLua = false; auto visibilityMode = faset::render::VisibilityMode::Direct; + auto temporalMode = faset::render::TemporalMode::Off; + float renderScale = 1.f; std::string visibilityName = "direct"; std::uint64_t maximumFrames = 0; std::set options; @@ -244,7 +314,8 @@ int player_main(int argc, char** argv) { << "faset_player [--scene PATH] [--assets CACHE] [--frames N] " "[--headless] [--capture PATH.ppm] [--validate] [--control PATH] " "[--profile PATH.json] [--debug-physics] [--project ROOT] " - "[--watch-lua] [--visibility direct|gpu-frustum|gpu-occlusion]\n" + "[--watch-lua] [--visibility direct|gpu-frustum|gpu-occlusion] " + "[--temporal off|taa|upscale] [--render-scale 0.5..1]\n" "No --scene: open scene.fscene beside the executable. CACHE contains " "assets//.\n" "Headless uses offscreen Vulkan; --frames uses the configured fixed " @@ -262,6 +333,9 @@ int player_main(int argc, char** argv) { "--visibility selects the renderer for this Player run; Direct is " "the default. GPU modes require their packaged shader bundle and " "device capabilities.\n" + "--temporal selects scene TAA or temporal upscaling; Off is the default. " + "--render-scale applies only to upscale and must be at least 0.5 " + "and less than 1. UI remains at output resolution.\n" "Keys: A/D horizontal, W/S vertical, Space jump, E interact, P pause, " "N single-step, F3 physics boxes, Escape quit.\n"; return 0; @@ -298,6 +372,18 @@ int player_main(int argc, char** argv) { "or gpu-occlusion"); } else if (arg == "--frames") maximumFrames = count(value()); + else if (arg == "--temporal") { + const auto selected = value(); + if (selected == "off") + temporalMode = faset::render::TemporalMode::Off; + else if (selected == "taa") + temporalMode = faset::render::TemporalMode::TAA; + else if (selected == "upscale") + temporalMode = faset::render::TemporalMode::Upscale; + else + throw std::invalid_argument("--temporal must be off, taa or upscale"); + } else if (arg == "--render-scale") + renderScale = render_scale_value(value()); else if (arg == "--headless") headless = true; else if (arg == "--validate") @@ -309,6 +395,7 @@ int player_main(int argc, char** argv) { else throw std::invalid_argument("Unknown option: " + arg); } + (void)faset::render::temporal_internal_extent(1280, 720, temporalMode, renderScale); if (options.contains("--profile") && (profilePath.empty() || !options.contains("--frames") || maximumFrames > 100000 || validateOnly)) @@ -413,6 +500,8 @@ int player_main(int argc, char** argv) { renderConfig.headless = headless; renderConfig.validation = true; renderConfig.visibility_mode = visibilityMode; + renderConfig.temporal_mode = temporalMode; + renderConfig.render_scale = renderScale; faset::render::Renderer renderer(renderConfig); const auto rendererReady = Clock::now(); std::vector profile; @@ -604,6 +693,13 @@ int player_main(int argc, char** argv) { << "using " << visibility_mode_name(renderer.stats().effective_visibility_mode) << " rendering.\n"; + if (frames == 0 && temporalMode != renderer.stats().effective_temporal_mode) + std::cerr << "Requested " << temporal_mode_name(temporalMode) + << " temporal rendering is unavailable (" + << temporal_fallback_name(renderer.stats().temporal_fallback_reason) + << "); using " + << temporal_mode_name(renderer.stats().effective_temporal_mode) + << ".\n"; const auto frameFinished = Clock::now(); if (frames == 0) firstFrameMs = milliseconds(started, frameFinished); @@ -649,6 +745,12 @@ int player_main(int argc, char** argv) { {"visibility_mode", visibilityName}, {"effective_visibility_mode", visibility_mode_name(stats.effective_visibility_mode)}, + {"temporal_mode", temporal_mode_name(temporalMode)}, + {"render_scale", renderScale}, + {"effective_temporal_mode", + temporal_mode_name(stats.effective_temporal_mode)}, + {"temporal_fallback_reason", + temporal_fallback_name(stats.temporal_fallback_reason)}, {"effective_lighting_path", stats.effective_lighting_path}, {"simulation_mode", "synthetic_fixed_timestep"}, {"fixed_delta_seconds", config.fixedDelta}, @@ -678,6 +780,9 @@ int player_main(int argc, char** argv) { {"visibility_mode", visibilityName}, {"effective_visibility_mode", visibility_mode_name(stats.effective_visibility_mode)}, + {"temporal_mode", temporal_mode_name(temporalMode)}, + {"effective_temporal_mode", + temporal_mode_name(stats.effective_temporal_mode)}, {"gpu_visibility_active", stats.gpu_visibility_active}, {"validation_errors", stats.validation_errors}} .dump() diff --git a/docs/design/p3-temporal-diagnostics-reference.png b/docs/design/p3-temporal-diagnostics-reference.png new file mode 100644 index 0000000..04836b0 Binary files /dev/null and b/docs/design/p3-temporal-diagnostics-reference.png differ diff --git a/docs/manual/editor/diagnostics.md b/docs/manual/editor/diagnostics.md index 71aba0b..b31ce24 100644 --- a/docs/manual/editor/diagnostics.md +++ b/docs/manual/editor/diagnostics.md @@ -12,6 +12,12 @@ On Windows, use `windows-debug` for both presets and `build/windows-debug/faset_ Use the **Visibility** selector to compare **Direct**, **GPU frustum**, and **GPU occlusion** on the same open scene. This is a live renderer setting for the Editor viewport; it does not change the scene or exported game. The selected mode is independent of **Freeze counters**. The counters describe the previous completed frame, so render one more frame after changing modes before reading them. **Effective path** names the algorithm that actually ran. A **Fallback from** line appears when device or target capabilities prevent the selected mode; for example, GPU occlusion may use GPU frustum if HZB is unavailable. +Use the **Temporal** selector for **Off**, **TAA**, or **Upscale**. Upscale shows a +50–99% render-scale slider; output UI remains sharp. The requested/effective +mode, fallback reason, internal extent, history reset reason and temporal GPU +pass times are shown separately from visibility and HZB history. This selector +only changes the live Editor viewport. See [Temporal rendering](temporal.md). + The panel reports the previous completed frame: renderer wall time, GPU timestamp time where available, synchronous readback time, draw calls, packed vertices, culled meshes, textures, explicit Vulkan allocation sizes, actual validation availability/errors, and GPU pass-label count. It also shows whether GPU visibility ran, submitted indirect bins, visible instances, frustum rejects, deferred and post-pass visible instances, HZB history validity, counts per prepared LOD level, and GPU pass timings where available. GPU counts are explicitly marked unavailable until the first frame rendered with diagnostics open; only a displayed zero is a measured zero. **Previous HZB history: invalid** is expected after a camera cut or resize until compatible depth history is available. A current HZB preview can still exist after that first frame because it was built from the current depth. Renderer wall time includes waiting for GPU work; it is not thread CPU usage. Memory excludes driver-internal allocations. The overlay itself adds drawing work, so hide it for a baseline performance measurement. In **GPU occlusion** mode, enable **Show HZB** to inspect the current grayscale depth pyramid. The **Mip** slider selects a pyramid level; the preview starts at mip 3 to keep its readback small. A larger mip number shows coarser depth. The preview reads the HZB only while the panel and toggle are open, and only once per completed frame or mip change. Switching it off or closing the panel releases the preview; its GPU texture retires when the next frame begins. Opening diagnostics also enables readback of GPU visibility counters, which is disabled again when the panel closes. Disable the HZB preview for performance comparisons: its diagnostic copy and texture upload add GPU and CPU work. **Freeze counters** does not freeze the HZB image. diff --git a/docs/manual/editor/profiling.md b/docs/manual/editor/profiling.md index 8180efc..b2c64cf 100644 --- a/docs/manual/editor/profiling.md +++ b/docs/manual/editor/profiling.md @@ -118,6 +118,32 @@ and reads back the full image, so `cpu_ms` is wall time including waits, not CPU utilization. An open scene can run slower with HZB; visibility correctness and full-frame speed are separate findings. +## Compare temporal modes + +Use one scene, output resolution, camera sequence, visibility path, binary and GPU +for Off, TAA and Upscale. Run enough frames to include both the first-frame reset +and steady-state accumulation. Keep the raw captures as well as timing samples: + +```sh +./faset_player --headless --frames 240 --profile off.json --temporal off +./faset_player --headless --frames 240 --profile taa.json --temporal taa +./faset_player --headless --frames 240 --profile upscale.json \ + --temporal upscale --render-scale 0.67 +``` + +The profile records requested and effective temporal modes, fallback and history +reset reason, internal/output extent, jitter, and valid previous-transform count +per completed frame. `gpu_temporal_resolve_ms`, `gpu_temporal_composite_ms`, and +`gpu_ui_ms` are separate submitted GPU pass times when timestamp queries work; +otherwise they are `null`. `gpu_allocated_bytes` includes live temporal targets +and histories, subject to the allocation limits described above. Compare full +frame GPU and renderer wall time too: scene raster savings can be offset by +resolve, memory and synchronous readback. A valid frame-level history flag says +the previous frame may be sampled, not that every pixel accepted it. For image +quality, inspect a still thin edge, a slow pan and a newly uncovered surface, and +compare the same frame against Off. See [Temporal rendering](temporal.md) for +mode controls and native C++ configuration. + ## Current performance scope The accepted MVP path uses direct draws and CPU culling; P2 adds optional GPU diff --git a/docs/manual/editor/temporal.md b/docs/manual/editor/temporal.md new file mode 100644 index 0000000..453b0d8 --- /dev/null +++ b/docs/manual/editor/temporal.md @@ -0,0 +1,54 @@ +# Temporal rendering + +Faset renders the scene with **Off** by default. In the optional Editor diagnostics +panel (**F12**), choose **TAA** to accumulate a full-resolution scene over successive +frames, or **Upscale** to render the scene at a lower resolution and reconstruct it +at the output resolution. The Upscale slider accepts 50–99%; 67% is a useful +starting point for visual comparison. UI text and controls always render at output +resolution after the scene resolve. Shadow maps keep their own unjittered views. + +The diagnostic selector affects only the current Editor viewport. It does not edit +the scene, gameplay code, or an exported Player. The panel's **Requested** and +**Effective** fields identify a device fallback. It also shows internal and output +extent, whether the previous completed frame's color history was eligible, the +reason it reset, and separate GPU times for resolve, composite and UI where +timestamp queries are available. A reset on the first frame, camera cut, changed +view, resize, scale switch or compatible shader reload is expected. A valid history +does not imply every pixel reused it: newly visible surfaces can still reject +their individual history samples. + +For a Player or exported game, select the mode at launch: + +```sh +./faset_player --headless --frames 120 --temporal taa --profile taa.json +./faset_player --headless --frames 120 --temporal upscale \ + --render-scale 0.67 --profile upscale.json +``` + +`--temporal` accepts `off`, `taa`, or `upscale`. Off and TAA use scale `1`; Upscale +requires a scale from `0.5` inclusive to `1` exclusive. An invalid mode or scale +stops startup with an error. If Vulkan compute or the required image formats are +unavailable, the renderer falls back to Off and records its effective mode and +reason in the profile. Direct, GPU frustum and GPU occlusion visibility can be +combined with either temporal mode. See [Profiling](profiling.md) for how to compare +their timings fairly. + +Native renderer users can make the same choice without modifying gameplay scripts: + +```cpp +faset::render::RendererConfig config; +config.temporal_mode = faset::render::TemporalMode::Upscale; +config.render_scale = 0.67f; +faset::render::Renderer renderer(config); + +// A live viewport switch recreates scene targets and resets color history. +renderer.set_temporal_mode(faset::render::TemporalMode::TAA); +``` + +Provide a stable `DrawItem::instance_key` for moving opaque objects so the renderer +can find their previous model transform. Camera cuts must be marked in the +`Snapshot`; cuts, teleports and incompatible projection changes reject old history. +World transparency and sprites use the scene depth/order and reject stale color on +their reactive pixels. TAA and Upscale are optional image-quality paths; compare +them against Off on the actual game scene, especially thin geometry, slow pans, +newly revealed surfaces and moving transparent content. diff --git a/include/faset/render/renderer.hpp b/include/faset/render/renderer.hpp index 48e2648..37f5b04 100644 --- a/include/faset/render/renderer.hpp +++ b/include/faset/render/renderer.hpp @@ -239,6 +239,7 @@ class Renderer { VisibilityMode visibility_mode() const; void set_temporal_mode(TemporalMode mode, float render_scale = 1.f); TemporalMode temporal_mode() const; + float render_scale() const; void set_visibility_diagnostics(bool enabled); // Reads the most recently completed HZB mip for editor diagnostics only. // Normal visibility decisions remain entirely on the GPU. diff --git a/mkdocs.yml b/mkdocs.yml index 6d35b79..0fcd432 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -46,6 +46,7 @@ nav: - Assets and Blender: editor/assets.md - Lighting: editor/lighting.md - GPU visibility and mesh LOD: editor/visibility-lod.md + - Temporal rendering: editor/temporal.md - Build, Play, and export: editor/export.md - Profiling and measurements: editor/profiling.md - Optional developer diagnostics: editor/diagnostics.md diff --git a/shaders/temporal.slang b/shaders/temporal.slang index 1773577..c9ef655 100644 --- a/shaders/temporal.slang +++ b/shaders/temporal.slang @@ -8,6 +8,7 @@ struct TemporalResolveParameters { float4 outputSceneRect; // output-pixel x/y/width/height float4 internalSceneRect; // internal-pixel x/y/width/height uint4 flags; // x = prior history valid + float4 jitterMotion; // xy = current-minus-prior local UV; z = static camera }; [[vk::push_constant]] ConstantBuffer temporalParameters; [[vk::binding(0,0)]] Texture2D currentSceneColor; @@ -74,15 +75,20 @@ void temporalResolveMain(uint3 dispatchId : SV_DispatchThreadID) { if (insideScene && temporalParameters.flags.x != 0) { const float4 centerMotion = sceneVelocityAt(currentPixel); - if (all(isfinite(centerMotion)) && centerMotion.w > 0 && - centerMotion.z >= 0 && centerMotion.z <= 1) { - float4 selectedMotion = centerMotion; + const bool centerValid = all(isfinite(centerMotion)) && centerMotion.w > 0 && + centerMotion.z >= 0 && centerMotion.z <= 1; + float4 selectedMotion = centerMotion; + bool stationarySilhouette = false; + bool stationaryForegroundEdge = false; + if (centerValid) { float selectedDepth = currentDepth; const float currentTolerance = .002 + .01 * currentDepth; + bool touchesFar = false; [unroll] for (int dy = -1; dy <= 1; ++dy) [unroll] for (int dx = -1; dx <= 1; ++dx) { const int2 neighbor = clampScenePixel(currentPixel + int2(dx, dy)); const float depth = sceneDepthAt(neighbor); + touchesFar = touchesFar || depth >= .999; const float4 motion = sceneVelocityAt(neighbor); if (all(isfinite(motion)) && motion.w > 0 && motion.z >= 0 && motion.z <= 1 && abs(depth - currentDepth) <= currentTolerance && @@ -91,6 +97,35 @@ void temporalResolveMain(uint3 dispatchId : SV_DispatchThreadID) { selectedMotion = motion; } } + const float2 mismatchPixels = + (centerMotion.xy - temporalParameters.jitterMotion.xy) * outputRect.zw; + stationaryForegroundEdge = touchesFar && + temporalParameters.jitterMotion.z > .5 && + dot(mismatchPixels, mismatchPixels) < .01; + } else if (currentDepth >= .999 && temporalParameters.jitterMotion.z > .5) { + // The far side of a *static* subpixel silhouette has no center + // velocity. Borrow only a neighbor whose motion is indistinguishable + // from camera jitter. Moving/revealed edges keep strict rejection. + float bestDistance = 1e30; + [unroll] for (int dy = -1; dy <= 1; ++dy) + [unroll] for (int dx = -1; dx <= 1; ++dx) { + const int2 neighbor = clampScenePixel(currentPixel + int2(dx, dy)); + const float depth = sceneDepthAt(neighbor); + const float4 motion = sceneVelocityAt(neighbor); + const float2 mismatchPixels = + (motion.xy - temporalParameters.jitterMotion.xy) * outputRect.zw; + const float distance = float(dx * dx + dy * dy); + if (depth < .999 && all(isfinite(motion)) && motion.w > 0 && + motion.z >= 0 && motion.z <= 1 && + dot(mismatchPixels, mismatchPixels) < .01 && + distance < bestDistance) { + bestDistance = distance; + selectedMotion = motion; + stationarySilhouette = true; + } + } + } + if (centerValid || stationarySilhouette) { const float2 previousLocalUV = sceneLocalUV - selectedMotion.xy; if (all(isfinite(previousLocalUV)) && all(previousLocalUV >= 0) && all(previousLocalUV < 1)) { @@ -102,8 +137,11 @@ void temporalResolveMain(uint3 dispatchId : SV_DispatchThreadID) { int2(outputExtent) - 1); const float priorDepth = previousHistoryDepth.Load(int3(priorPixel, 0)); const float depthTolerance = .002 + .01 * selectedMotion.z; - if (isfinite(priorDepth) && - abs(priorDepth - selectedMotion.z) <= depthTolerance) { + const bool matchingSurface = isfinite(priorDepth) && + abs(priorDepth - selectedMotion.z) <= depthTolerance; + const bool matchingFar = (stationarySilhouette || stationaryForegroundEdge) && + isfinite(priorDepth) && priorDepth >= .999; + if (matchingSurface || matchingFar) { float3 low = float3(1e30), high = float3(-1e30); [unroll] for (int dy = -1; dy <= 1; ++dy) [unroll] for (int dx = -1; dx <= 1; ++dx) { @@ -112,8 +150,10 @@ void temporalResolveMain(uint3 dispatchId : SV_DispatchThreadID) { high = max(high, color); } const float2 motionPixels = selectedMotion.xy * outputRect.zw; - const float weight = .9 * saturate(centerMotion.w) / - (1 + .5 * length(motionPixels)); + const float weight = stationarySilhouette || matchingFar + ? .3 + : .9 * saturate(centerMotion.w) / + (1 + .5 * length(motionPixels)); const float3 priorColor = clamp(historyBilinear(previousOutputUV).rgb, low, high); resolved.rgb = lerp(currentColor.rgb, priorColor, weight); diff --git a/src/editor/build_service.cpp b/src/editor/build_service.cpp index 5ad1c1c..84a93bd 100644 --- a/src/editor/build_service.cpp +++ b/src/editor/build_service.cpp @@ -338,7 +338,16 @@ struct BuildService::Impl { "gpuShadowMain.spv", "gpuCullMain.spv", "gpuHzbMain.spv", "gpuPostCullMain.spv", "gpuVertexMain.reflection.json", "gpuShadowMain.reflection.json", "gpuCullMain.reflection.json", - "gpuHzbMain.reflection.json", "gpuPostCullMain.reflection.json"}) + "gpuHzbMain.reflection.json", "gpuPostCullMain.reflection.json", + "temporalResolveMain.spv", "temporalResolveMain.reflection.json", + "temporalCompositeVertexMain.spv", + "temporalCompositeVertexMain.reflection.json", + "temporalCompositeFragmentMain.spv", + "temporalCompositeFragmentMain.reflection.json", + "temporalVertexMain.spv", "temporalVertexMain.reflection.json", + "temporalFragmentMain.spv", "temporalFragmentMain.reflection.json", + "gpuTemporalVertexMain.spv", + "gpuTemporalVertexMain.reflection.json"}) copy_required_file(native_directory / "shaders" / file, staging / "shaders" / file); copy_runtime_libraries(job, player, staging, native_directory, configuration); Json manifest{{"format", "faset.build"}, @@ -590,7 +599,16 @@ struct BuildService::Impl { "gpuShadowMain.spv", "gpuCullMain.spv", "gpuHzbMain.spv", "gpuPostCullMain.spv", "gpuVertexMain.reflection.json", "gpuShadowMain.reflection.json", "gpuCullMain.reflection.json", - "gpuHzbMain.reflection.json", "gpuPostCullMain.reflection.json"}) + "gpuHzbMain.reflection.json", "gpuPostCullMain.reflection.json", + "temporalResolveMain.spv", "temporalResolveMain.reflection.json", + "temporalCompositeVertexMain.spv", + "temporalCompositeVertexMain.reflection.json", + "temporalCompositeFragmentMain.spv", + "temporalCompositeFragmentMain.reflection.json", + "temporalVertexMain.spv", "temporalVertexMain.reflection.json", + "temporalFragmentMain.spv", "temporalFragmentMain.reflection.json", + "gpuTemporalVertexMain.spv", + "gpuTemporalVertexMain.reflection.json"}) copy_required_file(build_directory / "shaders" / shader, staging / "shaders" / shader); for (const auto& entry : fs::directory_iterator(build_directory)) { diff --git a/src/editor/debug_overlay.cpp b/src/editor/debug_overlay.cpp index c9d18b7..be572c5 100644 --- a/src/editor/debug_overlay.cpp +++ b/src/editor/debug_overlay.cpp @@ -28,6 +28,42 @@ const char* visibility_label(render::VisibilityMode mode) { } return "Unknown"; } +const char* temporal_label(render::TemporalMode mode) { + switch (mode) { + case render::TemporalMode::Off: return "Off"; + case render::TemporalMode::TAA: return "TAA"; + case render::TemporalMode::Upscale: return "Upscale"; + } + return "Unknown"; +} +const char* temporal_fallback_label(render::TemporalFallbackReason reason) { + using Reason = render::TemporalFallbackReason; + switch (reason) { + case Reason::None: return "none"; + case Reason::ComputeUnavailable: return "compute unavailable"; + case Reason::FormatUnavailable: return "format unavailable"; + case Reason::ExtentUnsupported: return "extent unsupported"; + } + return "unknown"; +} +const char* temporal_reset_label(render::TemporalResetReason reason) { + using Reason = render::TemporalResetReason; + switch (reason) { + case Reason::None: return "none"; + case Reason::FirstFrame: return "first frame"; + case Reason::CameraCut: return "camera cut"; + case Reason::CameraDiscontinuity: return "camera discontinuity"; + case Reason::ViewChanged: return "view changed"; + case Reason::ViewportChanged: return "viewport changed"; + case Reason::ProjectionChanged: return "projection changed"; + case Reason::Resize: return "resize"; + case Reason::ModeChanged: return "mode changed"; + case Reason::ScaleChanged: return "scale changed"; + case Reason::ShaderReload: return "shader reload"; + case Reason::Unsupported: return "unsupported"; + } + return "unknown"; +} ImGuiKey key(std::string_view name) { if (name.size() == 1 && name[0] >= 'A' && name[0] <= 'Z') return static_cast(ImGuiKey_A + name[0] - 'A'); @@ -58,6 +94,7 @@ struct DebugOverlay::Impl { std::uint32_t overlay_buttons{}, editor_buttons{}; std::array pointer{-1, -1}; float scale{}; + float last_upscale_scale{.67f}; std::array window_rect{}; render::FrameStats displayed; std::shared_ptr atlas; @@ -266,6 +303,38 @@ void DebugOverlay::append(render::Snapshot& output, render::Renderer& renderer, } const auto selected_mode = renderer.visibility_mode(); ImGui::TextDisabled("Renderer mode; no scene or export changes"); + ImGui::TextUnformatted("Temporal"); + const auto current_temporal = renderer.temporal_mode(); + if (current_temporal == render::TemporalMode::Upscale) + state.last_upscale_scale = renderer.render_scale(); + const struct { + const char* label; + render::TemporalMode value; + } temporal_modes[] = {{"Off", render::TemporalMode::Off}, + {"TAA", render::TemporalMode::TAA}, + {"Upscale", render::TemporalMode::Upscale}}; + for (int i = 0; i < 3; ++i) { + if (i) + ImGui::SameLine(); + const bool selected = current_temporal == temporal_modes[i].value; + if (selected) + ImGui::PushStyleColor(ImGuiCol_Button, ImVec4{.35f, .33f, .43f, 1.f}); + if (ImGui::Button(temporal_modes[i].label, {button_width, 0})) + renderer.set_temporal_mode(temporal_modes[i].value, + temporal_modes[i].value == render::TemporalMode::Upscale + ? state.last_upscale_scale : 1.f); + if (selected) + ImGui::PopStyleColor(); + } + if (renderer.temporal_mode() == render::TemporalMode::Upscale) { + int percent = static_cast(std::lround(renderer.render_scale() * 100.f)); + if (ImGui::SliderInt("Render scale", &percent, 50, 99, "%d%%")) { + state.last_upscale_scale = float(percent) / 100.f; + renderer.set_temporal_mode(render::TemporalMode::Upscale, + state.last_upscale_scale); + } + } + ImGui::TextDisabled("Viewport only; exported Player settings are separate"); ImGui::Separator(); ImGui::TextUnformatted("Previous completed frame"); ImGui::SameLine(); @@ -381,6 +450,26 @@ void DebugOverlay::append(render::Snapshot& output, render::Renderer& renderer, ImGui::Text("Prepared LOD: %u / %u / %u / %u+", stats.lod_counts[0], stats.lod_counts[1], stats.lod_counts[2], stats.lod_counts[3]); ImGui::Separator(); + ImGui::TextUnformatted("Temporal reconstruction"); + ImGui::Text("Requested: %s Effective: %s", + temporal_label(stats.requested_temporal_mode), + temporal_label(stats.effective_temporal_mode)); + if (stats.requested_temporal_mode != stats.effective_temporal_mode) + ImGui::TextDisabled("Fallback: %s", + temporal_fallback_label(stats.temporal_fallback_reason)); + if (stats.effective_temporal_mode != render::TemporalMode::Off) { + ImGui::Text("Internal: %u x %u Output: %u x %u", + stats.temporal_internal_width, stats.temporal_internal_height, + renderer.width(), renderer.height()); + ImGui::Text("History: %s Reset: %s", + stats.temporal_history_valid ? "valid" : "invalid", + temporal_reset_label(stats.temporal_reset_reason)); + if (stats.gpu_ms > 0) + ImGui::Text("GPU: resolve %.2f composite %.2f UI %.2f ms", + stats.gpu_temporal_resolve_ms, + stats.gpu_temporal_composite_ms, stats.gpu_ui_ms); + } + ImGui::Separator(); ImGui::TextUnformatted("Lighting and shadows"); ImGui::Text("Lighting path: %s", stats.effective_lighting_path.c_str()); ImGui::Text("Local lights: %u submitted, %u omitted", diff --git a/src/render/renderer.cpp b/src/render/renderer.cpp index 3fcd258..6a3c78f 100644 --- a/src/render/renderer.cpp +++ b/src/render/renderer.cpp @@ -217,6 +217,8 @@ struct TemporalResources { TemporalCapabilities capabilities{}; TemporalHistoryState history; Mat4 previous_jittered_vp{identity}; + Mat4 previous_unjittered_vp{identity}; + std::array previous_jitter{}; std::uint64_t shader_generation{1}; std::uint32_t internal_width{}, internal_height{}, completed_index{}; bool has_completed_image{}; @@ -236,7 +238,8 @@ struct Renderer::Impl { VkDevice device{}; VkQueue queue{}; std::uint32_t queue_family{}; - std::uint32_t max_compute_groups_x{}, max_storage_buffer_range{}, + std::uint32_t max_compute_groups_x{}, max_compute_groups_y{}, + max_storage_buffer_range{}, max_image_dimension{}; bool independent_blend_supported{}; VkCommandPool pool{}; @@ -757,6 +760,7 @@ struct Renderer::Impl { timestamp_period = properties.limits.timestampPeriod; timestamp_bits = queues[i].timestampValidBits; max_compute_groups_x = properties.limits.maxComputeWorkGroupCount[0]; + max_compute_groups_y = properties.limits.maxComputeWorkGroupCount[1]; max_storage_buffer_range = properties.limits.maxStorageBufferRange; max_image_dimension = properties.limits.maxImageDimension2D; independent_blend_supported = features.features.independentBlend; @@ -798,7 +802,8 @@ struct Renderer::Impl { VK_FORMAT_FEATURE_STORAGE_IMAGE_BIT); temporal.capabilities.extent = width <= max_image_dimension && height <= max_image_dimension && - (width + 7) / 8 <= max_compute_groups_x; + (width + 7) / 8 <= max_compute_groups_x && + (height + 7) / 8 <= max_compute_groups_y; } } } @@ -923,7 +928,8 @@ struct Renderer::Impl { temporal.has_completed_image = false; scene.hzb_history_valid = false; temporal.capabilities.extent = width <= max_image_dimension && - height <= max_image_dimension && (width + 7) / 8 <= max_compute_groups_x; + height <= max_image_dimension && (width + 7) / 8 <= max_compute_groups_x && + (height + 7) / 8 <= max_compute_groups_y; const auto effective = select_effective_temporal_mode(config.temporal_mode, temporal.capabilities); const auto internal = temporal_internal_extent(width, height, effective, @@ -1698,7 +1704,7 @@ struct Renderer::Impl { check(vkCreateDescriptorSetLayout(device, &descriptor_info, nullptr, &temporal.composite_layout), "Create temporal composite descriptor layout"); - VkPushConstantRange push{VK_SHADER_STAGE_COMPUTE_BIT, 0, 64}; + VkPushConstantRange push{VK_SHADER_STAGE_COMPUTE_BIT, 0, 80}; VkPipelineLayoutCreateInfo layout_info{}; layout_info.sType = VK_STRUCTURE_TYPE_PIPELINE_LAYOUT_CREATE_INFO; layout_info.setLayoutCount = 1; @@ -1862,7 +1868,8 @@ struct Renderer::Impl { } for (auto module : modules) vkDestroyShaderModule(device, module, nullptr); - refresh_temporal_descriptors(); + if (!temporal.descriptor_pool) + refresh_temporal_descriptors(); } GpuVertex gpu_vertex(const Vertex& v, const DrawItem& item, const Mat4& vp, const Mat4* previous_model = nullptr, @@ -3415,11 +3422,19 @@ struct Renderer::Impl { std::array dimensions; std::array output_rect, internal_rect; std::array flags; + std::array jitter_motion; }; - static_assert(sizeof(ResolvePush) == 64); + static_assert(sizeof(ResolvePush) == 80); + const bool static_camera = statistics.temporal_history_valid && + temporal.previous_unjittered_vp == snapshot.view_projection; ResolvePush parameters{{width, height, raster_width, raster_height}, output_scene_viewport, scene_viewport, - {statistics.temporal_history_valid ? 1u : 0u, 0, 0, 0}}; + {statistics.temporal_history_valid ? 1u : 0u, 0, 0, 0}, + {(statistics.temporal_jitter[0] - + temporal.previous_jitter[0]) * .5f, + (statistics.temporal_jitter[1] - + temporal.previous_jitter[1]) * .5f, + static_camera ? 1.f : 0.f, 0.f}}; vkCmdBindPipeline(command, VK_PIPELINE_BIND_POINT_COMPUTE, temporal.resolve_pipeline); vkCmdBindDescriptorSets(command, VK_PIPELINE_BIND_POINT_COMPUTE, @@ -3567,8 +3582,14 @@ struct Renderer::Impl { statistics.gpu_main_raster_ms = elapsed; else if (label == "BuildCurrentHZB") statistics.gpu_hzb_ms = elapsed; else if (label == "PostCull") statistics.gpu_post_cull_ms = elapsed; - else if (label == "PostRasterAndUI") + else if (label == "PostRasterAndUI" || label == "PostRasterScene") statistics.gpu_post_raster_ms = elapsed; + else if (label == "TemporalResolve") + statistics.gpu_temporal_resolve_ms = elapsed; + else if (label == "TemporalComposite") + statistics.gpu_temporal_composite_ms = elapsed; + else if (label == "UI") + statistics.gpu_ui_ms = elapsed; } } if (swap_index) { @@ -3633,6 +3654,8 @@ struct Renderer::Impl { scene.previous_view_id = view_id; scene.hzb_history_valid = occlusion; temporal.previous_jittered_vp = raster_vp; + temporal.previous_unjittered_vp = snapshot.view_projection; + temporal.previous_jitter = statistics.temporal_jitter; temporal.history.complete(temporal_key); if (temporal_active) { temporal.completed_index = temporal.has_completed_image @@ -3663,6 +3686,12 @@ struct Renderer::Impl { for (const auto& image : scene.hzb) statistics.gpu_allocated_bytes += image.allocation_size; } + statistics.gpu_allocated_bytes += temporal.scene_color.allocation_size + + temporal.velocity.allocation_size; + for (const auto& image : temporal.history_color) + statistics.gpu_allocated_bytes += image.allocation_size; + for (const auto& image : temporal.history_depth) + statistics.gpu_allocated_bytes += image.allocation_size; statistics.validation_errors = validation_errors.load(); statistics.cpu_ms = std::chrono::duration(std::chrono::steady_clock::now() - start) @@ -3686,23 +3715,35 @@ bool Renderer::reload_shaders(std::string& error) { auto previous_ui = r.ui_pipeline; auto previous_shadow = r.shadow_pipeline; auto previous_sprite = r.sprite_pipeline; + auto previous_temporal_ui = r.temporal_ui_pipeline; auto previous_layout_fingerprints = r.shader_layouts; auto previous_gpu_fingerprints = r.scene.shader_layouts; auto previous_gpu = r.scene.graphics_pipeline; auto previous_cull = r.scene.cull_pipeline; auto previous_post = r.scene.post_pipeline; auto previous_hzb = r.scene.hzb_pipeline; + const auto previous_temporal_layouts = r.temporal.shader_layouts; + const auto previous_temporal_scene_layouts = r.temporal.scene_shader_layouts; + const std::array previous_temporal_pipelines{ + r.temporal.resolve_pipeline, r.temporal.composite_pipeline, + r.temporal.direct_pipeline, r.temporal.transparent_pipeline, + r.temporal.gpu_pipeline}; r.pipeline_layout = {}; r.pipeline = {}; r.ui_pipeline = {}; r.shadow_pipeline = {}; r.sprite_pipeline = {}; + r.temporal_ui_pipeline = {}; r.scene.graphics_pipeline = r.scene.cull_pipeline = r.scene.post_pipeline = r.scene.hzb_pipeline = {}; + r.temporal.resolve_pipeline = r.temporal.composite_pipeline = + r.temporal.direct_pipeline = r.temporal.transparent_pipeline = + r.temporal.gpu_pipeline = {}; try { r.make_pipelines(); if (r.scene.graphics_pipeline_layout) r.make_scene_pipelines(); + r.make_temporal_interfaces_and_pipelines(); } catch (const std::exception& exception) { if (r.pipeline) vkDestroyPipeline(r.device, r.pipeline, nullptr); @@ -3712,23 +3753,38 @@ bool Renderer::reload_shaders(std::string& error) { vkDestroyPipeline(r.device, r.shadow_pipeline, nullptr); if (r.sprite_pipeline) vkDestroyPipeline(r.device, r.sprite_pipeline, nullptr); + if (r.temporal_ui_pipeline) + vkDestroyPipeline(r.device, r.temporal_ui_pipeline, nullptr); if (r.pipeline_layout) vkDestroyPipelineLayout(r.device, r.pipeline_layout, nullptr); for (auto pipeline : {r.scene.graphics_pipeline, r.scene.cull_pipeline, r.scene.post_pipeline, r.scene.hzb_pipeline}) if (pipeline) vkDestroyPipeline(r.device, pipeline, nullptr); + for (auto pipeline : {r.temporal.resolve_pipeline, r.temporal.composite_pipeline, + r.temporal.direct_pipeline, r.temporal.transparent_pipeline, + r.temporal.gpu_pipeline}) + if (pipeline) + vkDestroyPipeline(r.device, pipeline, nullptr); r.pipeline_layout = previous_layout; r.pipeline = previous; r.ui_pipeline = previous_ui; r.shadow_pipeline = previous_shadow; r.sprite_pipeline = previous_sprite; + r.temporal_ui_pipeline = previous_temporal_ui; r.scene.graphics_pipeline = previous_gpu; r.scene.cull_pipeline = previous_cull; r.scene.post_pipeline = previous_post; r.scene.hzb_pipeline = previous_hzb; + r.temporal.resolve_pipeline = previous_temporal_pipelines[0]; + r.temporal.composite_pipeline = previous_temporal_pipelines[1]; + r.temporal.direct_pipeline = previous_temporal_pipelines[2]; + r.temporal.transparent_pipeline = previous_temporal_pipelines[3]; + r.temporal.gpu_pipeline = previous_temporal_pipelines[4]; r.shader_layouts = previous_layout_fingerprints; r.scene.shader_layouts = previous_gpu_fingerprints; + r.temporal.shader_layouts = previous_temporal_layouts; + r.temporal.scene_shader_layouts = previous_temporal_scene_layouts; error = exception.what(); return false; } @@ -3736,10 +3792,15 @@ bool Renderer::reload_shaders(std::string& error) { vkDestroyPipeline(r.device, previous_ui, nullptr); vkDestroyPipeline(r.device, previous_shadow, nullptr); vkDestroyPipeline(r.device, previous_sprite, nullptr); + vkDestroyPipeline(r.device, previous_temporal_ui, nullptr); for (auto pipeline : {previous_gpu, previous_cull, previous_post, previous_hzb}) if (pipeline) vkDestroyPipeline(r.device, pipeline, nullptr); + for (auto pipeline : previous_temporal_pipelines) + if (pipeline) + vkDestroyPipeline(r.device, pipeline, nullptr); vkDestroyPipelineLayout(r.device, previous_layout, nullptr); + ++r.temporal.shader_generation; error.clear(); return true; } @@ -3792,6 +3853,9 @@ void Renderer::set_temporal_mode(TemporalMode mode, float scale) { TemporalMode Renderer::temporal_mode() const { return impl_->config.temporal_mode; } +float Renderer::render_scale() const { + return impl_->config.render_scale; +} void Renderer::set_visibility_diagnostics(bool enabled) { impl_->config.visibility_diagnostics = enabled; } diff --git a/src/render/shader_contract.cpp b/src/render/shader_contract.cpp index d822ee9..f1fbc3f 100644 --- a/src/render/shader_contract.cpp +++ b/src/render/shader_contract.cpp @@ -211,14 +211,15 @@ void validate_temporal_layout(const Json& layout, std::string_view entry) { "temporal push constants are malformed"); if (resolve) { require(constants.size() == 1 && constants[0].at("offset") == 0 && - constants[0].at("size") == 64 && spirv_constants.size() == 1, + constants[0].at("size") == 80 && spirv_constants.size() == 1, "temporal resolve push block changed"); const auto& members = constants[0].at("members"); const auto& spirv_members = spirv_constants[0].at("members"); - require(members.size() == 4 && spirv_members.size() == 4, + require(members.size() == 5 && spirv_members.size() == 5, "temporal resolve push members changed"); - const char* types[] = {"uint32x4", "float32x4", "float32x4", "uint32x4"}; - for (std::size_t i = 0; i < 4; ++i) + const char* types[] = {"uint32x4", "float32x4", "float32x4", "uint32x4", + "float32x4"}; + for (std::size_t i = 0; i < 5; ++i) require(members[i].at("offset") == 16 * i && members[i].at("size") == 16 && members[i].at("type") == types[i] && spirv_members[i].at("member") == i && diff --git a/tests/build_service_tests.cpp b/tests/build_service_tests.cpp index 5417bd1..79e5c33 100644 --- a/tests/build_service_tests.cpp +++ b/tests/build_service_tests.cpp @@ -10,6 +10,7 @@ #include #include #include +#include #ifndef _WIN32 #include #endif @@ -211,6 +212,14 @@ int integration(const fs::path& root) { "Export has a separate CMake directory"); require(read_json(directory / "manifest.json").at("configuration") == "Release", "Export manifest records the actual profile"); + for (const auto* entry : {"temporalResolveMain", "temporalCompositeVertexMain", + "temporalCompositeFragmentMain", "temporalVertexMain", + "temporalFragmentMain", "gpuTemporalVertexMain"}) { + for (const auto* suffix : {".spv", ".reflection.json"}) + require(fs::is_regular_file(directory / "shaders" / + (std::string(entry) + suffix)), + "Export contains compiled and reflected temporal shader"); + } Process player( {{result.result.at("executable").get(), "--headless", "--frames", "3", "--capture", path_to_utf8(directory / "verification.ppm")}, @@ -219,6 +228,22 @@ int integration(const fs::path& root) { std::cout << collect(player); require(fs::file_size(directory / "verification.ppm") > 1000, "Exported game rendered a frame"); + for (const auto& [mode, scale] : + {std::pair{"taa", "1.0"}, std::pair{"upscale", "0.67"}}) { + const auto profile = directory / (std::string("profile-") + mode + ".json"); + Process temporalPlayer( + {{result.result.at("executable").get(), "--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"); diff --git a/tests/editor_debug_overlay.cpp b/tests/editor_debug_overlay.cpp index 77d38dc..c7a6bef 100644 --- a/tests/editor_debug_overlay.cpp +++ b/tests/editor_debug_overlay.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(), diff --git a/tests/player_diagnostics_test.py b/tests/player_diagnostics_test.py index a8f7a5b..6b34ae1 100644 --- a/tests/player_diagnostics_test.py +++ b/tests/player_diagnostics_test.py @@ -45,6 +45,44 @@ with tempfile.TemporaryDirectory(prefix="faset-player-diagnostics-") as temporar lighting["effective_sun_cascades"] == 0 and \ lighting["local_shadow_faces"] == 0, lighting + for temporal_mode, scale in [("off", None), ("taa", None), ("upscale", "0.67")]: + temporal_profile = root / f"temporal-{temporal_mode}.json" + arguments = [sys.argv[1], "--scene", str(scene), "--headless", "--frames", "2", + "--temporal", temporal_mode, "--profile", str(temporal_profile)] + if scale is not None: + arguments += ["--render-scale", scale] + selected = subprocess.run(arguments, capture_output=True, text=True, + encoding="utf-8", timeout=30) + assert selected.returncode == 0, (temporal_mode, selected.stdout, selected.stderr) + temporal_report = json.loads(temporal_profile.read_text(encoding="utf-8")) + assert temporal_report["temporal_mode"] == temporal_mode, temporal_report + assert abs(temporal_report["render_scale"] - + (float(scale) if scale else 1.0)) < 0.000001 + assert temporal_report["effective_temporal_mode"] == temporal_mode, temporal_report + for sample in temporal_report["samples"]: + for key in ["requested_temporal_mode", "effective_temporal_mode", + "temporal_fallback_reason", "temporal_reset_reason", + "temporal_history_valid", "temporal_internal_width", + "temporal_internal_height", "temporal_jitter", + "gpu_temporal_resolve_ms", "gpu_temporal_composite_ms", "gpu_ui_ms"]: + assert key in sample, (key, sample) + assert sample["effective_temporal_mode"] == temporal_mode, sample + if temporal_mode != "off": + assert sample["temporal_internal_width"] > 0, sample + assert sample["temporal_internal_height"] > 0, sample + assert sample["gpu_temporal_resolve_ms"] is not None, sample + if temporal_mode != "off": + assert not temporal_report["samples"][0]["temporal_history_valid"] + assert temporal_report["samples"][1]["temporal_history_valid"] + + for invalid in [["--temporal", "missing"], ["--render-scale", "NaN"], + ["--temporal", "taa", "--render-scale", "0.67"], + ["--temporal", "upscale", "--render-scale", "1.0"]]: + result = subprocess.run([sys.argv[1], *invalid], capture_output=True, + text=True, encoding="utf-8", timeout=20) + assert result.returncode != 0 and ("temporal" in result.stderr.lower() or + "scale" in result.stderr.lower()), result + # The same linked v2 schema must validate without registering or invoking behavior. validated = subprocess.run([sys.argv[1], "--scene", str(scene), "--validate"], capture_output=True, text=True, encoding="utf-8", timeout=20) diff --git a/tests/render_reload_tests.cpp b/tests/render_reload_tests.cpp index 59e0ae0..0c33fca 100644 --- a/tests/render_reload_tests.cpp +++ b/tests/render_reload_tests.cpp @@ -48,7 +48,10 @@ int main() { fs::create_directories(bundle); for (const auto* entry : {"vertexMain", "fragmentMain", "shadowMain", "gpuVertexMain", "gpuShadowMain", "gpuCullMain", - "gpuHzbMain", "gpuPostCullMain"}) + "gpuHzbMain", "gpuPostCullMain", "temporalResolveMain", + "temporalCompositeVertexMain", "temporalCompositeFragmentMain", + "temporalVertexMain", "temporalFragmentMain", + "gpuTemporalVertexMain"}) for (const auto* extension : {".spv", ".reflection.json"}) { const auto name = std::string(entry) + extension; fs::copy_file(path_from_utf8(FASET_TEST_SHADER_DIRECTORY) / name, bundle / name); @@ -88,7 +91,10 @@ int main() { render::Renderer renderer(configuration); const auto baseline_only = temporary / "baseline-only"; fs::create_directories(baseline_only); - for (const auto* entry : {"vertexMain", "fragmentMain", "shadowMain"}) + for (const auto* entry : {"vertexMain", "fragmentMain", "shadowMain", + "temporalResolveMain", "temporalCompositeVertexMain", + "temporalCompositeFragmentMain", "temporalVertexMain", + "temporalFragmentMain", "gpuTemporalVertexMain"}) for (const auto* extension : {".spv", ".reflection.json"}) { const auto name = std::string(entry) + extension; fs::copy_file(bundle / name, baseline_only / name); @@ -124,6 +130,13 @@ int main() { opaque_cube.instance_key = "shader-reload-cube"; opaque_cube.cast_shadow = false; opaque_scene.draws.push_back(opaque_cube); + auto temporal_configuration = configuration; + temporal_configuration.temporal_mode = render::TemporalMode::TAA; + render::Renderer temporal_renderer(temporal_configuration); + temporal_renderer.render(opaque_scene); + temporal_renderer.render(opaque_scene); + require(temporal_renderer.stats().temporal_history_valid, + "Temporal reload fixture has a completed color history"); gpu_renderer.render(opaque_scene); const auto gpu_expected = gpu_renderer.pixels(); render::Snapshot scene; @@ -141,7 +154,10 @@ int main() { fs::create_directories(native_io_path(deep_bundle)); for (const auto* entry : {"vertexMain", "fragmentMain", "shadowMain", "gpuVertexMain", "gpuShadowMain", "gpuCullMain", - "gpuHzbMain", "gpuPostCullMain"}) + "gpuHzbMain", "gpuPostCullMain", "temporalResolveMain", + "temporalCompositeVertexMain", "temporalCompositeFragmentMain", + "temporalVertexMain", "temporalFragmentMain", + "gpuTemporalVertexMain"}) for (const auto* extension : {".spv", ".reflection.json"}) { const auto name = std::string(entry) + extension; atomic_write(deep_bundle / name, read_text(bundle / name)); @@ -179,6 +195,17 @@ int main() { require(renderer.stats().validation_errors == 0, "Rejected bytecode must not reach Vulkan validation"); }; + const auto temporal_resolve = read_text(bundle / "temporalResolveMain.spv"); + fs::remove(native_io_path(bundle / "temporalResolveMain.spv")); + std::string temporal_error; + require(!temporal_renderer.reload_shaders(temporal_error) && !temporal_error.empty(), + "A partial temporal package must reject reload atomically"); + temporal_renderer.render(opaque_scene); + require(temporal_renderer.stats().effective_temporal_mode == render::TemporalMode::TAA && + temporal_renderer.stats().temporal_history_valid && + temporal_renderer.stats().validation_errors == 0, + "Rejected temporal reload retains active mode, pixels and history"); + atomic_write(bundle / "temporalResolveMain.spv", temporal_resolve); atomic_write(bundle / "fragmentMain.spv", "damaged bytecode"); retained(); restore(); @@ -236,6 +263,13 @@ int main() { require(renderer.reload_shaders(error), "Compatible shader edit reloads successfully"); require(gpu_renderer.reload_shaders(error), "Compatible fragment edit reloads GPU scene pipeline"); + require(temporal_renderer.reload_shaders(error), + "Complete compatible package reloads temporal pipelines"); + temporal_renderer.render(opaque_scene); + require(!temporal_renderer.stats().temporal_history_valid && + temporal_renderer.stats().temporal_reset_reason == + render::TemporalResetReason::ShaderReload, + "Successful temporal shader reload rejects stale color history"); gpu_renderer.render(opaque_scene); const auto gpu_changed = gpu_renderer.pixels(); require(gpu_changed != gpu_expected, diff --git a/tests/render_temporal_acceptance_tests.cpp b/tests/render_temporal_acceptance_tests.cpp index bb78117..4a49354 100644 --- a/tests/render_temporal_acceptance_tests.cpp +++ b/tests/render_temporal_acceptance_tests.cpp @@ -2,8 +2,11 @@ #include #include +#include #include +#include #include +#include using namespace faset::render; using namespace faset::render::temporal_test; @@ -75,6 +78,7 @@ void lower_resolution_scene_and_output_ui() { auto frame = lit_scene(width, height); frame.draws.push_back(cube({0, 0, 0}, {.8f, .6f, .25f, 1}, "thin-scene")); frame.ui_quads.push_back({2, 2, 25, 12, {.8f, .7f, .25f, 1}}); + frame.scene_rect = {13, 17, 287, 199}; upscale.render(frame); require(upscale.stats().effective_temporal_mode == TemporalMode::Upscale && upscale.stats().temporal_internal_width == 215 && @@ -83,6 +87,82 @@ void lower_resolution_scene_and_output_ui() { "0.67 upscale rasterizes 215x161 while capture remains 320x240"); require(upscale.stats().validation_errors == 0, "Upscale image resize and composite pass Vulkan validation"); + const auto output_ui = pixel(upscale.pixels(), width, 4, 4); + upscale.render(frame); + require(upscale.stats().temporal_history_valid, + "A compatible upscaled second frame has output-resolution history"); + upscale.set_temporal_mode(TemporalMode::Upscale, .5f); + upscale.render(frame); + require(upscale.stats().temporal_internal_width == 160 && + upscale.stats().temporal_internal_height == 120 && + !upscale.stats().temporal_history_valid && + upscale.stats().temporal_reset_reason == TemporalResetReason::ScaleChanged && + pixel(upscale.pixels(), width, 4, 4) == output_ui, + "Changing upscale factor resets history without changing sharp output UI"); + upscale.set_temporal_mode(TemporalMode::TAA); + upscale.render(frame); + require(upscale.stats().temporal_reset_reason == TemporalResetReason::ScaleChanged && + upscale.stats().temporal_internal_width == width && + upscale.stats().temporal_internal_height == height, + "Switching from upscale to 1:1 TAA resets the changed internal scale"); + upscale.set_temporal_mode(TemporalMode::Off); + upscale.render(frame); + require(upscale.stats().effective_temporal_mode == TemporalMode::Off && + pixel(upscale.pixels(), width, 4, 4) == output_ui, + "Switching Off restores the full-resolution baseline and sharp UI"); + + upscale.resize(319, 241); + frame = lit_scene(319, 241); + frame.scene_rect = {13, 17, 285, 199}; + upscale.set_temporal_mode(TemporalMode::Upscale, .5f); + upscale.render(frame); + require(upscale.stats().temporal_internal_width == 160 && + upscale.stats().temporal_internal_height == 121 && + upscale.pixels().size() == std::size_t(319) * 241 * 4 && + upscale.stats().validation_errors == 0, + "Odd output and offset scene rectangle preserve ceil-rounded internal extent"); +} + +double frame_variation(const std::vector>& 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(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> resolved, unaccumulated; + for (unsigned phase = 0; phase < 16; ++phase) { + accumulated.render(frame); + if (phase >= 4) + resolved.push_back(accumulated.pixels()); + frame.camera_cut = true; // Same jitter phase, but no prior color may be read. + spatial.render(frame); + if (phase >= 4) + unaccumulated.push_back(spatial.pixels()); + frame.camera_cut = false; + } + const Region edge_area{25, 12, 110, 95}; + const double raw = frame_variation(unaccumulated, width, edge_area); + const double temporal = frame_variation(resolved, width, edge_area); + std::cerr << "Static-edge mean frame variation: unaccumulated=" << raw + << " TAA=" << temporal << '\n'; + require(raw > .05 && temporal < raw * .98, + "After warm-up TAA must reduce static edge shimmer across Halton phases"); + require(accumulated.stats().validation_errors == 0 && + spatial.stats().validation_errors == 0, + "Static-edge temporal sequence must not raise Vulkan validation errors"); } } // namespace @@ -91,4 +171,5 @@ int main() { moving_reveal_and_camera_resets(VisibilityMode::GpuFrustum); moving_reveal_and_camera_resets(VisibilityMode::GpuOcclusion); lower_resolution_scene_and_output_ui(); + static_edge_reduces_jitter_variation(); } diff --git a/tests/render_temporal_graph_tests.cpp b/tests/render_temporal_graph_tests.cpp index 61d0a31..95d5caf 100644 --- a/tests/render_temporal_graph_tests.cpp +++ b/tests/render_temporal_graph_tests.cpp @@ -53,6 +53,12 @@ void offset_scene_and_sharp_ui() { "Pixels outside the offset scene rectangle retain the Off clear result"); require(stats.validation_errors == 0, "Temporal graph attachment store/load and transitions pass Vulkan validation"); + if (stats.gpu_ms > 0) + require(stats.gpu_temporal_resolve_ms > 0 && + stats.gpu_temporal_composite_ms > 0, + "Temporal resolve and composite expose independent GPU pass timings"); + require(stats.gpu_allocated_bytes > off.stats().gpu_allocated_bytes, + "Temporal scene, velocity and output histories count toward live GPU memory"); } } // namespace diff --git a/tests/render_temporal_shader_contract_tests.cpp b/tests/render_temporal_shader_contract_tests.cpp index 41043dd..5c5bdce 100644 --- a/tests/render_temporal_shader_contract_tests.cpp +++ b/tests/render_temporal_shader_contract_tests.cpp @@ -50,8 +50,8 @@ int main() { auto metadata = faset::read_json(reflection); require(metadata["layout"]["stage"] == "compute" && metadata["layout"]["descriptors"].size() == 7 && - metadata["layout"]["push_constants"][0]["size"] == 64, - "Temporal resolve ABI contains seven images and a 64-byte push block"); + metadata["layout"]["push_constants"][0]["size"] == 80, + "Temporal resolve ABI contains seven images and an 80-byte push block"); metadata["layout"]["descriptors"][5]["binding"] = 8; metadata["layout_fingerprint"] = faset::sha256(metadata["layout"].dump()); faset::atomic_write_json(reflection, metadata);