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

# Conflicts:
#	PLAN.md
#	docs/IMPLEMENTATION.md
#	docs/manual/editor/diagnostics.md
#	docs/manual/editor/lighting.md
#	docs/manual/editor/profiling.md
#	docs/validation/README.md
#	docs/validation/p3-lighting-2026-09-24/README.md
This commit is contained in:
Emil
2026-09-24 04:08:01 +03:00
43 changed files with 3963 additions and 139 deletions
+115 -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,35 @@ 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)},
{"gpu_light_tiles_ms",
gpuMeasured ? Json(sample.lighting.gpu_light_tiles_ms) : Json(nullptr)},
{"light_tile_count", sample.lighting.light_tile_count},
{"light_tile_counts_valid", sample.lighting.light_tile_counts_valid},
{"light_tile_candidate_count", sample.lighting.light_tile_counts_valid
? Json(sample.lighting.light_tile_candidate_count) : Json(nullptr)},
{"light_tile_overflow_count", sample.lighting.light_tile_counts_valid
? Json(sample.lighting.light_tile_overflow_count) : Json(nullptr)}});
}
return {{"samples", std::move(frames)},
{"summary_ms",
@@ -234,6 +310,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 +322,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 +341,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 +380,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 +403,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 +508,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 +701,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 +753,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 +788,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()
+70 -1
View File
@@ -17,6 +17,32 @@ foreach(FASET_ENTRY vertexMain fragmentMain shadowMain)
DEPENDS "${PROJECT_SOURCE_DIR}/shaders/baseline.slang" "${PROJECT_SOURCE_DIR}/tools/compile_shader.py" VERBATIM)
list(APPEND FASET_SHADER_OUTPUTS "${FASET_SHADER_OUTPUT}" "${FASET_SHADER_DIRECTORY}/${FASET_ENTRY}.reflection.json")
endforeach()
foreach(FASET_ENTRY temporalVertexMain temporalFragmentMain)
set(FASET_SHADER_OUTPUT "${FASET_SHADER_DIRECTORY}/${FASET_ENTRY}.spv")
add_custom_command(OUTPUT "${FASET_SHADER_OUTPUT}" "${FASET_SHADER_DIRECTORY}/${FASET_ENTRY}.reflection.json"
COMMAND "${Python3_EXECUTABLE}" "${PROJECT_SOURCE_DIR}/tools/compile_shader.py"
--compiler "${SLANGC_EXECUTABLE}" --source "${PROJECT_SOURCE_DIR}/shaders/baseline.slang"
--entry "${FASET_ENTRY}" --output "${FASET_SHADER_DIRECTORY}"
BYPRODUCTS "${FASET_SHADER_DIRECTORY}/${FASET_ENTRY}.slang-reflection.json"
DEPENDS "${PROJECT_SOURCE_DIR}/shaders/baseline.slang" "${PROJECT_SOURCE_DIR}/tools/compile_shader.py" VERBATIM)
list(APPEND FASET_SHADER_OUTPUTS "${FASET_SHADER_OUTPUT}" "${FASET_SHADER_DIRECTORY}/${FASET_ENTRY}.reflection.json")
endforeach()
set(FASET_SHADER_OUTPUT "${FASET_SHADER_DIRECTORY}/gpuTemporalVertexMain.spv")
add_custom_command(OUTPUT "${FASET_SHADER_OUTPUT}" "${FASET_SHADER_DIRECTORY}/gpuTemporalVertexMain.reflection.json"
COMMAND "${Python3_EXECUTABLE}" "${PROJECT_SOURCE_DIR}/tools/compile_shader.py"
--compiler "${SLANGC_EXECUTABLE}" --source "${PROJECT_SOURCE_DIR}/shaders/gpu_scene.slang"
--entry gpuTemporalVertexMain --define FASET_GPU_GRAPHICS=1 --output "${FASET_SHADER_DIRECTORY}"
BYPRODUCTS "${FASET_SHADER_DIRECTORY}/gpuTemporalVertexMain.slang-reflection.json"
DEPENDS "${PROJECT_SOURCE_DIR}/shaders/gpu_scene.slang" "${PROJECT_SOURCE_DIR}/tools/compile_shader.py" VERBATIM)
list(APPEND FASET_SHADER_OUTPUTS "${FASET_SHADER_OUTPUT}" "${FASET_SHADER_DIRECTORY}/gpuTemporalVertexMain.reflection.json")
set(FASET_SHADER_OUTPUT "${FASET_SHADER_DIRECTORY}/lightTileMain.spv")
add_custom_command(OUTPUT "${FASET_SHADER_OUTPUT}" "${FASET_SHADER_DIRECTORY}/lightTileMain.reflection.json"
COMMAND "${Python3_EXECUTABLE}" "${PROJECT_SOURCE_DIR}/tools/compile_shader.py"
--compiler "${SLANGC_EXECUTABLE}" --source "${PROJECT_SOURCE_DIR}/shaders/light_tiles.slang"
--entry lightTileMain --output "${FASET_SHADER_DIRECTORY}"
BYPRODUCTS "${FASET_SHADER_DIRECTORY}/lightTileMain.slang-reflection.json"
DEPENDS "${PROJECT_SOURCE_DIR}/shaders/light_tiles.slang" "${PROJECT_SOURCE_DIR}/tools/compile_shader.py" VERBATIM)
list(APPEND FASET_SHADER_OUTPUTS "${FASET_SHADER_OUTPUT}" "${FASET_SHADER_DIRECTORY}/lightTileMain.reflection.json")
foreach(FASET_ENTRY gpuVertexMain gpuShadowMain gpuCullMain gpuHzbMain gpuPostCullMain)
if(FASET_ENTRY STREQUAL "gpuVertexMain" OR FASET_ENTRY STREQUAL "gpuShadowMain")
set(FASET_GPU_DEFINE FASET_GPU_GRAPHICS=1)
@@ -34,13 +60,28 @@ foreach(FASET_ENTRY gpuVertexMain gpuShadowMain gpuCullMain gpuHzbMain gpuPostCu
DEPENDS "${PROJECT_SOURCE_DIR}/shaders/gpu_scene.slang" "${PROJECT_SOURCE_DIR}/tools/compile_shader.py" VERBATIM)
list(APPEND FASET_SHADER_OUTPUTS "${FASET_SHADER_OUTPUT}" "${FASET_SHADER_DIRECTORY}/${FASET_ENTRY}.reflection.json")
endforeach()
foreach(FASET_ENTRY temporalResolveMain temporalCompositeVertexMain temporalCompositeFragmentMain)
if(FASET_ENTRY STREQUAL "temporalResolveMain")
set(FASET_TEMPORAL_DEFINE FASET_TEMPORAL_RESOLVE=1)
else()
set(FASET_TEMPORAL_DEFINE FASET_TEMPORAL_COMPOSITE=1)
endif()
set(FASET_SHADER_OUTPUT "${FASET_SHADER_DIRECTORY}/${FASET_ENTRY}.spv")
add_custom_command(OUTPUT "${FASET_SHADER_OUTPUT}" "${FASET_SHADER_DIRECTORY}/${FASET_ENTRY}.reflection.json"
COMMAND "${Python3_EXECUTABLE}" "${PROJECT_SOURCE_DIR}/tools/compile_shader.py"
--compiler "${SLANGC_EXECUTABLE}" --source "${PROJECT_SOURCE_DIR}/shaders/temporal.slang"
--entry "${FASET_ENTRY}" --define "${FASET_TEMPORAL_DEFINE}" --output "${FASET_SHADER_DIRECTORY}"
BYPRODUCTS "${FASET_SHADER_DIRECTORY}/${FASET_ENTRY}.slang-reflection.json"
DEPENDS "${PROJECT_SOURCE_DIR}/shaders/temporal.slang" "${PROJECT_SOURCE_DIR}/tools/compile_shader.py" VERBATIM)
list(APPEND FASET_SHADER_OUTPUTS "${FASET_SHADER_OUTPUT}" "${FASET_SHADER_DIRECTORY}/${FASET_ENTRY}.reflection.json")
endforeach()
add_custom_command(OUTPUT "${FASET_SHADER_DIRECTORY}/compatibility.spv"
COMMAND "${SLANGC_EXECUTABLE}" "${PROJECT_SOURCE_DIR}/shaders/compatibility.hlsl"
-entry compatibilityMain -stage compute -target spirv -profile spirv_1_6
-o "${FASET_SHADER_DIRECTORY}/compatibility.spv"
DEPENDS "${PROJECT_SOURCE_DIR}/shaders/compatibility.hlsl" VERBATIM)
add_custom_target(faset_shaders DEPENDS ${FASET_SHADER_OUTPUTS} "${FASET_SHADER_DIRECTORY}/compatibility.spv")
add_library(faset_render "${PROJECT_SOURCE_DIR}/src/render/renderer.cpp" "${PROJECT_SOURCE_DIR}/src/render/math.cpp" "${PROJECT_SOURCE_DIR}/src/render/render_graph.cpp" "${PROJECT_SOURCE_DIR}/src/render/shader_contract.cpp" "${PROJECT_SOURCE_DIR}/src/render/lighting.cpp")
add_library(faset_render "${PROJECT_SOURCE_DIR}/src/render/renderer.cpp" "${PROJECT_SOURCE_DIR}/src/render/math.cpp" "${PROJECT_SOURCE_DIR}/src/render/render_graph.cpp" "${PROJECT_SOURCE_DIR}/src/render/shader_contract.cpp" "${PROJECT_SOURCE_DIR}/src/render/lighting.cpp" "${PROJECT_SOURCE_DIR}/src/render/temporal.cpp" "${PROJECT_SOURCE_DIR}/src/render/temporal_reference.cpp")
target_include_directories(faset_render PUBLIC "${PROJECT_SOURCE_DIR}/include")
target_compile_features(faset_render PUBLIC cxx_std_20)
target_link_libraries(faset_render PRIVATE Vulkan::Vulkan SDL3::SDL3 faset_core)
@@ -53,10 +94,38 @@ if(BUILD_TESTING)
set_tests_properties(render_lighting_sun PROPERTIES LABELS "gpu;p3")
add_test(NAME render_lighting_local COMMAND faset_render_lighting_gpu_tests --local)
set_tests_properties(render_lighting_local PROPERTIES LABELS "gpu;p3")
add_test(NAME render_lighting_tiled COMMAND faset_render_lighting_gpu_tests --tiled)
set_tests_properties(render_lighting_tiled PROPERTIES LABELS "gpu;p3")
add_executable(faset_render_lighting_policy_tests "${PROJECT_SOURCE_DIR}/tests/render_lighting_policy_tests.cpp")
target_link_libraries(faset_render_lighting_policy_tests PRIVATE faset_render)
add_test(NAME render_lighting_policy COMMAND faset_render_lighting_policy_tests)
set_tests_properties(render_lighting_policy PROPERTIES LABELS "p3")
add_executable(faset_render_temporal_graph_tests "${PROJECT_SOURCE_DIR}/tests/render_temporal_graph_tests.cpp")
target_link_libraries(faset_render_temporal_graph_tests PRIVATE faset_render)
add_test(NAME render_temporal_graph COMMAND faset_render_temporal_graph_tests)
set_tests_properties(render_temporal_graph PROPERTIES LABELS "gpu")
add_executable(faset_render_temporal_acceptance_tests "${PROJECT_SOURCE_DIR}/tests/render_temporal_acceptance_tests.cpp")
target_link_libraries(faset_render_temporal_acceptance_tests PRIVATE faset_render)
add_test(NAME render_temporal_acceptance COMMAND faset_render_temporal_acceptance_tests)
set_tests_properties(render_temporal_acceptance PROPERTIES LABELS "gpu")
add_executable(faset_render_temporal_shader_contract_tests "${PROJECT_SOURCE_DIR}/tests/render_temporal_shader_contract_tests.cpp")
target_include_directories(faset_render_temporal_shader_contract_tests PRIVATE "${PROJECT_SOURCE_DIR}/src/render")
target_link_libraries(faset_render_temporal_shader_contract_tests PRIVATE faset_render faset_core)
target_compile_definitions(faset_render_temporal_shader_contract_tests PRIVATE FASET_TEST_SHADER_DIRECTORY="${FASET_SHADER_DIRECTORY}")
add_test(NAME render_temporal_shader_contract COMMAND faset_render_temporal_shader_contract_tests)
add_executable(faset_render_temporal_reference_tests "${PROJECT_SOURCE_DIR}/tests/render_temporal_reference_tests.cpp")
target_link_libraries(faset_render_temporal_reference_tests PRIVATE faset_render)
add_test(NAME render_temporal_reference COMMAND faset_render_temporal_reference_tests)
add_executable(faset_render_temporal_lifecycle_tests "${PROJECT_SOURCE_DIR}/tests/render_temporal_lifecycle_tests.cpp")
target_link_libraries(faset_render_temporal_lifecycle_tests PRIVATE faset_render)
add_test(NAME render_temporal_lifecycle COMMAND faset_render_temporal_lifecycle_tests)
set_tests_properties(render_temporal_lifecycle PROPERTIES LABELS "gpu")
add_executable(faset_render_temporal_motion_tests "${PROJECT_SOURCE_DIR}/tests/render_temporal_motion_tests.cpp")
target_link_libraries(faset_render_temporal_motion_tests PRIVATE faset_render)
add_test(NAME render_temporal_motion COMMAND faset_render_temporal_motion_tests)
add_executable(faset_render_temporal_policy_tests "${PROJECT_SOURCE_DIR}/tests/render_temporal_policy_tests.cpp")
target_link_libraries(faset_render_temporal_policy_tests PRIVATE faset_render)
add_test(NAME render_temporal_policy COMMAND faset_render_temporal_policy_tests)
add_executable(faset_render_tests "${PROJECT_SOURCE_DIR}/tests/render_tests.cpp")
target_link_libraries(faset_render_tests PRIVATE faset_render SDL3::SDL3)
add_test(NAME render_graph COMMAND faset_render_tests --unit)
+35
View File
@@ -520,6 +520,41 @@ real-diagnostic-navigation assertion still awaits its Windows CI result.
Windows had no active Khronos validation layer, and no physical Windows GPU
performance result is claimed.
## P3 lighting checkpoint — authored lights and bounded shadow views
At source revision `b191ae0`, the versioned `faset.light` schema and SceneView
extract directional, point, and spot lights. Any authored Light, even disabled,
suppresses the compatibility sun; scenes without a Light keep their previous
appearance. The renderer validates all local records, then selects at most 128
by priority, projected influence, and stable ID. A single typed lighting
descriptor ABI serves Direct and P2 GPU graphics: materials remain set 0,
lighting is set 1, GPU scene graphics data moves to set 2, and existing push
constant sizes remain unchanged. Both paths shade the same sun/local PBR lights
before tone mapping.
A pure CPU shadow planner builds up to four texel-snapped sun cascades from an
explicit camera frustum, ending at at most 80 world units; a low-level Snapshot
without the frustum keeps one shadow view. Shadow caster bounds come from the
source LOD-0 draw and are tested against the light view, independently of
camera/P2 culling. The Vulkan backend renders the sun to its own D32 atlas and
point/spot shadows to a separate 4×4 D32 atlas. A point light claims six faces
atomically, a spot one. Both atlases try 2048² and then 1024² if required by
capabilities or allocation. The combined frame budget is 4096 caster draws;
scheduled tiles are cleared and redrawn each frame. Overflow, disabled shadow,
or unavailable atlas leaves a submitted light illuminating without shadow.
There is no hidden sun raster when the sun is absent, its shadow is disabled, or
the scene only has sprites. Atlas ownership, dropout, submitted light counts,
actual raster work and GPU timings are exposed in `FrameStats`, Player profiles
and the optional Editor diagnostics overlay.
The implementation's Linux Debug checkpoint at `a5fb216` built all targets and
ran 60 CTests with no failures; the existing native window lifecycle test
skipped under the compositor. The optional ImGui overlay passed its dedicated
test in an enabled build. After benchmark integration at `b191ae0`, six focused
tests passed, including the real Vulkan benchmark smoke. These are bounded
checks, not a final P3 acceptance run. The [lighting validation record](validation/p3-lighting-2026-09-24/README.md)
lists cases, exact revision, and remaining Windows/Release evidence.
The fixed-scene Release reference-GPU sweep uses 1920×1080, 0/4/16/32/64/128
lights, Direct/GPU frustum/GPU occlusion, shadows on/off, three independent
repeats, ten warm-up and thirty measured frames per configuration. It reached
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

+6
View File
@@ -35,6 +35,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.
+34 -6
View File
@@ -147,6 +147,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.
## Measure P3 lighting and shadows
A Player `--profile` sample includes `effective_lighting_path`, local lights
@@ -216,9 +242,11 @@ raw frames, shader hashes, and the decision.
The accepted MVP path uses direct draws and CPU culling; P2 adds optional GPU
visibility for opaque static meshes, with prepared LODs supplied by the project.
Both paths currently use one graphics queue and synchronous full-image
capture/readback. Use measurements to find the next bottleneck before introducing
parallel jobs or expanding GPU-driven rendering. Neither an offscreen capture
benchmark nor a tiny demo is a promise of a production frame budget. Observed
measurements and follow-up targets belong in the implementation acceptance report
with their source revision and method.
P3 adds local lights and bounded sun/local shadow atlases. The benchmark's
`lighting_path` and a Player profile's `effective_lighting_path` identify the
algorithm actually used. Both paths currently use one graphics queue and
synchronous full-image capture/readback. Use measurements to find the next
bottleneck before introducing parallel jobs or expanding GPU-driven rendering.
Neither an offscreen capture benchmark nor a tiny demo is a promise of a
production frame budget. Observed measurements and follow-up targets belong in
the implementation acceptance report with their source revision and method.
+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.
@@ -0,0 +1,12 @@
Test project /home/emil/Desktop/.worktrees/Faset_Engine-p3-lighting/build/linux-debug
Start 9: render_lighting_policy
1/2 Test #9: render_lighting_policy ............. Passed 0.04 sec
Start 17: render_lighting_benchmark_schema
2/2 Test #17: render_lighting_benchmark_schema ... Passed 4.95 sec
100% tests passed, 0 tests failed out of 2
Label Time Summary:
p3 = 4.99 sec*proc (2 tests)
Total Test time (real) = 5.00 sec
@@ -0,0 +1,8 @@
Test project /home/emil/Desktop/.worktrees/Faset_Engine-p3-lighting/build/linux-debug
Test #7: render_lighting_sun
Test #8: render_lighting_local
Test #9: render_lighting_policy
Test #17: render_lighting_benchmark_schema
Test #18: render_lighting_benchmark_smoke
Total Tests: 5
@@ -0,0 +1,20 @@
warning: An executable named `mkdocs` is not provided by package `mkdocs-material` but is available via the dependency `mkdocs`. Consider using `uvx --from mkdocs mkdocs` instead.
 │ ⚠ Warning from the Material for MkDocs team
 │
 │ MkDocs 2.0, the underlying framework of Material for MkDocs,
 │ will introduce backward-incompatible changes, including:
 │
 │ × All plugins will stop working – the plugin system has been removed
 │ × All theme overrides will break – the theming system has been rewritten
 │ × No migration path exists – existing projects cannot be upgraded
 │ × Closed contribution model – community members can't report bugs
 │ × Currently unlicensed – unsuitable for production use
 │
 │ Our full analysis:
 │
 │ https://squidfunk.github.io/mkdocs-material/blog/2026/02/18/mkdocs-2.0/

INFO - Cleaning site directory
INFO - Building documentation to directory: /home/emil/Desktop/.worktrees/Faset_Engine-p3-lighting/build/manual
INFO - Documentation built in 0.84 seconds
+40 -7
View File
@@ -19,8 +19,10 @@ namespace fs = std::filesystem;
namespace {
struct Options {
unsigned lights{}, width{1920}, height{1080}, warmup{10}, frames{30}, run_index{};
bool shadows{}, validation{};
bool shadows{}, validation{}, tile_diagnostics{};
VisibilityMode visibility{VisibilityMode::Direct};
LightingMode lighting{LightingMode::Auto};
std::string light_layout{"dense"};
std::string commit{"unknown"}, driver{"unknown"};
fs::path csv, capture;
};
@@ -48,6 +50,8 @@ Options parse(int argc, char** argv) {
"--shadows on|off --visibility direct|gpu-frustum|gpu-occlusion "
"--csv PATH [--width N --height N --warmup N --frames N "
"--run-index N --commit SHA --driver NAME --validation on|off "
"--lighting auto|forward|tiled --light-layout dense|localized "
"--tile-diagnostics on|off "
"--capture PATH]\n";
std::exit(0);
}
@@ -72,11 +76,24 @@ Options parse(int argc, char** argv) {
if (value != "on" && value != "off")
throw std::invalid_argument("--validation must be on or off");
options.validation = value == "on";
} else if (name == "--tile-diagnostics") {
if (value != "on" && value != "off")
throw std::invalid_argument("--tile-diagnostics must be on or off");
options.tile_diagnostics = value == "on";
} else if (name == "--visibility") {
if (value == "direct") options.visibility = VisibilityMode::Direct;
else if (value == "gpu-frustum") options.visibility = VisibilityMode::GpuFrustum;
else if (value == "gpu-occlusion") options.visibility = VisibilityMode::GpuOcclusion;
else throw std::invalid_argument("Unknown visibility mode: " + value);
} else if (name == "--lighting") {
if (value == "auto") options.lighting = LightingMode::Auto;
else if (value == "forward") options.lighting = LightingMode::Forward;
else if (value == "tiled") options.lighting = LightingMode::Tiled;
else throw std::invalid_argument("Unknown lighting mode: " + value);
} else if (name == "--light-layout") {
if (value != "dense" && value != "localized")
throw std::invalid_argument("--light-layout must be dense or localized");
options.light_layout = value;
} else throw std::invalid_argument("Unknown option: " + name);
}
constexpr std::array allowed_lights{0u, 4u, 16u, 32u, 64u, 128u};
@@ -146,7 +163,7 @@ Snapshot benchmark_scene(const Options& options) {
.6f + .4f * float(i % 3 == 1),
.6f + .4f * float(i % 3 == 2), 1};
light.intensity = 5.f;
light.range = 8.f;
light.range = options.light_layout == "localized" ? 1.75f : 8.f;
light.casts_shadow = options.shadows;
scene.local_lights.push_back(std::move(light));
}
@@ -160,6 +177,8 @@ void benchmark(const Options& options) {
config.headless = true;
config.validation = options.validation;
config.visibility_mode = options.visibility;
config.lighting_mode = options.lighting;
config.visibility_diagnostics = options.tile_diagnostics;
auto renderer = Renderer(config);
const auto scene = benchmark_scene(options);
for (unsigned i = 0; i < options.warmup; ++i)
@@ -169,14 +188,18 @@ void benchmark(const Options& options) {
std::ofstream csv(faset::native_io_path(options.csv));
if (!csv)
throw std::runtime_error("Cannot open benchmark CSV: " + faset::path_to_utf8(options.csv));
csv << "light_count,shadows,visibility,effective_visibility,lighting_path,"
csv << "light_count,light_layout,shadows,visibility,effective_visibility,lighting_path,"
"requested_lighting,"
"build_configuration,run_index,frame,"
"device,driver,commit,width,height,validation_enabled,validation_errors,"
"submitted_local_lights,omitted_local_lights,"
"requested_local_shadow_faces,rendered_local_shadow_faces,dropped_shadow_faces,"
"shadow_atlas_full_drops,shadow_tiles,draw_calls,gpu_bytes,"
"gpu_main_raster_ms,gpu_post_raster_ms,gpu_post_visible,visibility_counters_valid,"
"gpu_sun_shadow_ms,gpu_local_shadow_ms,gpu_shadow_ms,gpu_ms,cpu_ms,readback_cpu_ms\n";
"gpu_sun_shadow_ms,gpu_local_shadow_ms,gpu_shadow_ms,gpu_light_tiles_ms,"
"gpu_build_plus_raster_ms,light_tile_count,light_tile_counts_valid,"
"light_tile_candidate_count,light_tile_overflow_count,"
"gpu_ms,cpu_ms,readback_cpu_ms\n";
csv << std::fixed << std::setprecision(6);
for (unsigned frame = 0; frame < options.frames; ++frame) {
renderer.render(scene);
@@ -189,9 +212,13 @@ void benchmark(const Options& options) {
throw std::runtime_error("Requested visibility path fell back during benchmark");
if (stats.gpu_main_raster_ms <= 0 || stats.gpu_ms <= 0)
throw std::runtime_error("GPU raster or frame timestamp was unavailable");
csv << options.lights << ',' << (options.shadows ? "on" : "off") << ','
csv << options.lights << ',' << options.light_layout << ','
<< (options.shadows ? "on" : "off") << ','
<< mode_name(options.visibility) << ',' << mode_name(stats.effective_visibility_mode)
<< ',' << stats.effective_lighting_path << ',' << FASET_BENCHMARK_CONFIGURATION << ','
<< ',' << stats.effective_lighting_path << ','
<< (options.lighting == LightingMode::Forward ? "forward" :
options.lighting == LightingMode::Tiled ? "tiled" : "auto") << ','
<< FASET_BENCHMARK_CONFIGURATION << ','
<< options.run_index << ',' << frame << ',';
csv_text(csv, stats.device);
csv << ',';
@@ -208,7 +235,13 @@ void benchmark(const Options& options) {
<< stats.gpu_main_raster_ms << ',' << stats.gpu_post_raster_ms << ','
<< stats.gpu_post_visible << ',' << (stats.visibility_counters_valid ? 1 : 0)
<< ',' << stats.gpu_sun_shadow_ms << ',' << stats.gpu_local_shadow_ms << ','
<< (stats.gpu_sun_shadow_ms + stats.gpu_local_shadow_ms) << ',' << stats.gpu_ms << ','
<< (stats.gpu_sun_shadow_ms + stats.gpu_local_shadow_ms) << ','
<< stats.gpu_light_tiles_ms << ','
<< (stats.gpu_main_raster_ms + stats.gpu_post_raster_ms +
stats.gpu_light_tiles_ms) << ','
<< stats.light_tile_count << ',' << (stats.light_tile_counts_valid ? 1 : 0)
<< ',' << stats.light_tile_candidate_count << ','
<< stats.light_tile_overflow_count << ',' << stats.gpu_ms << ','
<< stats.cpu_ms << ',' << stats.readback_cpu_ms << '\n';
}
if (!csv)
+25
View File
@@ -6,6 +6,7 @@
#include <optional>
#include <string>
#include <vector>
#include <faset/render/temporal.hpp>
namespace faset::render {
using Vec2 = std::array<float, 2>;
@@ -136,6 +137,7 @@ struct Snapshot {
std::optional<CameraFrustum> camera_frustum{};
};
enum class VisibilityMode { Direct, GpuFrustum, GpuOcclusion };
enum class LightingMode { Auto, Forward, Tiled };
// CPU-only validation used before publishing a game or creating Vulkan pipelines.
void validate_shader_bundle(const std::filesystem::path& directory);
void validate_gpu_shader_bundle(const std::filesystem::path& directory);
@@ -146,6 +148,11 @@ struct RendererConfig {
bool headless{false};
bool validation{true};
VisibilityMode visibility_mode{VisibilityMode::Direct};
TemporalMode temporal_mode{TemporalMode::Off};
float render_scale{1.f};
// Auto uses measured Forward on the reference workload. Tiled can be forced
// for comparison and falls back to Forward if its device path is unavailable.
LightingMode lighting_mode{LightingMode::Auto};
// GPU counter readback is diagnostic-only; normal visibility uses no CPU feedback.
bool visibility_diagnostics{false};
// Optional isolated shader bundle, useful for editor preview and shader reload tests.
@@ -204,6 +211,21 @@ struct FrameStats {
double gpu_main_cull_ms{}, gpu_main_raster_ms{}, gpu_hzb_ms{};
double gpu_post_cull_ms{}, gpu_post_raster_ms{};
double gpu_sun_shadow_ms{}, gpu_local_shadow_ms{};
TemporalMode requested_temporal_mode{TemporalMode::Off};
TemporalMode effective_temporal_mode{TemporalMode::Off};
TemporalFallbackReason temporal_fallback_reason{TemporalFallbackReason::None};
TemporalResetReason temporal_reset_reason{TemporalResetReason::FirstFrame};
bool temporal_history_valid{};
std::uint32_t temporal_valid_motion_instances{};
std::uint32_t temporal_internal_width{}, temporal_internal_height{};
std::array<float, 2> temporal_jitter{};
double gpu_temporal_resolve_ms{}, gpu_temporal_composite_ms{}, gpu_ui_ms{};
std::vector<std::string> graph_passes;
double gpu_light_tiles_ms{};
std::uint32_t light_tile_count{};
// Optional tile-list readback, valid only when visibility diagnostics are on.
bool light_tile_counts_valid{};
std::uint32_t light_tile_candidate_count{}, light_tile_overflow_count{};
std::string effective_lighting_path{"forward"};
std::string device;
};
@@ -224,6 +246,9 @@ class Renderer {
void resize(std::uint32_t width, std::uint32_t height);
void set_visibility_mode(VisibilityMode);
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.
+103
View File
@@ -0,0 +1,103 @@
#pragma once
#include <array>
#include <cstdint>
#include <optional>
#include <string>
namespace faset::render {
enum class TemporalMode { Off, TAA, Upscale };
struct TemporalCapabilities {
bool compute{};
bool formats{};
bool extent{};
};
enum class TemporalFallbackReason {
None,
ComputeUnavailable,
FormatUnavailable,
ExtentUnsupported
};
TemporalFallbackReason temporal_fallback_reason(TemporalMode requested,
TemporalCapabilities available) noexcept;
TemporalMode select_effective_temporal_mode(TemporalMode requested,
TemporalCapabilities available) noexcept;
// Validate the requested mode and scale before allocating scene targets.
// Off/TAA are 1:1; Upscale accepts [0.5, 1). Ceil rounding is deterministic
// for odd output extents and never produces a zero-sized internal target.
std::array<std::uint32_t, 2> temporal_internal_extent(std::uint32_t output_width,
std::uint32_t output_height,
TemporalMode mode, float render_scale);
enum class TemporalResetReason {
None,
FirstFrame,
CameraCut,
CameraDiscontinuity,
ViewChanged,
ViewportChanged,
ProjectionChanged,
Resize,
ModeChanged,
ScaleChanged,
ShaderReload,
Unsupported
};
// Snapshot matrices/rect stay unjittered. This is independent of HZB history:
// Direct rendering and GPU frustum mode can still accumulate temporal color.
// A missing previous key means no completed frame is available to reuse.
struct TemporalHistoryKey {
std::string view_id;
std::uint32_t output_width{}, output_height{};
std::uint32_t internal_width{}, internal_height{};
std::array<float, 4> scene_rect{};
std::array<float, 16> projection{}, view_projection{};
std::array<float, 3> camera_eye{};
TemporalMode mode{TemporalMode::Off};
float render_scale{1};
std::uint64_t shader_generation{};
bool camera_cut{};
};
struct TemporalHistoryDecision {
bool valid{};
TemporalResetReason reason{TemporalResetReason::FirstFrame};
};
TemporalHistoryDecision
evaluate_temporal_history(const std::optional<TemporalHistoryKey>& previous,
const TemporalHistoryKey& current) noexcept;
// The renderer calls prepare before recording and complete only after a
// successful GPU submission. An exception or failed submission leaves the
// previously completed key intact. This state is independent of P2 HZB.
class TemporalHistoryState {
public:
TemporalHistoryDecision prepare(const TemporalHistoryKey& current) const noexcept;
void complete(const TemporalHistoryKey& rendered);
private:
std::optional<TemporalHistoryKey> completed_;
};
// Sixteen-phase Halton(2,3) offset in clip-space units for scene rasterization.
// UI, picking and history keys use unjittered space. Culling must account for
// this jitter with a conservative edge; HZB depth must match jittered geometry.
// A zero viewport extent throws std::invalid_argument.
std::array<float, 2> temporal_jitter(std::uint64_t frame_index,
std::uint32_t viewport_width,
std::uint32_t viewport_height);
// Scene-local normalized UV motion, current minus previous. Both clips use
// their own jittered scene VP and the same local vertex. Invalid/behind-eye
// clips have no usable history; the shader writes velocity validity zero.
std::optional<std::array<float, 2>>
project_motion(const std::array<float, 4>& current_clip,
const std::array<float, 4>& previous_clip) noexcept;
} // namespace faset::render
@@ -0,0 +1,45 @@
#pragma once
#include <array>
#include <cstdint>
#include <vector>
namespace faset::render {
// CPU-only 1:1 oracle for the temporal resolve shader. Color is already
// display-referred; motion is current-minus-previous normalized scene UV.
// The renderer does not call this per-pixel path in production.
struct TemporalReferencePixel {
std::array<float, 4> color{0, 0, 0, 1};
float depth{1};
std::array<float, 2> motion{};
float previous_depth{1};
bool motion_valid{};
float reactive{};
};
struct TemporalReferenceFrame {
std::uint32_t width{}, height{};
std::vector<TemporalReferencePixel> pixels;
};
struct TemporalReferenceHistory {
std::uint32_t width{}, height{};
std::vector<std::array<float, 4>> color;
std::vector<float> depth;
};
struct TemporalReferenceResult {
TemporalReferenceHistory history;
std::vector<std::uint8_t> accepted;
std::uint32_t accepted_count{};
};
// A null previous history is the first-frame/cut fallback. Extents must agree
// when history is supplied; reset it instead of reprojecting stale dimensions.
// Input is bounded to four million pixels so synthetic tests cannot consume
// unbounded memory. Invalid motion rejects history for that pixel.
TemporalReferenceResult temporal_reference_resolve(
const TemporalReferenceFrame& current,
const TemporalReferenceHistory* previous = nullptr);
} // namespace faset::render
+11 -3
View File
@@ -37,11 +37,19 @@ struct InstanceUpdate {
bool previous_valid{};
};
// GPU instance metadata: history valid, stable slot, then generation low/high.
// GPU instance metadata: x bits 0/1 separately mark previous HZB bounds and
// previous temporal transform; the other lanes carry stable slot/generation.
// A zero generation identifies an anonymous, untracked draw.
inline constexpr std::uint32_t gpu_hzb_history_bit = 1U;
inline constexpr std::uint32_t gpu_temporal_history_bit = 2U;
constexpr std::array<std::uint32_t, 4>
gpu_instance_metadata(const InstanceUpdate& update, bool history_compatible) noexcept {
return {update.previous_valid && history_compatible ? 1U : 0U, update.slot,
gpu_instance_metadata(const InstanceUpdate& update, bool hzb_compatible,
bool temporal_compatible = false) noexcept {
return {update.previous_valid
? (hzb_compatible ? gpu_hzb_history_bit : 0U) |
(temporal_compatible ? gpu_temporal_history_bit : 0U)
: 0U,
update.slot,
static_cast<std::uint32_t>(update.generation),
static_cast<std::uint32_t>(update.generation >> 32)};
}
+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
+72 -3
View File
@@ -50,6 +50,9 @@ struct ShadowViewGpu {
[[vk::binding(1,1)]] StructuredBuffer<LocalLightGpu> localLights;
[[vk::binding(2,1)]] StructuredBuffer<ShadowViewGpu> shadowViews;
[[vk::binding(3,1)]] Texture2D<float> localShadowAtlas;
// 4-word header, then 66 words per 16x16 tile: count, overflow, 64 indices.
// An overflowing tile evaluates the full submitted list instead of losing light.
[[vk::binding(4,1)]] StructuredBuffer<uint> lightTileWords;
[shader("vertex")]
VertexOutput vertexMain(VertexInput v) {
VertexOutput o;
@@ -122,8 +125,7 @@ float3 directBRDF(float3 base, float rough, float metal, float3 n, float3 view,
float3 spec=d*g*fresnel/max(4.0*nv*nl,0.001);
return ((1.0-fresnel)*(1.0-metal)*base/pi+spec)*nl;
}
[shader("fragment")]
float4 fragmentMain(VertexOutput v) : SV_Target {
float4 shadeScene(VertexOutput v) {
float4 sampled = colorMap.Sample(colorSampler, v.uv);
if (dot(v.normal,v.normal) < 1e-12) {
// UI/sprite tint is in display space; sRGB textures were decoded by Vulkan.
@@ -171,7 +173,20 @@ float4 fragmentMain(VertexOutput v) : SV_Target {
linear += directBRDF(base.rgb, rough, metal, n, view, l) *
lighting.sunColor.rgb * (lighting.sunDirectionIntensity.w * 3.0 * visibility);
}
for (uint i=0; i<lighting.counts.x; ++i) {
uint candidateCount = lighting.counts.x;
uint tileBase = 0;
bool tileList = false;
if (lightTileWords[1] != 0 && lightTileWords[0] != 0 && lightTileWords[2] != 0) {
uint tileX = min(uint(v.position.x) / 16u, lightTileWords[0] - 1u);
uint tileY = min(uint(v.position.y) / 16u, lightTileWords[2] - 1u);
tileBase = 4u + (tileY * lightTileWords[0] + tileX) * 66u;
if (lightTileWords[tileBase + 1u] == 0) {
candidateCount = min(lightTileWords[tileBase], lighting.counts.x);
tileList = true;
}
}
for (uint candidate=0; candidate<candidateCount; ++candidate) {
uint i = tileList ? lightTileWords[tileBase + 2u + candidate] : candidate;
LocalLightGpu light=localLights[i];
float3 delta=light.positionRange.xyz-v.world;
float distanceSquared=max(dot(delta,delta),1e-6);
@@ -202,3 +217,57 @@ float4 fragmentMain(VertexOutput v) : SV_Target {
linear=linear/(1.0+linear);
return float4(pow(max(linear,0),float3(1.0/2.2)),base.a);
}
[shader("fragment")]
float4 fragmentMain(VertexOutput v) : SV_Target { return shadeScene(v); }
struct TemporalVertexInput {
float4 clip : POSITION;
float3 world : TEXCOORD0;
float3 normal : NORMAL;
float4 color : COLOR0;
float2 material : TEXCOORD1;
float2 uv : TEXCOORD2;
float4 previousClip : TEXCOORD3;
float motionValid : TEXCOORD4;
};
struct TemporalVertexOutput {
float4 position : SV_Position;
float3 world : TEXCOORD0;
float3 normal : NORMAL;
float4 color : COLOR0;
float2 material : TEXCOORD1;
float2 uv : TEXCOORD2;
float4 previousClip : TEXCOORD3;
float4 currentClip : TEXCOORD4;
float motionValid : TEXCOORD5;
};
[shader("vertex")]
TemporalVertexOutput temporalVertexMain(TemporalVertexInput v) {
TemporalVertexOutput o;
o.position=v.clip; o.world=v.world; o.normal=v.normal; o.color=v.color;
o.material=v.material; o.uv=v.uv; o.previousClip=v.previousClip;
o.currentClip=v.clip; o.motionValid=v.motionValid;
return o;
}
struct TemporalFragmentOutput {
float4 color : SV_Target0;
float4 velocity : SV_Target1;
};
[shader("fragment")]
TemporalFragmentOutput temporalFragmentMain(TemporalVertexOutput v) {
VertexOutput shading;
shading.position=v.position; shading.world=v.world; shading.normal=v.normal;
shading.color=v.color; shading.material=v.material; shading.uv=v.uv;
TemporalFragmentOutput result;
result.color=shadeScene(shading);
result.velocity=float4(0);
if (v.motionValid > .5 && result.color.a >= .999 &&
all(isfinite(v.currentClip)) && all(isfinite(v.previousClip)) &&
v.currentClip.w > 0 && v.previousClip.w > 0) {
result.velocity.xy=(v.currentClip.xy / v.currentClip.w -
v.previousClip.xy / v.previousClip.w) * .5;
result.velocity.z=v.previousClip.z / v.previousClip.w;
result.velocity.w=1;
}
return result;
}
+39 -2
View File
@@ -26,8 +26,9 @@ struct InstanceRecord {
float4 currentExtent; // 160..175: world AABB half extents
float4 previousCenter; // 176..191
float4 previousExtent; // 192..207
uint4 metadata; // 208..223: x=previousValid, y=stableSlot,
uint4 metadata; // 208..223: x bits 0=HZB, 1=temporal prior valid; y=stableSlot,
// z=generation low 32, w=generation high 32 (zero = untracked)
column_major float4x4 previousModel; // 224..287
};
struct ViewRecord {
column_major float4x4 currentViewProjection; // 0..63
@@ -81,6 +82,42 @@ GpuSceneOutput gpuVertexMain(GpuSceneVertex vertex, uint drawInstance : SV_Vulka
return output;
}
struct GpuSceneTemporalOutput {
float4 position : SV_Position;
float3 world : TEXCOORD0;
float3 normal : NORMAL;
float4 color : COLOR0;
float2 material : TEXCOORD1;
float2 uv : TEXCOORD2;
float4 previousClip : TEXCOORD3;
float4 currentClip : TEXCOORD4;
float motionValid : TEXCOORD5;
};
[shader("vertex")]
GpuSceneTemporalOutput gpuTemporalVertexMain(GpuSceneVertex vertex,
uint drawInstance : SV_VulkanInstanceID) {
InstanceRecord instance = gfxInstances[gfxVisibleIds[gpuFrame.drawInfo.x + drawInstance]];
ViewRecord view = gfxViews[0];
float4 local = float4(vertex.position, 1);
float4 world = mul(instance.model, local);
GpuSceneTemporalOutput output;
output.position = mul(view.currentViewProjection, world);
output.currentClip = output.position;
output.previousClip = mul(view.previousViewProjection,
mul(instance.previousModel, local));
output.motionValid = (instance.metadata.x & 2u) != 0u ? 1.0 : 0.0;
output.world = world.xyz;
float3 normal = float3(dot(instance.normalRow0.xyz, vertex.normal),
dot(instance.normalRow1.xyz, vertex.normal),
dot(instance.normalRow2.xyz, vertex.normal));
float normalLength = length(normal);
output.normal = normalLength > 1e-8 ? normal / normalLength : float3(0, 0, 0);
output.color = vertex.color * instance.color;
output.material = instance.material.xy;
output.uv = vertex.uv;
return output;
}
[shader("vertex")]
float4 gpuShadowMain(GpuSceneVertex vertex, uint drawInstance : SV_VulkanInstanceID) : SV_Position {
InstanceRecord instance = gfxInstances[gfxVisibleIds[gpuFrame.drawInfo.x + drawInstance]];
@@ -200,7 +237,7 @@ void gpuCullMain(uint3 dispatchId : SV_DispatchThreadID) {
ViewRecord view = cullViews[0];
if (!inFrustum(instance.currentCenter, instance.currentExtent,
view.currentViewProjection)) return;
bool guessedHidden = view.flags.x != 0 && instance.metadata.x != 0 &&
bool guessedHidden = view.flags.x != 0 && (instance.metadata.x & 1u) != 0 &&
occluded(instance.previousCenter, instance.previousExtent,
view.previousViewProjection, view.previousViewport,
view.previousHzbSize, previousHzb);
+75
View File
@@ -0,0 +1,75 @@
// Conservative depth-free 16x16 Forward+ construction. One invocation owns
// one tile, so indices remain in the same sorted order as the forward reference.
struct LocalLightGpu {
float4 positionRange;
float4 directionCosOuter;
float4 colorIntensity;
float4 coneTypeShadowView;
float4 reserved;
};
struct TileBuildParameters {
column_major float4x4 viewProjection;
float4 viewport; // x, y, width, height in framebuffer pixels
uint4 dimensions; // tilesX, tilesY, submitted local lights, capacity (<= 64)
};
[[vk::push_constant]] ConstantBuffer<TileBuildParameters> build;
[[vk::binding(0,0)]] StructuredBuffer<LocalLightGpu> localLights;
[[vk::binding(1,0)]] RWStructuredBuffer<uint> tileWords;
bool sphereTouchesPlane(float3 center, float radius, float4 plane) {
// The final epsilon admits boundary/rounding cases rather than dropping a
// light. We intentionally do not use scene depth or reject near-plane cuts.
return dot(plane, float4(center, 1.0)) + radius * length(plane.xyz) >= -1e-4;
}
[shader("compute")]
[numthreads(64, 1, 1)]
void lightTileMain(uint3 dispatchId : SV_DispatchThreadID) {
uint tileId = dispatchId.x;
uint tilesX = build.dimensions.x;
uint tilesY = build.dimensions.y;
if (tileId >= tilesX * tilesY) return;
if (tileId == 0) {
tileWords[0] = tilesX;
tileWords[1] = 1;
tileWords[2] = tilesY;
tileWords[3] = min(build.dimensions.w, 64u);
}
uint tileX = tileId % tilesX;
uint tileY = tileId / tilesX;
float x0 = float(tileX * 16u);
float y0 = float(tileY * 16u);
float x1 = x0 + 16.0;
float y1 = y0 + 16.0;
float left = 2.0 * (x0 - build.viewport.x) / build.viewport.z - 1.0;
float right = 2.0 * (x1 - build.viewport.x) / build.viewport.z - 1.0;
float top = 2.0 * (y0 - build.viewport.y) / build.viewport.w - 1.0;
float bottom = 2.0 * (y1 - build.viewport.y) / build.viewport.w - 1.0;
float4 xRow = mul(float4(1, 0, 0, 0), build.viewProjection);
float4 yRow = mul(float4(0, 1, 0, 0), build.viewProjection);
float4 wRow = mul(float4(0, 0, 0, 1), build.viewProjection);
float4 leftPlane = xRow - left * wRow;
float4 rightPlane = right * wRow - xRow;
float4 topPlane = yRow - top * wRow;
float4 bottomPlane = bottom * wRow - yRow;
uint base = 4u + tileId * 66u;
uint count = 0;
bool overflow = false;
for (uint i = 0; i < build.dimensions.z; ++i) {
LocalLightGpu light = localLights[i];
if (light.colorIntensity.w <= 0.0) continue;
float3 center = light.positionRange.xyz;
float radius = light.positionRange.w;
if (!sphereTouchesPlane(center, radius, leftPlane) ||
!sphereTouchesPlane(center, radius, rightPlane) ||
!sphereTouchesPlane(center, radius, topPlane) ||
!sphereTouchesPlane(center, radius, bottomPlane)) continue;
if (count < min(build.dimensions.w, 64u))
tileWords[base + 2u + count] = i;
else
overflow = true;
++count;
}
tileWords[base] = min(count, min(build.dimensions.w, 64u));
tileWords[base + 1u] = overflow ? 1u : 0u;
}
+184
View File
@@ -0,0 +1,184 @@
// Temporal scene resolve and full-resolution composite. No material/lighting
// descriptors are consumed here; the scene was shaded before this pass.
// Velocity target: xy = current-minus-prior scene-local UV, z = expected prior
// clip depth, w = opaque motion validity (zero for reactive/invalid pixels).
#if defined(FASET_TEMPORAL_RESOLVE)
struct TemporalResolveParameters {
uint4 dimensions; // output width/height, internal width/height
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;
[[vk::binding(1,0)]] Texture2D<float> currentSceneDepth;
[[vk::binding(2,0)]] Texture2D<float4> currentSceneVelocity;
[[vk::binding(3,0)]] Texture2D<float4> previousHistoryColor;
[[vk::binding(4,0)]] Texture2D<float> previousHistoryDepth;
[[vk::binding(5,0)]] [vk::image_format("rgba16f")]
RWTexture2D<float4> nextHistoryColor;
[[vk::binding(6,0)]] [vk::image_format("r32f")]
RWTexture2D<float> nextHistoryDepth;
int2 clampScenePixel(int2 pixel) {
return clamp(pixel, int2(0), int2(temporalParameters.dimensions.zw) - 1);
}
float4 sceneColorAt(int2 pixel) {
return currentSceneColor.Load(int3(clampScenePixel(pixel), 0));
}
float sceneDepthAt(int2 pixel) {
return currentSceneDepth.Load(int3(clampScenePixel(pixel), 0));
}
float4 sceneVelocityAt(int2 pixel) {
return currentSceneVelocity.Load(int3(clampScenePixel(pixel), 0));
}
float4 historyBilinear(float2 uv) {
float2 position = uv * float2(temporalParameters.dimensions.xy) - .5;
int2 base = int2(floor(position));
float2 fraction = position - float2(base);
int2 limit = int2(temporalParameters.dimensions.xy) - 1;
int2 p00 = clamp(base, int2(0), limit);
int2 p10 = clamp(base + int2(1, 0), int2(0), limit);
int2 p01 = clamp(base + int2(0, 1), int2(0), limit);
int2 p11 = clamp(base + int2(1, 1), int2(0), limit);
float4 top = lerp(previousHistoryColor.Load(int3(p00, 0)),
previousHistoryColor.Load(int3(p10, 0)), fraction.x);
float4 bottom = lerp(previousHistoryColor.Load(int3(p01, 0)),
previousHistoryColor.Load(int3(p11, 0)), fraction.x);
return lerp(top, bottom, fraction.y);
}
[shader("compute")]
[numthreads(8, 8, 1)]
void temporalResolveMain(uint3 dispatchId : SV_DispatchThreadID) {
const uint2 outputPixel = dispatchId.xy;
const uint2 outputExtent = temporalParameters.dimensions.xy;
const uint2 internalExtent = temporalParameters.dimensions.zw;
if (outputPixel.x >= outputExtent.x || outputPixel.y >= outputExtent.y) return;
const float2 center = float2(outputPixel) + .5;
const float4 outputRect = temporalParameters.outputSceneRect;
const float4 internalRect = temporalParameters.internalSceneRect;
const bool insideScene = all(center >= outputRect.xy) &&
all(center < outputRect.xy + outputRect.zw) &&
all(outputRect.zw > 0);
const float2 sceneLocalUV = insideScene
? (center - outputRect.xy) / outputRect.zw : float2(0);
const float2 internalPosition = insideScene
? internalRect.xy + sceneLocalUV * internalRect.zw
: center / float2(outputExtent) * float2(internalExtent);
const int2 currentPixel = clampScenePixel(int2(floor(internalPosition)));
const float4 currentColor = sceneColorAt(currentPixel);
const float currentDepth = sceneDepthAt(currentPixel);
float4 resolved = currentColor;
if (insideScene && temporalParameters.flags.x != 0) {
const float4 centerMotion = sceneVelocityAt(currentPixel);
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 &&
depth < selectedDepth) {
selectedDepth = depth;
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)) {
const float2 previousOutputUV =
(outputRect.xy + previousLocalUV * outputRect.zw) / float2(outputExtent);
if (all(previousOutputUV >= 0) && all(previousOutputUV < 1)) {
const int2 priorPixel = clamp(
int2(floor(previousOutputUV * float2(outputExtent))), int2(0),
int2(outputExtent) - 1);
const float priorDepth = previousHistoryDepth.Load(int3(priorPixel, 0));
const float depthTolerance = .002 + .01 * selectedMotion.z;
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) {
const float3 color = sceneColorAt(currentPixel + int2(dx, dy)).rgb;
low = min(low, color);
high = max(high, color);
}
const float2 motionPixels = selectedMotion.xy * outputRect.zw;
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);
}
}
}
}
}
nextHistoryColor[outputPixel] = resolved;
nextHistoryDepth[outputPixel] = currentDepth;
}
#elif defined(FASET_TEMPORAL_COMPOSITE)
[[vk::binding(0,0)]] Texture2D<float4> resolvedHistoryColor;
[shader("vertex")]
float4 temporalCompositeVertexMain(float4 clip : POSITION) : SV_Position {
return clip;
}
[shader("fragment")]
float4 temporalCompositeFragmentMain(float4 position : SV_Position) : SV_Target {
// Scene shading is already display-referred. No second tone or gamma pass.
return resolvedHistoryColor.Load(int3(int2(position.xy), 0));
}
#else
#error Select FASET_TEMPORAL_RESOLVE or FASET_TEMPORAL_COMPOSITE.
#endif
+22 -2
View File
@@ -566,12 +566,22 @@ struct BuildService::Impl {
copy_required_file(player, staging / ("faset_player" + executable_suffix()));
copy_required_file(exporter, staging / ("faset_schema_exporter" + executable_suffix()));
for (const auto* file : {"vertexMain.spv", "fragmentMain.spv", "shadowMain.spv",
"lightTileMain.spv", "lightTileMain.reflection.json",
"vertexMain.reflection.json", "fragmentMain.reflection.json",
"shadowMain.reflection.json", "gpuVertexMain.spv",
"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 files = Json::array();
@@ -821,12 +831,22 @@ struct BuildService::Impl {
copy_required_file(build_directory / ("faset_player" + executable_suffix()),
staging / ("faset_player" + executable_suffix()));
for (const auto* shader : {"vertexMain.spv", "fragmentMain.spv", "shadowMain.spv",
"lightTileMain.spv", "lightTileMain.reflection.json",
"vertexMain.reflection.json", "fragmentMain.reflection.json",
"shadowMain.reflection.json", "gpuVertexMain.spv",
"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)) {
+97
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,8 +450,36 @@ 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("Light tiles: %u; GPU build %.2f ms",
stats.light_tile_count, stats.gpu_light_tiles_ms);
if (stats.light_tile_counts_valid)
ImGui::Text("Tile entries: %u; overflow tiles: %u",
stats.light_tile_candidate_count,
stats.light_tile_overflow_count);
else if (stats.light_tile_count)
ImGui::TextDisabled("Tile entry counts unavailable until diagnostics readback");
ImGui::Text("Local lights: %u submitted, %u omitted",
stats.submitted_local_lights, stats.omitted_local_lights);
ImGui::Text("Sun cascades: %u / %u effective",
+1071 -71
View File
File diff suppressed because it is too large Load Diff
+184 -23
View File
@@ -30,15 +30,56 @@ void locations(const Json& fields, std::initializer_list<const char*> types, con
++index;
}
}
void validate_tile_layout(const Json& layout) {
require(layout.at("stage") == "compute", "light tile shader stage changed");
const auto& descriptors = layout.at("descriptors");
require(descriptors.is_array() && descriptors.size() == 2,
"light tile descriptor count changed");
for (std::size_t i = 0; i < 2; ++i)
require(descriptors[i].at("set") == 0 && descriptors[i].at("binding") == i &&
descriptors[i].at("count") == 1 &&
descriptors[i].at("type") == "storage_buffer" &&
descriptors[i].at("element_stride") == (i == 0 ? 80 : 4),
"light tile descriptor ABI changed");
const auto& constants = layout.at("push_constants");
require(constants.is_array() && constants.size() == 1 &&
constants[0].at("offset") == 0 && constants[0].at("size") == 96,
"light tile push size changed");
const auto& members = constants[0].at("members");
require(members.is_array() && members.size() == 3,
"light tile push members changed");
const int offsets[] = {0, 64, 80};
const char* types[] = {"float32x4x4", "float32x4", "uint32x4"};
for (std::size_t i = 0; i < 3; ++i)
require(members[i].at("offset") == offsets[i] &&
members[i].at("size") == (i == 0 ? 64 : 16) &&
members[i].at("type") == types[i],
"light tile push field changed");
const auto& blocks = layout.at("spirv_push_constants");
require(blocks.is_array() && blocks.size() == 1 &&
blocks[0].at("members").size() == 3,
"light tile SPIR-V push block changed");
const auto& actual = blocks[0].at("members");
for (std::size_t i = 0; i < 3; ++i)
require(actual[i].at("member") == i && actual[i].at("offset") == offsets[i],
"light tile SPIR-V push offset changed");
require(actual[0].at("matrix_layout") == "row-major" &&
actual[0].at("matrix_stride") == 16,
"light tile SPIR-V matrix storage convention changed");
locations(layout.at("inputs"), {}, "light tile inputs");
locations(layout.at("outputs"), {}, "light tile outputs");
}
void validate_layout(const Json& layout, std::string_view entry) {
const bool fragment = entry == "fragmentMain";
const bool fragment = entry == "fragmentMain" || entry == "temporalFragmentMain";
const bool temporal = entry == "temporalVertexMain" ||
entry == "temporalFragmentMain";
require(layout.at("stage") == (fragment ? "fragment" : "vertex"), "shader stage changed");
const auto& descriptors = layout.at("descriptors");
require(descriptors.is_array() && descriptors.size() == 8, "descriptor count changed");
require(descriptors.is_array() && descriptors.size() == 9, "descriptor count changed");
for (std::size_t i = 0; i < descriptors.size(); ++i) {
const auto& binding = descriptors[i];
const auto set = i < 4 ? 0 : 1;
const auto slot = i % 4;
const auto slot = set == 0 ? i : i - 4;
require(binding.at("set") == set && binding.at("binding") == slot &&
binding.at("count") == 1,
"descriptor set, binding or array count changed");
@@ -46,8 +87,8 @@ void validate_layout(const Json& layout, std::string_view entry) {
: slot == 3 ? "sampled_image_2d" : "storage_buffer";
require(binding.at("type") == expected_type,
"descriptor type changed");
if (set == 1 && slot < 3)
require(binding.at("element_stride") == (slot == 2 ? 112 : 80),
if (set == 1 && slot != 3)
require(binding.at("element_stride") == (slot == 4 ? 4 : slot == 2 ? 112 : 80),
"lighting storage record stride changed");
require(fragment || !binding.at("used").get<bool>(),
"vertex texture bindings are unsupported");
@@ -77,24 +118,42 @@ void validate_layout(const Json& layout, std::string_view entry) {
"SPIR-V matrix storage convention changed");
}
if (fragment) {
locations(layout.at("inputs"),
{"float32x3", "float32x3", "float32x4", "float32x2", "float32x2"},
"fragment inputs");
locations(layout.at("outputs"), {"float32x4"}, "fragment outputs");
if (temporal) {
locations(layout.at("inputs"),
{"float32x3", "float32x3", "float32x4", "float32x2", "float32x2",
"float32x4", "float32x4", "float32"}, "temporal fragment inputs");
locations(layout.at("outputs"), {"float32x4", "float32x4"},
"temporal fragment outputs");
} else {
locations(layout.at("inputs"),
{"float32x3", "float32x3", "float32x4", "float32x2", "float32x2"},
"fragment inputs");
locations(layout.at("outputs"), {"float32x4"}, "fragment outputs");
}
} else {
locations(layout.at("inputs"),
{"float32x4", "float32x3", "float32x3", "float32x4", "float32x2", "float32x2"},
"vertex inputs");
if (temporal) {
locations(layout.at("inputs"),
{"float32x4", "float32x3", "float32x3", "float32x4", "float32x2",
"float32x2", "float32x4", "float32"}, "temporal vertex inputs");
locations(layout.at("outputs"),
{"float32x3", "float32x3", "float32x4", "float32x2", "float32x2",
"float32x4", "float32x4", "float32"}, "temporal vertex outputs");
} else {
locations(layout.at("inputs"),
{"float32x4", "float32x3", "float32x3", "float32x4", "float32x2", "float32x2"},
"vertex inputs");
if (entry == "vertexMain")
locations(layout.at("outputs"),
{"float32x3", "float32x3", "float32x4", "float32x2", "float32x2"},
"vertex outputs");
else
locations(layout.at("outputs"), {}, "shadow outputs");
}
}
}
void validate_gpu_layout(const Json& layout, std::string_view entry) {
const bool graphics = entry == "gpuVertexMain" || entry == "gpuShadowMain";
const bool graphics = entry == "gpuVertexMain" || entry == "gpuShadowMain" ||
entry == "gpuTemporalVertexMain";
const bool hzb = entry == "gpuHzbMain";
const bool compute = !graphics;
require(layout.at("stage") == (compute ? "compute" : "vertex"), "GPU shader stage changed");
@@ -102,8 +161,8 @@ void validate_gpu_layout(const Json& layout, std::string_view entry) {
const std::size_t expected_count = graphics ? 3 : hzb ? 2 : 10;
require(descriptors.is_array() && descriptors.size() == expected_count,
"GPU descriptor count changed");
const std::array<int, 10> compute_strides{224, 16, 16, 4, 16, 4, 4, 0, 0, 208};
const std::array<int, 3> graphics_strides{224, 4, 208};
const std::array<int, 10> compute_strides{288, 16, 16, 4, 16, 4, 4, 0, 0, 208};
const std::array<int, 3> graphics_strides{288, 4, 208};
for (std::size_t i = 0; i < expected_count; ++i) {
const auto& binding = descriptors[i];
require(binding.at("set") == (graphics ? 2 : 0) && binding.at("binding") == i &&
@@ -147,7 +206,13 @@ void validate_gpu_layout(const Json& layout, std::string_view entry) {
locations(layout.at("inputs"),
{"float32x3", "float32x3", "float32x4", "float32x2"},
"GPU vertex inputs");
if (entry == "gpuVertexMain")
if (entry == "gpuVertexMain" || entry == "gpuTemporalVertexMain")
if (entry == "gpuTemporalVertexMain")
locations(layout.at("outputs"),
{"float32x3", "float32x3", "float32x4", "float32x2", "float32x2",
"float32x4", "float32x4", "float32"},
"GPU temporal vertex outputs");
else
locations(layout.at("outputs"),
{"float32x3", "float32x3", "float32x4", "float32x2", "float32x2"},
"GPU vertex outputs");
@@ -158,6 +223,78 @@ void validate_gpu_layout(const Json& layout, std::string_view entry) {
locations(layout.at("outputs"), {}, "GPU compute outputs");
}
}
void validate_temporal_layout(const Json& layout, std::string_view entry) {
const bool resolve = entry == "temporalResolveMain";
const bool vertex = entry == "temporalCompositeVertexMain";
require(resolve || vertex || entry == "temporalCompositeFragmentMain",
"unknown temporal shader entry");
require(layout.at("stage") == (resolve ? "compute" : vertex ? "vertex" : "fragment"),
"temporal shader stage changed");
const auto& descriptors = layout.at("descriptors");
require(descriptors.is_array() && descriptors.size() == (resolve ? 7u : 1u),
"temporal descriptor count changed");
for (std::size_t i = 0; i < descriptors.size(); ++i) {
const auto& descriptor = descriptors[i];
require(descriptor.at("set") == 0 && descriptor.at("binding") == i &&
descriptor.at("count") == 1,
"temporal descriptor set, binding or count changed");
require(descriptor.at("type") ==
(resolve && i >= 5 ? "storage_image_2d" : "sampled_image_2d"),
"temporal image descriptor type changed");
require(descriptor.at("used") == (resolve || !vertex),
"temporal entry uses an unexpected image binding");
}
const auto& constants = layout.at("push_constants");
const auto& spirv_constants = layout.at("spirv_push_constants");
require(constants.is_array() && spirv_constants.is_array(),
"temporal push constants are malformed");
if (resolve) {
require(constants.size() == 1 && constants[0].at("offset") == 0 &&
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() == 5 && spirv_members.size() == 5,
"temporal resolve push members changed");
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 &&
spirv_members[i].at("offset") == 16 * i,
"temporal resolve push member ABI changed");
} else
require(constants.empty() && spirv_constants.empty(),
"temporal composite unexpectedly uses push constants");
if (resolve) {
locations(layout.at("inputs"), {}, "temporal resolve inputs");
locations(layout.at("outputs"), {}, "temporal resolve outputs");
} else if (vertex) {
locations(layout.at("inputs"), {"float32x4"}, "temporal composite vertex inputs");
locations(layout.at("outputs"), {}, "temporal composite vertex outputs");
} else {
locations(layout.at("inputs"), {}, "temporal composite fragment inputs");
locations(layout.at("outputs"), {"float32x4"},
"temporal composite fragment outputs");
}
const auto& input_builtins = layout.at("input_builtins");
require(input_builtins.is_array() && input_builtins.size() == (vertex ? 0u : 1u),
"temporal entry input builtin count changed");
if (!vertex)
require(input_builtins[0].at("semantic") ==
(resolve ? "SV_DISPATCHTHREADID" : "SV_POSITION") &&
input_builtins[0].at("type") ==
(resolve ? "uint32x3" : "float32x4"),
"temporal entry input builtin changed");
const auto& output_builtins = layout.at("output_builtins");
require(output_builtins.is_array() && output_builtins.size() == (vertex ? 1u : 0u),
"temporal entry output builtin count changed");
if (vertex)
require(output_builtins[0].at("semantic") == "SV_POSITION" &&
output_builtins[0].at("type") == "float32x4",
"temporal composite vertex position changed");
}
void validate_spirv(const std::vector<std::uint32_t>& words, std::uint32_t execution_model) {
require(words.size() >= 5 && words[0] == 0x07230203 && words[1] >= 0x00010000 &&
words[1] <= 0x00010600 && words[3] > 0 && words[3] < (1u << 20) && words[4] == 0,
@@ -183,7 +320,7 @@ void validate_spirv(const std::vector<std::uint32_t>& words, std::uint32_t execu
require(entry_found, "SPIR-V main entry point missing");
}
detail::ShaderCode load(const std::filesystem::path& directory, const char* entry,
bool gpu = false) {
bool gpu = false, bool temporal = false) {
const auto bytes = read_bounded(directory / (std::string(entry) + ".spv"), 16 * 1024 * 1024);
require(bytes.size() >= 20 && bytes.size() % 4 == 0, "invalid SPIR-V byte length");
const auto metadata = Json::parse(
@@ -196,24 +333,34 @@ detail::ShaderCode load(const std::filesystem::path& directory, const char* entr
const auto& layout = metadata.at("layout");
const auto fingerprint = faset::sha256(layout.dump());
require(metadata.at("layout_fingerprint") == fingerprint, "layout fingerprint mismatch");
if (gpu)
if (temporal)
validate_temporal_layout(layout, entry);
else if (gpu)
validate_gpu_layout(layout, entry);
else if (std::string_view(entry) == "lightTileMain")
validate_tile_layout(layout);
else
validate_layout(layout, entry);
detail::ShaderCode result;
result.layout_fingerprint = fingerprint;
result.words.resize(bytes.size() / 4);
std::memcpy(result.words.data(), bytes.data(), bytes.size());
validate_spirv(result.words, gpu ? ((std::string_view(entry) == "gpuVertexMain" ||
std::string_view(entry) == "gpuShadowMain") ? 0u : 5u)
: (std::string_view(entry) == "fragmentMain" ? 4u : 0u));
const auto stage = std::string_view(entry) == "lightTileMain" ? 5u : temporal
? (std::string_view(entry) == "temporalResolveMain" ? 5u
: std::string_view(entry) == "temporalCompositeVertexMain" ? 0u : 4u)
: gpu ? ((std::string_view(entry) == "gpuVertexMain" ||
std::string_view(entry) == "gpuShadowMain" ||
std::string_view(entry) == "gpuTemporalVertexMain") ? 0u : 5u)
: ((std::string_view(entry) == "fragmentMain" ||
std::string_view(entry) == "temporalFragmentMain") ? 4u : 0u);
validate_spirv(result.words, stage);
return result;
}
} // namespace
std::array<detail::ShaderCode, 3>
std::array<detail::ShaderCode, 4>
detail::load_shader_bundle(const std::filesystem::path& directory) {
return {load(directory, "vertexMain"), load(directory, "fragmentMain"),
load(directory, "shadowMain")};
load(directory, "shadowMain"), load(directory, "lightTileMain")};
}
std::array<detail::ShaderCode, 5>
detail::load_gpu_shader_bundle(const std::filesystem::path& directory) {
@@ -221,8 +368,22 @@ detail::load_gpu_shader_bundle(const std::filesystem::path& directory) {
load(directory, "gpuCullMain", true), load(directory, "gpuHzbMain", true),
load(directory, "gpuPostCullMain", true)};
}
std::array<detail::ShaderCode, 3>
detail::load_temporal_shader_bundle(const std::filesystem::path& directory) {
return {load(directory, "temporalResolveMain", false, true),
load(directory, "temporalCompositeVertexMain", false, true),
load(directory, "temporalCompositeFragmentMain", false, true)};
}
std::array<detail::ShaderCode, 3>
detail::load_temporal_scene_shader_bundle(const std::filesystem::path& directory) {
return {load(directory, "temporalVertexMain"),
load(directory, "temporalFragmentMain"),
load(directory, "gpuTemporalVertexMain", true)};
}
void validate_shader_bundle(const std::filesystem::path& directory) {
(void)detail::load_shader_bundle(directory);
(void)detail::load_temporal_shader_bundle(directory);
(void)detail::load_temporal_scene_shader_bundle(directory);
}
void validate_gpu_shader_bundle(const std::filesystem::path& directory) {
(void)detail::load_gpu_shader_bundle(directory);
+7 -1
View File
@@ -10,7 +10,13 @@ struct ShaderCode {
std::vector<std::uint32_t> words;
std::string layout_fingerprint;
};
std::array<ShaderCode, 3> load_shader_bundle(const std::filesystem::path& directory);
// Direct graphics plus the independent 16x16 light-tile compute entry.
std::array<ShaderCode, 4> load_shader_bundle(const std::filesystem::path& directory);
// Order: opaque vertex, optional instanced shadow vertex, main cull, HZB, post cull.
std::array<ShaderCode, 5> load_gpu_shader_bundle(const std::filesystem::path& directory);
// Order: temporal resolve compute, full-screen composite vertex and fragment.
std::array<ShaderCode, 3> load_temporal_shader_bundle(const std::filesystem::path& directory);
// Direct temporal vertex/fragment and GPU temporal vertex; set 0 material,
// set 1 lighting and set 2 GPU scene stay at their established bindings.
std::array<ShaderCode, 3> load_temporal_scene_shader_bundle(const std::filesystem::path& directory);
} // namespace faset::render::detail
+198
View File
@@ -0,0 +1,198 @@
#include <faset/render/temporal.hpp>
#include <algorithm>
#include <cmath>
#include <stdexcept>
namespace faset::render {
TemporalFallbackReason temporal_fallback_reason(TemporalMode requested,
TemporalCapabilities available) noexcept {
if (requested == TemporalMode::Off)
return TemporalFallbackReason::None;
if (!available.compute)
return TemporalFallbackReason::ComputeUnavailable;
if (!available.formats)
return TemporalFallbackReason::FormatUnavailable;
if (!available.extent)
return TemporalFallbackReason::ExtentUnsupported;
return TemporalFallbackReason::None;
}
TemporalMode select_effective_temporal_mode(TemporalMode requested,
TemporalCapabilities available) noexcept {
return temporal_fallback_reason(requested, available) == TemporalFallbackReason::None
? requested : TemporalMode::Off;
}
std::array<std::uint32_t, 2> temporal_internal_extent(std::uint32_t output_width,
std::uint32_t output_height,
TemporalMode mode, float render_scale) {
if ((mode != TemporalMode::Off && mode != TemporalMode::TAA &&
mode != TemporalMode::Upscale) ||
!output_width || !output_height || !std::isfinite(render_scale) ||
(mode == TemporalMode::Upscale
? render_scale < 0.5f || render_scale >= 1.f
: render_scale != 1.f))
throw std::invalid_argument("Invalid temporal mode, scale or output extent");
if (mode != TemporalMode::Upscale)
return {output_width, output_height};
const auto scaled = [&](std::uint32_t extent) {
return static_cast<std::uint32_t>(
std::max(1.0, std::ceil(static_cast<double>(extent) * render_scale)));
};
return {scaled(output_width), scaled(output_height)};
}
namespace {
float halton(std::uint32_t index, std::uint32_t base) noexcept {
float sample = 0.f;
float place = 1.f / static_cast<float>(base);
while (index != 0) {
sample += static_cast<float>(index % base) * place;
index /= base;
place /= static_cast<float>(base);
}
return sample;
}
bool finite_camera(const TemporalHistoryKey& key) noexcept {
for (float value : key.camera_eye)
if (!std::isfinite(value))
return false;
for (float value : key.view_projection)
if (!std::isfinite(value))
return false;
return true;
}
std::array<float, 3> normalized_view_row(const TemporalHistoryKey& key, int row) noexcept {
std::array<float, 3> direction{key.view_projection[row], key.view_projection[row + 4],
key.view_projection[row + 8]};
const float length_squared = direction[0] * direction[0] +
direction[1] * direction[1] + direction[2] * direction[2];
if (!std::isfinite(length_squared) || length_squared < 1e-12f)
return {};
const float reciprocal = 1.f / std::sqrt(length_squared);
for (float& component : direction)
component *= reciprocal;
return direction;
}
bool large_axis_turn(const std::array<float, 3>& a,
const std::array<float, 3>& b) noexcept {
if (a == std::array<float, 3>{} || b == std::array<float, 3>{})
return true;
const float dot = a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
return !std::isfinite(dot) || dot < 0.70710678f; // turn greater than 45 degrees
}
bool camera_discontinuity(const TemporalHistoryKey& previous,
const TemporalHistoryKey& current) noexcept {
if (!finite_camera(previous) || !finite_camera(current))
return true;
float translation_squared{};
for (int axis = 0; axis < 3; ++axis) {
const float delta = current.camera_eye[axis] - previous.camera_eye[axis];
translation_squared += delta * delta;
}
// A conservative world-space threshold catches unmarked teleports. Ordinary
// camera motion and smaller view changes are handled by motion vectors.
if (!std::isfinite(translation_squared) || translation_squared > 25.f)
return true;
// Compare horizontal and vertical camera axes too: comparing only forward
// cannot detect a sudden roll about the unchanged viewing direction.
for (int row = 0; row < 2; ++row)
if (large_axis_turn(normalized_view_row(previous, row),
normalized_view_row(current, row)))
return true;
// Perspective VP encodes forward in row four. Orthographic projection has
// a constant fourth row, so its third row carries the viewing direction.
auto a = normalized_view_row(previous, 3);
auto b = normalized_view_row(current, 3);
if (a == std::array<float, 3>{} && b == std::array<float, 3>{}) {
a = normalized_view_row(previous, 2);
b = normalized_view_row(current, 2);
}
return large_axis_turn(a, b);
}
} // namespace
TemporalHistoryDecision
evaluate_temporal_history(const std::optional<TemporalHistoryKey>& previous,
const TemporalHistoryKey& current) noexcept {
auto reset = [](TemporalResetReason reason) { return TemporalHistoryDecision{false, reason}; };
if (current.mode == TemporalMode::Off)
return reset(previous && previous->mode != TemporalMode::Off
? TemporalResetReason::ModeChanged : TemporalResetReason::None);
if (current.camera_cut)
return reset(TemporalResetReason::CameraCut);
if (!previous)
return reset(TemporalResetReason::FirstFrame);
const auto& before = *previous;
if (before.view_id != current.view_id)
return reset(TemporalResetReason::ViewChanged);
if (before.output_width != current.output_width ||
before.output_height != current.output_height)
return reset(TemporalResetReason::Resize);
if (before.internal_width != current.internal_width ||
before.internal_height != current.internal_height ||
before.render_scale != current.render_scale)
return reset(TemporalResetReason::ScaleChanged);
if (before.mode != current.mode)
return reset(TemporalResetReason::ModeChanged);
if (before.scene_rect != current.scene_rect)
return reset(TemporalResetReason::ViewportChanged);
if (before.projection != current.projection)
return reset(TemporalResetReason::ProjectionChanged);
if (before.shader_generation != current.shader_generation)
return reset(TemporalResetReason::ShaderReload);
if (camera_discontinuity(before, current))
return reset(TemporalResetReason::CameraDiscontinuity);
return {true, TemporalResetReason::None};
}
TemporalHistoryDecision
TemporalHistoryState::prepare(const TemporalHistoryKey& current) const noexcept {
return evaluate_temporal_history(completed_, current);
}
void TemporalHistoryState::complete(const TemporalHistoryKey& rendered) {
if (rendered.mode == TemporalMode::Off)
completed_.reset();
else
completed_ = rendered;
}
std::array<float, 2> temporal_jitter(std::uint64_t frame_index,
std::uint32_t viewport_width,
std::uint32_t viewport_height) {
if (viewport_width == 0 || viewport_height == 0)
throw std::invalid_argument("temporal jitter requires a nonzero viewport extent");
const auto phase = static_cast<std::uint32_t>(frame_index % 16) + 1;
return {(halton(phase, 2) - 0.5f) * (2.f / static_cast<float>(viewport_width)),
(halton(phase, 3) - 0.5f) * (2.f / static_cast<float>(viewport_height))};
}
std::optional<std::array<float, 2>>
project_motion(const std::array<float, 4>& current_clip,
const std::array<float, 4>& previous_clip) noexcept {
for (float coordinate : current_clip)
if (!std::isfinite(coordinate))
return std::nullopt;
for (float coordinate : previous_clip)
if (!std::isfinite(coordinate))
return std::nullopt;
if (current_clip[3] <= 0.f || previous_clip[3] <= 0.f)
return std::nullopt;
std::array<float, 2> motion{};
for (std::size_t axis = 0; axis < 2; ++axis) {
const float current_uv = current_clip[axis] / current_clip[3] * 0.5f + 0.5f;
const float previous_uv = previous_clip[axis] / previous_clip[3] * 0.5f + 0.5f;
motion[axis] = current_uv - previous_uv;
if (!std::isfinite(motion[axis]))
return std::nullopt;
}
return motion;
}
} // namespace faset::render
+145
View File
@@ -0,0 +1,145 @@
#include <faset/render/temporal_reference.hpp>
#include <algorithm>
#include <cmath>
#include <limits>
#include <stdexcept>
namespace faset::render {
namespace {
constexpr std::uint64_t max_reference_pixels = 4'000'000;
std::size_t checked_pixel_count(std::uint32_t width, std::uint32_t height) {
const auto count = std::uint64_t(width) * height;
if (!width || !height || count > max_reference_pixels)
throw std::invalid_argument("Temporal reference image extent is unsupported");
return static_cast<std::size_t>(count);
}
bool valid_motion(const TemporalReferencePixel& pixel) noexcept {
return pixel.motion_valid && pixel.reactive < 1.f &&
std::isfinite(pixel.motion[0]) && std::isfinite(pixel.motion[1]) &&
std::isfinite(pixel.previous_depth) && pixel.previous_depth >= 0.f &&
pixel.previous_depth <= 1.f;
}
float bilinear_channel(const TemporalReferenceHistory& history, float u, float v,
std::size_t channel) noexcept {
const float x = u * history.width - .5f;
const float y = v * history.height - .5f;
const auto x0 = static_cast<int>(std::floor(x));
const auto y0 = static_cast<int>(std::floor(y));
const float tx = x - static_cast<float>(x0);
const float ty = y - static_cast<float>(y0);
const auto sample = [&](int sx, int sy) {
const auto px = std::clamp(sx, 0, static_cast<int>(history.width) - 1);
const auto py = std::clamp(sy, 0, static_cast<int>(history.height) - 1);
return history.color[std::size_t(py) * history.width + px][channel];
};
const float top = std::lerp(sample(x0, y0), sample(x0 + 1, y0), tx);
const float bottom = std::lerp(sample(x0, y0 + 1), sample(x0 + 1, y0 + 1), tx);
return std::lerp(top, bottom, ty);
}
} // namespace
TemporalReferenceResult temporal_reference_resolve(const TemporalReferenceFrame& current,
const TemporalReferenceHistory* previous) {
const auto count = checked_pixel_count(current.width, current.height);
if (current.pixels.size() != count)
throw std::invalid_argument("Temporal reference current pixel count differs from extent");
if (previous &&
(previous->width != current.width || previous->height != current.height ||
previous->color.size() != count || previous->depth.size() != count))
throw std::invalid_argument("Temporal reference history extent or record count differs");
for (const auto& pixel : current.pixels) {
if (!std::isfinite(pixel.depth) || pixel.depth < 0.f || pixel.depth > 1.f ||
!std::isfinite(pixel.reactive) || pixel.reactive < 0.f || pixel.reactive > 1.f)
throw std::invalid_argument("Temporal reference current depth/reactivity is invalid");
for (float channel : pixel.color)
if (!std::isfinite(channel))
throw std::invalid_argument("Temporal reference current color is nonfinite");
}
if (previous)
for (std::size_t i = 0; i < count; ++i) {
if (!std::isfinite(previous->depth[i]) || previous->depth[i] < 0.f ||
previous->depth[i] > 1.f)
throw std::invalid_argument("Temporal reference history depth is invalid");
for (float channel : previous->color[i])
if (!std::isfinite(channel))
throw std::invalid_argument("Temporal reference history color is nonfinite");
}
TemporalReferenceResult result;
result.history.width = current.width;
result.history.height = current.height;
result.history.color.resize(count);
result.history.depth.resize(count);
result.accepted.resize(count);
for (std::uint32_t y = 0; y < current.height; ++y)
for (std::uint32_t x = 0; x < current.width; ++x) {
const auto i = std::size_t(y) * current.width + x;
const auto& center = current.pixels[i];
auto& output = result.history.color[i];
output = center.color;
result.history.depth[i] = center.depth;
if (!previous || !valid_motion(center))
continue;
// Dilate from the nearest compatible depth at a silhouette, but
// never borrow foreground motion for newly exposed background or
// resurrect a center pixel with no previous transform.
const TemporalReferencePixel* selected = &center;
const float current_depth_tolerance = .002f + .01f * center.depth;
const auto min_y = y ? y - 1 : y;
const auto min_x = x ? x - 1 : x;
const auto max_y = std::min(y + 1, current.height - 1);
const auto max_x = std::min(x + 1, current.width - 1);
for (auto sy = min_y; sy <= max_y; ++sy)
for (auto sx = min_x; sx <= max_x; ++sx) {
const auto& candidate = current.pixels[std::size_t(sy) * current.width + sx];
if (valid_motion(candidate) &&
std::abs(candidate.depth - center.depth) <=
current_depth_tolerance && candidate.depth < selected->depth)
selected = &candidate;
}
const float u = (static_cast<float>(x) + .5f) / current.width - selected->motion[0];
const float v = (static_cast<float>(y) + .5f) / current.height - selected->motion[1];
if (!std::isfinite(u) || !std::isfinite(v) || u < 0.f || v < 0.f ||
u >= 1.f || v >= 1.f)
continue;
const auto px = std::min(static_cast<std::uint32_t>(u * previous->width),
previous->width - 1);
const auto py = std::min(static_cast<std::uint32_t>(v * previous->height),
previous->height - 1);
const float sampled_depth = previous->depth[std::size_t(py) * previous->width + px];
const float depth_tolerance = .002f + .01f * selected->previous_depth;
if (std::abs(sampled_depth - selected->previous_depth) > depth_tolerance)
continue;
const float motion_pixels = std::hypot(selected->motion[0] * current.width,
selected->motion[1] * current.height);
const float weight = .9f * (1.f - center.reactive) / (1.f + .5f * motion_pixels);
if (!std::isfinite(weight) || weight <= 0.f)
continue;
for (std::size_t channel = 0; channel < 3; ++channel) {
float neighborhood_min = std::numeric_limits<float>::infinity();
float neighborhood_max = -neighborhood_min;
for (auto sy = min_y; sy <= max_y; ++sy)
for (auto sx = min_x; sx <= max_x; ++sx) {
const auto value = current.pixels[std::size_t(sy) * current.width + sx]
.color[channel];
neighborhood_min = std::min(neighborhood_min, value);
neighborhood_max = std::max(neighborhood_max, value);
}
const float prior = std::clamp(bilinear_channel(*previous, u, v, channel),
neighborhood_min, neighborhood_max);
output[channel] = std::lerp(center.color[channel], prior, weight);
}
result.accepted[i] = 1;
++result.accepted_count;
}
return result;
}
} // namespace faset::render
+1 -1
View File
@@ -209,7 +209,7 @@ int test_main(int argc, char** argv) {
read_text(config.project_root / "schema-export-count.txt") == "3",
"Corrupt shader cannot be a cache hit");
const auto directory = path_from_utf8(first.result.at("directory").get<std::string>());
for (const auto* entry : {"gpuVertexMain", "gpuShadowMain", "gpuCullMain",
for (const auto* entry : {"lightTileMain", "gpuVertexMain", "gpuShadowMain", "gpuCullMain",
"gpuHzbMain", "gpuPostCullMain"})
for (const auto* extension : {".spv", ".reflection.json"})
check(fs::is_regular_file(directory / "shaders" /
+1 -1
View File
@@ -90,7 +90,7 @@ int tool_main(int argc, char** argv) {
for (const auto* target : {"faset_player", "faset_schema_exporter"})
fs::copy_file(self, build / (std::string(target) + suffix),
fs::copy_options::overwrite_existing);
for (const auto* entry : {"vertexMain", "fragmentMain", "shadowMain",
for (const auto* entry : {"vertexMain", "fragmentMain", "shadowMain", "lightTileMain",
"gpuVertexMain", "gpuShadowMain", "gpuCullMain",
"gpuHzbMain", "gpuPostCullMain"})
for (const auto* extension : {".spv", ".reflection.json"})
+25
View File
@@ -14,6 +14,7 @@
#include <faset/scripting/project.hpp>
#include <iostream>
#include <thread>
#include <utility>
#ifndef _WIN32
#include <csignal>
#endif
@@ -311,6 +312,14 @@ int integration(const fs::path& root) {
"Export has a separate CMake directory");
require(read_json(directory / "manifest.json").at("configuration") == "Release",
"Export manifest records the actual profile");
for (const auto* entry : {"temporalResolveMain", "temporalCompositeVertexMain",
"temporalCompositeFragmentMain", "temporalVertexMain",
"temporalFragmentMain", "gpuTemporalVertexMain"}) {
for (const auto* suffix : {".spv", ".reflection.json"})
require(fs::is_regular_file(directory / "shaders" /
(std::string(entry) + suffix)),
"Export contains compiled and reflected temporal shader");
}
Process player(
{{result.result.at("executable").get<std::string>(), "--headless", "--frames", "3",
"--capture", path_to_utf8(directory / "verification.ppm")},
@@ -319,6 +328,22 @@ int integration(const fs::path& root) {
std::cout << collect(player);
require(fs::file_size(directory / "verification.ppm") > 1000,
"Exported game rendered a frame");
for (const auto& [mode, scale] :
{std::pair{"taa", "1.0"}, std::pair{"upscale", "0.67"}}) {
const auto profile = directory / (std::string("profile-") + mode + ".json");
Process temporalPlayer(
{{result.result.at("executable").get<std::string>(), "--headless", "--frames",
"2", "--temporal", mode, "--render-scale", scale, "--profile",
path_to_utf8(profile)},
directory,
{}});
std::cout << collect(temporalPlayer);
const auto report = read_json(profile);
require(report.at("temporal_mode") == mode &&
report.at("effective_temporal_mode") == mode &&
report.at("samples").size() == 2,
"Relocated Player selects and profiles temporal rendering");
}
atomic_write_json(root / ("result-" + std::to_string(dimension) + ".json"), result.result);
}
auto source = read_text(config.project_root / "Scripts" / "Gameplay.cpp");
+16 -1
View File
@@ -44,6 +44,21 @@ int main(int argc, char** argv) {
overlay.append(scene, renderer, 1.f / 60.f);
require(renderer.visibility_mode() == render::VisibilityMode::GpuFrustum,
"Clicking GPU frustum switches the live renderer");
render::Event temporal_click = mode_click;
temporal_click.x = 205;
temporal_click.y = 154;
temporal_click.type = render::Event::Type::MouseDown;
require(overlay.process_events(std::span(&temporal_click, 1)).empty(),
"Temporal mode button captures pointer down");
scene.ui_triangles.clear();
overlay.append(scene, renderer, 1.f / 60.f);
temporal_click.type = render::Event::Type::MouseUp;
require(overlay.process_events(std::span(&temporal_click, 1)).empty(),
"Temporal mode button captures pointer up");
scene.ui_triangles.clear();
overlay.append(scene, renderer, 1.f / 60.f);
require(renderer.temporal_mode() == render::TemporalMode::TAA,
"Clicking TAA switches the live Editor renderer");
scene.ui_triangles.clear();
overlay.append(scene, renderer, 1.f / 60.f);
renderer.render(scene);
@@ -125,7 +140,7 @@ int main(int argc, char** argv) {
render::Event preview_click;
preview_click.button = 1;
preview_click.x = 34;
preview_click.y = 405;
preview_click.y = 463;
const auto click_preview = [&] {
preview_click.type = render::Event::Type::MouseDown;
require(hzb_overlay.process_events(std::span(&preview_click, 1)).empty(),
+41 -1
View File
@@ -39,12 +39,52 @@ with tempfile.TemporaryDirectory(prefix="faset-player-diagnostics-") as temporar
"shadow_caster_budget_drops", "shadow_unavailable_drops",
"shadow_caster_draws", "sun_shadow_atlas_bytes",
"local_shadow_atlas_bytes", "gpu_main_raster_ms",
"gpu_sun_shadow_ms", "gpu_local_shadow_ms"]:
"gpu_sun_shadow_ms", "gpu_local_shadow_ms",
"gpu_light_tiles_ms", "light_tile_count", "light_tile_counts_valid",
"light_tile_candidate_count", "light_tile_overflow_count"]:
assert field in lighting, (field, lighting)
assert lighting["submitted_local_lights"] == 0 and \
lighting["effective_sun_cascades"] == 0 and \
lighting["local_shadow_faces"] == 0, lighting
for temporal_mode, scale in [("off", None), ("taa", None), ("upscale", "0.67")]:
temporal_profile = root / f"temporal-{temporal_mode}.json"
arguments = [sys.argv[1], "--scene", str(scene), "--headless", "--frames", "2",
"--temporal", temporal_mode, "--profile", str(temporal_profile)]
if scale is not None:
arguments += ["--render-scale", scale]
selected = subprocess.run(arguments, capture_output=True, text=True,
encoding="utf-8", timeout=30)
assert selected.returncode == 0, (temporal_mode, selected.stdout, selected.stderr)
temporal_report = json.loads(temporal_profile.read_text(encoding="utf-8"))
assert temporal_report["temporal_mode"] == temporal_mode, temporal_report
assert abs(temporal_report["render_scale"] -
(float(scale) if scale else 1.0)) < 0.000001
assert temporal_report["effective_temporal_mode"] == temporal_mode, temporal_report
for sample in temporal_report["samples"]:
for key in ["requested_temporal_mode", "effective_temporal_mode",
"temporal_fallback_reason", "temporal_reset_reason",
"temporal_history_valid", "temporal_internal_width",
"temporal_internal_height", "temporal_jitter",
"gpu_temporal_resolve_ms", "gpu_temporal_composite_ms", "gpu_ui_ms"]:
assert key in sample, (key, sample)
assert sample["effective_temporal_mode"] == temporal_mode, sample
if temporal_mode != "off":
assert sample["temporal_internal_width"] > 0, sample
assert sample["temporal_internal_height"] > 0, sample
assert sample["gpu_temporal_resolve_ms"] is not None, sample
if temporal_mode != "off":
assert not temporal_report["samples"][0]["temporal_history_valid"]
assert temporal_report["samples"][1]["temporal_history_valid"]
for invalid in [["--temporal", "missing"], ["--render-scale", "NaN"],
["--temporal", "taa", "--render-scale", "0.67"],
["--temporal", "upscale", "--render-scale", "1.0"]]:
result = subprocess.run([sys.argv[1], *invalid], capture_output=True,
text=True, encoding="utf-8", timeout=20)
assert result.returncode != 0 and ("temporal" in result.stderr.lower() or
"scale" in result.stderr.lower()), result
# The same linked v2 schema must validate without registering or invoking behavior.
validated = subprocess.run([sys.argv[1], "--scene", str(scene), "--validate"],
capture_output=True, text=True, encoding="utf-8", timeout=20)
+101 -3
View File
@@ -21,13 +21,14 @@ Frame capture(Renderer& renderer, const Snapshot& scene) {
renderer.render(scene);
return {renderer.pixels(), renderer.stats()};
}
Renderer make_renderer(VisibilityMode mode) {
Renderer make_renderer(VisibilityMode mode, LightingMode lighting = LightingMode::Auto) {
RendererConfig config;
config.width = 320;
config.height = 240;
config.headless = true;
config.validation = true;
config.visibility_mode = mode;
config.lighting_mode = lighting;
config.visibility_diagnostics = true;
return Renderer(config);
}
@@ -286,17 +287,114 @@ void local() {
require(brightened > 20,
"Point light with dropped atlas faces still illuminates unshadowed");
}
void tiled() {
for (auto visibility : {VisibilityMode::Direct, VisibilityMode::GpuFrustum,
VisibilityMode::GpuOcclusion}) {
auto forward = make_renderer(visibility, LightingMode::Forward);
auto tiles = make_renderer(visibility, LightingMode::Tiled);
auto fixture = local_scene(LocalLight::Kind::Point, false);
auto no_lights = fixture;
no_lights.local_lights.clear();
const auto empty_tiled = capture(tiles, no_lights);
require(empty_tiled.stats.effective_lighting_path == "forward" &&
empty_tiled.stats.light_tile_count == 0,
"Forced tiles correctly fall back when no local lights are submitted");
fixture.scene_rect = {32, 24, 256, 192};
fixture.local_lights.front().casts_shadow = false;
auto spot = fixture.local_lights.front();
spot.kind = LocalLight::Kind::Spot;
spot.stable_id = "second-spot";
spot.position = {1.5f, 2, 0};
spot.direction = {0, -1, 0};
spot.intensity = 7;
spot.range = 4;
fixture.local_lights.push_back(spot);
auto outside = spot;
outside.stable_id = "offscreen-light";
outside.position = {100, 100, 100};
outside.range = 2;
fixture.local_lights.push_back(outside);
const auto expected = capture(forward, fixture);
const auto actual = capture(tiles, fixture);
require(expected.stats.effective_lighting_path == "forward" &&
actual.stats.effective_lighting_path == "tiled" &&
actual.stats.gpu_light_tiles_ms > 0 &&
actual.stats.light_tile_count > 0,
"Forced 16x16 tile construction reports its actual GPU work");
require(actual.stats.validation_errors == 0,
"Forward+ tile build and fragment reads pass Vulkan validation");
require(actual.stats.light_tile_overflow_count == 0 &&
actual.stats.light_tile_candidate_count <
actual.stats.light_tile_count * 3,
"Depth-free tile lists exclude an offscreen light without overflow");
compare_frames(expected, actual);
auto near_plane = local_scene(LocalLight::Kind::Point, true);
near_plane.local_lights.front().position = {0, 5, 7.95f};
near_plane.local_lights.front().range = 15;
const auto near_forward = capture(forward, near_plane);
const auto near_tiled = capture(tiles, near_plane);
require(near_tiled.stats.effective_lighting_path == "tiled" &&
near_tiled.stats.light_tile_counts_valid,
"Near-plane crossing light and its shadow use actual tile lists");
compare_frames(near_forward, near_tiled);
forward.resize(336, 256);
tiles.resize(336, 256);
fixture.scene_rect = {40, 32, 248, 176};
const auto resized_forward = capture(forward, fixture);
const auto resized_tiled = capture(tiles, fixture);
require(resized_tiled.stats.light_tile_count == 21 * 16,
"Forward+ rebuilds its grid after a drawable resize");
compare_frames(resized_forward, resized_tiled);
// Eighty coincident lights cover the same central tiles. A 64-index tile
// must evaluate the entire submitted list instead of losing late lights.
fixture.local_lights.clear();
for (int i = 0; i < 80; ++i) {
auto light = point_face_scene({0, 0, 1}, false).local_lights.front();
light.stable_id = "overflow-" + std::to_string(i);
light.position = {0, 3, 0};
light.intensity = .45f;
light.range = 8;
light.casts_shadow = false;
fixture.local_lights.push_back(light);
}
const auto all_forward = capture(forward, fixture);
const auto all_tiled = capture(tiles, fixture);
auto automatic = make_renderer(visibility, LightingMode::Auto);
const auto dense_auto = capture(automatic, fixture);
require(dense_auto.stats.effective_lighting_path == "forward" &&
dense_auto.stats.light_tile_count == 0,
"Auto avoids tile construction for unmeasured dense overlap");
require(all_tiled.stats.submitted_local_lights == 80 &&
all_tiled.stats.effective_lighting_path == "tiled" &&
all_tiled.stats.light_tile_overflow_count > 0,
"Overflow fixture submits all eighty lights through Forward+");
compare_frames(all_forward, all_tiled);
fixture.local_lights.resize(64);
const auto first_sixty_four = capture(forward, fixture);
std::size_t extra_light_pixels{};
for (std::size_t i = 0; i < all_forward.pixels.size(); i += 4)
extra_light_pixels += int(all_forward.pixels[i]) >
int(first_sixty_four.pixels[i]) + 2;
require(extra_light_pixels > 20,
"Overflow fixture visibly depends on lights past index 63");
}
}
} // namespace
int main(int argc, char** argv) {
try {
if (argc != 2)
throw std::invalid_argument("Expected --sun or --local");
throw std::invalid_argument("Expected --sun, --local, or --tiled");
if (std::string(argv[1]) == "--sun")
sun();
else if (std::string(argv[1]) == "--local")
local();
else if (std::string(argv[1]) == "--tiled")
tiled();
else
throw std::invalid_argument("Expected --sun or --local");
throw std::invalid_argument("Expected --sun, --local, or --tiled");
std::cout << "Shadow atlas and Direct/GPU lighting parity passed\n";
} catch (const std::exception& error) {
std::cerr << error.what() << '\n';
+73 -5
View File
@@ -46,9 +46,12 @@ int main() {
try {
const auto bundle = temporary / "shaders";
fs::create_directories(bundle);
for (const auto* entry : {"vertexMain", "fragmentMain", "shadowMain",
for (const auto* entry : {"vertexMain", "fragmentMain", "shadowMain", "lightTileMain",
"gpuVertexMain", "gpuShadowMain", "gpuCullMain",
"gpuHzbMain", "gpuPostCullMain"})
"gpuHzbMain", "gpuPostCullMain", "temporalResolveMain",
"temporalCompositeVertexMain", "temporalCompositeFragmentMain",
"temporalVertexMain", "temporalFragmentMain",
"gpuTemporalVertexMain"})
for (const auto* extension : {".spv", ".reflection.json"}) {
const auto name = std::string(entry) + extension;
fs::copy_file(path_from_utf8(FASET_TEST_SHADER_DIRECTORY) / name, bundle / name);
@@ -88,7 +91,10 @@ int main() {
render::Renderer renderer(configuration);
const auto baseline_only = temporary / "baseline-only";
fs::create_directories(baseline_only);
for (const auto* entry : {"vertexMain", "fragmentMain", "shadowMain"})
for (const auto* entry : {"vertexMain", "fragmentMain", "shadowMain", "lightTileMain",
"temporalResolveMain", "temporalCompositeVertexMain",
"temporalCompositeFragmentMain", "temporalVertexMain",
"temporalFragmentMain", "gpuTemporalVertexMain"})
for (const auto* extension : {".spv", ".reflection.json"}) {
const auto name = std::string(entry) + extension;
fs::copy_file(bundle / name, baseline_only / name);
@@ -124,8 +130,49 @@ int main() {
opaque_cube.instance_key = "shader-reload-cube";
opaque_cube.cast_shadow = false;
opaque_scene.draws.push_back(opaque_cube);
auto temporal_configuration = configuration;
temporal_configuration.temporal_mode = render::TemporalMode::TAA;
render::Renderer temporal_renderer(temporal_configuration);
temporal_renderer.render(opaque_scene);
temporal_renderer.render(opaque_scene);
require(temporal_renderer.stats().temporal_history_valid,
"Temporal reload fixture has a completed color history");
gpu_renderer.render(opaque_scene);
const auto gpu_expected = gpu_renderer.pixels();
auto tiled_configuration = configuration;
tiled_configuration.lighting_mode = render::LightingMode::Tiled;
render::Renderer tiled_renderer(tiled_configuration);
auto lit_scene = opaque_scene;
render::LocalLight point;
point.stable_id = "reload-point";
point.position = {1, 1, 3};
point.intensity = 5;
point.range = 8;
point.casts_shadow = false;
lit_scene.local_lights.push_back(point);
tiled_renderer.render(lit_scene);
require(tiled_renderer.stats().effective_lighting_path == "tiled" &&
tiled_renderer.stats().validation_errors == 0,
"Tiled lighting is active before shader reload");
const auto tiled_expected = tiled_renderer.pixels();
const auto original_tile_spirv = read_text(bundle / "lightTileMain.spv");
atomic_write(bundle / "lightTileMain.spv", "damaged tile bytecode");
std::string tile_error;
require(!tiled_renderer.reload_shaders(tile_error) && !tile_error.empty(),
"Rejected light tile shader preserves the working pipeline");
tiled_renderer.render(lit_scene);
require(tiled_renderer.stats().effective_lighting_path == "tiled" &&
tiled_renderer.pixels() == tiled_expected &&
tiled_renderer.stats().validation_errors == 0,
"Rejected light tile shader retains tiled lighting and pixels");
atomic_write(bundle / "lightTileMain.spv", original_tile_spirv);
require(tiled_renderer.reload_shaders(tile_error),
"Compatible light tile shader reloads successfully");
tiled_renderer.render(lit_scene);
require(tiled_renderer.stats().effective_lighting_path == "tiled" &&
tiled_renderer.pixels() == tiled_expected &&
tiled_renderer.stats().validation_errors == 0,
"Compatible light tile reload preserves tiled pixels");
render::Snapshot scene;
scene.ui_quads.push_back({0, 0, 32, 64, {1, .8f, .4f, 1}});
scene.sprites.push_back({{.5f, 0, .5f}, {1, 2}, {.2f, 1, .4f, 1}});
@@ -139,9 +186,12 @@ int main() {
require(deep_bundle.native().size() > 300,
"Shader file fixture must exceed the legacy Windows path limit");
fs::create_directories(native_io_path(deep_bundle));
for (const auto* entry : {"vertexMain", "fragmentMain", "shadowMain",
for (const auto* entry : {"vertexMain", "fragmentMain", "shadowMain", "lightTileMain",
"gpuVertexMain", "gpuShadowMain", "gpuCullMain",
"gpuHzbMain", "gpuPostCullMain"})
"gpuHzbMain", "gpuPostCullMain", "temporalResolveMain",
"temporalCompositeVertexMain", "temporalCompositeFragmentMain",
"temporalVertexMain", "temporalFragmentMain",
"gpuTemporalVertexMain"})
for (const auto* extension : {".spv", ".reflection.json"}) {
const auto name = std::string(entry) + extension;
atomic_write(deep_bundle / name, read_text(bundle / name));
@@ -179,6 +229,17 @@ int main() {
require(renderer.stats().validation_errors == 0,
"Rejected bytecode must not reach Vulkan validation");
};
const auto temporal_resolve = read_text(bundle / "temporalResolveMain.spv");
fs::remove(native_io_path(bundle / "temporalResolveMain.spv"));
std::string temporal_error;
require(!temporal_renderer.reload_shaders(temporal_error) && !temporal_error.empty(),
"A partial temporal package must reject reload atomically");
temporal_renderer.render(opaque_scene);
require(temporal_renderer.stats().effective_temporal_mode == render::TemporalMode::TAA &&
temporal_renderer.stats().temporal_history_valid &&
temporal_renderer.stats().validation_errors == 0,
"Rejected temporal reload retains active mode, pixels and history");
atomic_write(bundle / "temporalResolveMain.spv", temporal_resolve);
atomic_write(bundle / "fragmentMain.spv", "damaged bytecode");
retained();
restore();
@@ -236,6 +297,13 @@ int main() {
require(renderer.reload_shaders(error), "Compatible shader edit reloads successfully");
require(gpu_renderer.reload_shaders(error),
"Compatible fragment edit reloads GPU scene pipeline");
require(temporal_renderer.reload_shaders(error),
"Complete compatible package reloads temporal pipelines");
temporal_renderer.render(opaque_scene);
require(!temporal_renderer.stats().temporal_history_valid &&
temporal_renderer.stats().temporal_reset_reason ==
render::TemporalResetReason::ShaderReload,
"Successful temporal shader reload rejects stale color history");
gpu_renderer.render(opaque_scene);
const auto gpu_changed = gpu_renderer.pixels();
require(gpu_changed != gpu_expected,
+175
View File
@@ -0,0 +1,175 @@
#include "render_temporal_fixtures.hpp"
#include <faset/render/temporal.hpp>
#include <array>
#include <cmath>
#include <cstdint>
#include <iostream>
#include <memory>
#include <vector>
using namespace faset::render;
using namespace faset::render::temporal_test;
namespace {
void moving_reveal_and_camera_resets(VisibilityMode visibility) {
constexpr std::uint32_t width = 160, height = 120;
auto config = headless_config(width, height, visibility);
config.temporal_mode = TemporalMode::TAA;
Renderer taa(config);
config.temporal_mode = TemporalMode::Off;
Renderer off(config);
auto frame = lit_scene(width, height);
frame.draws.push_back(cube({0, 0, -2}, {.1f, .95f, .2f, 1}, "background"));
frame.draws.push_back(cube({0, 0, 1}, {.95f, .1f, .1f, 1}, "door"));
frame.ui_quads.push_back({2, 2, 25, 12, {.8f, .7f, .25f, 1}});
taa.render(frame);
require(!taa.stats().temporal_history_valid &&
taa.stats().temporal_reset_reason == TemporalResetReason::FirstFrame,
"The first TAA frame must use only current color");
taa.render(frame);
require(taa.stats().temporal_history_valid,
"An unchanged second frame must accept eligible temporal history");
frame.draws[1].model = transform({3, 0, 1});
taa.render(frame);
off.render(frame);
require(mean_rgb_error(taa.pixels(), off.pixels(), width, height, {75, 55, 10, 10}) <= 8.0,
"Opening a foreground door reveals current background without old-color trail");
require(taa.stats().validation_errors == 0,
"Moving-disocclusion resolve must pass Vulkan validation");
require(pixel(taa.pixels(), width, 4, 4) == pixel(off.pixels(), width, 4, 4),
"Moving scene and TAA must leave UI pixel-exact");
frame.camera_cut = true;
frame.eye = {1, 0, 6};
frame.view_projection = multiply(frame.projection, look_at(frame.eye, {0, 0, 0}));
taa.render(frame);
off.render(frame);
require(!taa.stats().temporal_history_valid &&
taa.stats().temporal_reset_reason == TemporalResetReason::CameraCut &&
mean_rgb_error(taa.pixels(), off.pixels(), width, height,
{75, 55, 10, 10}) <= 8.0,
"Explicit camera cut must discard stale color immediately");
frame.camera_cut = false;
frame.eye = {7, 0, 6};
frame.view_projection = multiply(frame.projection, look_at(frame.eye, {0, 0, 0}));
taa.render(frame);
require(!taa.stats().temporal_history_valid &&
taa.stats().temporal_reset_reason ==
TemporalResetReason::CameraDiscontinuity,
"An unmarked large camera teleport also discards history");
frame.eye = {0, 0, 6};
frame.view_projection = multiply(frame.projection, look_at(frame.eye, {0, 0, 0}));
frame.draws[0].mesh = std::make_shared<Mesh>(*frame.draws[0].mesh);
taa.render(frame);
require(taa.stats().validation_errors == 0,
"Mesh identity change and camera return must keep temporal output valid");
}
void lower_resolution_scene_and_output_ui() {
constexpr std::uint32_t width = 320, height = 240;
auto config = headless_config(width, height, VisibilityMode::Direct);
config.temporal_mode = TemporalMode::Upscale;
config.render_scale = .67f;
Renderer upscale(config);
auto frame = lit_scene(width, height);
frame.draws.push_back(cube({0, 0, 0}, {.8f, .6f, .25f, 1}, "thin-scene"));
frame.ui_quads.push_back({2, 2, 25, 12, {.8f, .7f, .25f, 1}});
frame.scene_rect = {13, 17, 287, 199};
upscale.render(frame);
require(upscale.stats().effective_temporal_mode == TemporalMode::Upscale &&
upscale.stats().temporal_internal_width == 215 &&
upscale.stats().temporal_internal_height == 161 &&
upscale.pixels().size() == std::size_t(width) * height * 4,
"0.67 upscale rasterizes 215x161 while capture remains 320x240");
require(upscale.stats().validation_errors == 0,
"Upscale image resize and composite pass Vulkan validation");
const auto output_ui = pixel(upscale.pixels(), width, 4, 4);
upscale.render(frame);
require(upscale.stats().temporal_history_valid,
"A compatible upscaled second frame has output-resolution history");
upscale.set_temporal_mode(TemporalMode::Upscale, .5f);
upscale.render(frame);
require(upscale.stats().temporal_internal_width == 160 &&
upscale.stats().temporal_internal_height == 120 &&
!upscale.stats().temporal_history_valid &&
upscale.stats().temporal_reset_reason == TemporalResetReason::ScaleChanged &&
pixel(upscale.pixels(), width, 4, 4) == output_ui,
"Changing upscale factor resets history without changing sharp output UI");
upscale.set_temporal_mode(TemporalMode::TAA);
upscale.render(frame);
require(upscale.stats().temporal_reset_reason == TemporalResetReason::ScaleChanged &&
upscale.stats().temporal_internal_width == width &&
upscale.stats().temporal_internal_height == height,
"Switching from upscale to 1:1 TAA resets the changed internal scale");
upscale.set_temporal_mode(TemporalMode::Off);
upscale.render(frame);
require(upscale.stats().effective_temporal_mode == TemporalMode::Off &&
pixel(upscale.pixels(), width, 4, 4) == output_ui,
"Switching Off restores the full-resolution baseline and sharp UI");
upscale.resize(319, 241);
frame = lit_scene(319, 241);
frame.scene_rect = {13, 17, 285, 199};
upscale.set_temporal_mode(TemporalMode::Upscale, .5f);
upscale.render(frame);
require(upscale.stats().temporal_internal_width == 160 &&
upscale.stats().temporal_internal_height == 121 &&
upscale.pixels().size() == std::size_t(319) * 241 * 4 &&
upscale.stats().validation_errors == 0,
"Odd output and offset scene rectangle preserve ceil-rounded internal extent");
}
double frame_variation(const std::vector<std::vector<std::uint8_t>>& frames,
std::uint32_t width, Region region) {
require(frames.size() >= 2, "Temporal variation metric needs multiple frames");
double sum{};
for (std::size_t i = 1; i < frames.size(); ++i)
sum += mean_rgb_error(frames[i], frames[i - 1], width,
static_cast<std::uint32_t>(frames[i].size() / (width * 4)),
region);
return sum / double(frames.size() - 1);
}
void static_edge_reduces_jitter_variation() {
constexpr std::uint32_t width = 160, height = 120;
auto config = headless_config(width, height, VisibilityMode::Direct);
config.temporal_mode = TemporalMode::TAA;
Renderer accumulated(config), spatial(config);
auto frame = lit_scene(width, height);
frame.clear_color = {0, 0, 0, 1};
frame.draws.push_back(cube({0, 0, 0}, {1, 1, 1, 1}, "static-edge"));
std::vector<std::vector<std::uint8_t>> resolved, unaccumulated;
for (unsigned phase = 0; phase < 16; ++phase) {
accumulated.render(frame);
if (phase >= 4)
resolved.push_back(accumulated.pixels());
frame.camera_cut = true; // Same jitter phase, but no prior color may be read.
spatial.render(frame);
if (phase >= 4)
unaccumulated.push_back(spatial.pixels());
frame.camera_cut = false;
}
const Region edge_area{25, 12, 110, 95};
const double raw = frame_variation(unaccumulated, width, edge_area);
const double temporal = frame_variation(resolved, width, edge_area);
std::cerr << "Static-edge mean frame variation: unaccumulated=" << raw
<< " TAA=" << temporal << '\n';
require(raw > .05 && temporal < raw * .98,
"After warm-up TAA must reduce static edge shimmer across Halton phases");
require(accumulated.stats().validation_errors == 0 &&
spatial.stats().validation_errors == 0,
"Static-edge temporal sequence must not raise Vulkan validation errors");
}
} // namespace
int main() {
moving_reveal_and_camera_resets(VisibilityMode::Direct);
moving_reveal_and_camera_resets(VisibilityMode::GpuFrustum);
moving_reveal_and_camera_resets(VisibilityMode::GpuOcclusion);
lower_resolution_scene_and_output_ui();
static_edge_reduces_jitter_variation();
}
+82
View File
@@ -0,0 +1,82 @@
#pragma once
#include <faset/render/renderer.hpp>
#include <cmath>
#include <cstddef>
#include <cstdint>
#include <memory>
#include <stdexcept>
#include <string>
#include <vector>
namespace faset::render::temporal_test {
inline void require(bool condition, const char* message) {
if (!condition)
throw std::runtime_error(message);
}
struct Region {
std::uint32_t x{}, y{}, width{}, height{};
};
inline double mean_rgb_error(const std::vector<std::uint8_t>& actual,
const std::vector<std::uint8_t>& reference,
std::uint32_t image_width, std::uint32_t image_height,
Region region) {
require(actual.size() == reference.size() &&
actual.size() == std::size_t(image_width) * image_height * 4 &&
region.width && region.height && region.x <= image_width &&
region.y <= image_height && region.width <= image_width - region.x &&
region.height <= image_height - region.y,
"Temporal image metric requires equal images and an in-bounds ROI");
std::uint64_t difference{};
for (auto y = region.y; y < region.y + region.height; ++y)
for (auto x = region.x; x < region.x + region.width; ++x) {
const auto base = (std::size_t(y) * image_width + x) * 4;
for (std::size_t channel = 0; channel < 3; ++channel)
difference += static_cast<std::uint64_t>(
std::abs(int(actual[base + channel]) - int(reference[base + channel])));
}
return double(difference) / (double(region.width) * region.height * 3);
}
inline std::array<std::uint8_t, 4> pixel(const std::vector<std::uint8_t>& rgba,
std::uint32_t width, std::uint32_t x,
std::uint32_t y) {
const auto base = (std::size_t(y) * width + x) * 4;
require(base + 3 < rgba.size(), "Requested temporal test pixel is outside the image");
return {rgba[base], rgba[base + 1], rgba[base + 2], rgba[base + 3]};
}
inline DrawItem cube(Vec3 position, Color color, std::string key) {
DrawItem draw;
draw.mesh = cube_mesh();
draw.model = transform(position);
draw.color = color;
draw.instance_key = std::move(key);
draw.cast_shadow = false;
return draw;
}
inline Snapshot lit_scene(std::uint32_t width, std::uint32_t height) {
Snapshot frame;
frame.view_id = "temporal-acceptance-main";
frame.eye = {0, 0, 6};
frame.projection = perspective(.9f, float(width) / float(height), .1f, 50.f);
frame.view_projection = multiply(frame.projection, look_at(frame.eye, {0, 0, 0}));
frame.authored_lights_present = true;
frame.sun = SunLight{"test-sun", {-.5f, -1.f, -.3f}, {1, 1, 1, 1}, 3.f, false};
return frame;
}
inline RendererConfig headless_config(std::uint32_t width, std::uint32_t height,
VisibilityMode visibility) {
RendererConfig config;
config.width = width;
config.height = height;
config.headless = true;
config.validation = true;
config.visibility_mode = visibility;
return config;
}
} // namespace faset::render::temporal_test
+67
View File
@@ -0,0 +1,67 @@
#include "render_temporal_fixtures.hpp"
#include <faset/render/temporal.hpp>
#include <algorithm>
#include <string>
#include <vector>
using namespace faset::render;
using namespace faset::render::temporal_test;
namespace {
std::size_t position(const std::vector<std::string>& passes, const char* name) {
const auto found = std::find(passes.begin(), passes.end(), name);
require(found != passes.end(), "Required temporal graph pass is absent");
return static_cast<std::size_t>(found - passes.begin());
}
void offset_scene_and_sharp_ui() {
constexpr std::uint32_t width = 191, height = 127;
auto config = headless_config(width, height, VisibilityMode::GpuOcclusion);
config.temporal_mode = TemporalMode::TAA;
Renderer taa(config);
config.temporal_mode = TemporalMode::Off;
Renderer off(config);
auto frame = lit_scene(141, 103);
frame.scene_rect = {13, 7, 141, 103};
frame.draws.push_back(cube({0, 0, 0}, {.9f, .55f, .25f, 1}, "opaque"));
frame.draws.push_back(cube({.4f, 0, 1}, {.2f, .6f, .9f, .45f}, "translucent"));
Sprite sprite;
sprite.position = {-.7f, -.4f, .7f};
sprite.size = {.8f, .8f};
sprite.color = {.3f, .8f, .3f, .8f};
frame.sprites.push_back(sprite);
frame.ui_quads.push_back({2, 2, 24, 14, {.95f, .8f, .2f, 1}});
frame.ui_text.push_back({2, 22, "Temporal UI", {1, 1, 1, 1}, 12});
taa.render(frame);
off.render(frame);
const auto& stats = taa.stats();
require(stats.effective_temporal_mode == TemporalMode::TAA &&
stats.effective_visibility_mode == VisibilityMode::GpuOcclusion,
"Graph acceptance must exercise temporal resolve after P2 occlusion");
const auto& passes = stats.graph_passes;
require(position(passes, "PostRasterScene") < position(passes, "TemporalResolve") &&
position(passes, "TemporalResolve") <
position(passes, "TemporalComposite") &&
position(passes, "TemporalComposite") < position(passes, "UI"),
"Post-cull scene color/velocity must resolve before final-resolution UI");
require(pixel(taa.pixels(), width, 4, 4) == pixel(off.pixels(), width, 4, 4),
"Opaque UI remains pixel-exact and unjittered on an offset scene viewport");
require(pixel(taa.pixels(), width, 180, 120) == pixel(off.pixels(), width, 180, 120),
"Pixels outside the offset scene rectangle retain the Off clear result");
require(stats.validation_errors == 0,
"Temporal graph attachment store/load and transitions pass Vulkan validation");
if (stats.gpu_ms > 0)
require(stats.gpu_temporal_resolve_ms > 0 &&
stats.gpu_temporal_composite_ms > 0,
"Temporal resolve and composite expose independent GPU pass timings");
require(stats.gpu_allocated_bytes > off.stats().gpu_allocated_bytes,
"Temporal scene, velocity and output histories count toward live GPU memory");
}
} // namespace
int main() {
offset_scene_and_sharp_ui();
}
+101
View File
@@ -0,0 +1,101 @@
#include <faset/render/renderer.hpp>
#include <faset/render/temporal.hpp>
#include <memory>
#include <stdexcept>
#include <string>
using namespace faset::render;
namespace {
void require(bool value, const char* message) {
if (!value)
throw std::runtime_error(message);
}
Snapshot scene(std::shared_ptr<const Mesh> mesh) {
Snapshot frame;
frame.view_id = "temporal-lifecycle-main";
frame.eye = {0, 0, 6};
frame.projection = perspective(.9f, 1.f, .1f, 50.f);
frame.view_projection = multiply(frame.projection, look_at(frame.eye, {0, 0, 0}));
DrawItem cube;
cube.mesh = std::move(mesh);
cube.instance_key = "rigid-cube";
cube.cast_shadow = false;
frame.draws.push_back(std::move(cube));
return frame;
}
void previous_transform_does_not_depend_on_hzb(VisibilityMode visibility) {
RendererConfig config;
config.width = config.height = 128;
config.headless = true;
config.validation = true;
config.visibility_mode = visibility;
config.temporal_mode = TemporalMode::TAA;
config.render_scale = 1.f;
Renderer renderer(config);
auto frame = scene(cube_mesh());
renderer.render(frame);
require(!renderer.stats().temporal_history_valid &&
renderer.stats().temporal_valid_motion_instances == 0,
"First frame must reject temporal color and previous transforms");
frame.draws[0].model = transform({0.25f, 0, 0});
renderer.render(frame);
require(!renderer.stats().hzb_valid,
"Direct and GPU frustum paths have no previous HZB in this fixture");
require(renderer.stats().temporal_history_valid &&
renderer.stats().temporal_valid_motion_instances == 1,
"A stable opaque draw retains its previous model without HZB history");
frame.draws[0].mesh = std::make_shared<Mesh>(*frame.draws[0].mesh);
renderer.render(frame);
require(renderer.stats().temporal_valid_motion_instances == 0,
"Changing mesh identity invalidates prior geometry motion");
require(renderer.stats().validation_errors == 0,
"Temporal lifecycle must not trigger Vulkan validation errors");
}
void aborted_frame_cannot_advance_history() {
RendererConfig config;
config.width = config.height = 128;
config.headless = true;
config.visibility_mode = VisibilityMode::Direct;
config.temporal_mode = TemporalMode::TAA;
Renderer renderer(config);
auto frame = scene(cube_mesh());
renderer.render(frame);
renderer.render(frame);
require(renderer.stats().temporal_history_valid,
"Fixture must have committed history before the aborted frame");
const auto completed_frame = renderer.stats().frame;
auto invalid = frame;
invalid.draws[0].model = transform({1, 0, 0});
invalid.local_lights.push_back(LocalLight{});
invalid.local_lights.back().range = -1.f;
bool rejected = false;
try {
renderer.render(invalid);
} catch (const std::invalid_argument&) {
rejected = true;
}
require(rejected, "Late light validation must abort before command submission");
require(renderer.stats().frame == completed_frame,
"An aborted frame must not count as completed");
frame.draws[0].model = transform({0.25f, 0, 0});
renderer.render(frame);
require(renderer.stats().temporal_history_valid &&
renderer.stats().temporal_valid_motion_instances == 1,
"The next successful frame must reuse the last committed transform/history");
}
} // namespace
int main() {
previous_transform_does_not_depend_on_hzb(VisibilityMode::Direct);
previous_transform_does_not_depend_on_hzb(VisibilityMode::GpuFrustum);
aborted_frame_cannot_advance_history();
}
+71
View File
@@ -0,0 +1,71 @@
#include <faset/render/renderer.hpp>
#include <faset/render/temporal.hpp>
#include <faset/render/visibility.hpp>
#include <array>
#include <cmath>
#include <memory>
#include <stdexcept>
using namespace faset::render;
namespace {
void require(bool value, const char* message) {
if (!value)
throw std::runtime_error(message);
}
void motion_uses_current_minus_previous_scene_uv() {
const auto motion = project_motion(std::array<float, 4>{0.2f, -0.2f, 0.6f, 1.f},
std::array<float, 4>{0.f, 0.f, 0.4f, 1.f});
require(motion && std::abs((*motion)[0] - 0.1f) < 1e-6f &&
std::abs((*motion)[1] + 0.1f) < 1e-6f,
"Motion sign and units must be current minus previous normalized scene UV");
const auto perspective = project_motion(std::array<float, 4>{0.6f, 0.f, 0.8f, 2.f},
std::array<float, 4>{0.2f, 0.f, 0.5f, 1.f});
require(perspective && std::abs((*perspective)[0] - 0.05f) < 1e-6f,
"Motion must divide each frame's clip coordinates by its own W");
require(!project_motion({0, 0, 0, 1}, {0, 0, 0, 0}),
"A previous vertex on the eye plane cannot carry valid motion");
require(!project_motion({0, 0, 0, 1}, {0, 0, 0, -1}),
"A previous vertex behind the camera cannot carry valid motion");
require(!project_motion({INFINITY, 0, 0, 1}, {0, 0, 0, 1}),
"Nonfinite clip coordinates cannot enter temporal history");
}
void hzb_and_color_history_have_independent_bits() {
auto mesh = cube_mesh();
auto replacement = std::make_shared<Mesh>(*mesh);
const Bounds bounds{{-1, -1, -1}, {1, 1, 1}};
InstanceTracker tracker;
const auto model_a = transform({0, 0, 0});
const auto model_b = transform({1, 0, 0});
const auto first = tracker.update("cube", mesh, model_a, bounds, "main");
require(gpu_instance_metadata(first, true, true)[0] == 0,
"A first-frame tracked instance has neither prior HZB nor color history");
tracker.finish_frame();
const auto moved = tracker.update("cube", mesh, model_b, bounds, "main");
require(moved.previous_valid && moved.previous_model == model_a,
"A stable instance must retain its prior model in Direct or GPU mode");
require(gpu_instance_metadata(moved, true, false)[0] == 1,
"Only prior HZB eligibility sets bit zero");
require(gpu_instance_metadata(moved, false, true)[0] == 2,
"Temporal transform eligibility must not require HZB history");
require(gpu_instance_metadata(moved, true, true)[0] == 3,
"Both independent history bits may be valid in GPU occlusion mode");
require(gpu_instance_metadata(moved, false, false)[0] == 0,
"Neither history bit may leak after an incompatible frame");
tracker.finish_frame();
const auto replaced = tracker.update("cube", replacement, model_b, bounds, "main");
require(!replaced.previous_valid && gpu_instance_metadata(replaced, true, true)[0] == 0,
"A mesh identity change must reject both previous bounds and motion");
require(gpu_instance_metadata({}, true, true)[0] == 0,
"An anonymous draw cannot inherit another draw's transform");
}
} // namespace
int main() {
motion_uses_current_minus_previous_scene_uv();
hzb_and_color_history_have_independent_bits();
}
+250
View File
@@ -0,0 +1,250 @@
#include <faset/render/temporal.hpp>
#include <cmath>
#include <limits>
#include <optional>
#include <stdexcept>
namespace {
using namespace faset::render;
void require(bool condition, const char* message) {
if (!condition)
throw std::runtime_error(message);
}
void capability_fallback() {
constexpr TemporalCapabilities full{true, true, true};
require(select_effective_temporal_mode(TemporalMode::Off, {}) == TemporalMode::Off,
"Off must not require temporal GPU capabilities");
require(temporal_fallback_reason(TemporalMode::Off, {}) == TemporalFallbackReason::None,
"Explicit Off is not a capability fallback");
require(select_effective_temporal_mode(TemporalMode::TAA, full) == TemporalMode::TAA,
"Available TAA must remain active");
require(select_effective_temporal_mode(TemporalMode::Upscale, full) == TemporalMode::Upscale,
"Available upscaling must remain active");
require(select_effective_temporal_mode(TemporalMode::TAA, {false, true, true}) ==
TemporalMode::Off &&
temporal_fallback_reason(TemporalMode::TAA, {false, true, true}) ==
TemporalFallbackReason::ComputeUnavailable,
"Missing compute must produce a named Off fallback");
require(temporal_fallback_reason(TemporalMode::TAA, {true, false, true}) ==
TemporalFallbackReason::FormatUnavailable,
"Missing sampled/storage formats must identify the format fallback");
require(temporal_fallback_reason(TemporalMode::Upscale, {true, true, false}) ==
TemporalFallbackReason::ExtentUnsupported,
"An unsupported target extent must identify the extent fallback");
}
TemporalHistoryKey steady_view() {
TemporalHistoryKey key;
key.view_id = "main-camera";
key.output_width = key.internal_width = 320;
key.output_height = key.internal_height = 240;
key.scene_rect = {7, 11, 301, 219};
key.projection = {1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1};
key.view_projection = key.projection;
key.mode = TemporalMode::TAA;
key.render_scale = 1;
key.shader_generation = 4;
return key;
}
void rendered_history_and_camera_motion() {
const auto previous = steady_view();
auto current = previous;
require(evaluate_temporal_history(std::nullopt, current).reason ==
TemporalResetReason::FirstFrame,
"A first temporal frame must not claim history");
current.camera_eye = {0.5f, 0, 0};
current.view_projection[12] = -0.5f;
const auto ordinary_motion = evaluate_temporal_history(previous, current);
require(ordinary_motion.valid && ordinary_motion.reason == TemporalResetReason::None,
"Ordinary camera motion must retain compatible history");
current.camera_cut = true;
require(evaluate_temporal_history(previous, current).reason == TemporalResetReason::CameraCut,
"An explicit cut must override otherwise compatible history");
current.camera_cut = false;
current.camera_eye = {6, 0, 0};
require(evaluate_temporal_history(previous, current).reason ==
TemporalResetReason::CameraDiscontinuity,
"A large unmarked teleport must invalidate history");
current = previous;
current.view_projection[10] = -1;
require(evaluate_temporal_history(previous, current).reason ==
TemporalResetReason::CameraDiscontinuity,
"A large camera turn must invalidate history");
current = previous;
current.view_projection[0] = -1;
current.view_projection[5] = -1;
require(evaluate_temporal_history(previous, current).reason ==
TemporalResetReason::CameraDiscontinuity,
"A 180-degree roll must invalidate history even when forward is unchanged");
current = previous;
current.view_projection[0] = 0.98480775f;
current.view_projection[1] = 0.17364818f;
current.view_projection[4] = -0.17364818f;
current.view_projection[5] = 0.98480775f;
require(evaluate_temporal_history(previous, current).valid,
"An ordinary small camera roll must retain compatible history");
current = previous;
current.view_projection[0] = std::numeric_limits<float>::quiet_NaN();
require(evaluate_temporal_history(previous, current).reason ==
TemporalResetReason::CameraDiscontinuity,
"Nonfinite camera matrices cannot admit history");
}
void incompatible_view_state() {
const auto previous = steady_view();
auto current = previous;
current.view_id = "other-camera";
require(evaluate_temporal_history(previous, current).reason == TemporalResetReason::ViewChanged,
"A second view cannot inherit another view's color history");
current = previous;
++current.output_width;
require(evaluate_temporal_history(previous, current).reason == TemporalResetReason::Resize,
"Output resize invalidates history");
current = previous;
current.scene_rect[0] += 1;
require(evaluate_temporal_history(previous, current).reason ==
TemporalResetReason::ViewportChanged,
"Moving the editor viewport invalidates history");
current = previous;
current.projection[0] += 0.1f;
require(evaluate_temporal_history(previous, current).reason ==
TemporalResetReason::ProjectionChanged,
"FOV or aspect change invalidates history");
current = previous;
current.mode = TemporalMode::Upscale;
current.render_scale = 0.67f;
current.internal_width = 215;
current.internal_height = 161;
require(evaluate_temporal_history(previous, current).reason ==
TemporalResetReason::ScaleChanged,
"New internal render scale invalidates full-resolution history");
current = previous;
current.mode = TemporalMode::Off;
require(evaluate_temporal_history(previous, current).reason ==
TemporalResetReason::ModeChanged,
"Turning temporal processing off invalidates history");
current = previous;
++current.shader_generation;
require(evaluate_temporal_history(previous, current).reason ==
TemporalResetReason::ShaderReload,
"A changed shading generation invalidates history");
}
void intentionally_disabled_temporal_mode() {
auto off = steady_view();
off.mode = TemporalMode::Off;
const auto first = evaluate_temporal_history(std::nullopt, off);
require(!first.valid && first.reason == TemporalResetReason::None,
"Explicit Off has no temporal history to reset or unsupported fallback to report");
const auto later = evaluate_temporal_history(off, off);
require(!later.valid && later.reason == TemporalResetReason::None,
"Continuing in Off must remain a deliberate non-temporal mode");
}
void deterministic_jitter() {
const auto first = temporal_jitter(0, 320, 240);
const auto second = temporal_jitter(1, 320, 240);
require(std::abs(first[0]) < 1e-7f && std::abs(first[1] + 1.f / 720.f) < 1e-7f,
"First Halton(2,3) sample must be a clip-space offset");
require(std::abs(second[0] + 1.f / 640.f) < 1e-7f &&
std::abs(second[1] - 1.f / 720.f) < 1e-7f,
"Second Halton sample must visit a different subpixel position");
require(first == temporal_jitter(16, 320, 240),
"The finite sequence must repeat on frame sixteen");
require(std::abs(temporal_jitter(0, 640, 480)[1] * 2.f - first[1]) < 1e-7f,
"Clip jitter must scale inversely with viewport extent");
for (std::uint64_t frame = 0; frame < 16; ++frame) {
const auto sample = temporal_jitter(frame, 319, 241);
require(std::abs(sample[0]) <= 1.f / 319.f &&
std::abs(sample[1]) <= 1.f / 241.f,
"Every jitter sample must stay within half an output pixel");
}
bool rejected_zero_extent = false;
try {
(void)temporal_jitter(0, 0, 240);
} catch (const std::invalid_argument&) {
rejected_zero_extent = true;
}
require(rejected_zero_extent, "Zero viewport extent cannot produce finite clip jitter");
}
void render_scale_policy() {
const auto full = temporal_internal_extent(320, 240, TemporalMode::Off, 1.f);
require(full == std::array<std::uint32_t, 2>{320, 240},
"Off keeps scene and output at the same extent");
require(temporal_internal_extent(319, 241, TemporalMode::TAA, 1.f) ==
std::array<std::uint32_t, 2>{319, 241},
"TAA is a one-to-one reconstruction mode");
require(temporal_internal_extent(320, 240, TemporalMode::Upscale, .67f) ==
std::array<std::uint32_t, 2>{215, 161},
"Upscale uses deterministic ceil dimensions for odd pixel products");
require(temporal_internal_extent(1, 1, TemporalMode::Upscale, .5f) ==
std::array<std::uint32_t, 2>{1, 1},
"A supported output always has at least one internal pixel");
auto rejected = [](TemporalMode mode, float scale) {
try {
(void)temporal_internal_extent(320, 240, mode, scale);
return false;
} catch (const std::invalid_argument&) {
return true;
}
};
require(rejected(TemporalMode::TAA, .75f) && rejected(TemporalMode::Upscale, 1.f) &&
rejected(TemporalMode::Upscale, .49f) &&
rejected(TemporalMode::Upscale, std::numeric_limits<float>::quiet_NaN()) &&
rejected(TemporalMode::Off, .67f) &&
rejected(static_cast<TemporalMode>(42), 1.f),
"Invalid mode/scale combinations must fail before target allocation");
bool zero_rejected = false;
try {
(void)temporal_internal_extent(0, 240, TemporalMode::TAA, 1.f);
} catch (const std::invalid_argument&) {
zero_rejected = true;
}
require(zero_rejected, "Zero output width is not a valid temporal target");
}
void completed_frames_only_become_history() {
TemporalHistoryState history;
auto frame = steady_view();
require(history.prepare(frame).reason == TemporalResetReason::FirstFrame,
"A prepared first frame has no committed history");
history.complete(frame);
require(history.prepare(frame).valid,
"A successfully completed frame becomes reusable history");
auto failed_frame = frame;
failed_frame.view_id = "failed-submit-view";
require(history.prepare(failed_frame).reason == TemporalResetReason::ViewChanged,
"A candidate view switch is detected before submission");
try {
throw std::runtime_error("synthetic queue submit failure");
} catch (const std::runtime_error&) {
// The caller never invokes complete() on a failed submission.
}
require(history.prepare(frame).valid,
"A failed submission must not replace the last completed history key");
require(history.prepare(failed_frame).reason == TemporalResetReason::ViewChanged,
"An uncommitted frame must not become the next frame's predecessor");
frame.mode = TemporalMode::Off;
history.complete(frame);
frame.mode = TemporalMode::TAA;
require(history.prepare(frame).reason == TemporalResetReason::FirstFrame,
"Completing an Off frame discards temporal history");
}
} // namespace
int main() {
capability_fallback();
intentionally_disabled_temporal_mode();
rendered_history_and_camera_motion();
incompatible_view_state();
deterministic_jitter();
render_scale_policy();
completed_frames_only_become_history();
}
+166
View File
@@ -0,0 +1,166 @@
#include <faset/render/temporal_reference.hpp>
#include <array>
#include <cmath>
#include <cstdint>
#include <stdexcept>
#include <vector>
using namespace faset::render;
namespace {
void require(bool value, const char* message) {
if (!value)
throw std::runtime_error(message);
}
TemporalReferenceFrame solid(std::uint32_t width, std::uint32_t height,
float intensity = .5f, float depth = .5f) {
TemporalReferenceFrame frame;
frame.width = width;
frame.height = height;
frame.pixels.resize(std::size_t(width) * height);
for (auto& pixel : frame.pixels) {
pixel.color = {intensity, intensity, intensity, 1.f};
pixel.depth = depth;
pixel.previous_depth = depth;
pixel.motion_valid = true;
}
return frame;
}
TemporalReferenceHistory history_from(const TemporalReferenceFrame& frame) {
TemporalReferenceHistory history;
history.width = frame.width;
history.height = frame.height;
for (const auto& pixel : frame.pixels) {
history.color.push_back(pixel.color);
history.depth.push_back(pixel.depth);
}
return history;
}
void first_frame_and_malformed_inputs() {
auto frame = solid(2, 2, .4f);
const auto first = temporal_reference_resolve(frame, nullptr);
require(first.accepted_count == 0 && first.accepted.size() == 4,
"A first frame cannot accept previous color");
for (std::size_t i = 0; i < frame.pixels.size(); ++i)
require(first.history.color[i] == frame.pixels[i].color &&
first.history.depth[i] == frame.pixels[i].depth && !first.accepted[i],
"First-frame fallback writes current color and depth exactly");
auto malformed = frame;
malformed.pixels.pop_back();
bool rejected = false;
try {
(void)temporal_reference_resolve(malformed, nullptr);
} catch (const std::invalid_argument&) {
rejected = true;
}
require(rejected, "Image extent and pixel count must agree");
auto wrong_extent = first.history;
wrong_extent.width = 3;
rejected = false;
try {
(void)temporal_reference_resolve(frame, &wrong_extent);
} catch (const std::invalid_argument&) {
rejected = true;
}
require(rejected, "Incompatible history extent must be reset by the caller");
}
void reprojection_sign_and_depth_rejection() {
auto frame = solid(4, 1);
frame.pixels[0].color = {0, 0, 0, 1};
frame.pixels[1].color = {0, 0, 0, 1};
frame.pixels[3].color = {1, 1, 1, 1};
frame.pixels[2].motion = {.25f, 0};
auto prior = history_from(frame);
prior.color[1] = {.2f, .2f, .2f, 1};
prior.color[3] = {.8f, .8f, .8f, 1};
const auto moved = temporal_reference_resolve(frame, &prior);
require(moved.accepted[2] && moved.history.color[2][0] < .5f,
"Positive current-minus-prior motion must sample the pixel to the left");
prior.depth[1] = .2f;
const auto revealed = temporal_reference_resolve(frame, &prior);
require(!revealed.accepted[2] && revealed.history.color[2] == frame.pixels[2].color,
"A previous-depth disagreement rejects newly exposed background color");
frame.pixels[2].motion = {2, 0};
const auto outside = temporal_reference_resolve(frame, &prior);
require(!outside.accepted[2] && outside.history.color[2] == frame.pixels[2].color,
"Reprojection outside the old scene rejects history");
}
void neighborhood_clamp_and_reactive_pixels() {
auto frame = solid(3, 3);
for (std::size_t i = 0; i < frame.pixels.size(); ++i) {
const float tone = i % 2 ? .4f : .6f;
frame.pixels[i].color = {tone, tone, tone, 1};
}
frame.pixels[4].color = {.5f, .5f, .5f, 1};
auto prior = history_from(frame);
prior.color[4] = {10, 10, 10, 1};
const auto clipped = temporal_reference_resolve(frame, &prior);
require(clipped.accepted[4] && clipped.history.color[4][0] > .5f &&
clipped.history.color[4][0] <= .6f,
"Neighborhood clamp bounds a bright stale history sample");
frame.pixels[4].reactive = .5f;
const auto softened = temporal_reference_resolve(frame, &prior);
require(softened.accepted[4] && softened.history.color[4][0] > .5f &&
softened.history.color[4][0] < clipped.history.color[4][0],
"Partial reactivity reduces the accepted history weight");
frame.pixels[4].reactive = 1.f;
const auto reactive = temporal_reference_resolve(frame, &prior);
require(!reactive.accepted[4] && reactive.history.color[4] == frame.pixels[4].color,
"Reactive transparency/sprite pixels must use current color");
frame.pixels[4].reactive = 0;
frame.pixels[4].motion_valid = false;
const auto anonymous = temporal_reference_resolve(frame, &prior);
require(!anonymous.accepted[4] && anonymous.history.color[4] == frame.pixels[4].color,
"An anonymous or replaced object cannot borrow neighboring history");
frame.pixels[4].motion_valid = true;
frame.pixels[4].motion[0] = INFINITY;
const auto invalid_motion = temporal_reference_resolve(frame, &prior);
require(!invalid_motion.accepted[4] &&
invalid_motion.history.color[4] == frame.pixels[4].color,
"Nonfinite motion rejects history without contaminating output");
}
void nearest_depth_motion_dilation() {
auto frame = solid(3, 3, .5f, .9f);
frame.pixels[4].depth = .8f;
frame.pixels[4].previous_depth = .8f;
frame.pixels[3].depth = .2f;
frame.pixels[3].previous_depth = .2f;
frame.pixels[3].motion = {1.f / 3.f, 0};
frame.pixels[1].color = {0, 0, 0, 1};
frame.pixels[5].color = {1, 1, 1, 1};
auto prior = history_from(frame);
prior.depth[4] = .1f; // Undilated center motion would fail this depth check.
prior.depth[3] = .2f;
prior.color[3] = {.1f, .1f, .1f, 1};
const auto exposed_background = temporal_reference_resolve(frame, &prior);
require(!exposed_background.accepted[4] &&
exposed_background.history.color[4] == frame.pixels[4].color,
"A newly exposed deep background pixel must not borrow foreground motion");
frame.pixels[4].depth = .203f;
frame.pixels[4].previous_depth = .203f;
const auto dilated = temporal_reference_resolve(frame, &prior);
require(dilated.accepted[4] && dilated.history.color[4][0] < .5f,
"Nearest-depth foreground motion should fill a valid silhouette pixel");
frame.pixels[4].motion_valid = false;
const auto invalid_center = temporal_reference_resolve(frame, &prior);
require(!invalid_center.accepted[4],
"Dilation must not resurrect missing per-object previous transforms");
}
} // namespace
int main() {
first_frame_and_malformed_inputs();
reprojection_sign_and_depth_rejection();
neighborhood_clamp_and_reactive_pixels();
nearest_depth_motion_dilation();
}
@@ -0,0 +1,72 @@
#include "shader_contract.hpp"
#include <faset/core/hash.hpp>
#include <faset/core/io.hpp>
#include <filesystem>
#include <stdexcept>
#include <string>
namespace fs = std::filesystem;
namespace {
void require(bool value, const char* message) {
if (!value)
throw std::runtime_error(message);
}
template <class Function> void must_reject(Function&& function, const char* message) {
try {
function();
} catch (const std::exception&) {
return;
}
throw std::runtime_error(message);
}
} // namespace
int main() {
const auto original = fs::path(FASET_TEST_SHADER_DIRECTORY);
const auto temporary = fs::temp_directory_path() /
faset::path_from_utf8("Faset temporal shaders " + faset::new_id());
struct Cleanup {
fs::path path;
~Cleanup() { std::error_code error; fs::remove_all(faset::native_io_path(path), error); }
} cleanup{temporary};
fs::create_directories(faset::native_io_path(temporary));
constexpr const char* entries[] = {"temporalResolveMain", "temporalCompositeVertexMain",
"temporalCompositeFragmentMain"};
for (const auto* entry : entries)
for (const auto* extension : {".spv", ".reflection.json"}) {
const auto name = std::string(entry) + extension;
fs::copy_file(faset::native_io_path(original / name),
faset::native_io_path(temporary / name));
}
const auto bundle = faset::render::detail::load_temporal_shader_bundle(temporary);
for (const auto& shader : bundle)
require(!shader.words.empty() && !shader.layout_fingerprint.empty(),
"Every temporal shader entry has valid checked SPIR-V and reflection");
auto reflection = temporary / "temporalResolveMain.reflection.json";
auto metadata = faset::read_json(reflection);
require(metadata["layout"]["stage"] == "compute" &&
metadata["layout"]["descriptors"].size() == 7 &&
metadata["layout"]["push_constants"][0]["size"] == 80,
"Temporal resolve ABI contains seven images and an 80-byte push block");
metadata["layout"]["descriptors"][5]["binding"] = 8;
metadata["layout_fingerprint"] = faset::sha256(metadata["layout"].dump());
faset::atomic_write_json(reflection, metadata);
must_reject([&] { (void)faset::render::detail::load_temporal_shader_bundle(temporary); },
"A rehashed temporal image binding change must be rejected");
faset::atomic_write_json(
reflection, faset::read_json(original / "temporalResolveMain.reflection.json"));
auto fragment = temporary / "temporalCompositeFragmentMain.spv";
const auto bytes = faset::read_text(fragment);
faset::atomic_write(fragment, "corrupt");
must_reject([&] { (void)faset::render::detail::load_temporal_shader_bundle(temporary); },
"A broken composite shader cannot enter the temporal bundle");
faset::atomic_write(fragment, bytes);
fs::remove(faset::native_io_path(temporary / "temporalCompositeVertexMain.spv"));
must_reject([&] { (void)faset::render::detail::load_temporal_shader_bundle(temporary); },
"A missing temporal entry cannot enter a complete shader bundle");
}
+48 -6
View File
@@ -56,14 +56,34 @@ class ReflectionTests(unittest.TestCase):
(d["set"], d["binding"]): (d["type"], d.get("element_stride"))
for d in fragment["layout"]["descriptors"]
}
self.assertEqual([lighting[1, i] for i in range(4)],
self.assertEqual([lighting[1, i] for i in range(5)],
[("storage_buffer", 80), ("storage_buffer", 80),
("storage_buffer", 112), ("sampled_image_2d", None)])
("storage_buffer", 112), ("sampled_image_2d", None),
("storage_buffer", 4)])
graphics = {
(d["set"], d["binding"]): d["element_stride"]
for d in gpu_vertex["layout"]["descriptors"]
}
self.assertEqual([graphics[2, i] for i in range(3)], [224, 4, 208])
self.assertEqual([graphics[2, i] for i in range(3)], [288, 4, 208])
def test_light_tile_compute_reflection(self):
compiler = os.environ["FASET_TEST_SLANGC"]
with tempfile.TemporaryDirectory(prefix="faset-light-tiles-abi-") as directory:
process = subprocess.run(
[sys.executable, str(SCRIPT), "--compiler", compiler, "--source",
str(SCRIPT.parents[1] / "shaders" / "light_tiles.slang"), "--entry",
"lightTileMain", "--output", directory],
capture_output=True, text=True,
)
self.assertEqual(process.returncode, 0, process.stderr)
layout = json.loads((Path(directory) / "lightTileMain.reflection.json").read_text())["layout"]
self.assertEqual(layout["stage"], "compute")
self.assertEqual(
[(item["set"], item["binding"], item["type"], item.get("element_stride"))
for item in layout["descriptors"]],
[(0, 0, "storage_buffer", 80), (0, 1, "storage_buffer", 4)],
)
self.assertEqual(layout["push_constants"][0]["size"], 96)
def test_gpu_vertex_paths_do_not_require_shader_draw_parameters(self):
# SV_InstanceID makes Slang subtract BaseInstance and emit DrawParameters.
@@ -92,9 +112,31 @@ class ReflectionTests(unittest.TestCase):
self.assertIn(1, capabilities) # Shader
self.assertNotIn(4427, capabilities) # DrawParameters
def test_temporal_composite_does_not_require_optional_draw_parameters(self):
compiler = os.environ["FASET_TEST_SLANGC"]
with tempfile.TemporaryDirectory(prefix="faset-temporal-composite-") as directory:
process = subprocess.run(
[sys.executable, str(SCRIPT), "--compiler", compiler, "--source",
str(SCRIPT.parents[1] / "shaders" / "temporal.slang"), "--entry",
"temporalCompositeVertexMain", "--define", "FASET_TEMPORAL_COMPOSITE=1",
"--output", directory], capture_output=True, text=True,
)
self.assertEqual(process.returncode, 0, process.stderr)
bytecode = (Path(directory) / "temporalCompositeVertexMain.spv").read_bytes()
words = struct.unpack(f"<{len(bytecode) // 4}I", bytecode)
capabilities = set()
offset = 5
while offset < len(words):
count, opcode = words[offset] >> 16, words[offset] & 0xffff
self.assertGreater(count, 0)
if opcode == 17:
capabilities.add(words[offset + 1])
offset += count
self.assertNotIn(4427, capabilities) # DrawParameters
def test_gpu_storage_resources_keep_kind_and_stride(self):
parameters = [
parameter("instances", 0, "structuredBuffer", "read", 224),
parameter("instances", 0, "structuredBuffer", "read", 288),
parameter("visibleIds", 1, "structuredBuffer", "readWrite", 4),
parameter("depthOutput", 2, "texture2D", "readWrite"),
parameter("depthInput", 3, "texture2D", "read"),
@@ -111,7 +153,7 @@ class ReflectionTests(unittest.TestCase):
descriptors = layout["descriptors"]
self.assertEqual(
[(d["type"], d.get("element_stride")) for d in descriptors],
[("storage_buffer", 224), ("storage_buffer", 4),
[("storage_buffer", 288), ("storage_buffer", 4),
("storage_image_2d", None), ("sampled_image_2d", None)],
)
@@ -128,7 +170,7 @@ class ReflectionTests(unittest.TestCase):
metadata = json.loads((Path(directory) / "gpuCullMain.reflection.json").read_text())
bindings = {item["binding"]: item for item in metadata["layout"]["descriptors"]}
self.assertEqual([bindings[n]["element_stride"] for n in (0, 1, 2, 3, 4, 5, 6, 9)],
[224, 16, 16, 4, 16, 4, 4, 208])
[288, 16, 16, 4, 16, 4, 4, 208])
self.assertEqual([bindings[n]["type"] for n in (7, 8)],
["sampled_image_2d", "sampled_image_2d"])