Expose and package temporal rendering with guarded edge history
Native and manual checks / native (ubuntu-24.04) (push) Failing after 37s
Native and manual checks / manual (push) Successful in 27s
Windows editor and software Vulkan / windows-graphics (push) Canceled after 0s
Native and manual checks / native (windows-2025) (push) Canceled after 0s

This commit is contained in:
Emil
2026-09-24 03:59:22 +03:00
parent 0dcc8790a0
commit 7297d01436
19 changed files with 633 additions and 29 deletions
+107 -2
View File
@@ -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<ProfileSample>& 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<std::string> 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/<id>/.\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<ProfileSample> 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()
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

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