From d21745c790da0b87ff8c7f9d5011dc9691e2f37a Mon Sep 17 00:00:00 2001 From: Nick Newson Date: Wed, 19 Aug 2026 20:02:10 +0100 Subject: [PATCH] Shadow pass: decide in preparation, record from the plan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A shadow map may be reused only when every input that produced its pixels is unchanged, and the frame has to establish that BEFORE it records. While one walk filtered casters, resolved their LOD and rasterised them, that question could not be asked without doing the work it exists to avoid. The pass is therefore two halves now: prepareShadowFrame (graphics/shadow_pass_prepare.hpp) decides, and Shadows::recordPass records what it decided. recordPass takes a ShadowFramePlan and nothing else — no draw spans, no view set, no resolver, no validity argument. The plan carries every view's transform, extent, depth bias, depth mode and light, the draws each of its layers rasterises in order, and what each view does this frame. What is left in render/shadows.cpp is genuinely Vulkan: barriers, dynamic rendering, and per-draw state. THE COMPARISON IS STRUCTURAL, NOT A HASH PreparedShadowView / PreparedShadowDraw describe the work in the values that reach the GPU — the model matrix written to ShadowUBO, the resolved index buffer, the effective cull mode — and not in the higher-level quantities that explain them. Two transforms can share an AABB; a snapped cascade origin plus a near/far pair explains a matrix without being one; a LOD level names a choice without being the geometry that choice selected. Each would compare equal while rasterising different pixels, which is why the architectural review's "coarse validity check" (light dir + snapped origin + a caster epoch) was rejected on inspection rather than implemented. A 64-bit digest is refused on the same grounds: a probabilistic correctness argument for a decision whose failure mode is a silently wrong image — shadows from a frame that no longer exists, with no error and no crash. THREE PARALLEL AUTHORITIES RETIRED Each was a second place a value the GPU rasterises with could live, and Each was a second place a value the GPU rasterises with could live, and a cache is only as sound as the agreement between what it compares and what the GPU reads. * The shadow matrix TABLE. ShadowUBO carried every shadow matrix in the frame — 32 of them, 2 KB written per shadow object per frame — so a push constant could index one row, and the point path inferred its depth mode from where a matrix happened to live. The view's matrix now arrives in the push block that already carried one for the self-shadow path, and radialDepth says what it means. The shadow_matrix_guard CTest case fails the build if a per-draw table comes back. * The point light's position and range. pointCasters_ was a renderer-side array the pass reached by arithmetic on a face slot, duplicating a position the view set already held one line away. setPointLight now takes the effective range and validates it, and checks that all six face descriptors name one light position; ShadowRenderView::pointLightDepth() reports both, and only for a point view. * The caster's model matrix and the world scale derived from it, now one constructed ShadowCasterPose. Its default is explicitly UNSTATED, because a defaulted Mat4 is a real matrix: a producer that forgot the field would hand the comparison a constant transform while the GPU rasterised the object's actual one, and every frame would compare equal — a shadow map reused forever for something that is moving. Preparation is terminal on it. It is deliberately not part of ShadowGeometryRequest::valid(), because an unstated pose is still resolvable (whole mesh, InvalidCaster) and that degraded path has to keep working for a non-finite transform out of a broken animation. ELIGIBILITY BEFORE, CONFIRMATION AFTER ShadowMapValidity is now applied twice per frame, in a fixed order, both from the completed view set. As ELIGIBILITY it decides which families may be prepared at all — preparation resolves casters and STAGES hysteresis, so a family that will neither record nor be sampled must not be resolved, and deriving the answer from the finished plan would be too late to prevent that. As CONFIRMATION it is derived from the plan that was actually built and judged against the counts eligibility expected, which is what reaches the receiver in LightUBO::shadowMapValidMask. The expected counts matter: two active spots of which one prepared would otherwise satisfy "some slot is sampleable" while the other light sampled a stale map. DIAGNOSTICS: CLAIMED IS NOT RASTERISED The two facts coincided only because recording was the only thing that happened. Preparation now CLAIMS each row (naming the logical view it describes) and observes every draw it walks; the recorder counts raster passes and re-CHECKS the claim, so rasterising view B into the row view A claimed is refused rather than reported under A's name. For the same reason the resolver's read-back is renamed noteContent / contentResolution: a reused map holds its casters without drawing them, and attributing content to rasterisation would blank the ShadowLod tint on every cached view. NOT IN THIS CHANGE There is no residency store, so shadowViewDisposition sees no resident content and answers Recorded for every active view. That is what makes the restructure verifiable: the frame decides and draws exactly what it did before, so any difference in the per-view diagnostics is a defect rather than the intended effect. The law is consulted rather than hard-coded at the call site, so the reuse stage adds the store and changes nothing else. VERIFICATION The per-view diagnostic dump was captured before any edit and re-diffed after: byte-identical row sets across DamagedHelmet, LightsPunctualLamp and ShadowLodDemo — all five families, including the self family's two-layers-one-selection accounting — plus identical family recording lines. tests-full passes (153,326 assertions, 1,680 cases, five build guards), as does the Linux CI replica with clang-tidy. 12 new [ShadowPassPrepare] cases pin the parts the dump cannot see: a suppressed family never reaches the resolver, the filter runs before resolution, point position and range arrive exactly, and an unstated pose stops the frame. Render smoke is VUID-free on five scenes and on --no-shadows / --no-shadow-lod / --debug-shadow-lod. --- CMakeLists.txt | 15 + README.md | 2 +- cmake/check_gpu_limits.cmake | 23 +- cmake/check_shadow_matrix.cmake | 82 +++ docs/architecturalreview.md | 2 +- docs/codereview.md | 14 +- docs/lod.md | 10 +- docs/onboarding.md | 52 +- docs/review-order.md | 13 +- docs/roadmap.md | 70 +- docs/shadowplans.md | 17 +- include/fire_engine/graphics/frame_info.hpp | 7 - include/fire_engine/graphics/gpu_limits.hpp | 32 +- .../graphics/shadow_diagnostics.hpp | 62 +- .../fire_engine/graphics/shadow_face_cull.hpp | 69 ++ .../graphics/shadow_geometry_request.hpp | 81 ++- .../graphics/shadow_lod_resolver.hpp | 54 +- .../fire_engine/graphics/shadow_pass_plan.hpp | 490 +++++++++++++ .../graphics/shadow_pass_prepare.hpp | 99 +++ .../graphics/shadow_render_view.hpp | 63 +- include/fire_engine/render/descriptors.hpp | 18 +- include/fire_engine/render/renderer.hpp | 42 +- include/fire_engine/render/shadows.hpp | 160 ++--- include/fire_engine/render/ubo.hpp | 51 +- shaders/gpu_limits.glsl | 15 +- shaders/shadow.vert | 13 +- shaders/shadow_depth.glsl | 11 +- shaders/shadow_push.glsl | 14 +- src/graphics/object.cpp | 29 +- src/graphics/shadow_diagnostics.cpp | 53 +- src/graphics/shadow_lod_resolver.cpp | 29 +- src/graphics/shadow_pass_plan.cpp | 437 ++++++++++++ src/graphics/shadow_pass_prepare.cpp | 365 ++++++++++ src/graphics/shadow_render_view.cpp | 67 +- src/render/debug_overlay.cpp | 7 +- src/render/descriptors.cpp | 14 +- src/render/pipeline.cpp | 6 +- src/render/renderer.cpp | 199 ++++-- src/render/shadows.cpp | 617 +++++------------ tests/graphics/test_frame_info.cpp | 27 +- tests/graphics/test_shadow_diagnostics.cpp | 178 ++++- tests/graphics/test_shadow_lod_resolver.cpp | 50 +- tests/graphics/test_shadow_pass_plan.cpp | 651 ++++++++++++++++++ tests/graphics/test_shadow_pass_prepare.cpp | 444 ++++++++++++ tests/graphics/test_shadow_render_view.cpp | 140 ++-- tests/render/test_ubo.cpp | 19 +- 46 files changed, 3881 insertions(+), 1032 deletions(-) create mode 100644 cmake/check_shadow_matrix.cmake create mode 100644 include/fire_engine/graphics/shadow_face_cull.hpp create mode 100644 include/fire_engine/graphics/shadow_pass_plan.hpp create mode 100644 include/fire_engine/graphics/shadow_pass_prepare.hpp create mode 100644 src/graphics/shadow_pass_plan.cpp create mode 100644 src/graphics/shadow_pass_prepare.cpp create mode 100644 tests/graphics/test_shadow_pass_plan.cpp create mode 100644 tests/graphics/test_shadow_pass_prepare.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index ee587a6d..3b4082c9 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -105,6 +105,8 @@ add_library(fireengine SHARED src/graphics/shadow_caster_alpha.cpp src/graphics/shadow_bias.cpp src/graphics/shadow_map_validity.cpp + src/graphics/shadow_pass_plan.cpp + src/graphics/shadow_pass_prepare.cpp src/graphics/shadow_caster_deformation.cpp src/graphics/shadow_render_view.cpp src/graphics/shadow_diagnostics.cpp @@ -447,6 +449,8 @@ add_executable(test_fire_engine tests/graphics/test_shadow_caster_alpha.cpp tests/graphics/test_shadow_bias.cpp tests/graphics/test_shadow_map_validity.cpp + tests/graphics/test_shadow_pass_plan.cpp + tests/graphics/test_shadow_pass_prepare.cpp tests/graphics/test_shadow_render_view.cpp tests/graphics/test_texture.cpp tests/graphics/test_sampler_settings.cpp @@ -522,6 +526,7 @@ add_custom_target(tests-full COMMAND ${CMAKE_CTEST_COMMAND} --output-on-failure -R shader_block_guards COMMAND ${CMAKE_CTEST_COMMAND} --output-on-failure -R shadow_bias_guard COMMAND ${CMAKE_CTEST_COMMAND} --output-on-failure -R gpu_limits_guard + COMMAND ${CMAKE_CTEST_COMMAND} --output-on-failure -R shadow_matrix_guard DEPENDS test_fire_engine COMMENT "Running the full test suite, including [slow] tests" ) @@ -567,6 +572,16 @@ add_test( -P ${PROJECT_SOURCE_DIR}/cmake/check_gpu_limits.cmake ) +# Arc 2 #4 step 1: the shadow transform reaches the GPU once, in the recorded view's push constants. +# A reintroduced per-draw matrix table renders correctly on the day and silently detaches the shadow +# cache's comparison from what the GPU actually rasterises with. +add_test( + NAME shadow_matrix_guard + COMMAND ${CMAKE_COMMAND} + -DSHADER_DIR=${PROJECT_SOURCE_DIR}/shaders + -P ${PROJECT_SOURCE_DIR}/cmake/check_shadow_matrix.cmake +) + find_program(CLANG_TIDY_EXE NAMES clang-tidy) if(CLANG_TIDY_EXE) get_target_property(FIRE_ENGINE_TIDY_SOURCES fireengine SOURCES) diff --git a/README.md b/README.md index 42f55e01..e1e9048c 100644 --- a/README.md +++ b/README.md @@ -232,7 +232,7 @@ The transient pipelines are destroyed once the bake completes; only the resultin - 13 SSAO/contact-shadow texture On swapchain resize, only the `kMaxFramesInFlight` set-1 descriptors need rewriting (sceneColor, post-process targets, and any future recreated globals) via `Descriptors::updateGlobalDescriptors`; the global set-2 bindless descriptors are untouched, and forward set 0 is never allocated (pushed per draw). -- Separate descriptor layouts for the skybox (SkyboxUBO + samplerCube + LightUBO), shadow (ShadowUBO with `lightViewProj[]` + SkinUBO + MorphUBO + MorphTargets SSBO + first self-shadow depth/sampler, plus `ShadowPushConstants` on the vertex/fragment stages), post-process (HDR sampler at 0 + bloom mip 0 sampler at 1, plus `PostProcessPushConstants { float bloomStrength }`), and bloom-down / bloom-up (single input mip sampler + `BloomPushConstants` on the fragment stage) +- Separate descriptor layouts for the skybox (SkyboxUBO + samplerCube + LightUBO), shadow (ShadowUBO — per-object model + hasSkin — plus SkinUBO + MorphUBO + MorphTargets SSBO + first self-shadow depth/sampler, with the view's own transform arriving in `ShadowPushConstants` on the vertex/fragment stages), post-process (HDR sampler at 0 + bloom mip 0 sampler at 1, plus `PostProcessPushConstants { float bloomStrength }`), and bloom-down / bloom-up (single input mip sampler + `BloomPushConstants` on the fragment stage) - Two forward pipeline variants share the shader + binding layout: - **opaque** (no blend, depth write) — OPAQUE and MASK materials. Cull mode is a **dynamic state** (`VK_DYNAMIC_STATE_CULL_MODE`, core Vulkan 1.3) set per draw, so single-sided (cull back) and double-sided (cull none) geometry share this one pipeline; `DrawCommand::doubleSided` carries the choice. - **blend** (cull none, `SRC_ALPHA / ONE_MINUS_SRC_ALPHA` blend, no depth write) — BLEND materials. Kept as a separate static-blend pipeline because dynamic blend state isn't available on MoltenVK (see [Limitations](#limitations)). diff --git a/cmake/check_gpu_limits.cmake b/cmake/check_gpu_limits.cmake index 8d5126e2..916d8206 100644 --- a/cmake/check_gpu_limits.cmake +++ b/cmake/check_gpu_limits.cmake @@ -1,12 +1,13 @@ # Guard: the limits shared by C++ and GLSL are declared ONCE, in shaders/gpu_limits.glsl, and both # sides actually read them. # -# These numbers size UBO arrays and index the shadow matrix table. A one-sided change — a shader -# raising a caster count that the C++ struct still writes at the old size, or the reverse — compiles -# cleanly in both languages and then reads the wrong region of a bound buffer: every index stays in -# range, so there is no validation error and no crash, just a shadow matrix taken from another -# family's slot. `SHADOW_TOTAL_MATRIX_COUNT = 32`, `SHADOW_POINT_MATRIX_BASE = 8` and the caster -# counts were each hand-transcribed exactly that way before this guard existed. +# These numbers size the arrays in blocks both languages bind. A one-sided change — a shader raising +# a caster count that the C++ struct still writes at the old size, or the reverse — compiles cleanly +# in both languages and then reads the wrong region of a bound buffer: every index stays in range, so +# there is no validation error and no crash, just one light's matrix read from another's slot. The +# caster counts, the cascade count and the SSAO kernel size were each hand-transcribed exactly that +# way before this guard existed. (The shadow MATRIX-TABLE constants it also used to cover are gone: +# every shadow path rasterises with its view's pushed matrix — see `shadow_matrix_guard`.) # # Two halves, and BOTH are needed. The GLSL half fails if a shader re-declares a shared name or # stops including the file. The C++ half fails if graphics/gpu_limits.hpp stops including the shared @@ -46,10 +47,6 @@ set(shared_names MAX_SPOT_SHADOW_CASTERS MAX_POINT_SHADOW_CASTERS CUBE_FACE_COUNT - SHADOW_CASCADE_MATRIX_BASE - SHADOW_SPOT_MATRIX_BASE - SHADOW_POINT_MATRIX_BASE - SHADOW_TOTAL_MATRIX_COUNT SHADOW_MAP_VALID_CASCADES SHADOW_MAP_VALID_WORLD_ONLY SHADOW_MAP_VALID_SELF @@ -67,10 +64,6 @@ set(cpp_names kMaxSpotShadowCasters kMaxPointShadowCasters kCubeFaceCount - kShadowCascadeMatrixBase - kShadowSpotMatrixBase - kShadowPointMatrixBase - kShadowTotalMatrixCount kShadowMapValidCascades kShadowMapValidWorldOnly kShadowMapValidSelf @@ -100,8 +93,6 @@ set(consumers "light_ubo.glsl:MAX_SPOT_SHADOW_CASTERS:2" "light_ubo.glsl:MAX_SKINNED_SELF_SHADOW_CASTERS:2" "light_ubo.glsl:MAX_POINT_SHADOW_CASTERS:1" - "shadow.vert:SHADOW_TOTAL_MATRIX_COUNT:1" - "shadow_depth.glsl:SHADOW_POINT_MATRIX_BASE:1" "self_shadow_second.glsl:MAX_SKINNED_SELF_SHADOW_CASTERS:1" "shader.frag:SHADOW_CASCADE_COUNT:4" # cascade search init + bound, blend factor, debug divisor "shader.frag:MAX_SKINNED_SELF_SHADOW_CASTERS:1" diff --git a/cmake/check_shadow_matrix.cmake b/cmake/check_shadow_matrix.cmake new file mode 100644 index 00000000..0bbd6948 --- /dev/null +++ b/cmake/check_shadow_matrix.cmake @@ -0,0 +1,82 @@ +# Guard: ONE shadow transform reaches the GPU, and it is the recorded view's. +# +# The shadow pass used to push a 32-matrix table of every shadow view into every draw's per-object +# UBO and select a row with a push constant. That made the transform a per-DRAW lookup rather than a +# property of the view being recorded, and it left two descriptions of the same value: the table the +# vertex shader indexed, and the matrix everything else reasoned about. Arc 2 #4 needs those to be +# one thing — a cached shadow map may only be reused if the matrix compared is the matrix rasterised +# with — so `pc.lightViewProj` is now the only shadow transform, for every family. +# +# Reintroducing the table would compile and render correctly on the frame it was added; the damage +# is that the cache's comparison would silently stop describing what the GPU does. Nothing else can +# catch that, hence a build-time check. +# +# The same reasoning covers the depth discriminator: the point path branched on "is this matrix +# index at or past the point base", inferring a depth mode from where a matrix happened to live. +# It now reads `pc.radialDepth`, which is `PreparedShadowView::depthMode()` and nothing else. +# +# Invoked as a CTest case; needs SHADER_DIR. + +if(NOT DEFINED SHADER_DIR) + message(FATAL_ERROR "SHADER_DIR must be set (path to shaders/)") +endif() + +include("${CMAKE_CURRENT_LIST_DIR}/strip_glsl_comments.cmake") + +set(offenders "") + +# Every shader, so a NEW shadow stage cannot quietly reintroduce either pattern. +file(GLOB shader_sources + "${SHADER_DIR}/*.glsl" "${SHADER_DIR}/*.vert" "${SHADER_DIR}/*.frag" "${SHADER_DIR}/*.comp") +foreach(shader IN LISTS shader_sources) + file(READ "${shader}" shader_text) + strip_glsl_comments("${shader_text}" shader_code) + get_filename_component(shader_name "${shader}" NAME) + + # An ARRAY of light matrices — the table itself, wherever it is declared. `mat4 lightViewProj[N]` + # in a block, or an index into one. + if(shader_code MATCHES "lightViewProj[ \t]*\\[") + list(APPEND offenders + "${shader_name} indexes or declares lightViewProj[] — the per-draw shadow matrix table is retired; use pc.lightViewProj, the matrix of the view being recorded") + endif() + # The ShadowUBO member it used to live in. + if(shader_code MATCHES "shadow[ \t]*\\.[ \t]*lightViewProj") + list(APPEND offenders + "${shader_name} reads shadow.lightViewProj — ShadowUBO carries the object's model matrix only") + endif() + # The selector. Its absence is what forces a depth mode to be stated rather than inferred. + if(shader_code MATCHES "matrixIndex") + list(APPEND offenders + "${shader_name} mentions matrixIndex — the push block carries radialDepth (the view's depth mode) in its place") + endif() +endforeach() + +# And the positive half: the paths that must consume the pushed values still do. Without these the +# checks above would pass on a shader that had stopped drawing anything at all. +set(required + "shadow.vert:pc[ \t]*\\.[ \t]*lightViewProj:the shadow vertex stage must rasterise with the pushed view matrix" + "shadow_push.glsl:int[ \t]+radialDepth:the shared push block must declare the depth-mode discriminator" + "shadow_depth.glsl:pc[ \t]*\\.[ \t]*radialDepth:the point-face depth path must branch on the pushed depth mode") +foreach(entry IN LISTS required) + string(REPLACE ":" ";" parts "${entry}") + list(GET parts 0 required_file) + list(GET parts 1 required_pattern) + list(GET parts 2 required_reason) + set(path "${SHADER_DIR}/${required_file}") + if(NOT EXISTS "${path}") + list(APPEND offenders "${required_file} is missing from ${SHADER_DIR}") + continue() + endif() + file(READ "${path}" required_text) + strip_glsl_comments("${required_text}" required_code) + if(NOT required_code MATCHES "${required_pattern}") + list(APPEND offenders "${required_file}: ${required_reason}") + endif() +endforeach() + +if(offenders) + string(REPLACE ";" "\n " report "${offenders}") + message(FATAL_ERROR "shadow matrix guard failed:\n ${report}") +endif() + +message(STATUS "shadow matrix guard: pc.lightViewProj is the only shadow transform; depth mode is stated, not inferred") diff --git a/docs/architecturalreview.md b/docs/architecturalreview.md index 6199b668..f77026c4 100644 --- a/docs/architecturalreview.md +++ b/docs/architecturalreview.md @@ -376,7 +376,7 @@ exists; the tiered review inherited the filename.)* | 1 | ✅ Skip redundant world-CSM when no skinned casters *(branch `review-shadow-taa-fixes`)* | B | S | §2.2 | | 2 | ✅ Skip empty self-shadow slots *(same branch; implementation is simpler than proposed — unassigned slots are provably never sampled, so no clear-once/dirty bit is needed, they are skipped outright)* | B | S | §2.1 | | 3 | ✅ `static_assert(kMaxFramesInFlight == 2)` on the TAA history index *(same branch)* | A | XS | §2.4 | -| 4 | Static-scene CSM caching (light+fit+caster epoch) | B | M | §2.1 | +| 4 | Static-scene CSM caching (light+fit+caster epoch) — **in progress, branch `shadow-static-cascade-cache`**. The review's "coarse validity check" was rejected on inspection: light dir + snapped origin + a caster epoch explains a matrix without being one, and a wrong answer here is a silently wrong image. What landed instead is the exact content descriptor (`graphics/shadow_pass_plan.hpp`, structural comparison, no hash) and the PREPARATION phase that builds it (`graphics/shadow_pass_prepare.hpp`) — filtering, LOD resolution and diagnostics moved out of recording, so `Shadows::recordPass` now consumes the plan alone. Every view is still `Recorded` (no residency store yet), verified by a byte-identical per-view diagnostic dump across three scenes and all five families. The residency store + reuse is the remaining half. | B | M | §2.1 | | 5 | Compute pre-skinning pass (unify with cloth pattern) | B | L | §1.3 | | 6 | Batch image barriers into single `DependencyInfo`s | C | S | §1.2 | | 7 | Physics per-step scratch persistence | B | S | §3.1 | diff --git a/docs/codereview.md b/docs/codereview.md index 355c353d..5f884af9 100644 --- a/docs/codereview.md +++ b/docs/codereview.md @@ -561,17 +561,21 @@ hash specialisation, so ordinary callers cannot accidentally manufacture a plaus Several constants in [`gpu_limits.hpp`](include/fire_engine/graphics/gpu_limits.hpp) and [`constants.hpp`](include/fire_engine/render/constants.hpp) encode relationships manually: -- `kShadowSpotMatrixBase` is the literal `4` rather than - `kShadowCascadeMatrixBase + kShadowCascadeCount`; +- ~~`kShadowSpotMatrixBase` is the literal `4` rather than + `kShadowCascadeMatrixBase + kShadowCascadeCount`~~ — **moot** (branch + `shadow-static-cascade-cache`): the shadow matrix TABLE is gone. The transform now reaches the GPU + once, in the recorded view's push constants, so there are no bases to derive and no table to index + the wrong row of; `shadow_matrix_guard` fails the build if one comes back; - cubemap mip counts repeat values derivable from their extents; -- `kShadowTotalMatrixCount` depends on several signed and unsigned values with no range assertion; +- ~~`kShadowTotalMatrixCount` depends on several signed and unsigned values with no range + assertion~~ — **moot**, same change: the constant no longer exists; - projection, shadow range, TAA sample count, and mip settings have no compile-time relational validation. Use C++23/standard-library derivation where possible, for example `std::bit_width(extent)` for a full power-of-two mip chain. Add `static_assert`s or a `consteval` validator for relationships such -as positive near planes, far > near, non-zero TAA cycle, power-of-two extents, and non-overlapping -shadow matrix ranges. +as positive near planes, far > near, non-zero TAA cycle and power-of-two extents. (Non-overlapping +shadow matrix ranges are no longer expressible — the table they indexed is gone.) Group defaults by domain (`CameraDefaults`, `ShadowDefaults`, `IblDefaults`, etc.) or at least put them in nested namespaces. The current global `k...` list is manageable today, but grouping would diff --git a/docs/lod.md b/docs/lod.md index f43b415c..fadbfd68 100644 --- a/docs/lod.md +++ b/docs/lod.md @@ -81,9 +81,13 @@ Object::writeForwardUniforms() [per draw, per frame] `proj[1][1]`, and viewport height. It sets the chosen level's `indexBuffer`/`indexCount` on the `DrawCommand`, plus `lodLevel` (used only by the LOD-tint debug view). This is the FORWARD draw only. Since SH-03 a caster's *shadow* level is not chosen here at all: the shadow command carries a - `ShadowGeometryRequest` (LOD span, conservative world scale, caster id + generation) with no index - buffer, and each shadow view resolves its own level in its own shadow-map texels — see - [`shadowplans.md`](shadowplans.md) § SH-03 and `graphics/shadow_lod_resolver.hpp`. Two caster + `ShadowGeometryRequest` (LOD span, a `ShadowCasterPose` pairing the caster's world matrix with the + conservative world scale derived from it, caster id + generation) with no index buffer, and each + shadow view resolves its own level in its own shadow-map texels — see + [`shadowplans.md`](shadowplans.md) § SH-03 and `graphics/shadow_lod_resolver.hpp`. Since arc 2 #4 + that resolution happens in the shadow pass's PREPARATION phase + (`graphics/shadow_pass_prepare.hpp`), before anything is recorded, because the same walk decides + whether the view's map can be reused at all. Two caster properties override that selection outright, and both are classified at this same seam: a caster that DEFORMS after the simplifier measured it (SH-04) and one whose coverage is an ALPHA CUTOUT (SH-05) resolve to the whole mesh, with `DeformableFallback` / `AlphaMaskedFallback` as the reason diff --git a/docs/onboarding.md b/docs/onboarding.md index d551bae3..dc1d8555 100644 --- a/docs/onboarding.md +++ b/docs/onboarding.md @@ -928,21 +928,50 @@ the same change — most have a test or guard that will catch you, but not all. directions: no shader may declare a shared name, each consumer must use the name rather than a literal, and each `k`-constant must be defined *as* the shared declaration. - **A shadow family's recording and its uploaded validity are one value** (`ShadowMapValidity`). - `Renderer::uploadFrameLighting` derives it once, from the COMPLETED view set — every producer has - run, including the world-only enablement that only `anySkinned` decides — then gates - `Shadows::recordPass` with it and uploads its packed form as `LightUBO::shadowMapValidMask`. If + `Renderer::prepareShadowPlan` applies it TWICE, in a fixed order, from the COMPLETED view set — + every producer has run, including the world-only enablement that only `anySkinned` decides. First + as ELIGIBILITY (`ShadowFamilyEligibility::eligible()`), which decides what may be PREPARED at all: + preparation resolves casters and stages hysteresis, so a family that will be neither recorded nor + sampled must not be resolved, and deriving the answer from the finished plan would be too late to + prevent that. Then as CONFIRMATION (`shadowMapValidityFromPlan`), from the plan that was actually + built and judged against the counts eligibility expected — a view that failed to prepare clears + its family's bit even though the family was eligible — and THAT is what + `uploadFrameLighting` puts in `LightUBO::shadowMapValidMask` and what the pass records. If you add a shadow family, or a new place that decides whether a family renders, route it through that value: a family skipped without the bit leaves the receiver sampling a depth image this frame never wrote, and nothing in the pipeline will complain. Every sampling path in `shader.frag` asks its bit first (the guard pins that too), including the raw-depth debug view, which has no valid `lights[0]` to read when the cascade family is invalid. +- **The shadow pass DECIDES in preparation and RECORDS from the plan — never both** (arc 2 #4). + `prepareShadowFrame` (`graphics/shadow_pass_prepare.hpp`) turns the frame's casters and the + completed view set into a `ShadowFramePlan`: it filters, resolves each caster's LOD per view, + claims the SH-01 diagnostic row, observes every draw it walks, and records what each view will + rasterise as `PreparedShadowView` / `PreparedShadowDraw`. `Shadows::recordPass` then takes that + plan and NOTHING else — no draw spans, no view set, no resolver. The reason is the cache this + builds toward: reusing a shadow map means knowing what would have been drawn without drawing it, + so a recorder that still resolved as it went could not answer the question. If you add anything + the pass rasterises with, add it to the prepared view or draw — a value read at record time that + the comparison never saw is a map kept when it should have been re-rendered. Two rules travel with + this: the model matrix in `PreparedShadowDraw` must be the SAME value written into + `ShadowUBO::model` (both come from `ShadowCasterPose`, whose matrix and derived `worldScale` are + one constructed value so they cannot drift), and the per-frame-ring buffer handles are carried for + recording but EXCLUDED from the comparison, since identical content alternates handles every + frame. +- **A caster that deforms poisons its view's reuse, and a diagnostic row is claimed once per frame.** + `PreparedShadowDraw::deformable` (from SH-04's classification) makes a draw compare unequal to + everything including itself, because skinning rewrites vertices with no revision any compared + field can see. And `ShadowViewStats::claimView` is preparation's (once per view, naming the + logical identity it describes) while `beginRasterPass` is the recorder's (once per depth image, + CHECKING that claim). They stopped coinciding the moment maps could be reused: a reused view is + claimed and observed while rasterising nothing, so a row forced to claim a raster pass in order to + be observed would report intended work as performed work. - **A shadow caster that deforms after the simplifier measured it may not select a level** (SH-04). The deviation channel is measured on the mesh as authored — bind pose, base weights, the vertex buffer at build time — so for skinned, morph-capable or storage-vertex geometry it describes a mesh that is never drawn, and skinning can amplify the displacement without bound. Classification lives in `graphics/shadow_caster_deformation.hpp` and rides on `ShadowGeometryRequest::deformation`, - which defaults to `Deformable` (the safe answer, like `worldScale`'s NaN — a producer that forgets - the field must not get the optimistic one). The resolver answers with + which defaults to `Deformable` (the safe answer, like an unstated `ShadowCasterPose`'s NaN scale — a + producer that forgets the field must not get the optimistic one). The resolver answers with `ShadowLodReason::DeformableFallback`, full detail, and an INFINITE projected error, and stages no hysteresis history. Do not express this by passing `lodEnabled = false`: that reports `LodDisabled`, which is a user's toggle, and the panel would then explain a safety fallback with @@ -1174,9 +1203,12 @@ the same change — most have a test or guard that will catch you, but not all. Renderer and REFUSE to start on anything unusable rather than falling back to the constant: a calibration input that silently becomes the default produces a sweep row that reads like a measurement of the value you asked for. Re-derive both values with `tools/shadow_lod_sweep.sh`. -- **The ShadowLod tint reads back through `drawnResolution(group, key)`** (SH-03 slice 5). - `Renderer::applyShadowLodTint` runs between `recordShadowPass` and the forward pass — the only - window where the per-view levels exist and the forward push constants have not been written yet. +- **The ShadowLod tint reads back through `contentResolution(group, key)`** (SH-03 slice 5; it was + `drawnResolution` until arc 2 #4 separated "this map HOLDS the caster" from "this frame rasterised + it" — a reused map holds its casters without drawing them). + `Renderer::applyShadowLodTint` runs in COLLECTION, right after `prepareShadowPlan` — the levels + exist as soon as the plan does, and the forward push constants have not been written yet. (It sat + between `recordShadowPass` and the forward pass while recording was what resolved them.) It asks ONE question of the focused view's family: what did that pass draw for this caster. `frameResolution(key)` is deliberately not the tint's query — it returns the SHARED decision, and a cascade and its world-only twin share one by design while drawing different casters (world-only @@ -1195,7 +1227,7 @@ the same change — most have a test or guard that will catch you, but not all. indexed by enum order and sized to `Key::Count`, so a count mismatch fails to compile — but a *reordering* silently maps the wrong physical key. Keep both in the same order. - **GPU array sizes ↔ shader array sizes.** `graphics/gpu_limits.hpp` (`kMaxLights`, `kMaxJoints`, - `kMaxMorphTargets`, shadow caster caps, `kShadowTotalMatrixCount`) must equal the array sizes + `kMaxMorphTargets`, shadow caster caps) must equal the array sizes declared in the shaders that consume those UBOs. - **Progressive LOD cuts ↔ VIPM morph targets.** `Geometry::load()` must build runtime LOD index buffers and VIPM morph data from the same `ProgressiveMesh`. `ProgressiveLod::collapseCount` is @@ -1291,7 +1323,7 @@ the same change — most have a test or guard that will catch you, but not all. - Collision broadphase: `src/collision/dynamic_aabb_tree_broad_phase.cpp` (default), `src/collision/sweep_and_prune_broad_phase.cpp` (alternative), behind `collision/broad_phase.hpp` - Narrowphase: `src/collision/narrow_phase.cpp` - Mesh component: `src/scene/mesh.cpp` -- Shadow-LOD selection model (SH-02): `include/fire_engine/graphics/shadow_view.hpp` + `src/graphics/shadow_view.cpp` — Vulkan-free view descriptors, texel projection, and `selectShadowLod`, with the per-cut shadow-deviation channel behind it in the simplifier (see [`lod.md`](lod.md) § The shadow-deviation channel). Pure and headless. SH-03 threaded it into the renderer: `graphics/shadow_lod_resolver.hpp` + `src/graphics/shadow_lod_resolver.cpp` resolve an unresolved caster per shadow view (a frame cache and a staged hysteresis history, both keyed on the full `(ShadowCasterId, generation, ShadowLogicalViewId)` — the LOGICAL view, not the physical slot, so the passes that must agree share one decision), the budget + coarsening ratio come from `render/constants.hpp`, and `kShadowLodBias` is retired. +- Shadow-LOD selection model (SH-02): `include/fire_engine/graphics/shadow_view.hpp` + `src/graphics/shadow_view.cpp` — Vulkan-free view descriptors, texel projection, and `selectShadowLod`, with the per-cut shadow-deviation channel behind it in the simplifier (see [`lod.md`](lod.md) § The shadow-deviation channel). Pure and headless. SH-03 threaded it into the renderer: `graphics/shadow_lod_resolver.hpp` + `src/graphics/shadow_lod_resolver.cpp` resolve an unresolved caster per shadow view (a frame cache and a staged hysteresis history, both keyed on the full `(ShadowCasterId, generation, ShadowLogicalViewId)` — the LOGICAL view, not the physical slot, so the passes that must agree share one decision), the budget + coarsening ratio come from `render/constants.hpp`, and `kShadowLodBias` is retired. Since arc 2 #4 that resolution happens during PREPARATION (`graphics/shadow_pass_prepare.hpp`), which builds the frame's `ShadowFramePlan` — read those two beside the resolver, because "which level did this view pick" and "may this view's map be reused" are now answered by the same walk. - Draw command generation + LOD selection: `src/graphics/object.cpp` - Mesh LOD / simplifier: `include/fire_engine/graphics/lod.hpp`, `src/graphics/mesh_simplifier.cpp` - GPU resource registry: `src/render/resources.cpp` diff --git a/docs/review-order.md b/docs/review-order.md index 1588f35a..ee6c2105 100644 --- a/docs/review-order.md +++ b/docs/review-order.md @@ -28,12 +28,15 @@ Read these first when a change touches build configuration, CI, or local tooling | `graphics/shadow_view.hpp` + `shadow_view.cpp` | The SH-02 shadow-LOD selection model, Vulkan-free. `ShadowView` is ENCAPSULATED (private state, static factories) so an invalid view can't be assembled by bypassing a convention; the perspective factory normalises `forward` itself. Three things carry their reasoning in comments and are easy to get subtly wrong: depth is the minimum signed forward projection over all EIGHT bounds corners (not centre or radial distance); the perspective projection uses `depth - worldError` because the displacement is finite and the projection steepens toward the light (a frustum-corner test measured 7.4256 texels against a 7.4021 first-order bound); and the LOD chain is validated BEFORE selection so an invalid deviation reports `InvalidCaster` rather than a plausible-looking `Selected` LOD0. `previousLevel` is valid only within one draw and one LOGICAL view — out of range is a reported error, never clamped. Budget and hysteresis ratio are arguments with no usable default: SH-03 supplies them from `constants.hpp`. | | `core/node_id.hpp` + `node_id.cpp` | The process-unique scene-node identity (SH-03 slice 1). `NodeIdentity` is a MOVE-AWARE owner: a moved-from node gets a FRESH id rather than sharing the moved-to one, so two live nodes never key the same shadow history. Lives in `core/` (not `scene/`) because `graphics/lighting.hpp` carries the id and must not include a scene header. The counter aborts on wrap rather than reissuing an id. | | `graphics/shadow_identity.hpp` + `shadow_identity.cpp` | The keys shadow-LOD hysteresis is stored under (SH-03 slice 1). `ShadowLogicalViewId` is ENCAPSULATED with validating factories per family (`cascade`/`worldOnly`/`self`/`spot`/`point`) — `Spot` and `Point` are separate kinds precisely because a shared "punctual" kind made `spot(9)` and `point(9, face 0)` collide. `ShadowLodStateKey` pairs the caster id with a GENERATION so a reloaded mesh cannot inherit the old geometry's dead band; a key can still be structurally invalid, and the eventual history owner must reject `!key.valid()` rather than store it. | -| `graphics/shadow_render_view.hpp` + `shadow_render_view.cpp` | The per-frame shadow view set (SH-03 slice 2) — the SINGLE authority for every shadow view: matrix, `ShadowView` descriptor, and logical identity in one read-only entry, addressed by the same physical `(group, slot)` the SH-01 overlay uses. Writers are per FAMILY (`setCascade`/`setSelf`/`setSpot`/`setPointLight`), each deriving the identity from the slot it writes and rejecting the wrong projection kind — and, since SH-07, the wrong bias-metrics kind, whose three packings share a shape and would otherwise be read as different quantities without complaint. `setPointLight` takes a WHOLE cube: one light identity, one metrics value, six per-face matrix/descriptor payloads, installed or cleared together, which makes "all six faces or none" and "the faces cannot disagree about the light" properties of the type rather than of the caller. `enableWorldOnly` stores nothing: it sets a bit and the world-only lookup ALIASES the cascade entry, so a cascade re-fitted after enabling moves both passes (a copy would only be equal at the instant it was taken). Absent means "inactive this frame"; an engaged entry with an INVALID projection is a different state and must stay visible as `InvalidView`. `activeCount` is NOT a dense prefix — iterate every physical slot and skip inactive ones. The render MATRIX has a stricter contract than the descriptor beside it — an invalid `ShadowView` is engaged and reported, but a non-finite matrix is rejected, since it would poison the cull frustum and the GPU transform while selection reported only `InvalidView`. `setPointLight` validates the light slot BEFORE flattening it (the flat index can wrap back into range). Every writer returns `[[nodiscard]] bool` and a rejection is TERMINAL in both builds — Dev stops at the set's own assertion inside the writer, NDEBUG returns false and the renderer's `rejectedShadowView` throws for a named `Fatal:` exit — because corrupt render input is not a condition to degrade through. That is what keeps "absent means inactive" true for every family. Extraction returns fixed-size arrays by value; every consumer (ShadowUBO array, LightUBO arrays, cull frustums, the shadow pass) is a projection of the set, and nothing else produces a shadow matrix. | -| `graphics/shadow_geometry_request.hpp` + `shadow_lod_resolver.hpp` + `shadow_lod_resolver.cpp` | SH-03's command seam. A shadow command carries a `ShadowGeometryRequest` (LOD span, conservative world scale, caster id + generation) and NO index buffer — an unresolved command that still carried one would be indistinguishable from a resolved one. `ShadowLodResolver` holds two stores that must not be conflated, both keyed on the full `(ShadowCasterId, generation, ShadowLogicalViewId)`: a per-FRAME cache — keyed on the LOGICAL view rather than the physical slot, which is why a cascade and its world-only twin (and a self slot's two depth layers) share one decision rather than agreeing by luck, with the caster and generation in the key because a view-only key would hand one caster's answer to another; and the cross-FRAME hysteresis history, STAGED during recording and committed only after a successful submit, so an abandoned frame leaves no dead band. Only a `Selected` reason writes history — a forced fallback says nothing about where the caster sits relative to its budget, and would erase the evidence the dead band is built on. An invalid key still draws but enters neither store. Slice 5 adds the READ-BACK the ShadowLod tint uses: `drawnResolution(group, key)` — what THAT family drew for this caster, or null. Combined on purpose, because asking "which level" and "did this pass draw it" separately invites forgetting the second, and forgetting it is invisible: a cascade and its world-only twin share one resolution by design but draw different casters (world-only excludes skinned ones, and cascades record first), so the level alone reports one pass's decision as another's. `frameResolution(key)` returns that shared decision regardless of provenance and is for inspecting the decision itself, not for attribution. Provenance is a per-family bitmask stored IN the same frame entry as the decision (one record, so it cannot exist without one), stamped at the draw itself and cleared per frame. | +| `graphics/shadow_render_view.hpp` + `shadow_render_view.cpp` | The per-frame shadow view set (SH-03 slice 2) — the SINGLE authority for every shadow view: matrix, `ShadowView` descriptor, and logical identity in one read-only entry, addressed by the same physical `(group, slot)` the SH-01 overlay uses. Writers are per FAMILY (`setCascade`/`setSelf`/`setSpot`/`setPointLight`), each deriving the identity from the slot it writes and rejecting the wrong projection kind — and, since SH-07, the wrong bias-metrics kind, whose three packings share a shape and would otherwise be read as different quantities without complaint. `setPointLight` takes a WHOLE cube: one light identity, one metrics value, the EFFECTIVE RANGE, and six per-face matrix/descriptor payloads, installed or cleared together, which makes "all six faces or none" and "the faces cannot disagree about the light" properties of the type rather than of the caller — the latter now checked rather than assumed (all six descriptors must carry the same light position, and the range must be finite and positive, since the stored depth is `distance / range`). Arc 2 #4 put those two there: a point face's light position and range are raster CONTENT (they reach the shader as `lightPosRange`, and a cached cube is only reusable while they are unchanged), so they belong beside the matrices rather than in an array the pass finds by arithmetic on a face slot — which is what `Renderer::pointCasters_` was. `pointLightDepth()` returns them together and ONLY for a point view: a cascade carries no light, and zeroes are a value a caller could read and push. `enableWorldOnly` stores nothing: it sets a bit and the world-only lookup ALIASES the cascade entry, so a cascade re-fitted after enabling moves both passes (a copy would only be equal at the instant it was taken). Absent means "inactive this frame"; an engaged entry with an INVALID projection is a different state and must stay visible as `InvalidView`. `activeCount` is NOT a dense prefix — iterate every physical slot and skip inactive ones. The render MATRIX has a stricter contract than the descriptor beside it — an invalid `ShadowView` is engaged and reported, but a non-finite matrix is rejected, since it would poison the cull frustum and the GPU transform while selection reported only `InvalidView`. `setPointLight` validates the light slot BEFORE flattening it (the flat index can wrap back into range). Every writer returns `[[nodiscard]] bool` and a rejection is TERMINAL in both builds — Dev stops at the set's own assertion inside the writer, NDEBUG returns false and the renderer's `rejectedShadowView` throws for a named `Fatal:` exit — because corrupt render input is not a condition to degrade through. That is what keeps "absent means inactive" true for every family. Extraction returns fixed-size arrays by value; every consumer (ShadowUBO array, LightUBO arrays, cull frustums, the shadow pass) is a projection of the set, and nothing else produces a shadow matrix. | +| `graphics/shadow_geometry_request.hpp` + `shadow_lod_resolver.hpp` + `shadow_lod_resolver.cpp` | SH-03's command seam. A shadow command carries a `ShadowGeometryRequest` (LOD span, `ShadowCasterPose`, caster id + generation) and NO index buffer — an unresolved command that still carried one would be indistinguishable from a resolved one. The POSE is the caster's world matrix and the conservative sigma_max DERIVED from it, as one constructed value: selection reads the scale, the shadow cache compares the matrix, and neither may be stated without the other. Its default is explicitly UNSTATED, because a defaulted `Mat4` is a real matrix — a producer that forgot it would hand the comparison a constant transform and every frame would compare equal. An unstated pose still RESOLVES (whole mesh, `InvalidCaster`, the same degraded answer a non-finite transform gets); what refuses it is preparation, terminally. `ShadowLodResolver` holds two stores that must not be conflated, both keyed on the full `(ShadowCasterId, generation, ShadowLogicalViewId)`: a per-FRAME cache — keyed on the LOGICAL view rather than the physical slot, which is why a cascade and its world-only twin (and a self slot's two depth layers) share one decision rather than agreeing by luck, with the caster and generation in the key because a view-only key would hand one caster's answer to another; and the cross-FRAME hysteresis history, STAGED during recording and committed only after a successful submit, so an abandoned frame leaves no dead band. Only a `Selected` reason writes history — a forced fallback says nothing about where the caster sits relative to its budget, and would erase the evidence the dead band is built on. An invalid key still draws but enters neither store. Slice 5 adds the READ-BACK the ShadowLod tint uses: `contentResolution(group, key)` — what THAT family's map HOLDS for this caster, or null. Content, not "drew this frame": arc 2 #4 separated the two, because a reused map holds exactly its casters while rasterising nothing, and attributing content to rasterisation would blank the tint on every cached view. Combined on purpose, because asking "which level" and "did this pass draw it" separately invites forgetting the second, and forgetting it is invisible: a cascade and its world-only twin share one resolution by design but draw different casters (world-only excludes skinned ones, and cascades record first), so the level alone reports one pass's decision as another's. `frameResolution(key)` returns that shared decision regardless of provenance and is for inspecting the decision itself, not for attribution. Provenance is a per-family bitmask stored IN the same frame entry as the decision (one record, so it cannot exist without one), stamped at the draw itself and cleared per frame. | | `graphics/frame_capture.hpp` + `frame_capture.cpp` | The Vulkan-free half of `--capture`: swapchain readback → tightly-packed RGBA8, plus the PNG write (stb_image_write). Two things it exists to get right, both silent when wrong: the BGRA/RGBA channel order, and the row PITCH (a linear image may pad rows — assuming `width * 4` shears the picture). An undersized mapping returns empty by contract, so the caller reports a failed capture rather than encoding whatever followed the buffer. Covered by `tests/graphics/test_frame_capture.cpp`. | | `tools/assetgen/png.py` | Byte-deterministic RGBA PNG writer. Note WHY it exists: `zlib.compress` output varies with the zlib build, so a committed asset embedding a compressed texture could differ between machines. Image data goes into DEFLATE **stored** blocks (RFC 1951, fully specified) with a hand-written Adler-32. Don't "optimise" it into real compression. | | `graphics/shadow_bias.hpp` + `.cpp` | **SH-07's bias law — the EXECUTABLE SPECIFICATION** for what `shaders/shadow_bias.glsl` runs per fragment. Pure and headless-tested because the arithmetic is where the mistakes live: the law takes `worldUnitsPerTexel`, `normalizedDepthPerWorldUnit`, `nDotL` and `filterRadiusTexels`, works in WORLD units and converts once at the end, which is what keeps it projection-independent and stops a scale being applied twice (the defect: `exp2(cascade)` stood in for a texel footprint AND a depth-range conversion simultaneously). The per-projection metrics beneath it are where the geometry differs — note the spot conversion carries the RAY-FORWARD COSINE (the slope term is measured along the light ray, the stored depth is projected onto the cone axis; omitting it over-converts ~41% at 45° off-axis) and the point footprint follows the MAJOR AXIS while its comparison depth stays radial. Degenerate metrics return 0, never infinity: 0 means acne, which is visible and locatable; infinity detaches every shadow in the frame. | -| `graphics/shadow_map_validity.hpp` + `.cpp` | **Which map families a frame RECORDS — one decision, used twice.** Pure and Vulkan-free: from `--no-shadows`, whether a primary directional light exists, and the per-family active view counts, it yields one bit per family. The renderer derives it once in `uploadFrameLighting` (after the COMPLETED view set — world-only is enabled last), gates `Shadows::recordPass` with it, and uploads `packedMask()` as `LightUBO::shadowMapValidMask`; the receiver answers fully lit for a clear bit. The two halves are the same value on purpose — skipping a family without telling the shader leaves it sampling depth this frame never wrote, which no validation layer or crash will report. Read the WHOLE-FAMILY rule carefully: cascades and world-only need EVERY cascade (a fragment picks its layer by depth, so a missing layer is a hole, not three-quarters of a map) plus a real directional light (they are fitted to a fallback direction otherwise, describing a sun that is not in the scene); point needs whole cubes, leaning on `setPointLight`'s atomicity; self and spot are per-slot and any active slot validates them. | +| `graphics/shadow_map_validity.hpp` + `.cpp` | **Which map families a frame RECORDS — one decision, used twice.** Pure and Vulkan-free: from `--no-shadows`, whether a primary directional light exists, and the per-family active view counts, it yields one bit per family. Since arc 2 #4 it is applied TWICE in a fixed order, both from `Renderer::prepareShadowPlan`: as ELIGIBILITY, from the COMPLETED view set (world-only is enabled last) BEFORE anything is prepared, because preparation resolves casters and stages hysteresis; and as CONFIRMATION (`shadowMapValidityFromPlan`), from the plan that was actually built and judged against the counts eligibility expected, which is what `uploadFrameLighting` puts in `LightUBO::shadowMapValidMask`. The receiver answers fully lit for a clear bit, and the pass records exactly the views that plan holds. The two halves are the same value on purpose — skipping a family without telling the shader leaves it sampling depth this frame never wrote, which no validation layer or crash will report. Read the WHOLE-FAMILY rule carefully: cascades and world-only need EVERY cascade (a fragment picks its layer by depth, so a missing layer is a hole, not three-quarters of a map) plus a real directional light (they are fitted to a fallback direction otherwise, describing a sun that is not in the scene); point needs whole cubes, leaning on `setPointLight`'s atomicity; self and spot are per-slot and any active slot validates them. | +| `graphics/shadow_pass_plan.hpp` + `.cpp` | **What a shadow view will RASTERISE, described exactly enough to decide whether last frame's image is still the right answer (arc 2 #4).** The hard part of caching a shadow map is the COMPARISON, not the skipping, so everything here is described in the values that reach the GPU — the model matrix written to `ShadowUBO`, the resolved index buffer, the effective cull, the extent and depth bias, and (point only) the light the radial ratio is measured against — never the higher-level quantities that explain them: two transforms can share an AABB, a snapped origin plus a near/far explains a matrix without being one, and a LOD level names a choice without being the geometry it selected. STRUCTURAL comparison, never a hash: a digest is a probabilistic argument for a decision whose failure is a silently wrong image. `PreparedShadowView` is ENCAPSULATED with per-kind factories, so a point face cannot be assembled with projected depth (the comparison would then omit the light while the shader still took its radial branch); its LAYERS come from its identity at construction, so an empty view still has the layers the recorder clears. Diagnostic fields (level, reason) and the recording payload (per-frame-ring buffer handles, which alternate every frame for identical content) are deliberately EXCLUDED from equality. A deformable draw never compares equal, not even to itself. `ShadowViewResidency` holds COMMITTED content only — adopted after submit, like the SH-03 hysteresis — and its absence is structural, because an image in the right layout is not an image holding an answer. `shadowMapValidityFromPlan` is the CONFIRMATION half of the validity law and needs the EXPECTED counts, not just the achieved ones: one of two spots preparing satisfies "some slot is sampleable" while the other light samples a stale map. | +| `graphics/shadow_pass_prepare.hpp` + `.cpp` | **The deciding half of the shadow pass (arc 2 #4).** Turns the frame's casters + the completed view set into a `ShadowFramePlan`: per view, claim the SH-01 row, filter, resolve the LOD, observe, build the prepared draws, then apply `shadowViewDisposition`. Vulkan-free, so every case that matters (a dropped caster, a suppressed family, a self view's two layers) is exercised without a device. Order is load-bearing three times over: FILTER before resolve (a caster this view drops must acquire no dead band against it); ELIGIBILITY before preparation (resolving STAGES hysteresis, so a family that will neither record nor be sampled must not be resolved at all) and CONFIRMATION after it; and families in RECORDING order — that last one for DETERMINISM rather than correctness, since the frame cache is keyed on caster id + generation + logical view and the world-only span is a subset of the same commands resolved against the same aliased view entry, so either order yields the same levels and what the stable order buys is comparable diagnostics between runs. Observation is per LAYER (a self view walks the caster set twice) while selection is counted once — its two layers are one decision. The view SET decides which slots exist: there are no active-count arguments to disagree with it. Terminal on a contradiction: a row claimed by two identities, a caster that resolves to no geometry, a caster with no stated pose (a default matrix compares equal forever, which is a map reused forever for something that is moving), or a view the plan refuses. | +| `graphics/shadow_face_cull.hpp` | SH-05's cull POLICY, Vulkan-free: `ShadowFaceCull` (what a family/layer wants) → `shadowEffectiveCull` (folded against the caster's `doubleSided`) → `ShadowEffectiveCull` (what the rasteriser does). It lives in `graphics/` because the cache's content descriptor has to record the EFFECTIVE answer, so the mapping cannot sit behind a Vulkan type; `render/shadows.hpp` keeps only the translation. Reversing an answer compiles and rasterises — a double-sided sheet front-culled casts nothing at all — so it is pinned by `tests/render/test_shadow_raster_policy.cpp`. | | `graphics/shadow_caster_alpha.hpp` + `.cpp` | **SH-05's classifier — the one place that decides whether a caster's shadow is its triangles or its cutout.** Returns `Masked` for `AlphaMode::Mask` alone: keyed on the DECLARED mode, not on whether a base-colour texture happens to be bound (a textureless MASK material still tests its base-colour factor's alpha, and a shadow that ignored it would occlude where the surface draws nothing), and BLEND maps to `Opaque` deliberately, leaving blend-shadow semantics an open decision. Same shape and same reasons as the SH-04 classifier below; both consumers (the resolver's LOD pin, the shadow pass' fragment path) read ONE derivation made in `Object::buildDrawCommands`. | | `graphics/shadow_caster_deformation.hpp` + `.cpp` | **SH-04's classifier — the one place that decides whether a caster's error claim is about the mesh that gets rasterised.** Returns `Deformable` for three carriers: a skinned/morph-weighted INSTANCE, morph-CAPABLE geometry (deliberately independent of current weights — classifying by weights would swap a caster's error model mid-animation), and storage-vertex geometry whose vertices a compute pass rewrites (cloth). A free function, not an `Object` member, so it is testable against a real `Geometry` without a GPU — classification and the resolver's response to it are pinned by separate tests, so neither is proven only by the other. | | `tools/shadow_lod_sweep.sh` | SH-03's calibration procedure as a script, so the budget and dead band can be re-derived rather than trusted. Read the header first: it names the three references that were rejected and why (`--no-lod` changes the visible geometry; a tiny budget still runs selection; whole-image PSNR dilutes a localised silhouette error). The metric compares the `--debug-shadow` visibility image and reports differing pixels against the reference's SHADOWED area, plus the worst pixel and an amplified difference image so "edge slivers" is something you look at. The dead-band half aggregates the per-frame `FE_LOG=render:debug` movement record over a whole animated run, and REVERSALS — not transitions — are the column that can justify a ratio. | @@ -59,7 +62,7 @@ Read these first when a change touches build configuration, CI, or local tooling | File | Pay attention to | |---|---| | `graphics/gpu_handle.hpp` | Opaque handle types — the contract that keeps `graphics/` Vulkan-free. | -| `graphics/gpu_limits.hpp` | `kMaxLights`, `kMaxJoints`, `kMaxMorphTargets`, shadow caster caps, `kShadowTotalMatrixCount`. **These must equal shader array sizes** — cross-file invariant. | +| `graphics/gpu_limits.hpp` | `kMaxLights`, `kMaxJoints`, `kMaxMorphTargets`, shadow caster caps. **These must equal shader array sizes** — cross-file invariant, and since the shared-limits work they are DEFINED as the declarations in `shaders/gpu_limits.glsl` rather than restated here. The shadow matrix-table layout (cascade/spot/point bases, total count) is gone: the transform reaches the GPU in the recorded view's push constants, one per view, and `shadow_matrix_guard` fails the build if a per-draw table returns. | | `render/constants.hpp` | Scalar render tunables (biases, IBL strengths, extents, FOV). Includes gpu_limits. Note the bias values — comments explain why they're conservative. | | `physics/physics_handle.hpp` / `collision/collider_id.hpp` | Stable opaque IDs linking scene↔physics↔broadphase. | | `core/log.hpp` | Runtime diagnostics. `FE_LOG` is parsed once; levels are `debug`/`info`/`warn`/`error`/`off`, with category overrides like `ragdoll:debug`. Engine code should use `log::debug/info/warn/error`, not direct stream or printf diagnostics. | @@ -199,7 +202,7 @@ Read these first when a change touches build configuration, CI, or local tooling | File | Pay attention to | |---|---| | `render/environment_precompute.hpp` + `.cpp` | Equirect→cubemap, irradiance, prefilter, BRDF LUT at startup. | -| `render/shadows.hpp` + `shadows.cpp` | **High-attention.** CSM directional + world-only CSM, spot layers, point cubemap-array, **dual-depth per-skinned-object self-shadow** (two passes: capture nearest surface, then `cullMode=eFront` for next occluder; in-shader `skinnedSelfShadowDepthEpsilon` safety net). `kMaxSkinnedSelfShadowCasters` cap. `recordPass` takes the shadow matrices + `cullingEnabled` and filters each cascade/spot/point-face draw list against its own `Frustum` (self-shadow slots aren't culled). **SH-05 made the pipeline choice per DRAW**: each family carries a `ShadowPipelinePair` (opaque / masked) selected by `DrawCommand::shadowRequest.alpha` — the ONE place a caster's classification is stored, and the same field the resolver reads — and an explicit `ShadowFaceCull` policy set as dynamic state per draw — `PerCaster` reads `doubleSided` (a double-sided caster culls nothing, which is what stopped a face-on sheet casting nothing at all), `AllFaces` serves the self-shadow first layer (which therefore no longer has a pipeline of its own), `BackFacesOnly` the second. Push constants moved from one push per view to one per draw, because `materialIndex` varies per draw. | +| `render/shadows.hpp` + `shadows.cpp` | **High-attention.** CSM directional + world-only CSM, spot layers, point cubemap-array, **dual-depth per-skinned-object self-shadow** (two layers: capture nearest surface, then `cullMode=eFront` for the next occluder; in-shader `skinnedSelfShadowDepthEpsilon` safety net). `kMaxSkinnedSelfShadowCasters` cap. **Since arc 2 #4 the pass is PLAN-ONLY**: `recordPass(cmd, plan, stats, profiler, frameIndex)` — no draw spans, no view set, no resolver, no validity argument. Everything it rasterises was decided by `prepareShadowFrame`, and each view's disposition says whether it records at all, so a recorder that could re-filter or re-resolve would be a second answer to the question the cache has already answered. What is left here is genuinely Vulkan: per-layer barriers (ReadOnly → Attachment → ReadOnly), dynamic rendering, `layerTarget` resolving (family, layer kind) → image + attachment view + `ShadowPipelinePair` as ONE value (the self pair differs in both, and picking them separately would rasterise the second layer into the image it samples), and per-draw state. **SH-05's pipeline choice is per DRAW**: the pair is selected by the prepared draw's `alpha` — the same value the comparison holds — and the cull mode is the prepared draw's EFFECTIVE `ShadowEffectiveCull`, translated here and decided in `graphics/shadow_face_cull.hpp`. Push constants are pushed per draw because `materialIndex` varies per draw; every other field is the view's, from the prepared view (matrix, depth mode, point light). `beginRasterPass` CHECKS the row's claim rather than making it — preparation claimed it — so rasterising view B into view A's row is refused rather than reported under A's name. | | `graphics/shadow_caster_bounds_frame.hpp` + `.cpp` | One frame's caster bounds and the single authority on them: built by `gatherShadowCasters` before the fit, read by the fit, the draw build and the diagnostics. Keyed by (`ShadowCasterId`, `ShadowCasterGeneration`); duplicate keys and missing lookups are TERMINAL, because both mean the prepass and the draw walk disagree about what the scene contains, and the alternative (a recompute, or a default empty box at the origin) is exactly the silent divergence this type exists to prevent. Lifetime is one frame — `reset()` per prepass, nothing cached on `Object`. | | `graphics/shadow_caster_bounds.hpp` | The SH-06 prepass type: one shadow caster's world bounds, its identity, and a `ShadowCasterBoundsKind` saying whether those bounds can be TRUSTED. `Exact` means the bounds were computed from the vertices that will draw, in their current pose (skinning and morph applied); `Stale` means a compute pass rewrites the vertices (cloth), so the CPU copy is the bind pose and the drawn geometry can be anywhere. The distinction is load-bearing: the depth range is fitted to these, and a range fitted to bounds that understate the geometry clips it — which is the defect the fixed extension was hiding. | | `render/cascade_fit.hpp` + `cascade_fit.cpp` | **High-attention (SH-06).** The CSM cascade fit as pure, Vulkan-free carriers: `CascadeReceiverFit::fit` produces the slice's stable light-space XY footprint, texel grid and **exact receiver min/max W from the eight corners**; `fitCasterAwareCascadeDepth` is the depth POLICY — near plane back to the furthest-upstream candidate caster (`classifyFootprint`, which is deliberately depth-INDEPENDENT so the policy never needs a depth range to choose one), far plane to the receiver volume, one `worldPerTexel` of slack on BOTH planes (that widens the depth span by an XY texel's world size; it is not a unit of depth precision). `fitLegacyCascadeDepth` remains as the pre-SH-06 fixed-extension fit and as the stale fallback. `backExtend` is IRRELEVANT on the Exact-only path — passing a NaN there still fits — and used only by the fallback. Both `CascadeReceiverFit` and `CascadeDepthFit` are ENCAPSULATED (read-only accessors, factory-only construction): the depth fit became a class when it gained a `CascadeDepthFitMode` (`LegacyFixedExtension` / `CasterAware` / `LegacyStaleFallback`), because a public aggregate would let a caller pair a mode with a matrix that did not produce it. A single `Stale` caster anywhere in the frame forces the legacy range; a non-finite Exact bound is TERMINAL, never skipped. Note the receiver fit is fed a slice that starts inside the previous cascade's blend band (`kShadowCascadeBlendFraction`, uploaded in `LightUBO::cascadeParams` so the shader and the fit share one value), since those receivers sample this cascade's map. `tests/render/test_cascade_fit.cpp` holds a verbatim copy of the pre-extraction lambda and asserts bit-identical legacy matrices. | diff --git a/docs/roadmap.md b/docs/roadmap.md index 3068fb2e..abd72392 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -81,11 +81,43 @@ The eight small/XS items landed on `review-shadow-taa-fixes` + `review-xs-cleanu the five the review prioritised, then five a later coverage audit found had no action item. In the review's priority order: -- **#4 [B/M] Static-scene CSM caching** (§2.1) — the renderer currently re-records every shadow pass - every frame. Needs the lightweight **epoch** idea from §5.1 (scene-transform / light / caster-set - epochs) so individual passes can skip without a frame-graph rewrite. **Consumes SH-01's - diagnostics and SH-03's per-view LOD contract** — a map's content signature must include the - shadow view descriptor and every selected LOD/front generation, not just a camera epoch +- **#4 [B/M] Static-scene CSM caching** (§2.1) — **in progress on `shadow-static-cascade-cache`.** + The §5.1 epoch idea was rejected once the comparison was written out: an epoch explains a matrix + without being one, and the failure mode is a silently wrong image (shadows from a frame that no + longer exists), so the descriptor is exact and structurally compared, never hashed. + **Landed on the branch:** the content descriptor and disposition law + (`graphics/shadow_pass_plan.hpp`), and the PREPARATION phase that builds it + (`graphics/shadow_pass_prepare.hpp`) — filtering, per-view LOD resolution and the SH-01 row + claim/observations all moved out of recording, leaving `Shadows::recordPass` consuming the plan + and nothing else. Two parallel authorities went with it: the shadow transform (now one push + constant per recorded view, guarded by `shadow_matrix_guard`) and the point light's + position + range (now in the view set, not a renderer-side array). + **Remaining:** the per-slot residency store, committed only after submit, and the reuse it + enables — every view is still `Recorded`, which is what let the restructure be verified as + decision-identical. Then #15 below, which is the same mechanism. + + **Sequenced after arc 4** (see below) — the reuse stage adds conditional release behaviour, and the + job that exercises it should be in place first. + + **The gate for that stage is agreed, and it is NOT the byte-identical row dump** — that gate + worked precisely because nothing changed, and reuse changes what the recorder does. The claim to + prove is: *on the second identical frame, every active CACHEABLE view is reused and every + deformable view is still recorded* — "every view is reused" is false for any self-shadow scene + under the current deformation law, and a gate asserting it would be failed by correct code. Three + layers of evidence: + 1. **Headless two-frame test.** First preparation records; a simulated post-submit commit installs + residency; the second identical preparation reuses the cacheable views. Also: an abandoned + frame commits nothing, and image recreation invalidates residency. + 2. **Mixed-content test.** Rigid views reuse while deformable views record in the same frame, and + the confirmed validity stays set for both — `Reused` is sampleable, so a family that is half + reused is fully valid. + 3. **Real-GPU gate**, as a local Vulkan script rather than a mocked command buffer: a mock would + test a second implementation of recording, while the real gate proves a reused view receives no + barrier, no clear and no draw *and* that its existing depth still shades correctly. On a static + `--no-taa` scene: the reused-frame capture matches the cold recorded reference; cacheable rows + keep identical candidate/drawn/LOD observations while reporting ZERO raster passes; a fully + reused family stamps no timestamps; validation stays VUID-free. + Contracts consumed: SH-01's diagnostics and SH-03's per-view LOD contract ([`shadowplans.md`](shadowplans.md) § Interaction). - **#5 [B/L] Compute pre-skinning pass** (§1.3) — skinning/morphing re-runs in every pass's vertex shader (~11× per skinned vertex per frame). `SoftBodySystem` already proves the compute pattern @@ -164,6 +196,34 @@ Further tiers of this review are expected to follow the [`review-order.md`](revi --- +## Arc 4 — Release-contract CI job + +**Trigger: after `shadow-static-cascade-cache` lands, and BEFORE arc 2 #4's residency/reuse work.** Its own branch. The order is deliberate: the reuse stage adds more conditional release behaviour to a set of checks nothing currently executes, so the job that proves those checks run should exist first. + +Rejection tests guarded by `#ifdef NDEBUG` — the release behaviour of every writer that asserts in +Dev and returns `false` under `NDEBUG` — **never run**. Both presets build `Dev`, locally and in CI, +so those blocks compile to nothing on every machine that has ever checked them. The gap was found +while adding `setPointLight`'s new range / one-light validation: a one-off local Release build was +needed to prove the new checks fire at all. + +**Death tests are not the fix.** They prove the Dev assertion fires; they say nothing about whether +the `NDEBUG` fallback returns `false` (or throws) correctly, which is the half that ships. + +The job: + +- Tag the conditional cases `[release-contract]`. +- Configure a genuine Release build with warnings-as-errors. +- Build the real library and test executable. +- Run `test_fire_engine "[release-contract]"` explicitly. +- Include a **sentinel assertion that `NDEBUG` is defined**, so a misconfigured job cannot pass by + selecting zero relevant cases. + +**Linux only, and deliberately not the whole suite.** These rejection semantics are +platform-independent, and running all tests under Release would mix this contract with the +optimisation-sensitive physics goldens. + +--- + ## Parked & revisit — trigger-based Not a backlog. Each item was investigated, has data behind the decision, and is picked up only on diff --git a/docs/shadowplans.md b/docs/shadowplans.md index 0f843446..bbdd78d5 100644 --- a/docs/shadowplans.md +++ b/docs/shadowplans.md @@ -419,7 +419,7 @@ Likely branch: `shadow-per-view-discrete-lod`. thing the table exists to show — were being ellipsised to a single character. 5. **Tint.** The ShadowLod debug view colours each mesh by the level ONE shadow view resolved for it — the panel's focused view, or cascade 0 by default, named in the overlay so the default is - never silent. The level is READ BACK through `ShadowLodResolver::drawnResolution(group, key)` — + never silent. The level is READ BACK through `ShadowLodResolver::contentResolution(group, key)` — what that FAMILY drew for this caster — never re-selected: a second selection would see a different history state and the picture would contradict the geometry it claims to describe. It is not `frameResolution(key)`, which returns the decision SHARED by every view with that @@ -504,7 +504,8 @@ What landed: policy switch. Deliberately NOT expressed by passing `lodEnabled = false`, which would have reported `LodDisabled` and conflated a user's toggle with a safety fallback: the panel would then answer "why is this caster at full detail?" with somebody else's reason. It defaults to - `Deformable` on the same principle as `worldScale`'s NaN — a producer that forgets the field must + `Deformable` on the same principle as an unstated `ShadowCasterPose`'s NaN scale — a producer that + forgets the field must not receive the optimistic answer. - **`ShadowLodReason::DeformableFallback`**, resolving to the whole mesh with an **infinite** projected error. Not zero: zero would rank a deformable caster as the most accurate in the frame, @@ -991,6 +992,18 @@ contracts from this work: - **Shadow caching:** a map's content signature includes the shadow view descriptor, stable draw ids, caster transform/deformation/material revisions, proxy identity, and every selected LOD/front generation. A camera epoch alone is insufficient. + **Consumed (arc 2 #4, preparation step).** The signature is `PreparedShadowView` / + `PreparedShadowDraw` (`graphics/shadow_pass_plan.hpp`), compared structurally rather than hashed, + and it is built by `prepareShadowFrame` (`graphics/shadow_pass_prepare.hpp`) — which is where this + plan's per-view LOD contract is now applied. **The selection moved:** filtering, resolution and + the SH-01 row claim + observations all happen during PREPARATION, before the pass records + anything, because deciding whether a map may be reused means knowing what would have been drawn + without drawing it. `Shadows::recordPass` consumes the resulting plan and holds no draw span, no + view set and no resolver. Two consequences for anything built on this plan's contracts: the + resolver's `noteContent` records what a map HOLDS rather than what was rasterised (a reused map + holds its casters without drawing them), and the ShadowLod tint reads that content during + collection instead of waiting for the shadow pass. Staging is unchanged — levels still commit + only after the frame is submitted. - **Compute pre-skinning:** expose the pre-deformed vertex buffer, exact deformed bounds, and a deformation revision through the shadow draw description. It can then replace the LOD0 deformable fallback and stop rerunning skin/morph work in every pass. diff --git a/include/fire_engine/graphics/frame_info.hpp b/include/fire_engine/graphics/frame_info.hpp index 8c7724a2..cb45dd29 100644 --- a/include/fire_engine/graphics/frame_info.hpp +++ b/include/fire_engine/graphics/frame_info.hpp @@ -1,11 +1,9 @@ #pragma once -#include #include #include #include -#include #include #include #include @@ -71,11 +69,6 @@ struct FrameInfo bool vdpmGpuBackend{false}; std::vector* vdpmRequestSink{nullptr}; PipelineHandle shadowPipeline{NullPipeline}; - // Light-space view-projection matrices for every shadow caster — cascades, - // spot lights, and the six faces of each point light. Layout matches - // ShadowUBO::lightViewProj. Object::render copies the full array into the - // per-draw ShadowUBO; the shadow vertex shader picks one via push constant. - std::array shadowViewProjs{}; }; } // namespace fire_engine diff --git a/include/fire_engine/graphics/gpu_limits.hpp b/include/fire_engine/graphics/gpu_limits.hpp index be71bd7e..3d6ea93c 100644 --- a/include/fire_engine/graphics/gpu_limits.hpp +++ b/include/fire_engine/graphics/gpu_limits.hpp @@ -64,23 +64,16 @@ inline constexpr uint32_t kShadowCascadeCount = shader_limits::SHADOW_CASCADE_CO inline constexpr int kMaxSpotShadowCasters = shader_limits::MAX_SPOT_SHADOW_CASTERS; inline constexpr int kMaxPointShadowCasters = shader_limits::MAX_POINT_SHADOW_CASTERS; -// Faces of a cube map — ONE authority, because this value participates in four separate things: -// logical-view key validation, shadow matrix indexing, image layer indexing, and the flat -// point-view slot arithmetic. Two definitions drifting apart would corrupt all of them at once, -// and quietly: every index would still be in range, just pointing at the wrong face. +// Faces of a cube map — ONE authority, because this value participates in three separate things: +// logical-view key validation, image layer indexing, and the flat point-view slot arithmetic. Two +// definitions drifting apart would corrupt all of them at once, and quietly: every index would +// still be in range, just pointing at the wrong face. inline constexpr std::uint32_t kCubeFaceCount = shader_limits::CUBE_FACE_COUNT; -// Shadow vertex shader projects each vertex into light-space using one of the -// ShadowUBO::lightViewProj matrices, picked via ShadowPushConstants::matrixIndex. -// [0..C-1] directional cascades -// [C..] spot lights, layout C + spotIndex -// [C+S..] point lights, layout (C + S) + 6 * cubeIndex + face -// where C = kShadowCascadeCount and S = kMaxSpotShadowCasters. The arithmetic itself is shared with -// the shaders, not repeated here — see shaders/gpu_limits.glsl. -inline constexpr int kShadowCascadeMatrixBase = shader_limits::SHADOW_CASCADE_MATRIX_BASE; -inline constexpr int kShadowSpotMatrixBase = shader_limits::SHADOW_SPOT_MATRIX_BASE; -inline constexpr int kShadowPointMatrixBase = shader_limits::SHADOW_POINT_MATRIX_BASE; -inline constexpr int kShadowTotalMatrixCount = shader_limits::SHADOW_TOTAL_MATRIX_COUNT; +// (The shadow MATRIX-TABLE layout — cascade/spot/point bases and a total count — is gone. It sized +// `ShadowUBO::lightViewProj[]`, a copy of every shadow matrix in the frame carried by every shadow +// draw so a push constant could select one row. Each path now rasterises with `pc.lightViewProj` +// from the view being recorded, so no slot arithmetic exists to keep in step.) // Which shadow-map families a frame recorded, packed into `LightUBO::shadowMapValidMask`. The // producer is `ShadowMapValidity::packedMask()` (graphics/shadow_map_validity.hpp); the consumer is @@ -92,15 +85,6 @@ inline constexpr std::int32_t kShadowMapValidSelf = shader_limits::SHADOW_MAP_VA inline constexpr std::int32_t kShadowMapValidSpot = shader_limits::SHADOW_MAP_VALID_SPOT; inline constexpr std::int32_t kShadowMapValidPoint = shader_limits::SHADOW_MAP_VALID_POINT; -// The layout the comment above describes, asserted rather than trusted: these are the relations the -// matrix table's users assume, and they must survive any future change to a family's capacity. -static_assert(kShadowCascadeMatrixBase == 0); -static_assert(kShadowSpotMatrixBase == - kShadowCascadeMatrixBase + static_cast(kShadowCascadeCount)); -static_assert(kShadowPointMatrixBase == kShadowSpotMatrixBase + kMaxSpotShadowCasters); -static_assert(kShadowTotalMatrixCount == - kShadowPointMatrixBase + static_cast(kCubeFaceCount) * kMaxPointShadowCasters); - // Bindless material textures: capacity of the global combined-image-sampler // array (forward set 2). Indexed directly by TextureHandle value, so it caps the // total number of textures Resources can allocate. Partially-bound, so unused / diff --git a/include/fire_engine/graphics/shadow_diagnostics.hpp b/include/fire_engine/graphics/shadow_diagnostics.hpp index ed9c79ef..f8c89b55 100644 --- a/include/fire_engine/graphics/shadow_diagnostics.hpp +++ b/include/fire_engine/graphics/shadow_diagnostics.hpp @@ -217,33 +217,53 @@ struct ShadowViewStats // sum of several unrelated decisions with no way to tell which view forced what. Subject to the // same `countSelection` rule as the histogram. std::array lodReasons{}; - // WHICH logical view these counters describe, recorded when the view is marked rasterised. + // WHICH logical view these counters describe, recorded when the plan claims the row. // // A physical slot is not an identity: spot, point and self assignments are compacted in // scene-gather order every frame, so slot 1 can be a different light next frame. Rows are still // addressed by slot (that is what the renderer rasterises), but anything that must refer to the // SAME view across frames — a panel selection, and from slice 5 the tint — has to key on this. - // Invalid only on a view that never rasterised. + // Invalid only on a view this frame's plan never CLAIMED — which is not the same as one that + // never rasterised: a claimed view may legitimately record nothing. ShadowLogicalViewId logicalId{}; - // Marks this view rasterised, and states WHICH view it is. The identity is required rather than - // optional: it is the only chance to record it, and a row that cannot say what it describes - // cannot be selected reliably later. + // ENGAGES this row and states WHICH view it is — the identity, and nothing about work. Called + // when the frame's plan claims this slot, which happens whether or not anything is recorded + // into it: a view whose map is REUSED is claimed and observed while rasterising nothing, and a + // row forced to claim a raster pass in order to be observed at all would report intended work + // as performed work. + // + // The identity is required rather than optional: this is the only chance to record it, and a + // row that cannot say what it describes cannot be selected reliably later. // // Returns false and changes NOTHING when the identity is invalid, or when this row already - // holds a different one. A row is one logical view's counters: two identities rasterising into - // the same physical slot in one frame would merge their draws, triangles and level - // distributions and then label the total as whichever came second — a plausible row describing - // no real view. Repeating the SAME identity is the normal case (a self-shadow slot's two depth - // layers). The caller must treat a false return as terminal; the counters are unusable evidence - // either way, and the renderer knows which view it was trying to record. + // holds a different one. A row is one logical view's counters: two identities claiming the same + // physical slot in one frame would merge their draws, triangles and level distributions and + // then label the total as whichever came second — a plausible row describing no real view. + // Repeating the SAME identity is the normal case (a self-shadow slot's two depth layers). The + // caller must treat a false return as terminal; the counters are unusable evidence either way, + // and the renderer knows which view it was trying to prepare. + [[nodiscard]] bool claimView(ShadowLogicalViewId view) noexcept; + // One rasterised layer of this view. Called by the RECORDER, once per depth image it actually + // brackets — so `rasterPasses` counts GPU work and only GPU work. + // + // The identity is passed again and CHECKED, never re-claimed. Once claiming moves to + // preparation, "some view claimed this row" stops being enough: the recorder could rasterise + // view B into the row view A claimed, and every counter would still read plausibly under A's + // name. This is the recorder's half of the agreement `claimView` enforces among producers. + // + // Returns false and counts NOTHING if the row was never claimed or holds a different identity. + // Terminal at the caller, for the same reason a merged row is. [[nodiscard]] bool beginRasterPass(ShadowLogicalViewId view) noexcept; // THE FOUR OBSERVATION RULES. Every per-view number in the panel is only readable because these // hold together; each one exists because breaking it produced a plausible-looking wrong answer: // - // 1. A view that was RASTERISED reports, even with nothing to draw. `beginRasterPass` runs - // before the span is walked, so an empty-but-rendered map — a real cost, and evidence a map - // is being rendered for no reason — is visible instead of vanishing. + // 1. A CLAIMED view reports, even with nothing to draw and even with nothing recorded. + // `claimView` runs before the caster set is walked, so an empty map — a real cost when it + // is rendered, and evidence a map is being kept for no reason — is visible instead of + // vanishing. Observation is per prepared LAYER: a self-shadow view's two depth layers each + // walk the set, which is why its candidate count is two per caster while its level + // distribution counts one (rule 4). // 2. ONE observation per walked draw, carrying the filter's verdict. A separate // add-candidate/add-drawn pair would let a caller record an accepted draw that was never // offered, and `drawn <= candidate` would stop being structural. @@ -260,7 +280,19 @@ struct ShadowViewStats // rejected draw was never resolved and has no level or reason to report. void observe(std::uint64_t fullDetailTriangles, bool accepted, std::uint64_t resolvedTriangles, std::uint32_t lodLevel, ShadowLodReason reason, bool countSelection) noexcept; - // Rasterised at all this frame — independent of whether anything was drawn into it. + // CLAIMED this frame — the row describes a view the plan named. Independent of whether anything + // was recorded into it (a reused map claims and observes without rasterising) and of whether + // anything was drawn. + [[nodiscard]] bool claimed() const noexcept + { + return logicalId.valid(); + } + // Rasterised at all this frame — a question about RECORDED WORK, which is what timing and the + // "did this map cost anything" questions want. `claimed() && !touched()` says only that no + // raster pass was recorded; WHY is the plan's disposition (a reused map, a view still to be + // recorded, or a recorder that omitted its work), and this cannot distinguish them. + // `!claimed()` is a slot this frame's plan never named — and that, not this, is what "absent" + // means. [[nodiscard]] bool touched() const noexcept { return rasterPasses != 0; diff --git a/include/fire_engine/graphics/shadow_face_cull.hpp b/include/fire_engine/graphics/shadow_face_cull.hpp new file mode 100644 index 00000000..9bebfcaf --- /dev/null +++ b/include/fire_engine/graphics/shadow_face_cull.hpp @@ -0,0 +1,69 @@ +#pragma once + +#include + +// Which faces a shadow draw keeps — the family's policy, and the EFFECTIVE answer it produces once +// resolved against the caster's own sidedness. +// +// Vulkan-free and here rather than in `render/shadows.hpp` because two consumers need it and only +// one of them may see Vulkan: the recorder turns the effective answer into a `vk::CullModeFlags`, +// and the cache's content descriptor (`graphics/shadow_pass_plan.hpp`) has to record what was +// actually rasterised. Deriving the effective mode twice — once for the draw, once for the +// descriptor — would let a cached map be reused for content it no longer matches. + +namespace fire_engine +{ + +// SH-05: which faces one shadow family keeps. A property of the PASS, not of the pipeline: the +// shadow pipelines declare cull mode dynamic, so this is set at record time and every family must +// name its policy — there is no static fallback to inherit if one forgets. +enum class ShadowFaceCull : std::uint8_t +{ + // Cascade / spot / point: the CASTER decides. Single-sided casters cull front faces (back faces + // carry the depth, which is what keeps receiver acne off); a double-sided material culls + // nothing, because front-culling a sheet authored face-on to the light discards the only faces + // it has and it casts no shadow at all. + PerCaster, + // Self-shadow FIRST layer: keep everything, so the first light-facing surface is captured + // whatever its winding. Was its own pipeline before SH-05 made cull mode dynamic. + AllFaces, + // Self-shadow SECOND layer: cull front faces so only back faces rasterise, which is what makes + // the dual-depth rejection well-founded rather than a coin-flip on marginal fragments. + BackFacesOnly, +}; + +// The resolved answer for ONE draw. Only two states exist in the shadow pass — no back-face culling +// variant — so this is the whole codomain, and it is a closed enum rather than a bitmask so a +// content descriptor comparing it cannot be fooled by an equal-but-differently-spelled flag set. +enum class ShadowEffectiveCull : std::uint8_t +{ + None, + FrontFaces, +}; + +// SH-05: the family's policy resolved against the caster's sidedness. +// +// Pure, and public for the same reason the pipeline choice is: every shadow pipeline declares cull +// mode dynamic, so this function IS the cull policy, and swapping two of its answers would silently +// restore the defect the item fixed (a double-sided sheet front-culled into casting nothing) or +// break the dual-depth self-shadow layer. Takes the caster's `doubleSided` flag rather than a +// DrawCommand so the mapping can be exercised exhaustively without building a draw. +[[nodiscard]] constexpr ShadowEffectiveCull shadowEffectiveCull(ShadowFaceCull policy, + bool casterIsDoubleSided) noexcept +{ + switch (policy) + { + case ShadowFaceCull::PerCaster: + // A double-sided caster culls NOTHING. Front-culling one authored face-on to the light + // discards the only faces it has, and it casts no shadow at all. + return casterIsDoubleSided ? ShadowEffectiveCull::None : ShadowEffectiveCull::FrontFaces; + case ShadowFaceCull::AllFaces: + return ShadowEffectiveCull::None; + case ShadowFaceCull::BackFacesOnly: + return ShadowEffectiveCull::FrontFaces; + } + // Unreachable for a valid policy; the switch is exhaustive over the enum. + return ShadowEffectiveCull::FrontFaces; +} + +} // namespace fire_engine diff --git a/include/fire_engine/graphics/shadow_geometry_request.hpp b/include/fire_engine/graphics/shadow_geometry_request.hpp index 4ef7bc49..ede8aa0c 100644 --- a/include/fire_engine/graphics/shadow_geometry_request.hpp +++ b/include/fire_engine/graphics/shadow_geometry_request.hpp @@ -7,6 +7,8 @@ #include #include #include +#include +#include namespace fire_engine { @@ -51,6 +53,61 @@ enum class ShadowCasterAlpha : std::uint8_t Masked, }; +// The caster's world transform and the error scale DERIVED from it, as ONE constructed value. +// +// Two representations of one transform, so they are not two fields. The matrix is what the shadow +// pass rasterises with (it is written into `ShadowUBO::model`, and the cache compares it to decide +// whether a map still holds the right pixels); `worldScale` is its conservative sigma_max, which +// carries an object-space deviation into world space. Nothing may set one without the other: a +// pose with a stale scale would select levels for a transform the GPU is not using, and a pose with +// a stale matrix would compare a caster that has moved as unchanged. +// +// STATED-NESS IS EXPLICIT, and that is the point of the class. A defaulted `Mat4` is a real matrix +// — zero, or identity depending on the type's default — so a producer that filled every other field +// and forgot this one would hand the comparison a constant transform while the GPU rasterised the +// object's actual one, and every frame would compare equal: a shadow map reused forever for a +// caster that is moving. `stated()` distinguishes "no pose was supplied" (a producer bug, terminal +// where it is consumed) from "a pose was supplied and is degenerate" (a non-finite transform from a +// broken animation, which the selector already survives as InvalidCaster). +class ShadowCasterPose +{ +public: + // NOT STATED. Present so a request can be default-constructed at all; never a usable pose. + ShadowCasterPose() = default; + + // The only way to state one. Derives the scale here rather than accepting it, so the two can + // never describe different transforms. + [[nodiscard]] static ShadowCasterPose fromModel(const Mat4& model) noexcept + { + ShadowCasterPose pose{}; + pose.model_ = model; + pose.worldScale_ = largestSingularValue(linearPart(model)); + pose.stated_ = true; + return pose; + } + + [[nodiscard]] bool stated() const noexcept + { + return stated_; + } + // The matrix the shadow pass rasterises this caster with. + [[nodiscard]] const Mat4& model() const noexcept + { + return model_; + } + // Conservative sigma_max of the model's linear part. NaN on an unstated pose, which the + // selector reports as InvalidCaster rather than silently treating as "no error". + [[nodiscard]] float worldScale() const noexcept + { + return worldScale_; + } + +private: + Mat4 model_{}; + float worldScale_{std::numeric_limits::quiet_NaN()}; + bool stated_{false}; +}; + // SH-03: a shadow caster described but NOT yet resolved to geometry. // // The whole point of the seam. A shadow command used to arrive with an index buffer already chosen @@ -71,15 +128,17 @@ struct ShadowGeometryRequest // exist at all. BufferHandle baseIndexBuffer{NullBuffer}; std::uint32_t baseIndexCount{0}; - // Conservative sigma_max of the model transform's linear part: the factor that carries an - // object-space deviation into world space. Computed once per caster, not per view. + // WHERE the caster is, and the scale that carries its object-space deviation into world space — + // one value, because they are one transform (see `ShadowCasterPose`). Read by selection (the + // scale) and by shadow-map caching (the matrix), which is why it must not be possible to supply + // one without the other. // - // Defaults to NaN, NOT to 0 or 1. Zero is a legitimate value — a singular transform really does - // flatten every deviation to nothing — so a producer that filled every other field and forgot - // this one would silently claim its caster has zero error and take the coarsest level in every - // view. NaN forces InvalidCaster instead, while an explicitly computed zero still selects - // normally. - float worldScale{std::numeric_limits::quiet_NaN()}; + // Unstated by default, and an unstated pose carries a NaN scale, NOT 0 or 1. Zero is a + // legitimate value — a singular transform really does flatten every deviation to nothing — so a + // producer that forgot this field would otherwise claim its caster has zero error and take the + // coarsest level in every view. NaN forces InvalidCaster instead, while an explicitly computed + // zero still selects normally. + ShadowCasterPose pose{}; // Identity for hysteresis. The generation is part of the key so a reloaded or replaced shadow // geometry cannot inherit the previous chain's dead band. ShadowCasterId casterId{ShadowCasterId::Invalid}; @@ -104,6 +163,12 @@ struct ShadowGeometryRequest // A request that can actually be resolved into a draw. False means the producer left it // unfilled — which must not silently become "full detail". + // + // The POSE is deliberately NOT part of this. A caster with no stated pose is still drawable — + // the whole mesh, reported as InvalidCaster, which is the same degraded answer a non-finite + // transform from a broken animation gets, and that path has to keep working. What an unstated + // pose breaks is the shadow CACHE (a default matrix compares equal forever), and that is + // checked where preparation builds the comparison, terminally. [[nodiscard]] bool valid() const noexcept { return baseIndexBuffer != NullBuffer && baseIndexCount > 0 && diff --git a/include/fire_engine/graphics/shadow_lod_resolver.hpp b/include/fire_engine/graphics/shadow_lod_resolver.hpp index 7d81309a..1afe5db4 100644 --- a/include/fire_engine/graphics/shadow_lod_resolver.hpp +++ b/include/fire_engine/graphics/shadow_lod_resolver.hpp @@ -104,34 +104,40 @@ class ShadowLodResolver // are not visible here, because they are not yet true. [[nodiscard]] std::size_t historyLevel(const ShadowLodStateKey& key) const noexcept; - // Records that `group`'s pass actually DREW this caster for this view. Called once per recorded - // draw, after the filter accepted it and the resolution produced geometry. + // Records that `group`'s map HOLDS this caster's geometry at this resolution. Called once per + // caster the family's prepared work includes, after the filter accepted it and the resolution + // produced geometry. // - // Membership is stamped onto the caster's EXISTING frame entry — a draw the resolver never + // CONTENT, not "drew this frame" — the distinction the shadow cache introduced. A view whose + // map was reused rasterised nothing, yet its image holds exactly this caster at exactly this + // level (that equality is why it was reused), and a consumer asking what the map contains must + // get the same answer either way. Attributing content to rasterisation would blank the LOD tint + // on every cached view and make a reused map look like an absent one. + // + // Membership is stamped onto the caster's EXISTING frame entry — a caster the resolver never // resolved cannot be marked, and is rejected rather than inventing an entry with no decision in // it. Provenance and resolution are two fields of one record for exactly that reason. // // Why it is needed at all: a cascade and its world-only twin share one logical view and - // therefore one resolution — that is the point, it makes them agree — but they do NOT draw the - // same casters, since world-only exists to exclude skinned ones. Cascades record first, so a - // consumer reading the level alone would report one for a caster the world-only pass never - // offered: the same "one view's answer presented as another's" failure this arc exists to - // remove. - void noteDrawn(ShadowViewGroup group, const ShadowLodStateKey& key) noexcept; - - // THE consumer's question: what `group`'s pass drew for this caster, or null if it drew nothing - // for it — culled, never offered, or drawn only by another family sharing this identity. + // therefore one resolution — that is the point, it makes them agree — but they do NOT contain + // the same casters, since world-only exists to exclude skinned ones. A consumer reading the + // level alone would report one for a caster the world-only map never held: the same "one view's + // answer presented as another's" failure this arc exists to remove. + void noteContent(ShadowViewGroup group, const ShadowLodStateKey& key) noexcept; + + // THE consumer's question: what `group`'s map holds for this caster, or null if it holds + // nothing for it — culled, never offered, or held only by another family sharing this identity. // - // Combined on purpose. Asking "which level" and "did this pass draw it" separately is asking a + // Combined on purpose. Asking "which level" and "is it in this map" separately is asking a // caller to remember the second, and forgetting it is invisible: the level is present and - // plausible, just from another pass. This returns a level ONLY when this family drew it. + // plausible, just from another pass. This returns a level ONLY when this family's map holds it. [[nodiscard]] const ResolvedShadowDraw* - drawnResolution(ShadowViewGroup group, const ShadowLodStateKey& key) const noexcept; + contentResolution(ShadowViewGroup group, const ShadowLodStateKey& key) const noexcept; // The shared DECISION for `key`, regardless of which families drew it — the entry the cache - // hands back to every view with this identity. Use `drawnResolution` to attribute it to a pass; - // this exists for callers reasoning about the decision itself (and for the tests that pin the - // sharing). Null means "never resolved this frame", never "level 0". + // hands back to every view with this identity. Use `contentResolution` to attribute it to a + // pass; this exists for callers reasoning about the decision itself (and for the tests that pin + // the sharing). Null means "never resolved this frame", never "level 0". [[nodiscard]] const ResolvedShadowDraw* frameResolution(const ShadowLodStateKey& key) const noexcept; @@ -164,17 +170,17 @@ class ShadowLodResolver // and re-deriving it costs a single frame of dead band on a caster that came back. static constexpr std::uint64_t kUnseenHistoryFrames = 120; - // ONE record per (caster, view) this frame: the decision, and which families acted on it. + // ONE record per (caster, view) this frame: the decision, and which families' maps HOLD it. // - // A second keyed container would let the two drift — provenance could exist for a caster with - // no decision, or outlive one — and every consumer would have to remember to consult both. As - // one entry, "drawn by this family" is a field of the decision, and marking a draw that was - // never resolved is impossible rather than merely wrong. + // A second keyed container would let the two drift — content could be attributed to a caster + // with no decision, or outlive one — and every consumer would have to remember to consult both. + // As one entry, "held by this family's map" is a field of the decision, and attributing content + // that was never resolved is impossible rather than merely wrong. struct FrameEntry { ResolvedShadowDraw resolved{}; // A bit per ShadowViewGroup. Small and fixed — the group count is a compile-time constant. - std::uint32_t drawnGroups{0}; + std::uint32_t contentGroups{0}; }; std::unordered_map frameCache_{}; diff --git a/include/fire_engine/graphics/shadow_pass_plan.hpp b/include/fire_engine/graphics/shadow_pass_plan.hpp new file mode 100644 index 00000000..8f81a591 --- /dev/null +++ b/include/fire_engine/graphics/shadow_pass_plan.hpp @@ -0,0 +1,490 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +// What a shadow view will RASTERISE this frame, described exactly enough to decide whether last +// frame's depth image is still the right answer (arc 2 #4 / §2.1). +// +// The whole difficulty of caching a shadow map is the comparison, not the skipping. A map may be +// reused only when every input that produced its pixels is unchanged, so this describes the draws +// in terms of the values that reach the GPU — the model matrix written to ShadowUBO, the resolved +// index buffer, the effective cull mode — and not in terms of the higher-level quantities that +// explain them. Two different transforms can share an AABB; a snapped cascade origin plus a +// near/far pair explains a matrix without being one; a LOD level names a choice without being the +// geometry that choice selected. Each of those would compare equal while rasterising different +// pixels. +// +// STRUCTURAL COMPARISON, not a hash. A 64-bit digest of this content would be a probabilistic +// correctness argument for a decision that silently produces a wrong image, and "wrong image" here +// means shadows from a frame that no longer exists. A hash may be added later as an ACCELERATOR in +// front of the equality test; it may never replace it. +// +// Vulkan-free, so the comparison and the disposition law are testable without a device — which is +// the only practical way to exercise the cases that matter (a moved caster, a re-fitted cascade, a +// swapped LOD carrier, a deformable in the set). + +namespace fire_engine +{ + +// One draw as it will be recorded. Every field here is either an input to the rasteriser or the +// identity of one; the diagnostic-only fields are called out below and excluded from equality. +struct PreparedShadowDraw +{ + // Identity, so a reloaded caster that happens to land the same matrix cannot pass as unchanged. + ShadowCasterId casterId{ShadowCasterId::Invalid}; + ShadowCasterGeneration generation{ShadowCasterGeneration::First}; + // The EXACT matrix the recorder writes into ShadowUBO. Not the caster's bounds and not its node + // transform: those are what produce this, and two of them can produce the same box. + // + // (The ShadowUBO buffer HANDLE is deliberately absent — it is a per-frame-ring handle, so + // identical content alternates handles every frame and would defeat the cache entirely.) + Mat4 model{}; + BufferHandle vertexBuffer{NullBuffer}; + // The RESOLVED carrier and count — what the LOD decision actually selected. Compared instead of + // trusting the level, because the level explains the choice while this is the geometry. + BufferHandle indexBuffer{NullBuffer}; + std::uint32_t indexCount{0}; + DrawIndexType indexType{DrawIndexType::UInt16}; + // SH-05: which fragment path rasterises this caster, and — for a MASKED one only — the material + // that path samples. The opaque path reads no material data at all, so the index is compared + // only once the alpha classes already agree and that class is `Masked`: two opaque variants of + // one mesh differing solely in material produce identical depth and must reuse. + // + // The material's SLOT CONTENT is immutable once registered (`registerMaterial` writes the + // packed block on first registration and dedups by identity), so the index fully describes it. + // If materials ever become mutable, a revision joins this struct. + ShadowCasterAlpha alpha{ShadowCasterAlpha::Opaque}; + std::uint32_t materialIndex{0}; + // The EFFECTIVE cull mode, already resolved against the family policy and the caster's + // sidedness — the value the recorder sets, from the same pure function it uses. + ShadowEffectiveCull cull{ShadowEffectiveCull::FrontFaces}; + // Poisons reuse for the whole view. A skinned, morph-capable or storage-vertex caster rewrites + // its vertices with no revision anything here can compare: same buffers, same matrix, different + // pixels. Arc 2 #5's deformation revision is what turns this exclusion into a comparison. + bool deformable{false}; + + // DIAGNOSTICS ONLY, excluded from equality (see `sameContent`). The level and reason describe + // the decision; `indexBuffer`/`indexCount` above describe its result, and it is the result that + // makes pixels. Comparing the level as well would reject a reuse that is genuinely identical + // (two levels resolving to one carrier), which is a correctness-neutral loss but a real one. + std::size_t level{0}; + ShadowLodReason reason{ShadowLodReason::Count}; + + // RECORDING PAYLOAD, also excluded from equality: the per-frame-ring buffer handles the + // recorder pushes as set 0. They alternate every frame for identical content — which is exactly + // why the content descriptor above cannot contain them — but the recorder still needs them, and + // carrying them here is what lets it consume prepared draws alone instead of reaching back to a + // DrawCommand and re-deriving what to draw. + BufferHandle shadowUbo{NullBuffer}; + BufferHandle skinUbo{NullBuffer}; + BufferHandle morphUbo{NullBuffer}; + BufferHandle morphSsbo{NullBuffer}; + + // Pixel-producing equality. Deliberately a named function rather than `operator==`, so nobody + // reaches for a defaulted comparison that would silently start including the diagnostic fields. + [[nodiscard]] bool sameContent(const PreparedShadowDraw& other) const noexcept; +}; + +// How a view's fragments produce the depth they store. NOT derivable from `viewProj`: a point face +// overwrites `gl_FragDepth` with a linear distance/range ratio computed from the light position and +// range in the push constants (`shaders/shadow_depth.glsl`), so two faces with identical matrices +// store different depth when the light moves or its range changes. +enum class ShadowDepthMode : std::uint8_t +{ + // Cascade / world-only / spot / self: fixed-function hardware depth from the projection. + Projected, + // Point faces: `length(worldPos - lightPos) / range`, clamped. The comparison sampler tests + // that same ratio, which is why the pass writes it rather than letting the face projection + // decide. + RadialRatio, +}; + +// The ONE derivation of a view's depth mode from its identity. `PreparedShadowView::depthMode()` is +// this, and so is the recorder's `radialDepth` push constant — a second mapping would let the +// comparison and the shader disagree about what a map holds. +[[nodiscard]] constexpr ShadowDepthMode shadowDepthModeFor(ShadowLogicalViewKind kind) noexcept +{ + return kind == ShadowLogicalViewKind::Point ? ShadowDepthMode::RadialRatio + : ShadowDepthMode::Projected; +} + +// The push-constant spelling of that mode (`ShadowPushConstants::radialDepth`). +[[nodiscard]] constexpr int shadowRadialDepthFlag(ShadowDepthMode mode) noexcept +{ + return mode == ShadowDepthMode::RadialRatio ? 1 : 0; +} + +// WHICH depth image of a view a layer fills. ONE enum, not a pass plus a target: the physical image +// and the fragment path are both functions of the family and this kind, so carrying them separately +// would make `SelfSecondDepth` writing image 0, or two layers claiming one image, expressible +// states that mean nothing. +enum class ShadowLayerKind : std::uint8_t +{ + // The view's depth map. Every family has exactly one, including the self-shadow FIRST layer, + // which captures the nearest light-facing surface. + Depth, + // Self-shadow only: the second depth layer, which samples the first and discards the surface it + // already recorded so the forward pass can sample the next useful occluder. A different + // fragment shader and a different cull, into a different image. + SelfSecondDepth, +}; + +// One depth image's worth of draws. Most views have exactly one layer; a self-shadow view has TWO — +// the same logical view rasterised into two images — which is why a layer exists at all rather than +// a view being a flat draw list. +// +// Its KIND is fixed when the view is created and the draws are the only thing that varies, so the +// topology of a view is a property of its identity rather than of the order somebody appended in. +struct PreparedShadowLayer +{ + ShadowLayerKind kind{ShadowLayerKind::Depth}; + std::vector draws{}; + + [[nodiscard]] bool sameContent(const PreparedShadowLayer& other) const noexcept; +}; + +// One view's prepared work: the transform and target it rasterises into, its per-view depth inputs, +// and the layers of draws in the order they will be recorded. +// +// ENCAPSULATED, like `ShadowView` and `ShadowLogicalViewId`, and for the same class of reason. As a +// public aggregate, a POINT face could be assembled with `Projected` depth: the comparison would +// then omit the light position and range while the shader still took its radial branch, so a moved +// light would keep a stale cube. The depth mode is therefore not a field at all — it is DERIVED +// from the logical view's kind, which the view set already guarantees matches the physical family +// it was written into. The factories enforce the other half: only a point identity may carry a +// light, and a point identity may not be prepared without one. +class PreparedShadowView +{ +public: + // Default is an INVALID view — never a usable one. Present so residency and containers can hold + // one, not so a caller can fill it in field by field. + PreparedShadowView() = default; + + // Fixed-function hardware depth: cascade, world-only, spot, self. Rejected (invalid result) for + // a point identity, which cannot store projected depth. + [[nodiscard]] static PreparedShadowView projected(ShadowLogicalViewId logicalId, + const Mat4& viewProj, std::uint32_t extent, + float depthBiasConstant, + float depthBiasSlope) noexcept; + // One cube face, storing `length(worldPos - lightPosition) / lightRange`. Rejected (invalid + // result) for any non-point identity: nothing else writes that ratio, so nothing else has a + // light to be compared against. + [[nodiscard]] static PreparedShadowView pointFace(ShadowLogicalViewId logicalId, + const Mat4& viewProj, std::uint32_t extent, + float depthBiasConstant, float depthBiasSlope, + Vec3 lightPosition, + float lightRange) noexcept; + + // False for a default-constructed value and for a factory given a mismatched identity. A plan + // containing one is a producer bug, not a degraded frame — the caller must refuse it. + [[nodiscard]] bool valid() const noexcept + { + return logicalId_.valid(); + } + // The MATRIX, not the fit that produced it. A cascade's snapped origin, extent and SH-06 + // caster-aware near/far all explain this value; reconstructing equivalence from them is a + // second derivation that can disagree with the one the GPU sees. + [[nodiscard]] const Mat4& viewProj() const noexcept + { + return viewProj_; + } + [[nodiscard]] std::uint32_t extent() const noexcept + { + return extent_; + } + [[nodiscard]] float depthBiasConstant() const noexcept + { + return depthBiasConstant_; + } + [[nodiscard]] float depthBiasSlope() const noexcept + { + return depthBiasSlope_; + } + // DERIVED from the identity, so "a point face prepared as projected" cannot be expressed. + [[nodiscard]] ShadowDepthMode depthMode() const noexcept + { + return shadowDepthModeFor(logicalId_.kind()); + } + // The light the stored ratio is measured against — a SHADER INPUT + // (`ShadowPushConstants::lightPosRange`), not a consequence of `viewProj`. Zero for every + // projected view, which carries none. + [[nodiscard]] const Vec3& lightPosition() const noexcept + { + return lightPosition_; + } + [[nodiscard]] float lightRange() const noexcept + { + return lightRange_; + } + // The logical view this content belongs to. A physical slot is reassigned between frames (dense + // per-family assignment in gather order), so residency keyed only by slot could match one + // light's content against another's. + [[nodiscard]] const ShadowLogicalViewId& logicalId() const noexcept + { + return logicalId_; + } + [[nodiscard]] std::span layers() const noexcept + { + return std::span{layers_.data(), layerCount_}; + } + // The FIRST layer's draws — the only layer every family has. A convenience for callers that are + // not the recorder (tests, diagnostics); the recorder walks `layers()` so a second layer cannot + // be silently skipped. + [[nodiscard]] std::span draws() const noexcept + { + return layerCount_ == 0 ? std::span{} + : std::span{layers_.front().draws}; + } + + // Appends to a layer this view HAS. Returns false if it has no layer of that kind — asking a + // cascade for its second self-shadow depth is a producer bug, not a draw to drop silently. + // + // There is no way to add a LAYER: topology comes from the identity at construction, so an empty + // view still has its layers and the recorder still clears them. A view whose layers appeared + // only when a draw did would let a first-use empty cascade be recorded, walk nothing, and leave + // the image's depth undefined while the plan called it sampleable. + [[nodiscard]] bool addDraw(ShadowLayerKind kind, const PreparedShadowDraw& draw); + // The common case: the view's own depth layer. + [[nodiscard]] bool addDraw(const PreparedShadowDraw& draw) + { + return addDraw(ShadowLayerKind::Depth, draw); + } + + // ORDER-SENSITIVE, and conservatively so. Depth-only rasterisation of these draws is + // order-independent in the image it produces, so a reordered but otherwise identical set would + // be a false miss — one wasted re-record, never a wrong image. Comparing order-insensitively + // would mean sorting or hashing per frame to save a case that does not arise: the draw order + // follows the scene's stable gather order. + [[nodiscard]] bool sameContent(const PreparedShadowView& other) const noexcept; + + // No draw may deform. One is enough to poison the view: its pixels change every frame with + // nothing here able to see it. + [[nodiscard]] bool cacheable() const noexcept; + +private: + // Gives the view the layers its identity implies — one, or two for self. Called by the + // factories, so there is no view in existence without them. + void buildLayers() noexcept; + + Mat4 viewProj_{}; + std::uint32_t extent_{0}; + float depthBiasConstant_{0.0f}; + float depthBiasSlope_{0.0f}; + Vec3 lightPosition_{}; + float lightRange_{0.0f}; + ShadowLogicalViewId logicalId_{}; + // FIXED CAPACITY, because the topology is: one layer, or two for self. An array keeps + // construction allocation-free, which is what lets the factories be honestly `noexcept` — a + // `noexcept` function that grows a vector terminates on a bad allocation instead of propagating + // it — and it drops a per-view allocation from every frame's preparation. + static constexpr std::size_t kMaxLayers = 2; + std::array layers_{}; + std::size_t layerCount_{0}; +}; + +// What a view does this frame. Three states, and deliberately NOT folded into `ShadowMapValidity` — +// that type answers the shader's question ("is this map safe to sample"), which stays Boolean. This +// answers the pass's question, which is a different one: a CSM with two cascades recorded and two +// reused is entirely valid and half the work. +enum class ShadowViewDisposition : std::uint8_t +{ + // Not engaged this frame, or engaged with nothing sampleable behind it. Nothing to record and + // nothing to sample. + Invalid, + // Resident content matches; the image already holds the right depth. Records nothing. + Reused, + // Records this frame, either because the content changed or because it can never be cached. + Recorded, +}; + +[[nodiscard]] std::string_view toString(ShadowViewDisposition disposition) noexcept; + +// The two derived questions. Everything downstream asks one of these rather than testing the +// enumerator, so "reused counts as sampleable" is stated once. +[[nodiscard]] constexpr bool shadowViewSampleable(ShadowViewDisposition disposition) noexcept +{ + return disposition == ShadowViewDisposition::Reused || + disposition == ShadowViewDisposition::Recorded; +} +[[nodiscard]] constexpr bool shadowViewRecords(ShadowViewDisposition disposition) noexcept +{ + return disposition == ShadowViewDisposition::Recorded; +} + +// One physical view slot's committed content — what its depth image actually holds. +// +// COMMITTED is the load-bearing word. A frame that prepared content and then never submitted (an +// out-of-date swapchain, a throw) must not leave a record claiming the image holds it, so content +// is staged during the frame and adopted only after the queue accepts the work — the same +// discipline the SH-03 hysteresis history already follows. +// +// Absence is STRUCTURAL: there is no content-plus-flag pair to get out of step, and no way to +// express "resident" with default-constructed content. An empty residency means the image's depth +// is undefined (creation transitions the layout but writes nothing), which is the one case where +// being in the right layout is not the same as holding an answer. +class ShadowViewResidency +{ +public: + [[nodiscard]] bool hasContent() const noexcept + { + return content_.has_value(); + } + // Null when nothing is committed. A pointer rather than a reference so the empty case has to be + // handled at the call site. + [[nodiscard]] const PreparedShadowView* content() const noexcept + { + return content_.has_value() ? &*content_ : nullptr; + } + // Adopt content AFTER the frame that recorded it was submitted — and only for a view whose + // disposition was `Recorded`. A `Reused` view did not touch its image, so committing its + // prepared work would replace the record of what the image holds with a description of a frame + // that never wrote to it. The two are equal in every compared field by construction (that is + // why it was reused), but not in the diagnostic ones, and the resident record must keep + // describing the recording that actually produced the depth. + void commit(PreparedShadowView content) noexcept + { + content_ = std::move(content); + } + // Forget it: the image was recreated or destroyed (a resize, a device loss), so whatever it + // held is gone. The owner of the images must call this, or a reuse would sample a fresh + // allocation's undefined depth. + void invalidate() noexcept + { + content_.reset(); + } + +private: + std::optional content_{}; +}; + +// The law. +// +// `active` is the view set's answer (SH-03): a slot the set reports inactive is Invalid regardless +// of what its image holds, because nothing this frame vouches for the matrix behind it. +[[nodiscard]] ShadowViewDisposition +shadowViewDisposition(bool active, const PreparedShadowView& prepared, + const ShadowViewResidency& resident) noexcept; + +// One frame's prepared work for every physical shadow view, plus what each will do. +// +// The single object the recorder receives. It exists so the pass has NOTHING else to consult: no +// draw spans to re-filter, no resolver to re-resolve against, no view set to re-read a matrix from. +// Whatever the comparison decided was the content is exactly what gets recorded, because it is the +// only description of the work that survives preparation. +// +// Indexed by the same physical `(group, slot)` as the diagnostics and the view set, so a row, a +// timing and a plan entry all name one view. +// TWO USES OF ONE LAW, in a fixed order (arc 2 #4). +// +// 1. ELIGIBILITY, derived from the view SET before anything is prepared. A family that cannot be +// sampled must not be prepared at all: preparation resolves casters and STAGES hysteresis, so a +// frame that prepared a suppressed family and was then submitted would commit dead-band +// decisions for views whose maps were neither recorded nor sampled. Deriving validity from the +// finished plan is too late to prevent that — by then the resolver has already been asked. +// +// 2. CONFIRMATION, derived from the finished PLAN and uploaded to the shader. What the receiver is +// told must describe the plan that was actually built, not the intention that preceded it: a +// view that failed to prepare (a rejected identity) has to remove its family's bit even though +// the family was eligible. +// +// CONFIRMATION NEEDS THE EXPECTED COUNTS, not just the achieved ones. Re-applying the eligibility +// law to the plan alone loses the information that matters for variable-size families: two active +// spots of which one prepared leaves `sampleableCount == 1`, which satisfies "any active slot", and +// the light that failed would sample stale depth. Twelve point faces of which six prepared still +// satisfy "a whole number of cubes". Validity is FAMILY-WIDE, so the question is not "is this a +// plausible family" but "did every view this family was eligible for actually make it". +// +// This snapshot is taken from the view SET before preparation, and is the thing preparation is +// judged against afterwards. +struct ShadowFamilyEligibility +{ + bool shadowsDisabled{false}; + bool primaryDirectionalLight{false}; + // Active views per family, indexed by ShadowViewGroup — what preparation is expected to + // produce. + std::array activeViews{}; + + // The eligibility answer itself: which families may be prepared at all. Preparation must + // consult this BEFORE resolving anything, because resolving stages hysteresis for views that a + // suppressed family will never record or sample. + [[nodiscard]] ShadowMapValidity eligible() const noexcept; +}; + +// What the receiver is told, derived from the finished plan and judged against the eligibility that +// authorised it. A family is valid only when it was eligible AND every view it was eligible for is +// sampleable in the plan. +[[nodiscard]] ShadowMapValidity +shadowMapValidityFromPlan(const class ShadowFramePlan& plan, + const ShadowFamilyEligibility& eligibility) noexcept; + +class ShadowFramePlan +{ +public: + // Start a frame: every slot unclaimed, every entry empty. + // + // This does NOT preserve the draw vectors' capacity — the entries are cleared, so each frame's + // views allocate again. The cost is bounded by the number of physical views (a few dozen small + // vectors per frame) and is not currently measured; if it ever shows up, the fix is for the + // plan to own the storage and hand a slot out to be filled in place, not to cache capacity + // behind a reset that claims more than it does. + void reset() noexcept; + + // Record one view's prepared work and its disposition. Returns FALSE for an invalid prepared + // view (a factory given a mismatched identity), an out-of-range slot, or a slot ALREADY CLAIMED + // this frame — each a producer bug the caller must refuse, not a frame to degrade through, + // exactly as a rejected view-set write is. + [[nodiscard]] bool add(ShadowViewGroup group, std::size_t slot, PreparedShadowView view, + ShadowViewDisposition disposition); + + // Null when this slot has no entry this frame. + [[nodiscard]] const PreparedShadowView* view(ShadowViewGroup group, + std::size_t slot) const noexcept; + // `Invalid` for a slot with no entry — absent and inactive are the same answer to the pass. + [[nodiscard]] ShadowViewDisposition disposition(ShadowViewGroup group, + std::size_t slot) const noexcept; + + // How many of a family's slots hold content that may be SAMPLED (recorded or reused). This is + // what `ShadowMapValidity` consumes: a CSM with two cascades recorded and two reused is fully + // valid, so counting only the recorded ones would blank the family in the shader. + [[nodiscard]] std::size_t sampleableCount(ShadowViewGroup group) const noexcept; + // Whether any of a family's slots RECORDS. Drives the family's timestamps: a family that reuses + // everything does no GPU work and must not open a timing span around nothing. + [[nodiscard]] bool records(ShadowViewGroup group) const noexcept; + // Whether the whole frame records nothing — the pass can then return immediately. + [[nodiscard]] bool recordsNothing() const noexcept; + + // Every physical point cube either has all six faces sampleable or none of them. A count alone + // cannot see a half-prepared cube beside a whole one, and half a cube is a light whose shadow + // depends on which way the receiver faces. + [[nodiscard]] bool pointCubesWhole() const noexcept; + +private: + struct Entry + { + PreparedShadowView view{}; + ShadowViewDisposition disposition{ShadowViewDisposition::Invalid}; + // Separate from `disposition`, because an Invalid claim is still a claim: without this, a + // producer that legitimately claimed a slot as Invalid could be silently overwritten by a + // second one, which is the case a plain "is it still default?" check would miss. + bool claimed{false}; + }; + std::array entries_{}; +}; + +} // namespace fire_engine diff --git a/include/fire_engine/graphics/shadow_pass_prepare.hpp b/include/fire_engine/graphics/shadow_pass_prepare.hpp new file mode 100644 index 00000000..8c0e0f5d --- /dev/null +++ b/include/fire_engine/graphics/shadow_pass_prepare.hpp @@ -0,0 +1,99 @@ +#pragma once + +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +// Turning a frame's shadow casters into a `ShadowFramePlan` (arc 2 #4 / §2.1). +// +// This is the half of the shadow pass that decides; `render/shadows.cpp` is the half that records. +// Everything that used to happen inside recording — the per-view filter, the LOD resolution, the +// diagnostic row claim and observations — happens HERE, because a shadow map can only be reused if +// the frame knows what it would have drawn BEFORE deciding whether to draw it. A recorder that +// still resolved as it went could not answer that question without doing the work it was trying to +// avoid. +// +// Vulkan-free and therefore headless-testable, which is the point: the cases that matter (a caster +// the filter drops, a family that must not be prepared at all, a self-shadow view's two layers) are +// exercised without a device. +// +// It MUTATES three things, all of them per-frame state the plan is derived from: +// +// * the RESOLVER — each accepted caster is resolved through it, so its frame cache and its STAGED +// hysteresis fill up here rather than during recording. The staged levels are still committed +// only after the frame is submitted; moving selection earlier does not move that boundary. +// * the STATS — preparation claims each row (naming the logical view it describes) and observes +// every draw it walks. Raster passes stay with the recorder, where the GPU work is. +// * the PLAN — the single object recording then consumes. + +namespace fire_engine +{ + +// One family's raster parameters. They belong to the plan's comparison (both reach the rasteriser: +// the extent is the viewport and scissor, the biases are `vkCmdSetDepthBias`), but they live in +// `render/constants.hpp`, which the Vulkan-free graphics layer cannot see — so the caller supplies +// them and preparation records them into each prepared view. +struct ShadowFamilyRaster +{ + std::uint32_t extent{0}; + float depthBiasConstant{0.0f}; + float depthBiasSlope{0.0f}; +}; + +// What preparation reads. Everything else it needs comes from the view SET, which is the authority +// on which physical views exist this frame and what each rasterises with. +// +// There is deliberately no "active spot count" or "active self count" here. Those were bounds the +// recorder carried beside the set, and a caller whose count disagreed with the set would either +// skip a view the set says is active or walk one it says is not. Preparation iterates every +// physical slot and asks the set, which is what makes "absent means inactive" true at the point of +// use. +struct ShadowPreparationInputs +{ + // Every shadow caster in the frame — what the cascade, spot and point families each filter for + // themselves. + std::span shadowDraws{}; + // The same set minus skinned casters, for the world-only CSM that self-shadowed meshes sample. + std::span worldOnlyShadowDraws{}; + // Casters carrying a self-shadow slot; each self view keeps the one draw that names its slot. + std::span selfShadowDraws{}; + + float lodBudgetTexels{0.0f}; + ShadowLodHysteresis hysteresis{}; + // When false no frustum is built, and every family's filter passes everything through — the + // `--no-cull` path, which must produce the same IMAGE with more draws. + bool cullingEnabled{true}; + + // Indexed by ShadowViewGroup. + std::array raster{}; +}; + +// Builds `plan` from the completed view set. +// +// `eligible` is the ELIGIBILITY answer (`ShadowFamilyEligibility::eligible()`), taken from the view +// set BEFORE this call. A family whose bit is clear is not prepared at all — not filtered, not +// resolved, not claimed — because resolving stages hysteresis, and a suppressed family's staged +// decisions would be committed for views whose maps are neither recorded nor sampled. The caller +// then derives the CONFIRMED validity from the finished plan (`shadowMapValidityFromPlan`), which +// is what the receiver is told. +// +// `plan` is reset first, so a caller cannot accumulate two frames into one. +// +// THROWS on a contradiction between what the frame thinks it is preparing and what the shadow state +// says — a diagnostic row claimed by two logical views, a caster that resolves to no geometry, a +// caster with no stated pose, or a prepared view the plan refuses. Each is corrupt render input or +// a producer bug, and both ways of continuing are worse than stopping: dropping the view leaves a +// shadow map holding another frame's content with nothing to say so, and degrading through it +// produces counters that read like a measurement and are not. +void prepareShadowFrame(const ShadowPreparationInputs& inputs, const ShadowRenderViewSet& views, + ShadowMapValidity eligible, ShadowLodResolver& resolver, + ShadowFrameStats& stats, ShadowFramePlan& plan); + +} // namespace fire_engine diff --git a/include/fire_engine/graphics/shadow_render_view.hpp b/include/fire_engine/graphics/shadow_render_view.hpp index 2ad2e85b..45f121fd 100644 --- a/include/fire_engine/graphics/shadow_render_view.hpp +++ b/include/fire_engine/graphics/shadow_render_view.hpp @@ -97,6 +97,20 @@ class ShadowViewMetrics ShadowViewMetricsKind kind_{ShadowViewMetricsKind::Orthographic}; }; +// What a POINT face's stored depth is measured against: the two values the pass pushes as +// `ShadowPushConstants::lightPosRange`, and which the forward compare sampler tests the same ratio +// against. +// +// Not a consequence of the face's matrix. A point face overwrites `gl_FragDepth` with +// `length(worldPos - position) / range` (`shaders/shadow_depth.glsl`), so two faces with identical +// matrices store different depth once the light moves or its range changes — which makes these +// raster CONTENT, and part of what decides whether a cached cube still holds the right pixels. +struct ShadowPointLightDepth +{ + Vec3 position{}; + float range{0.0f}; +}; + // One shadow view: the matrix the pass rasterises with, the projection descriptor LOD selection // reasons about, the fitted bias metrics the receiver converts with (SH-07), and the stable logical // identity hysteresis keys on (SH-03). @@ -130,19 +144,39 @@ class ShadowRenderView { return biasMetrics_; } + // The light a POINT face's radial depth is measured against, or nothing for every other family. + // + // Returned together and only for the family that has them, rather than exposed as two + // always-present fields: a cascade carries no light, and a zero position with a zero range is a + // value a caller could read and push. The POSITION comes from the face's own projection + // descriptor — the one `ShadowView::perspective` was built with, which is also what LOD + // selection measures depth from — so the pass and the selector cannot be looking at two + // different lights. + [[nodiscard]] std::optional pointLightDepth() const noexcept + { + if (logicalId_.kind() != ShadowLogicalViewKind::Point) + { + return std::nullopt; + } + return ShadowPointLightDepth{projection_.lightPosition(), pointLightRange_}; + } private: // Only the set constructs these, through its family writers, so no call site can assemble an // entry whose identity or projection kind contradicts the slot it lands in. friend class ShadowRenderViewSet; + // `pointLightRange` is POINT-ONLY depth data and zero everywhere else. It is a constructor + // argument rather than a later assignment for the reason the class is immutable at all: a range + // installed after the fact could be installed for the wrong face, or forgotten for one of six. ShadowRenderView(const Mat4& viewProj, const ShadowView& projection, - const ShadowViewMetrics& biasMetrics, - const ShadowLogicalViewId& logicalId) noexcept + const ShadowViewMetrics& biasMetrics, const ShadowLogicalViewId& logicalId, + float pointLightRange) noexcept : viewProj_{viewProj}, projection_{projection}, biasMetrics_{biasMetrics}, - logicalId_{logicalId} + logicalId_{logicalId}, + pointLightRange_{pointLightRange} { } @@ -150,6 +184,7 @@ class ShadowRenderView ShadowView projection_; ShadowViewMetrics biasMetrics_; ShadowLogicalViewId logicalId_; + float pointLightRange_{0.0f}; }; // The per-frame set of shadow views, addressed by PHYSICAL (group, slot) — the same addressing @@ -231,9 +266,17 @@ class ShadowRenderViewSet // that fails, all six slots are cleared and the call returns false — a five-face cube is not a // usable caster, and the "all six or none" contract was previously only a comment in the // renderer. + // + // `range` is the EFFECTIVE range the faces were projected with (the far plane, including the + // infinite-range fallback), which is what the pass measures radial depth against. It lives here + // rather than in a parallel array beside the set because it is raster content: it reaches the + // shader, and a cached cube is only reusable if it is unchanged. It must be finite and + // positive, and ALL SIX faces must report the same light position — the six descriptors are + // built from one light, so a disagreement means the caller assembled the cube from more than + // one, and half a cube's depth would then be measured against the wrong origin. [[nodiscard]] bool setPointLight(std::size_t lightSlot, NodeId light, const ShadowViewMetrics& biasMetrics, - std::span faces) noexcept; + float range, std::span faces) noexcept; // The view at a physical slot, or null when the slot is inactive or out of range. [[nodiscard]] const ShadowRenderView* find(ShadowViewGroup group, @@ -277,15 +320,9 @@ class ShadowRenderViewSet // The mapping is explicit per family, not a loop over all entries, because the destinations differ // in both layout and meaning: a generic copy would put a spot matrix in a point slot. -// The ShadowUBO / push-constant matrix array: cascades at kShadowCascadeMatrixBase, spots at -// kShadowSpotMatrixBase, point faces at kShadowPointMatrixBase + the flat slot. Inactive slots are -// identity. Cascades are MANDATORY (the directional pass always runs) and their absence asserts; -// punctual and self slots are legitimately inactive. -// -// World-only contributes no slot: it rasterises with its cascade's matrix, and its entry IS that -// cascade's entry, so there is never a second value to reconcile. -[[nodiscard]] std::array(kShadowTotalMatrixCount)> -shadowMatrixArray(const ShadowRenderViewSet& views) noexcept; +// (There is no combined matrix array any more. Every shadow path rasterises with the matrix in its +// view's push constants, taken from the entry the pass is recording, so nothing needs a table of +// every view's transform — and nothing can index the wrong row of one.) // LightUBO::cascadeViewProj — the forward shader's directional lookup. [[nodiscard]] std::array diff --git a/include/fire_engine/render/descriptors.hpp b/include/fire_engine/render/descriptors.hpp index 525d692a..5d3182fa 100644 --- a/include/fire_engine/render/descriptors.hpp +++ b/include/fire_engine/render/descriptors.hpp @@ -194,12 +194,18 @@ void pushForwardObjectDescriptors(vk::CommandBuffer cmd, const Resources& resour vk::PipelineLayout layout, const DrawCommand& dc); // Pushes a shadow draw's per-object set 0 inline via core 1.4 push descriptors — no -// allocated descriptor set. Bindings 0..3 are the per-object ShadowUBO + the -// skin/morph UBOs + morph SSBO carried on the DrawCommand; bindings 4/5 are the -// shared self-shadow first-depth image + sampler, read straight from Resources -// (global, identical for every shadow draw). `layout` is the shadow pipeline -// layout (set 0 is a push layout). +// allocated descriptor set. Bindings 0..3 are the four per-object buffers passed here +// (ShadowUBO + skin/morph UBOs + morph SSBO); bindings 4/5 are the shared self-shadow +// first-depth image + sampler, read straight from Resources (global, identical for every +// shadow draw). `layout` is the shadow pipeline layout (set 0 is a push layout). +// +// The four handles arrive as arguments rather than on a DrawCommand because the shadow pass no +// longer records from commands: it records from a `PreparedShadowDraw`, which carries exactly these +// four as its recording payload (arc 2 #4). They are per-frame-ring handles, which is why they are +// deliberately not part of what the shadow cache compares. void pushShadowObjectDescriptors(vk::CommandBuffer cmd, const Resources& resources, - vk::PipelineLayout layout, const DrawCommand& dc); + vk::PipelineLayout layout, BufferHandle shadowUbo, + BufferHandle skinUbo, BufferHandle morphUbo, + BufferHandle morphSsbo); } // namespace fire_engine diff --git a/include/fire_engine/render/renderer.hpp b/include/fire_engine/render/renderer.hpp index e44422ce..6aa5d1ba 100644 --- a/include/fire_engine/render/renderer.hpp +++ b/include/fire_engine/render/renderer.hpp @@ -18,6 +18,7 @@ #include #include #include +#include #include #include #include @@ -291,17 +292,27 @@ class Renderer // spot caster cap is hit. void assignSpotShadow(LightUBO& out, int packedSlot, const Lighting& light); // Registers the packed light as a point caster if there is room (advancing - // activePointCasters_ and pointCasters_) and populates its six cube-face - // slots in shadowViews_. No-op if the point caster cap is hit. + // activePointCasters_) and populates its six cube-face slots in shadowViews_ — including the + // light's position and effective range, which the faces store depth against. No-op if the point + // caster cap is hit. void assignPointShadow(LightUBO& out, int packedSlot, const Lighting& light); // Fills out.iblParams / out.shadowParams / out.environmentParams from the // engine-wide constants plus the debug-flag members. void writeIblAndDebugParams(LightUBO& out) const; void assignSelfShadowSlots(std::span drawCommands); - // Derives this frame's shadow-map validity from the COMPLETED view set and performs the single - // per-frame LightUBO upload. Must run after every view producer — cascades, punctual, self - // layers, and the world-only enablement that only `anySkinned` decides — because the mask it - // uploads and the families the shadow pass records are the same value. + // Builds this frame's shadow plan (arc 2 #4): what every physical shadow view will rasterise, + // and what each of them does. Must run after every view producer — cascades, punctual, self + // layers, and the world-only enablement that only `anySkinned` decides — and BEFORE + // `uploadFrameLighting`, because the mask that upload writes is derived from the finished plan. + // + // ELIGIBILITY first, from the completed set: a family that cannot be sampled is not prepared at + // all, since preparation resolves casters and stages hysteresis. CONFIRMATION second, from the + // plan that was actually built — a view that failed to prepare removes its family's bit even + // though the family was eligible. + void prepareShadowPlan(const DrawBuckets& buckets); + // Performs the single per-frame LightUBO upload, carrying the shadow-map validity + // `prepareShadowPlan` confirmed. The mask it uploads and the views the shadow pass records are + // the same plan. void uploadFrameLighting(); // Reports which shadow families the completed ring slot recorded, with their raster counts and // GPU time. This is how "`--no-shadows` suppresses RECORDING" is checked: a frame that still @@ -317,13 +328,16 @@ class Renderer void updateFrameLighting(RenderableScene& scene, Vec3 cameraPosition, Vec3 cameraTarget); [[nodiscard]] const DrawBuckets& collectDrawCommands(RenderableScene& scene, Vec3 cameraPosition, Vec3 cameraTarget); - void recordShadowPass(vk::CommandBuffer cmd, const DrawBuckets& buckets); + // Records the plan `prepareShadowPlan` built. Takes no buckets and no view set: since arc 2 #4 + // the pass records from the plan alone. + void recordShadowPass(vk::CommandBuffer cmd); // SH-03 slice 5: colour the ShadowLod debug view by the level the FOCUSED shadow view chose. // - // Runs BETWEEN the shadow pass and the forward pass, which is the only window in which the - // answer exists: the levels are decided per view during shadow recording, and the forward - // draws' push constants are written afterwards. It patches this frame's forward buckets, so it - // must be called after recordShadowPass and before recordForwardPass / recordDepthPrepass. + // Runs during COLLECTION, right after the plan is prepared — which is when the answer starts to + // exist. The levels used to be decided during shadow recording, so the tint had to sit between + // the shadow pass and the forward pass; now preparation resolves them before anything is + // recorded, and the tint patches this frame's forward buckets while they are still being built. + // It must still run before recordForwardPass / recordDepthPrepass write their push constants. // // A no-op unless the ShadowLod view is active — the tint is the only consumer, and walking the // buckets to compute a value nothing reads would be pure cost. @@ -505,9 +519,13 @@ class Renderer // twice: it gates the families in `Shadows::recordPass` and it is uploaded in // `LightUBO::shadowMapValidMask` for the receiver. See graphics/shadow_map_validity.hpp. ShadowMapValidity shadowMapValidity_{}; + // This frame's prepared shadow work (arc 2 #4): every view's transform and draws, and what each + // does. Built in collection from the completed view set, consumed by the pass, and the ONLY + // description of the shadow work that survives preparation — which is what lets the recorder + // hold no draw spans, no view set and no resolver. + ShadowFramePlan shadowPlan_{}; int activeSpotCasters_{0}; int activePointCasters_{0}; - std::array pointCasters_{}; // Timeline-semaphore frame pacing. timelineValue_ is the last value signalled; // frameTimelineValue_[slot] is the value the last submit using that // frame-in-flight slot signalled (gate cmd-buffer / per-frame-UBO reuse); diff --git a/include/fire_engine/render/shadows.hpp b/include/fire_engine/render/shadows.hpp index e283a1b5..5999f248 100644 --- a/include/fire_engine/render/shadows.hpp +++ b/include/fire_engine/render/shadows.hpp @@ -1,13 +1,11 @@ #pragma once -#include -#include - -#include -#include -#include -#include -#include +#include + +#include +#include +#include +#include #include #include #include @@ -18,33 +16,15 @@ namespace fire_engine class Device; -// Per-frame state for one active point shadow caster — the renderer hands one -// of these to recordPass for every point light that earned a shadow slot, so -// the shadow fragment shader can compute linear distance/range against the -// light's world position. -struct PointShadowCaster -{ - Vec3 worldPosition{}; - float range{0.0f}; -}; +// (There is no `PointShadowCaster` any more. A point light's world position and effective range are +// raster content of its six faces, so they live in the view set beside the matrices they belong to +// — `ShadowRenderView::pointLightDepth()` — and reach the pass inside the prepared view. The array +// that used to carry them beside the set was a second copy of a position the set already held, and +// the pass found it by arithmetic on the face slot.) -// SH-05: which faces one shadow family keeps. A property of the PASS, not of the pipeline: the -// shadow pipelines declare cull mode dynamic, so this is set at record time and every family must -// name its policy — there is no static fallback to inherit if one forgets. -enum class ShadowFaceCull : std::uint8_t -{ - // Cascade / spot / point: the CASTER decides. Single-sided casters cull front faces (back faces - // carry the depth, which is what keeps receiver acne off); a double-sided material culls - // nothing, because front-culling a sheet authored face-on to the light discards the only faces - // it has and it casts no shadow at all. - PerCaster, - // Self-shadow FIRST layer: keep everything, so the first light-facing surface is captured - // whatever its winding. Was its own pipeline before SH-05 made cull mode dynamic. - AllFaces, - // Self-shadow SECOND layer: cull front faces so only back faces rasterise, which is what makes - // the dual-depth rejection well-founded rather than a coin-flip on marginal fragments. - BackFacesOnly, -}; +// `ShadowFaceCull` and `shadowEffectiveCull` now live in `graphics/shadow_face_cull.hpp` — the +// policy is Vulkan-free and the cache's content descriptor has to record the effective answer, so +// the mapping cannot live behind a Vulkan type. This header keeps only the translation to Vulkan. // SH-05: one shadow family's two fragment paths. A draw picks between them by its caster's alpha // classification, so the pair travels together — a recording site that could be handed the opaque @@ -64,30 +44,24 @@ struct ShadowPipelinePair } }; -// SH-05: the faces one draw keeps — the family's policy, resolved against the caster's own -// sidedness for the families where the caster decides. -// -// Pure, and public for the same reason `forCaster` is: every shadow pipeline declares cull mode -// dynamic, so this function IS the cull policy, and swapping two of its answers would silently -// restore the defect the item fixed (a double-sided sheet front-culled into casting nothing) or -// break the dual-depth self-shadow layer. Takes the caster's `doubleSided` flag rather than a -// DrawCommand so the mapping can be exercised exhaustively without building a draw. +// The Vulkan spelling of one effective cull answer. The POLICY decision is +// `shadowEffectiveCull` (graphics/shadow_face_cull.hpp) and this is only its translation, so there +// is one place that decides and one place that speaks Vulkan — the cache's content descriptor +// records the same effective value this converts, rather than a parallel derivation of it. +[[nodiscard]] constexpr vk::CullModeFlags shadowCullMode(ShadowEffectiveCull cull) noexcept +{ + return cull == ShadowEffectiveCull::None ? vk::CullModeFlagBits::eNone + : vk::CullModeFlagBits::eFront; +} + +// Convenience for the recorder, which holds the family policy and the caster's sidedness: one call +// instead of nesting the two. Pinned by tests/render/test_shadow_raster_policy.cpp, which is what +// keeps a reversed answer from silently restoring the defect SH-05 fixed (a double-sided sheet +// front-culled into casting nothing) or breaking the dual-depth self-shadow layer. [[nodiscard]] constexpr vk::CullModeFlags shadowCullMode(ShadowFaceCull policy, bool casterIsDoubleSided) noexcept { - switch (policy) - { - case ShadowFaceCull::PerCaster: - // A double-sided caster culls NOTHING. Front-culling one authored face-on to the light - // discards the only faces it has, and it casts no shadow at all. - return casterIsDoubleSided ? vk::CullModeFlagBits::eNone : vk::CullModeFlagBits::eFront; - case ShadowFaceCull::AllFaces: - return vk::CullModeFlagBits::eNone; - case ShadowFaceCull::BackFacesOnly: - return vk::CullModeFlagBits::eFront; - } - // Unreachable for a valid policy; the switch is exhaustive over the enum. - return vk::CullModeFlagBits::eFront; + return shadowCullMode(shadowEffectiveCull(policy, casterIsDoubleSided)); } class Shadows @@ -112,50 +86,52 @@ class Shadows return shadowPipelines_.opaque; } - // `views` is the frame's shadow view set and the ONLY source of each iteration's transform - // (SH-03): every iteration takes one mandatory ShadowRenderView and uses its matrix to cull, - // its projection descriptor to select a LOD, and its logical identity to key hysteresis. A - // physical slot the set reports inactive is not rasterised — there is no second opinion to - // consult, which is what makes "absent means inactive" true at the point of use. - // - // `resolver` is MUTATED: each accepted caster is resolved through it (per-frame cache + staged - // hysteresis), so a caster's level is decided per VIEW rather than replayed from the camera. - // Resolution happens AFTER the per-view filter, so a caster this view rejects acquires no - // history against it. The caller commits or discards the staged history once the frame's fate - // is known. + // Records the frame's shadow work — and NOTHING ELSE decides what that work is (arc 2 #4). // - // `activeSelfShadowCasters` bounds the self-shadow slot loop (slots are assigned densely, and - // an unassigned slot's layers are never sampled — no fragment carries that slot index — so they - // need no clear). + // `plan` is the whole input. It carries every view's transform, extent, depth bias, depth mode + // and light, the draws each of its layers rasterises in order, and what each view DOES this + // frame. There is deliberately no draw span, no view set and no resolver here any more: the + // decisions were all made in `prepareShadowFrame`, and a recorder that could still re-filter or + // re-resolve would be a second answer to a question the cache has already answered. Whatever + // the comparison decided was the content is exactly what gets recorded, because it is the only + // description of the work that survives preparation. // - // `validity` decides WHICH FAMILIES RECORD, and it is the same value the receiver read in - // `LightUBO::shadowMapValidMask`. A family whose bit is clear draws nothing, clears nothing and - // stamps no timestamp, so its diagnostic rows and its GPU time both stay at zero — that is the - // honest report, since the views were not rasterised. Re-enabling is safe in the same frame: - // this pass runs before anything samples a map, so the frame that turns a family back on - // re-renders it before its first read. What makes SKIPPING safe is the other half of the same - // value: the receiver is told the family is invalid and answers fully lit, rather than sampling - // depth left behind by whichever frame last rendered it. + // WHICH VIEWS RECORD is each entry's disposition. A view marked `Reused` rasterises nothing — + // its image already holds the right depth — and a family with nothing to record stamps no + // timestamp, so its GPU time reads zero rather than measuring an empty span. A family the plan + // never prepared (suppressed by `--no-shadows`, or fitted to no light) draws nothing, clears + // nothing and times nothing, and the receiver was told the same thing through + // `LightUBO::shadowMapValidMask`, which is derived from this same plan. // - // `stats` (SH-01) is MUTATED: every iteration marks its view rasterised and observes every - // command it walks, so a view that renders nothing is still reported. Rows are keyed by - // PHYSICAL slot, which is stable across frames only while the dense light assignment is. The - // observed verdict is the FILTER's alone, which is what keeps `candidateDraws - drawnDraws` - // exactly the filter's yield. + // `stats` (SH-01) is MUTATED, but only with RASTER PASSES: preparation already claimed each row + // and observed every draw it walked. The identity is re-checked against the claim at every + // layer, so a recorder that rasterised view B into the row view A claimed is refused rather + // than reported under A's name. // - // THROWS if an accepted caster resolves to no geometry — a corrupt unresolved command, which is - // neither skippable (the caster would vanish from one shadow map silently) nor reportable as a - // cull. Every recoverable case resolves to the whole mesh instead. - void recordPass(vk::CommandBuffer cmd, std::span shadowDraws, - std::span worldOnlyShadowDraws, - std::span selfShadowDraws, int activeSelfShadowCasters, - int activeSpotCasters, std::span pointCasters, - const ShadowRenderViewSet& views, ShadowLodResolver& resolver, - float lodBudgetTexels, ShadowLodHysteresis hysteresis, bool cullingEnabled, - ShadowMapValidity validity, ShadowFrameStats& stats, + // THROWS if a recorded view's row was never claimed or holds a different identity — a + // contradiction between the plan and the diagnostics, which is not a degraded frame. + void recordPass(vk::CommandBuffer cmd, const ShadowFramePlan& plan, ShadowFrameStats& stats, const GpuProfiler& profiler, uint32_t frameIndex) const; private: + // Where one prepared layer rasterises: the depth image, the single-layer attachment view, the + // array layer its barriers target, and the fragment paths that write it. + // + // Resolved as ONE value from (family, layer kind) rather than four lookups at the call site. + // The self-shadow pair is why: its two layers differ in image AND in pipelines, and a call site + // free to pick them separately could bind the second layer's shaders to the first layer's image + // — which rasterises the dual-depth rejection into the map it is meant to be sampling, with + // every counter and timing still reading correctly. + struct LayerTarget + { + vk::Image image{}; + vk::ImageView view{}; + uint32_t layer{0}; + ShadowPipelinePair pipelines{}; + }; + [[nodiscard]] LayerTarget layerTarget(ShadowViewGroup group, std::size_t slot, + ShadowLayerKind kind) const; + Resources* resources_{nullptr}; Pipeline shadowPipeline_; Pipeline shadowMaskedPipeline_; diff --git a/include/fire_engine/render/ubo.hpp b/include/fire_engine/render/ubo.hpp index a70d48ac..ca2f16a4 100644 --- a/include/fire_engine/render/ubo.hpp +++ b/include/fire_engine/render/ubo.hpp @@ -313,22 +313,44 @@ struct EnvironmentPrefilterPushConstants float _pad2{0.0f}; }; -// Shadow matrix layout (kShadowCascadeMatrixBase / kShadowSpotMatrixBase / -// kShadowPointMatrixBase / kShadowTotalMatrixCount) lives in -// graphics/gpu_limits.hpp — the graphics-side FrameInfo sizes an array to match -// ShadowUBO::lightViewProj, so the count must be visible without including -// render/. +// PER-OBJECT ONLY. It used to carry a 32-matrix table of every shadow view in the frame, indexed +// per draw by a push constant — 2 KB written into every shadow object's buffer and bound at every +// draw, of which one matrix was read. The push block already carried a matrix for the self-shadow +// path, so every path now uses THAT one: the view's matrix is a property of the view being +// recorded, not a row a draw looks up. +// +// That also removed the last parallel authority on the shadow transform. A cached shadow map is +// only reusable if the matrix it was rasterised with is the matrix being compared, and while the +// table existed the comparison described one value and the GPU read another. struct ShadowUBO { alignas(16) Mat4 model; - alignas(16) Mat4 lightViewProj[kShadowTotalMatrixCount]; alignas(4) int hasSkin{0}; }; +// EXACT, like every other shader-visible block here. The array's removal changed this layout, and +// "model comes before hasSkin, and the whole thing is small" would not catch `hasSkin` drifting off +// the std140 offset the shader reads it from — the failure mode being a skinned caster rasterised +// unskinned (or the reverse) with no error anywhere. +static_assert(offsetof(ShadowUBO, model) == 0, "ShadowUBO std140 layout"); +static_assert(offsetof(ShadowUBO, hasSkin) == 64, "ShadowUBO std140 layout"); +static_assert(sizeof(ShadowUBO) == 80, "ShadowUBO std140 size (mat4 + int, rounded to 16)"); +static_assert(alignof(ShadowUBO) == 16, "std140 blocks are 16-byte aligned"); +// offsetof is only defined for standard-layout types, and the struct is memcpy'd into mapped GPU +// memory by writeMapped — both properties are load-bearing, not incidental. +static_assert(std::is_standard_layout_v); +static_assert(std::is_trivially_copyable_v); + struct ShadowPushConstants { - // Selects which lightViewProj[] matrix the vertex shader uses. - alignas(4) int matrixIndex{0}; + // How this view's fragments produce their stored depth: 0 = projected hardware depth, + // 1 = the radial distance/range ratio a point face writes (`shaders/shadow_depth.glsl`). + // + // It was a matrix-table index, and the point path discriminated on "index >= the point base" — + // a depth mode inferred from where a matrix happened to live. Now it says what it means, and + // the value comes from `PreparedShadowView::depthMode()`, which derives it from the view's + // identity. + alignas(4) int radialDepth{0}; // Per-skinned-object self-shadow layer for the dual-depth self pass. alignas(4) int selfShadowSlot{-1}; // Normalized-depth gap required before a fragment counts as the second @@ -339,12 +361,13 @@ struct ShadowPushConstants // surface is actually shaded with. Occupies what was explicit padding, so every offset around // it is unchanged. Read only by the masked fragment paths; the opaque ones ignore it. alignas(4) std::uint32_t materialIndex{0}; - // Point shadow (matrixIndex >= kShadowPointMatrixBase): xyz = light - // world position, w = effective range. shadow_depth.glsl writes linear distance - // / range so the cube-array compare sampler agrees with the main shader. - // Zero for cascade/spot shadow passes. + // Point shadow (`radialDepth == 1`): xyz = light world position, w = effective range. + // shadow_depth.glsl writes linear distance / range so the cube-array compare sampler agrees + // with the main shader. Zero for every projected-depth pass. alignas(16) float lightPosRange[4]{}; - // Used when matrixIndex < 0 for tightly-fit per-object self-shadow passes. + // THE matrix every shadow path rasterises with — cascade, world-only, spot, point face and both + // self-shadow layers alike. One value per recorded view, pushed with the rest of the view's + // constants. alignas(16) Mat4 lightViewProj{Mat4::identity()}; }; @@ -352,7 +375,7 @@ struct ShadowPushConstants // constants are a raw byte range with no driver-side reflection, so a member reordered or resized // here silently reinterprets the shader's fields — a shifted materialIndex would index a different // bindless material and mask a caster against somebody else's texture. -static_assert(offsetof(ShadowPushConstants, matrixIndex) == 0); +static_assert(offsetof(ShadowPushConstants, radialDepth) == 0); static_assert(offsetof(ShadowPushConstants, selfShadowSlot) == 4); static_assert(offsetof(ShadowPushConstants, selfShadowDepthEpsilon) == 8); static_assert(offsetof(ShadowPushConstants, materialIndex) == 12); diff --git a/shaders/gpu_limits.glsl b/shaders/gpu_limits.glsl index 7147d4a6..4cd29ca1 100644 --- a/shaders/gpu_limits.glsl +++ b/shaders/gpu_limits.glsl @@ -56,18 +56,9 @@ const int MAX_POINT_SHADOW_CASTERS = 4; // Faces of a cube map. const int CUBE_FACE_COUNT = 6; -// The shadow matrix table (ShadowUBO::lightViewProj), selected per draw by -// ShadowPushConstants::matrixIndex: -// [0 .. C-1] directional cascades -// [C .. C+S-1] spot lights -// [C+S ..] point lights, 6 * cubeIndex + face -// The bases are DERIVED here rather than written out, so moving a family's capacity moves every -// index that follows it in one edit. -const int SHADOW_CASCADE_MATRIX_BASE = 0; -const int SHADOW_SPOT_MATRIX_BASE = SHADOW_CASCADE_MATRIX_BASE + SHADOW_CASCADE_COUNT; -const int SHADOW_POINT_MATRIX_BASE = SHADOW_SPOT_MATRIX_BASE + MAX_SPOT_SHADOW_CASTERS; -const int SHADOW_TOTAL_MATRIX_COUNT = - SHADOW_POINT_MATRIX_BASE + CUBE_FACE_COUNT * MAX_POINT_SHADOW_CASTERS; +// (The shadow matrix TABLE is gone. Every shadow path rasterises with `pc.lightViewProj`, the +// matrix of the view being recorded, so there is no per-object array of every shadow transform and +// no slot arithmetic — cascade/spot/point bases and a total count — to keep in step.) // Which shadow-map families were RECORDED this frame, packed as a bitmask in // LightUBO::shadowMapValidMask. Not a limit, but it lives here for the same reason the limits do: diff --git a/shaders/shadow.vert b/shaders/shadow.vert index b4d018c8..06bbe319 100644 --- a/shaders/shadow.vert +++ b/shaders/shadow.vert @@ -1,12 +1,13 @@ #version 450 -// SHADOW_TOTAL_MATRIX_COUNT — the shared declaration graphics/gpu_limits.hpp re-exports as -// kShadowTotalMatrixCount, which is what sizes the C++ ShadowUBO this block must match. +// MAX_JOINTS and MORPH_WEIGHT_VEC4_COUNT for the skin and morph blocks below. #include "gpu_limits.glsl" +// PER-OBJECT ONLY. This block used to carry every shadow matrix in the frame — 32 of them, 2 KB, +// pushed at every shadow draw so a push constant could index one row. The view's matrix now arrives +// in the push block (`pc.lightViewProj`), which already carried one for the self-shadow path. layout(binding = 0) uniform ShadowUBO { mat4 model; - mat4 lightViewProj[SHADOW_TOTAL_MATRIX_COUNT]; int hasSkin; } shadow; @@ -77,6 +78,8 @@ void main() { worldPos = wp.xyz; uv0 = inTexCoord; uv1 = inTexCoord1; - mat4 lightMatrix = pc.matrixIndex < 0 ? pc.lightViewProj : shadow.lightViewProj[pc.matrixIndex]; - gl_Position = lightMatrix * wp; + // ONE matrix, from the push block, for every family. There is no per-object table to index into + // any more: the transform belongs to the view being recorded, and a draw that could select a + // different row was a second authority on what this pass rasterises with. + gl_Position = pc.lightViewProj * wp; } diff --git a/shaders/shadow_depth.glsl b/shaders/shadow_depth.glsl index b931ac30..30c0fcfc 100644 --- a/shaders/shadow_depth.glsl +++ b/shaders/shadow_depth.glsl @@ -6,16 +6,17 @@ // the main shader's samplerCubeArrayShadow on one of them only, and cutout casters would lose their // point shadows for a reason that looks like a bias problem. // -// SHADOW_POINT_MATRIX_BASE — the shared matrix-slot layout that puts the point faces last, from the -// one file graphics/gpu_limits.hpp also reads. -#include "gpu_limits.glsl" - // Point faces store linear distance / range into gl_FragDepth so the main pass' comparison sampler // tests the same ratio. Cascade, spot and self views write nothing here and keep the fixed-function // hardware depth, which is what keeps contact shadows attached. +// +// The discriminator is the view's DEPTH MODE, pushed as `radialDepth` from +// `PreparedShadowView::depthMode()`. It used to be "is this matrix index at or past the point base" +// — the right answer inferred from an unrelated fact, which stopped being available at all once the +// matrix table was retired. void writeShadowDepth(vec3 worldPos) { - if (pc.matrixIndex >= SHADOW_POINT_MATRIX_BASE) { + if (pc.radialDepth != 0) { float range = max(pc.lightPosRange.w, 1e-4); gl_FragDepth = clamp(length(worldPos - pc.lightPosRange.xyz) / range, 0.0, 1.0); } diff --git a/shaders/shadow_push.glsl b/shaders/shadow_push.glsl index 2ec2d155..c352d702 100644 --- a/shaders/shadow_push.glsl +++ b/shaders/shadow_push.glsl @@ -10,8 +10,11 @@ // Both stages see the whole range (the pipeline declares it vertex | fragment), so a stage that // reads only some fields still declares all of them. layout(push_constant) uniform ShadowPushConstants { - // Selects lightViewProj[] in the vertex stage; < 0 means "use pc.lightViewProj" (self-shadow). - int matrixIndex; + // How this view stores depth: 0 = projected hardware depth, 1 = the radial distance/range ratio + // a point face writes. It was an index into a per-object table of every shadow matrix in the + // frame, and the point path discriminated on "index >= the point base" — a depth mode inferred + // from where a matrix happened to live. The table is gone; this says what it means. + int radialDepth; // Per-skinned-object self-shadow layer for the dual-depth self pass. int selfShadowSlot; // Normalized-depth gap before a fragment counts as the second surface. @@ -21,9 +24,10 @@ layout(push_constant) uniform ShadowPushConstants { // rather than a shadow-only copy of it. Read by the masked fragment path only; occupies what // used to be explicit padding, so every offset around it is unchanged. uint materialIndex; - // Point shadow (matrixIndex >= SHADOW_POINT_MATRIX_BASE): xyz = light world position, - // w = effective range. Zero for cascade/spot/self passes. + // Point shadow (radialDepth == 1): xyz = light world position, w = effective range. Zero for + // every projected-depth pass. vec4 lightPosRange; - // Used when matrixIndex < 0, for the tightly-fit per-object self-shadow views. + // THE matrix every shadow path rasterises with — cascade, world-only, spot, point face and both + // self-shadow layers. One value per recorded view. mat4 lightViewProj; } pc; diff --git a/src/graphics/object.cpp b/src/graphics/object.cpp index 69e8f1be..e5a9012b 100644 --- a/src/graphics/object.cpp +++ b/src/graphics/object.cpp @@ -95,9 +95,11 @@ Vec3 skinnedPosition(const Vertex& vertex, Vec3 position, std::span // caster is, how its transform scales object-space error into world space, and who it is for // hysteresis purposes. // -// `worldScale` is the conservative sigma_max of the model's linear part — the same bound VDPM uses, -// computed once per caster rather than once per view because it is a property of the transform -// alone. +// The POSE carries both halves of the transform: the matrix the shadow pass rasterises this caster +// with, and the conservative sigma_max of its linear part — the same bound VDPM uses, computed once +// per caster rather than once per view because it is a property of the transform alone. They are +// one value so neither can be stated without the other (see `ShadowCasterPose`), and this `model` +// is the same argument `writeShadowUniforms` puts into `ShadowUBO::model` one call earlier. [[nodiscard]] ShadowGeometryRequest makeShadowRequest(const Geometry& geometry, ShadowCasterId casterId, ShadowCasterGeneration generation, const Mat4& model, @@ -107,7 +109,7 @@ ShadowGeometryRequest makeShadowRequest(const Geometry& geometry, ShadowCasterId return ShadowGeometryRequest{.lods = geometry.lods(), .baseIndexBuffer = geometry.indexBuffer(), .baseIndexCount = geometry.indexCount(), - .worldScale = largestSingularValue(linearPart(model)), + .pose = ShadowCasterPose::fromModel(model), .casterId = casterId, .generation = generation, .lodEnabled = lodEnabled, @@ -272,19 +274,14 @@ void Object::createForwardBindings(Resources& resources, VdpmGpuRegistry* regist void Object::createShadowBindings(Resources& resources) { - // Per-object ShadowUBO (model + per-cascade lightViewProj[4] + hasSkin), - // pushed as shadow set-0 binding 0 per draw. The skin / morph / morphSsbo - // buffers allocated by createForwardBindings are reused for the shadow draw — + // Per-object ShadowUBO (model + hasSkin), pushed as shadow set-0 binding 0 per draw. The skin / + // morph / morphSsbo buffers allocated by createForwardBindings are reused for the shadow draw — // no duplicate uploads — and the shared self-shadow image+sampler (bindings // 4/5) are pushed from Resources by the shadow pass. for (auto& binding : bindings_) { ShadowUBO initialShadow{}; initialShadow.model = Mat4::identity(); - for (Mat4& m : initialShadow.lightViewProj) - { - m = Mat4::identity(); - } auto shadowSet = resources.createMappedUniformBuffers(sizeof(ShadowUBO)); for (int i = 0; i < kMaxFramesInFlight; ++i) { @@ -717,15 +714,11 @@ void Object::writeForwardUniforms(const FrameInfo& frame, const Mat4& world, void Object::writeShadowUniforms(const FrameInfo& frame, const Mat4& world, bool hasSkin) { - // Shadow UBO (model + per-cascade lightViewProj[4] + hasSkin). The renderer - // buckets by pipeline so shadow draws replay inside the shadow pass and - // forward draws inside the forward pass. + // Shadow UBO: the object's world matrix and whether it skins, and nothing else. The light-space + // matrix belongs to the VIEW being recorded and arrives in the shadow pass' push constants, so + // there is no per-object copy of every shadow matrix in the frame any more. ShadowUBO shadowData{}; shadowData.model = world; - for (std::size_t i = 0; i < frame.shadowViewProjs.size(); ++i) - { - shadowData.lightViewProj[i] = frame.shadowViewProjs[i]; - } shadowData.hasSkin = hasSkin ? 1 : 0; for (auto& binding : bindings_) { diff --git a/src/graphics/shadow_diagnostics.cpp b/src/graphics/shadow_diagnostics.cpp index 8075fab5..e62744a6 100644 --- a/src/graphics/shadow_diagnostics.cpp +++ b/src/graphics/shadow_diagnostics.cpp @@ -56,25 +56,41 @@ std::string_view toString(ShadowViewGroup group) noexcept return "unknown"; } -bool ShadowViewStats::beginRasterPass(ShadowLogicalViewId view) noexcept +bool ShadowViewStats::claimView(ShadowLogicalViewId view) noexcept { - // VALIDATE FIRST, mutate second. Counting the pass before checking would leave a row that - // rejected the identity still claiming to have rasterised it. - assert(view.valid() && "a rasterised shadow view must say which logical view it is"); + // VALIDATE FIRST, mutate second. Engaging the row before checking would leave a row that + // rejected the identity still claiming to describe it. + assert(view.valid() && "a claimed shadow view must say which logical view it is"); if (!view.valid()) { return false; } - // An engaged row belongs to ONE logical view. The self-shadow families legitimately begin twice + // A claimed row belongs to ONE logical view. The self-shadow family legitimately claims twice // with the same identity (two depth layers, one view); a DIFFERENT identity arriving at the // same physical slot would merge two views' counters under one name. - assert((!touched() || logicalId == view) && + assert((!claimed() || logicalId == view) && "two logical views cannot share one diagnostic row in a frame"); - if (touched() && !(logicalId == view)) + if (claimed() && !(logicalId == view)) { return false; } logicalId = view; + return true; +} + +bool ShadowViewStats::beginRasterPass(ShadowLogicalViewId view) noexcept +{ + // A recorded layer belongs to a view the plan claimed, and to THAT view. Counting a pass for an + // unclaimed row attributes GPU work to nothing; counting one for a different identity + // attributes it to the wrong view, which is worse — the row stays plausible. Checked, never + // claimed: the recorder is not a producer of identity. + assert(claimed() && "a rasterised layer must belong to a claimed view"); + assert((!claimed() || logicalId == view) && + "the recorder is rasterising a different view than the one that claimed this row"); + if (!claimed() || !(logicalId == view)) + { + return false; + } ++rasterPasses; return true; } @@ -83,13 +99,15 @@ void ShadowViewStats::observe(std::uint64_t fullDetailTriangles, bool accepted, std::uint64_t resolvedTriangles, std::uint32_t lodLevel, ShadowLodReason reason, bool countSelection) noexcept { - // A draw can only be observed for a view the pass is rasterising. Debug trips at the source; - // release repairs the count to 1 rather than leaving a view that holds draws yet reports - // inactive — an inconsistency that would read as a diagnostics bug in the panel. - assert(rasterPasses != 0 && "observe() before beginRasterPass() for this view"); - if (rasterPasses == 0) + // A caster can only be observed for a view the plan CLAIMED — not for one that rasterised. + // Requiring a raster pass here would force preparation to claim GPU work it has not done, which + // is precisely what makes a reused map unobservable: it is claimed and observed while recording + // nothing. Debug trips at the source; release drops the observation rather than inventing an + // identity for it, since a row with counters and no name describes no view. + assert(claimed() && "observe() before claimView() for this view"); + if (!claimed()) { - rasterPasses = 1; + return; } ++candidateDraws; candidateTriangles += fullDetailTriangles; @@ -341,9 +359,12 @@ FocusedShadowView ShadowFrameStats::focused(ShadowViewFocus focus) const noexcep for (std::size_t slot = 0; slot < shadowViewSlotCount(focus.group); ++slot) { const ShadowViewStats& stats = views[base + slot]; - // `touched()` first: an untouched row's identity is stale by definition — it is whatever - // the slot last described, possibly frames ago. - if (stats.touched() && stats.logicalId == focus.view) + // `claimed()` first, NOT `touched()`: presence is whether this frame's plan named the row, + // and a view whose map was reused is present while recording nothing. Keying on rasterised + // work would make a focused view vanish the moment it became free — the case the cache + // exists to produce. An UNCLAIMED row's identity is stale by definition (whatever the slot + // last described, possibly frames ago), which is why the check is needed at all. + if (stats.claimed() && stats.logicalId == focus.view) { return FocusedShadowView{.stats = &stats, .slot = slot}; } diff --git a/src/graphics/shadow_lod_resolver.cpp b/src/graphics/shadow_lod_resolver.cpp index 6114d56c..05efdb13 100644 --- a/src/graphics/shadow_lod_resolver.cpp +++ b/src/graphics/shadow_lod_resolver.cpp @@ -86,8 +86,8 @@ ResolvedShadowDraw resolveShadowDraw(const ShadowGeometryRequest& request, } const ShadowLodSelection selection = - selectShadowLod(request.lods, projection, request.worldScale, worldBounds, budgetTexels, - hysteresis, previousLevel); + selectShadowLod(request.lods, projection, request.pose.worldScale(), worldBounds, + budgetTexels, hysteresis, previousLevel); // A forced fallback names LOD0, but LOD0 is `lods[0]`, whose buffers are the whole mesh — // resolve through the same path so a fallback and a deliberate LOD0 bind identical geometry. if (selection.level >= request.lods.size()) @@ -139,7 +139,7 @@ namespace { // One bit per family. Small and fixed — the group count is a compile-time constant — so the -// membership of every family that drew a caster fits in one map entry. +// membership of every family whose map holds a caster fits in one map entry. [[nodiscard]] std::uint32_t groupBit(ShadowViewGroup group) noexcept { const auto index = static_cast(group); @@ -149,7 +149,7 @@ namespace } // namespace -void ShadowLodResolver::noteDrawn(ShadowViewGroup group, const ShadowLodStateKey& key) noexcept +void ShadowLodResolver::noteContent(ShadowViewGroup group, const ShadowLodStateKey& key) noexcept { if (!key.valid()) { @@ -158,27 +158,30 @@ void ShadowLodResolver::noteDrawn(ShadowViewGroup group, const ShadowLodStateKey return; } const auto it = frameCache_.find(key); - // A draw can only be marked on a decision that exists. Reaching here without one means a pass - // drew a caster it never resolved, which the pass itself treats as terminal; creating an entry - // would manufacture provenance for a level nobody chose. - assert(it != frameCache_.end() && "a drawn shadow caster must have been resolved first"); + // CONTENT CAN ONLY BE ATTRIBUTED TO AN EXISTING RESOLUTION — the precondition is about the + // decision, not about rasterisation. Reaching here without an entry means a family claimed to + // hold a caster it never resolved, which the caller treats as terminal; creating an entry would + // manufacture content at a level nobody chose. Nothing here requires that a draw was recorded: + // a reused map holds its casters without any draw this frame. + assert(it != frameCache_.end() && + "a caster attributed to a family's map must have been resolved first"); if (it == frameCache_.end()) { return; } - it->second.drawnGroups |= groupBit(group); + it->second.contentGroups |= groupBit(group); } const ResolvedShadowDraw* -ShadowLodResolver::drawnResolution(ShadowViewGroup group, - const ShadowLodStateKey& key) const noexcept +ShadowLodResolver::contentResolution(ShadowViewGroup group, + const ShadowLodStateKey& key) const noexcept { if (!key.valid()) { return nullptr; } const auto it = frameCache_.find(key); - if (it == frameCache_.end() || (it->second.drawnGroups & groupBit(group)) == 0U) + if (it == frameCache_.end() || (it->second.contentGroups & groupBit(group)) == 0U) { return nullptr; } @@ -224,7 +227,7 @@ ResolvedShadowDraw ShadowLodResolver::resolve(const ShadowGeometryRequest& reque const ResolvedShadowDraw resolved = resolveShadowDraw( request, view.projection(), worldBounds, budgetTexels, hysteresis, historyLevel(key)); - frameCache_.emplace(key, FrameEntry{.resolved = resolved, .drawnGroups = 0}); + frameCache_.emplace(key, FrameEntry{.resolved = resolved, .contentGroups = 0}); if (resolved.reason == ShadowLodReason::Selected) { // ONLY a selected level is evidence about where this caster sits relative to its budget. diff --git a/src/graphics/shadow_pass_plan.cpp b/src/graphics/shadow_pass_plan.cpp new file mode 100644 index 00000000..7562ba72 --- /dev/null +++ b/src/graphics/shadow_pass_plan.cpp @@ -0,0 +1,437 @@ +#include "fire_engine/graphics/shadow_pass_plan.hpp" + +#include + +namespace fire_engine +{ + +namespace +{ + +// EXACT equality, component by component — `Vec3` offers no comparison and this is deliberately not +// an approximate one. A tolerance here would decide that a light which moved slightly still holds +// the same shadow map, which is a policy (and a wrong one: the texels differ), not a comparison. +[[nodiscard]] bool sameVec3(const Vec3& lhs, const Vec3& rhs) noexcept +{ + return lhs.x() == rhs.x() && lhs.y() == rhs.y() && lhs.z() == rhs.z(); +} + +} // namespace + +bool PreparedShadowDraw::sameContent(const PreparedShadowDraw& other) const noexcept +{ + // A deformable draw never compares equal — not even to itself. Its vertices are rewritten + // between frames by a skin, a morph or a compute pass, and every field here would still match. + // Answering "equal" would be answering a question this struct cannot see the inputs to. + if (deformable || other.deformable) + { + return false; + } + if (casterId != other.casterId || generation != other.generation || !(model == other.model) || + vertexBuffer != other.vertexBuffer || indexBuffer != other.indexBuffer || + indexCount != other.indexCount || indexType != other.indexType || alpha != other.alpha || + cull != other.cull) + { + return false; + } + // The material only reaches a MASKED caster's fragment shader. Comparing it for an opaque draw + // would reject a reuse for a value that path never reads — two opaque variants of one mesh, + // differing only in material, store identical depth. + return alpha != ShadowCasterAlpha::Masked || materialIndex == other.materialIndex; +} + +PreparedShadowView PreparedShadowView::projected(ShadowLogicalViewId logicalId, + const Mat4& viewProj, std::uint32_t extent, + float depthBiasConstant, + float depthBiasSlope) noexcept +{ + // A point face cannot store projected depth: its fragment stage overwrites gl_FragDepth with + // the radial ratio whatever this says. Returning an invalid view makes the mismatch impossible + // to build rather than merely wrong once built. + if (!logicalId.valid() || logicalId.kind() == ShadowLogicalViewKind::Point) + { + return PreparedShadowView{}; + } + PreparedShadowView view{}; + view.logicalId_ = logicalId; + view.viewProj_ = viewProj; + view.extent_ = extent; + view.depthBiasConstant_ = depthBiasConstant; + view.depthBiasSlope_ = depthBiasSlope; + view.buildLayers(); + return view; +} + +PreparedShadowView PreparedShadowView::pointFace(ShadowLogicalViewId logicalId, + const Mat4& viewProj, std::uint32_t extent, + float depthBiasConstant, float depthBiasSlope, + Vec3 lightPosition, float lightRange) noexcept +{ + if (!logicalId.valid() || logicalId.kind() != ShadowLogicalViewKind::Point) + { + return PreparedShadowView{}; + } + PreparedShadowView view{}; + view.logicalId_ = logicalId; + view.viewProj_ = viewProj; + view.extent_ = extent; + view.depthBiasConstant_ = depthBiasConstant; + view.depthBiasSlope_ = depthBiasSlope; + view.buildLayers(); + view.lightPosition_ = lightPosition; + view.lightRange_ = lightRange; + return view; +} + +void PreparedShadowView::buildLayers() noexcept +{ + // TOPOLOGY FROM IDENTITY. Every view gets its layers at construction, empty or not, because the + // recorder walks layers to know what to clear: a view whose layers appeared only when a draw + // did would leave a first-use empty cascade with undefined depth while the plan reported it + // sampleable. Self is the only family with two — its dual-depth pair — and it gets both here, + // so "all layers or none" is a property of the type. + // + // Allocation-free: the layers are a fixed-capacity array, so this only names them and says how + // many there are. That is what keeps the factories `noexcept` truthfully. + layers_[0].kind = ShadowLayerKind::Depth; + layers_[0].draws.clear(); + layers_[1].kind = ShadowLayerKind::SelfSecondDepth; + layers_[1].draws.clear(); + layerCount_ = logicalId_.kind() == ShadowLogicalViewKind::Self ? 2 : 1; +} + +bool PreparedShadowView::addDraw(ShadowLayerKind kind, const PreparedShadowDraw& draw) +{ + // Only the layers this view HAS — `layers_` always holds two, and `layerCount_` says how many + // of them are real. Scanning the array instead would let a cascade accept a self-second draw + // into storage nothing records. + for (std::size_t i = 0; i < layerCount_; ++i) + { + if (layers_[i].kind == kind) + { + layers_[i].draws.push_back(draw); + return true; + } + } + return false; +} + +bool PreparedShadowView::sameContent(const PreparedShadowView& other) const noexcept +{ + if (logicalId_ != other.logicalId_ || !(viewProj_ == other.viewProj_) || + extent_ != other.extent_ || depthBiasConstant_ != other.depthBiasConstant_ || + depthBiasSlope_ != other.depthBiasSlope_) + { + return false; + } + // The light the stored ratio is measured against — a shader input for point faces only, so it + // is compared only for the mode that reads it. A point light moving with an unchanged face + // matrix changes every texel; a cascade carries no light position at all. The MODE itself needs + // no comparison: it is derived from the identity, which was compared above. + if (depthMode() == ShadowDepthMode::RadialRatio && + (!sameVec3(lightPosition_, other.lightPosition_) || lightRange_ != other.lightRange_)) + { + return false; + } + // No cull POLICY here: the raster input is the per-draw effective cull below, and the policy + // only helps produce it — so the policy is not part of this type at all. + // + // EVERY layer, not just the first: a self-shadow view's two depth images are both its content, + // and a comparison that stopped at one would reuse a first layer whose second had changed. + return std::ranges::equal(layers(), other.layers(), + [](const PreparedShadowLayer& lhs, const PreparedShadowLayer& rhs) + { return lhs.sameContent(rhs); }); +} + +bool PreparedShadowView::cacheable() const noexcept +{ + return std::ranges::all_of(layers(), + [](const PreparedShadowLayer& layer) + { + return std::ranges::none_of(layer.draws, + [](const PreparedShadowDraw& draw) + { return draw.deformable; }); + }); +} + +bool PreparedShadowLayer::sameContent(const PreparedShadowLayer& other) const noexcept +{ + if (kind != other.kind) + { + return false; + } + return std::ranges::equal(draws, other.draws, + [](const PreparedShadowDraw& lhs, const PreparedShadowDraw& rhs) + { return lhs.sameContent(rhs); }); +} + +std::string_view toString(ShadowViewDisposition disposition) noexcept +{ + switch (disposition) + { + case ShadowViewDisposition::Invalid: + return "invalid"; + case ShadowViewDisposition::Reused: + return "reused"; + case ShadowViewDisposition::Recorded: + return "recorded"; + } + return "unknown"; +} + +namespace +{ + +// Does this logical identity belong in this physical slot? Every family's answer, in one place. +[[nodiscard]] bool placementValid(ShadowViewGroup group, std::size_t slot, + const ShadowLogicalViewId& id) noexcept +{ + switch (group) + { + case ShadowViewGroup::Cascade: + case ShadowViewGroup::WorldOnly: + // World-only deliberately SHARES the cascade identity (that is what makes the two passes + // agree), so both groups want a Cascade kind — and the index must be the slot, or a cascade + // would rasterise into another cascade's layer with its own matrix. + return id.kind() == ShadowLogicalViewKind::Cascade && id.id() == slot; + case ShadowViewGroup::Self: + return id.kind() == ShadowLogicalViewKind::Self; + case ShadowViewGroup::Spot: + return id.kind() == ShadowLogicalViewKind::Spot; + case ShadowViewGroup::Point: + // The face is part of the physical address: slot = lightSlot * 6 + face. A face landing in + // the wrong slot of the right cube points the same light's matrix at another face's image. + return id.kind() == ShadowLogicalViewKind::Point && + id.face() == slot % static_cast(kCubeFaceCount); + case ShadowViewGroup::Count: + break; + } + return false; +} + +} // namespace + +void ShadowFramePlan::reset() noexcept +{ + for (Entry& entry : entries_) + { + entry = Entry{}; + entry.claimed = false; + } +} + +bool ShadowFramePlan::add(ShadowViewGroup group, std::size_t slot, PreparedShadowView view, + ShadowViewDisposition disposition) +{ + if (static_cast(group) >= kShadowViewGroupCount || + slot >= shadowViewSlotCount(group)) + { + return false; + } + // An invalid prepared view means a factory was handed an identity it cannot serve — a point + // face asked for projected depth, or a default-constructed identity. Recording it would + // rasterise from a matrix nobody vouched for, and caching it would compare content that + // describes nothing. + if (!view.valid()) + { + return false; + } + // PLACEMENT. The recorder is about to trust this object exclusively — it has no view set to + // cross-check against — so an identity in the wrong physical slot has to be refused here. + // Nothing downstream could notice: a cascade identity in a spot slot rasterises the cascade's + // matrix into the spot map, and every counter and timing still reads plausibly. + if (!placementValid(group, slot, view.logicalId())) + { + return false; + } + // ONE LIGHT PER PHYSICAL CUBE. The six faces of a cube are one light's map; two lights sharing + // a cube would each render half of it and both sample all of it. `setPointLight` makes this + // atomic upstream, and the plan is a second producer of the same arrangement, so it checks too + // rather than assuming the caller preserved it. + if (group == ShadowViewGroup::Point) + { + const auto faces = static_cast(kCubeFaceCount); + const std::size_t cubeBase = (slot / faces) * faces; + for (std::size_t face = 0; face < faces; ++face) + { + const Entry& sibling = entries_[shadowViewIndex(group, cubeBase + face)]; + if (sibling.view.valid() && sibling.view.logicalId().id() != view.logicalId().id()) + { + return false; + } + } + } + // ONE CLAIM PER SLOT. A second producer writing the same physical view means two views are + // being prepared as one — the plan would record the last writer's work and cache it under the + // other's identity — so it is refused rather than resolved, exactly as the view set and the + // caster-bounds frame refuse a duplicate key. An earlier Invalid claim counts: it is still a + // producer having spoken for the slot. + Entry& entry = entries_[shadowViewIndex(group, slot)]; + if (entry.claimed) + { + return false; + } + entry.claimed = true; + // An entry with nothing to sample carries no content: keeping the prepared work for an Invalid + // disposition would let a later reader treat "engaged but unusable" as a description of the + // image. + entry.disposition = disposition; + entry.view = + disposition == ShadowViewDisposition::Invalid ? PreparedShadowView{} : std::move(view); + return true; +} + +const PreparedShadowView* ShadowFramePlan::view(ShadowViewGroup group, + std::size_t slot) const noexcept +{ + if (static_cast(group) >= kShadowViewGroupCount || + slot >= shadowViewSlotCount(group)) + { + return nullptr; + } + const Entry& entry = entries_[shadowViewIndex(group, slot)]; + return entry.view.valid() ? &entry.view : nullptr; +} + +ShadowViewDisposition ShadowFramePlan::disposition(ShadowViewGroup group, + std::size_t slot) const noexcept +{ + if (static_cast(group) >= kShadowViewGroupCount || + slot >= shadowViewSlotCount(group)) + { + return ShadowViewDisposition::Invalid; + } + return entries_[shadowViewIndex(group, slot)].disposition; +} + +std::size_t ShadowFramePlan::sampleableCount(ShadowViewGroup group) const noexcept +{ + std::size_t count = 0; + for (std::size_t slot = 0; slot < shadowViewSlotCount(group); ++slot) + { + if (shadowViewSampleable(disposition(group, slot))) + { + ++count; + } + } + return count; +} + +bool ShadowFramePlan::records(ShadowViewGroup group) const noexcept +{ + for (std::size_t slot = 0; slot < shadowViewSlotCount(group); ++slot) + { + if (shadowViewRecords(disposition(group, slot))) + { + return true; + } + } + return false; +} + +bool ShadowFramePlan::pointCubesWhole() const noexcept +{ + const auto faces = static_cast(kCubeFaceCount); + const std::size_t slots = shadowViewSlotCount(ShadowViewGroup::Point); + for (std::size_t cube = 0; cube * faces < slots; ++cube) + { + std::size_t sampleable = 0; + for (std::size_t face = 0; face < faces; ++face) + { + if (shadowViewSampleable(disposition(ShadowViewGroup::Point, cube * faces + face))) + { + ++sampleable; + } + } + if (sampleable != 0 && sampleable != faces) + { + return false; + } + } + return true; +} + +bool ShadowFramePlan::recordsNothing() const noexcept +{ + for (std::size_t g = 0; g < kShadowViewGroupCount; ++g) + { + if (records(static_cast(g))) + { + return false; + } + } + return true; +} + +ShadowMapValidity ShadowFamilyEligibility::eligible() const noexcept +{ + const auto count = [this](ShadowViewGroup group) + { return activeViews[static_cast(group)]; }; + return shadowMapValidity(ShadowMapValidityInputs{ + .shadowsDisabled = shadowsDisabled, + .primaryDirectionalLight = primaryDirectionalLight, + .activeCascadeViews = count(ShadowViewGroup::Cascade), + .activeWorldOnlyViews = count(ShadowViewGroup::WorldOnly), + .activeSelfViews = count(ShadowViewGroup::Self), + .activeSpotViews = count(ShadowViewGroup::Spot), + .activePointViews = count(ShadowViewGroup::Point), + }); +} + +ShadowMapValidity shadowMapValidityFromPlan(const ShadowFramePlan& plan, + const ShadowFamilyEligibility& eligibility) noexcept +{ + ShadowMapValidity validity = eligibility.eligible(); + + // EVERY ELIGIBLE VIEW MUST HAVE MADE IT. Comparing against the EXPECTED count is what catches + // the variable-size failures: one of two spots preparing leaves a sampleable count of 1, which + // the eligibility law alone would call a valid family while the other light samples depth from + // whenever its map was last written. A reused view counts as arrived — it is sampleable, which + // is the point — so this measures completeness, not work. + const auto complete = [&](ShadowViewGroup group) + { + return plan.sampleableCount(group) == + eligibility.activeViews[static_cast(group)]; + }; + validity.cascades = validity.cascades && complete(ShadowViewGroup::Cascade); + validity.worldOnly = validity.worldOnly && complete(ShadowViewGroup::WorldOnly); + validity.self = validity.self && complete(ShadowViewGroup::Self); + validity.spot = validity.spot && complete(ShadowViewGroup::Spot); + // Point additionally needs its cubes WHOLE. Counting cannot see a half-prepared cube beside a + // fully prepared one: six of twelve faces is a whole number of cubes by arithmetic and a light + // missing three faces in fact. + validity.point = validity.point && complete(ShadowViewGroup::Point) && plan.pointCubesWhole(); + return validity; +} + +ShadowViewDisposition shadowViewDisposition(bool active, const PreparedShadowView& prepared, + const ShadowViewResidency& resident) noexcept +{ + if (!active) + { + return ShadowViewDisposition::Invalid; + } + // FIRST USE. Image creation transitions every layer to the sampler's read-only layout but + // leaves the depth contents undefined, so "already in the right layout" is not "already holds + // an answer". A slot with no committed content records, whatever its prepared work looks like. + const PreparedShadowView* residentContent = resident.content(); + if (residentContent == nullptr) + { + return ShadowViewDisposition::Recorded; + } + if (!prepared.cacheable()) + { + return ShadowViewDisposition::Recorded; + } + // The resident side is checked too, not just the prepared one: content recorded when the view + // held a deformable caster describes geometry that has since moved, so it can never be matched + // against — even if this frame's set happens to be static. + if (!residentContent->cacheable()) + { + return ShadowViewDisposition::Recorded; + } + return prepared.sameContent(*residentContent) ? ShadowViewDisposition::Reused + : ShadowViewDisposition::Recorded; +} + +} // namespace fire_engine diff --git a/src/graphics/shadow_pass_prepare.cpp b/src/graphics/shadow_pass_prepare.cpp new file mode 100644 index 00000000..db8b8da1 --- /dev/null +++ b/src/graphics/shadow_pass_prepare.cpp @@ -0,0 +1,365 @@ +#include + +#include +#include +#include +#include +#include + +#include + +namespace fire_engine +{ + +namespace +{ + +// A diagnostic row that two logical views tried to claim in one frame, or a view prepared with no +// identity at all. Terminal because the alternative is a row whose counters are the sum of two +// unrelated views under one of their names — worse than no measurement, because it reads like one. +[[noreturn]] void contradictoryShadowViewRow(std::string_view group, std::size_t slot) +{ + throw std::runtime_error( + std::format("shadow view row {} slot {} was claimed by two different logical views in one " + "frame (or by a view with no identity)", + group, slot)); +} + +// A shadow command that could not be resolved into geometry (SH-03). TERMINAL, on the same +// reasoning as the view set's rejections: the request is corrupt render input, and both ways of +// continuing are worse than stopping — dropping the draw leaves a caster missing from one shadow +// map with nothing to say so, and counting it as filtered corrupts the one metric the per-view +// diagnostics promise. In a Dev build the resolver's own assertion fires first, at the request that +// was malformed; under NDEBUG this throw carries the same refusal to main(). +[[noreturn]] void unresolvableShadowCaster(std::uint32_t objectId) +{ + throw std::runtime_error( + std::format("shadow caster (objectId {}) resolved to no geometry — its unresolved command " + "carries no drawable base mesh", + objectId)); +} + +// A caster whose request never stated where it is. TERMINAL rather than degraded, and not for the +// selector's sake — it already reports an unusable pose as InvalidCaster and draws full detail — +// but for the CACHE's: an unstated pose carries a default matrix, which is the same value every +// frame, so the comparison would find the view unchanged while the GPU rasterised the caster's +// actual transform. That is a shadow map reused forever for something that is moving, with no +// symptom anywhere. A producer that forgot the pose has to be stopped, not compensated for. +[[noreturn]] void unstatedShadowCasterPose(std::uint32_t objectId) +{ + throw std::runtime_error( + std::format("shadow caster (objectId {}) has no stated pose — its request carries no model " + "matrix for the pass to rasterise or the cache to compare", + objectId)); +} + +// A view the plan refused, or one a factory could not build from its identity. Both mean the +// producer described a view that cannot exist in the slot it named — a point face asked for +// projected depth, an identity in the wrong family's slot, two producers claiming one slot. Nothing +// downstream could notice: every counter and timing would still read plausibly while one light's +// matrix rasterised into another's map. +[[noreturn]] void unpreparableShadowView(std::string_view group, std::size_t slot) +{ + throw std::runtime_error(std::format( + "shadow view {} slot {} could not be prepared — its identity does not fit the slot, or the " + "slot was already claimed this frame", + group, slot)); +} + +struct ShadowDrawFilter +{ + const Frustum* frustum{nullptr}; + int selfShadowSlot{-1}; + + [[nodiscard]] bool accepts(const DrawCommand& dc) const + { + if (selfShadowSlot >= 0 && dc.selfShadowSlot != selfShadowSlot) + { + return false; + } + if (frustum == nullptr) + { + return true; + } + // A caster whose bounds are STALE (cloth: a compute pass rewrites the vertices this box was + // measured from) cannot be rejected by them. The box says roughly where the caster was in + // its bind pose and nothing about where the drawn geometry is, so a frustum test against it + // can only produce false rejections — a cloth that is genuinely in this view, dropped. It + // is admitted until storage geometry carries a conservative envelope of its own. + if (dc.shadowBoundsKind != ShadowCasterBoundsKind::Exact) + { + return true; + } + return frustum->intersects(dc.shadowBounds); + } +}; + +// Which caster set a family draws from. One mapping, so a family cannot be prepared from the wrong +// span — the world-only CSM exists precisely to exclude skinned casters, and handing it the full +// set would restore the geometry it was built to leave out. +[[nodiscard]] std::span familyDraws(const ShadowPreparationInputs& inputs, + ShadowViewGroup group) noexcept +{ + switch (group) + { + case ShadowViewGroup::WorldOnly: + return inputs.worldOnlyShadowDraws; + case ShadowViewGroup::Self: + return inputs.selfShadowDraws; + case ShadowViewGroup::Cascade: + case ShadowViewGroup::Spot: + case ShadowViewGroup::Point: + return inputs.shadowDraws; + case ShadowViewGroup::Count: + break; + } + return {}; +} + +// SH-05: which faces one prepared LAYER keeps, before the caster's own sidedness is folded in. +// +// Keyed on the family AND the layer, in that order, because only the self family's layers +// disagree: its first depth image captures whatever the light sees first (so it keeps every face, +// whatever the winding), and its second keeps only back faces, which is what makes the dual-depth +// rejection well-founded rather than a coin-flip on marginal fragments. Everywhere else the CASTER +// decides — and a double-sided one culls nothing, since front-culling a sheet authored face-on to +// the light discards the only faces it has. +// +// One function rather than a family test wrapping a layer test: a layer-only mapping would answer +// "keep every face" for a cascade's depth layer, which is a policy no cascade has ever had. +[[nodiscard]] ShadowFaceCull layerCullPolicy(ShadowViewGroup group, ShadowLayerKind kind) noexcept +{ + if (group != ShadowViewGroup::Self) + { + return ShadowFaceCull::PerCaster; + } + return kind == ShadowLayerKind::SelfSecondDepth ? ShadowFaceCull::BackFacesOnly + : ShadowFaceCull::AllFaces; +} + +// Whether this family may be prepared at all — the eligibility answer, read per family so the +// decision is made once, above, rather than re-derived here. +[[nodiscard]] bool familyEligible(ShadowMapValidity eligible, ShadowViewGroup group) noexcept +{ + switch (group) + { + case ShadowViewGroup::Cascade: + return eligible.cascades; + case ShadowViewGroup::WorldOnly: + return eligible.worldOnly; + case ShadowViewGroup::Self: + return eligible.self; + case ShadowViewGroup::Spot: + return eligible.spot; + case ShadowViewGroup::Point: + return eligible.point; + case ShadowViewGroup::Count: + break; + } + return false; +} + +// One view's transform and depth inputs, from the set entry and the family's raster parameters. The +// POINT factory is chosen by the identity's kind, not by the group, so a face can only ever be +// prepared with the light its own descriptor was built from. +[[nodiscard]] PreparedShadowView prepareView(const ShadowRenderView& view, + const ShadowFamilyRaster& raster) noexcept +{ + if (const std::optional light = view.pointLightDepth()) + { + return PreparedShadowView::pointFace(view.logicalId(), view.viewProj(), raster.extent, + raster.depthBiasConstant, raster.depthBiasSlope, + light->position, light->range); + } + return PreparedShadowView::projected(view.logicalId(), view.viewProj(), raster.extent, + raster.depthBiasConstant, raster.depthBiasSlope); +} + +// One accepted caster as it will be recorded: the values that reach the rasteriser, the identity of +// the caster they belong to, and the per-frame ring handles the recorder pushes. +[[nodiscard]] PreparedShadowDraw prepareDraw(const DrawCommand& dc, + const ResolvedShadowDraw& resolved, + ShadowFaceCull cullPolicy) noexcept +{ + const ShadowGeometryRequest& request = dc.shadowRequest; + return PreparedShadowDraw{ + .casterId = request.casterId, + .generation = request.generation, + // The pose's matrix, which is the same `world` that was written into this draw's + // `ShadowUBO::model` — not a second derivation of where the caster is. + .model = request.pose.model(), + .vertexBuffer = dc.vertexBuffer, + // The RESOLVED carrier, never the command's: a shadow command carries none. + .indexBuffer = resolved.indexBuffer, + .indexCount = resolved.indexCount, + .indexType = dc.indexType, + .alpha = request.alpha, + .materialIndex = dc.materialIndex, + // The EFFECTIVE answer, from the same pure function the recorder's Vulkan translation + // consumes — so the compared value and the state that is set are one decision. + .cull = shadowEffectiveCull(cullPolicy, dc.doubleSided), + // SH-04's classification IS the cacheability question: a caster deformed after its geometry + // was measured rewrites its vertices with no revision any field here could compare. + .deformable = request.deformation == ShadowCasterDeformation::Deformable, + .level = resolved.level, + .reason = resolved.reason, + .shadowUbo = dc.shadowUbo, + .skinUbo = dc.skinUbo, + .morphUbo = dc.morphUbo, + .morphSsbo = dc.morphSsbo, + }; +} + +} // namespace + +void prepareShadowFrame(const ShadowPreparationInputs& inputs, const ShadowRenderViewSet& views, + ShadowMapValidity eligible, ShadowLodResolver& resolver, + ShadowFrameStats& stats, ShadowFramePlan& plan) +{ + plan.reset(); + + // NOTHING IS RESIDENT YET. The residency store arrives with the reuse half of this item; until + // then every view is a first use, which the law answers with `Recorded`. Consulting the law + // rather than writing `Recorded` here is deliberate: the disposition of a view is one decision + // with one home, and a hard-coded answer at the only call site would have to be found and + // removed later — exactly the kind of second authority this arc exists to retire. + const ShadowViewResidency noResidency{}; + + // Family order matches the order the pass records in. That is DETERMINISM, not correctness — a + // reversal would not change a single resolved level. A cascade and its world-only twin share + // one logical view, so whichever is prepared first is the one whose answer the resolver's frame + // cache hands to the other; but the key carries the caster id and generation as well as the + // view, the world-only span is a subset of the same commands with the same bounds, and both + // resolve against the same aliased view entry, so the second call gets the answer it would have + // computed anyway. What the stable order buys is comparability: the frame cache and the staged + // history fill in one order, and per-view observations accumulate in one order, so two runs of + // the same scene produce the same diagnostics. + for (std::size_t g = 0; g < kShadowViewGroupCount; ++g) + { + const auto group = static_cast(g); + if (!familyEligible(eligible, group)) + { + // Not filtered, not resolved, not claimed. Resolving stages hysteresis, and a family + // that will neither record nor be sampled must not leave decisions behind for the + // commit to adopt. + continue; + } + const std::span draws = familyDraws(inputs, group); + const ShadowFamilyRaster& raster = inputs.raster[g]; + + for (std::size_t slot = 0; slot < shadowViewSlotCount(group); ++slot) + { + // The SET decides which physical views exist. A slot it reports inactive is not + // prepared — there is no second opinion to consult, which is what makes "absent means + // inactive" true at the point of use. + const ShadowRenderView* view = views.find(group, slot); + if (view == nullptr) + { + continue; + } + + // CLAIM FIRST, before the caster set is walked, so a view that ends up drawing nothing + // is still a row in the report — an empty map that is rendered is a finding, and once + // maps can be reused an untouched claimed row is the normal case. + ShadowViewStats& viewStats = stats.view(group, slot); + if (!viewStats.claimView(view->logicalId())) + { + contradictoryShadowViewRow(toString(group), slot); + } + + PreparedShadowView prepared = prepareView(*view, raster); + if (!prepared.valid()) + { + unpreparableShadowView(toString(group), slot); + } + + // Culling frustum from the view's OWN matrix. Self layers pass everything through: they + // are already restricted to one caster by the slot filter, so a frustum test would only + // repeat it. Disabled culling passes everything through too. + const std::optional frustum = + inputs.cullingEnabled && group != ShadowViewGroup::Self + ? std::optional{Frustum::fromViewProj(view->viewProj())} + : std::nullopt; + const ShadowDrawFilter filter{ + .frustum = frustum ? &*frustum : nullptr, + .selfShadowSlot = group == ShadowViewGroup::Self ? static_cast(slot) : -1}; + + // PER LAYER, because a layer is a depth image: a self-shadow view rasterises the same + // caster set into two of them with different face policies, and each one's candidates + // are its own. The LOD decision is not — it belongs to the logical view — which is why + // only the first layer counts a selection. + const std::size_t layerCount = prepared.layers().size(); + for (std::size_t layer = 0; layer < layerCount; ++layer) + { + const ShadowLayerKind kind = prepared.layers()[layer].kind; + const ShadowFaceCull cullPolicy = layerCullPolicy(group, kind); + const bool countSelection = layer == 0; + + for (const DrawCommand& dc : draws) + { + // FILTER FIRST, resolve second. Selecting for a caster this view is about to + // drop would give it a dead band against a view it never appears in — a skinned + // caster would accumulate hysteresis against every other object's self-shadow + // map — and would evaluate wholly-rejected perspective casters outside the + // domain the projection model is good for. + const bool accepted = filter.accepts(dc); + const ResolvedShadowDraw resolved = + accepted ? resolver.resolve(dc.shadowRequest, *view, dc.shadowBounds, + inputs.lodBudgetTexels, inputs.hysteresis) + : ResolvedShadowDraw{}; + // TERMINAL, before anything is counted. The resolver returns drawable geometry + // for any request that carries base geometry — including every recoverable + // fallback — so a non-drawable result means the producer emitted a caster it + // could not describe. Skipping it would drop the caster from this shadow map + // silently, and folding it into the observed verdict would make it + // indistinguishable from a cull rejection, breaking the promise that + // `candidateDraws - drawnDraws` is exactly the filter's yield. + if (accepted && !resolved.drawable()) + { + unresolvableShadowCaster(dc.objectId); + } + if (accepted && !dc.shadowRequest.pose.stated()) + { + unstatedShadowCasterPose(dc.objectId); + } + // One observation per walked command, carrying the FILTER's verdict — nothing + // else. The full-detail count is what this view was OFFERED; the resolved count + // is what it will pay. + viewStats.observe(dc.shadowRequest.baseIndexCount / 3, accepted, + resolved.indexCount / 3, + static_cast(resolved.level), resolved.reason, + countSelection); + if (!accepted) + { + continue; + } + if (!prepared.addDraw(kind, prepareDraw(dc, resolved, cullPolicy))) + { + // A layer this view does not have. Unreachable while the kinds come from + // the view's own topology, and terminal rather than dropped because the + // draw would otherwise vanish from a map the plan still calls complete. + unpreparableShadowView(toString(group), slot); + } + // Recorded beside the draw it belongs to, so "this family's map holds this + // caster" cannot become true for a caster that was only considered. It is + // CONTENT, not "drew this frame": a reused map holds exactly this geometry + // without rasterising anything. + resolver.noteContent(group, ShadowLodStateKey{dc.shadowRequest.casterId, + dc.shadowRequest.generation, + view->logicalId()}); + } + } + + // The view is ACTIVE by construction — the set said so above — so the law's remaining + // questions are the cache's own: is there resident content, and does it match. + const ShadowViewDisposition disposition = + shadowViewDisposition(true, prepared, noResidency); + if (!plan.add(group, slot, std::move(prepared), disposition)) + { + unpreparableShadowView(toString(group), slot); + } + } + } +} + +} // namespace fire_engine diff --git a/src/graphics/shadow_render_view.cpp b/src/graphics/shadow_render_view.cpp index debc2b3e..81d06bc4 100644 --- a/src/graphics/shadow_render_view.cpp +++ b/src/graphics/shadow_render_view.cpp @@ -119,8 +119,10 @@ bool ShadowRenderViewSet::store(ShadowViewGroup group, std::size_t slot, views_[shadowViewIndex(group, slot)].reset(); return false; } + // Zero range: only a point face measures depth against a light, and `pointLightDepth()` refuses + // to report one for any other kind, so there is no value here to be read by mistake. views_[shadowViewIndex(group, slot)] = - ShadowRenderView{viewProj, projection, biasMetrics, logicalId}; + ShadowRenderView{viewProj, projection, biasMetrics, logicalId, 0.0f}; return true; } @@ -168,7 +170,7 @@ bool ShadowRenderViewSet::setSpot(std::size_t slot, NodeId light, const Mat4& vi } bool ShadowRenderViewSet::setPointLight( - std::size_t lightSlot, NodeId light, const ShadowViewMetrics& biasMetrics, + std::size_t lightSlot, NodeId light, const ShadowViewMetrics& biasMetrics, float range, std::span faces) noexcept { // ADDRESS first, before any flattening: `lightSlot * kCubeFaceCount + face` can wrap a huge @@ -186,14 +188,27 @@ bool ShadowRenderViewSet::setPointLight( // the previous frame left, which is a shadow that is wrong only when the light is looked at // from one direction. const ShadowLogicalViewId identity = ShadowLogicalViewId::point(light, 0); - bool acceptable = identity.valid() && biasMetrics.kind() == ShadowViewMetricsKind::PointLight; + // The RANGE is checked as strictly as the matrices, because the stored depth is + // `distance / range`: a zero or non-finite one makes every texel of all six faces NaN or + // infinite, and the compare sampler then answers a shadow test with no meaning at all. + bool acceptable = identity.valid() && biasMetrics.kind() == ShadowViewMetricsKind::PointLight && + std::isfinite(range) && range > 0.0f; + // ONE LIGHT, SIX FACES. The descriptors are built from a single light's position, so a + // disagreement means the caller assembled the cube from more than one — and the pass would then + // measure half the cube's depth from the wrong origin while every matrix still looked fine. + // Exact comparison: the six values come from one `Vec3`, so anything but equality is a + // different light, not a rounding difference. for (const ShadowPointFace& face : faces) { + const Vec3& facePosition = face.projection.lightPosition(); + const Vec3& firstPosition = faces.front().projection.lightPosition(); acceptable = acceptable && face.projection.kind() == ShadowViewKind::Perspective && - allFinite(face.viewProj); + allFinite(face.viewProj) && facePosition.x() == firstPosition.x() && + facePosition.y() == firstPosition.y() && facePosition.z() == firstPosition.z(); } - assert(acceptable && "a point light's cube needs six perspective faces, finite matrices, a " - "valid identity and PointLight metrics"); + assert(acceptable && "a point light's cube needs six perspective faces about ONE light " + "position, finite matrices, a positive range, a valid identity and " + "PointLight metrics"); if (!acceptable) { // Cleared, not left unwritten — see store(): whatever was there is a previous attempt's @@ -213,7 +228,7 @@ bool ShadowRenderViewSet::setPointLight( // about the light" true of the type rather than of the caller. views_[shadowViewIndex(ShadowViewGroup::Point, shadowPointViewSlot(lightSlot, face))] = ShadowRenderView{faces[face].viewProj, faces[face].projection, biasMetrics, - ShadowLogicalViewId::point(light, face)}; + ShadowLogicalViewId::point(light, face), range}; } return true; } @@ -257,44 +272,6 @@ std::size_t ShadowRenderViewSet::activeCount(ShadowViewGroup group) const noexce return count; } -std::array(kShadowTotalMatrixCount)> -shadowMatrixArray(const ShadowRenderViewSet& views) noexcept -{ - std::array(kShadowTotalMatrixCount)> matrices; - matrices.fill(Mat4::identity()); - - for (std::size_t cascade = 0; cascade < shadowViewSlotCount(ShadowViewGroup::Cascade); - ++cascade) - { - const ShadowRenderView* view = views.find(ShadowViewGroup::Cascade, cascade); - // Cascades are mandatory: the directional pass always runs, so a missing one means the fit - // did not happen — unlike a punctual or self slot, which is legitimately inactive. - assert(view != nullptr && "every cascade must be populated before extraction"); - if (view != nullptr) - { - matrices[static_cast(kShadowCascadeMatrixBase) + cascade] = - view->viewProj(); - } - } - for (std::size_t spot = 0; spot < shadowViewSlotCount(ShadowViewGroup::Spot); ++spot) - { - if (const ShadowRenderView* view = views.find(ShadowViewGroup::Spot, spot)) - { - matrices[static_cast(kShadowSpotMatrixBase) + spot] = view->viewProj(); - } - } - for (std::size_t flat = 0; flat < shadowViewSlotCount(ShadowViewGroup::Point); ++flat) - { - if (const ShadowRenderView* view = views.find(ShadowViewGroup::Point, flat)) - { - matrices[static_cast(kShadowPointMatrixBase) + flat] = view->viewProj(); - } - } - // World-only contributes nothing: its view IS the cascade's entry (an alias), so the cascade - // slot written above IS its matrix. There is no second value to reconcile. - return matrices; -} - std::array cascadeViewProjArray(const ShadowRenderViewSet& views) noexcept { diff --git a/src/render/debug_overlay.cpp b/src/render/debug_overlay.cpp index b1857e3f..576a6982 100644 --- a/src/render/debug_overlay.cpp +++ b/src/render/debug_overlay.cpp @@ -269,9 +269,12 @@ void drawShadowDiagnostics(const FrameStats& stats, RenderTunables& tunables) for (std::size_t slot = 0; slot < shadowViewSlotCount(group); ++slot) { const ShadowViewStats& view = shadow.view(group, slot); - if (!view.touched()) + if (!view.claimed()) { - continue; // a slot nothing rasterised into costs nothing and says nothing + continue; // a slot this frame's plan never named has nothing to report + // NOT `!touched()`: a claimed row that recorded nothing is a real row with real + // counters — a reused map, once caching lands — and hiding it here while the + // recording log reports it would make the panel and the log disagree. } char slotLabel[48]; formatShadowSlotLabel(slotLabel, sizeof(slotLabel), group, slot); diff --git a/src/render/descriptors.cpp b/src/render/descriptors.cpp index d7852596..5ff9996a 100644 --- a/src/render/descriptors.cpp +++ b/src/render/descriptors.cpp @@ -217,20 +217,22 @@ void pushForwardObjectDescriptors(vk::CommandBuffer cmd, const Resources& resour } void pushShadowObjectDescriptors(vk::CommandBuffer cmd, const Resources& resources, - vk::PipelineLayout layout, const DrawCommand& dc) + vk::PipelineLayout layout, BufferHandle shadowUbo, + BufferHandle skinUbo, BufferHandle morphUbo, + BufferHandle morphSsbo) { // Shadow set 0 is a push-descriptor layout. Bindings 0..3 are per-object // vertex-stage buffers (ShadowUBO + skin/morph UBOs + morph SSBO); each was // created exactly sized, so WholeSize is correct. The shadow draw reuses the - // forward skin/morph/morphSsbo handles carried on the DrawCommand. + // forward skin/morph/morphSsbo handles the caster's prepared draw carried through. const vk::DescriptorBufferInfo shadowInfo{ - .buffer = resources.vulkanBuffer(dc.shadowUbo), .offset = 0, .range = vk::WholeSize}; + .buffer = resources.vulkanBuffer(shadowUbo), .offset = 0, .range = vk::WholeSize}; const vk::DescriptorBufferInfo skinInfo{ - .buffer = resources.vulkanBuffer(dc.skinUbo), .offset = 0, .range = vk::WholeSize}; + .buffer = resources.vulkanBuffer(skinUbo), .offset = 0, .range = vk::WholeSize}; const vk::DescriptorBufferInfo morphInfo{ - .buffer = resources.vulkanBuffer(dc.morphUbo), .offset = 0, .range = vk::WholeSize}; + .buffer = resources.vulkanBuffer(morphUbo), .offset = 0, .range = vk::WholeSize}; const vk::DescriptorBufferInfo morphSsboInfo{ - .buffer = resources.vulkanBuffer(dc.morphSsbo), .offset = 0, .range = vk::WholeSize}; + .buffer = resources.vulkanBuffer(morphSsbo), .offset = 0, .range = vk::WholeSize}; // Bindings 4/5 are the shared self-shadow first-depth image + sampler — // global resources (same for every shadow draw), only sampled by the second diff --git a/src/render/pipeline.cpp b/src/render/pipeline.cpp index d3f439f1..d257246a 100644 --- a/src/render/pipeline.cpp +++ b/src/render/pipeline.cpp @@ -297,9 +297,9 @@ PipelineConfig Pipeline::shadowConfig() // pushed by pushShadowObjectDescriptors. selfShadowFirst/Second inherit this // (they copy shadowConfig). config.pushDescriptorSet0 = true; - // matrixIndex picks lightViewProj[] in the vertex stage. lightPosRange is - // consumed by the fragment shader's point-shadow branch (linear distance - // depth), so the push constant must be visible to both stages. + // The vertex stage rasterises with the block's lightViewProj — the recorded view's matrix — and + // the fragment stage reads radialDepth + lightPosRange for the point-face depth write, so the + // range must be visible to both stages. config.pushConstantRanges.emplace_back(vk::ShaderStageFlagBits::eVertex | vk::ShaderStageFlagBits::eFragment, 0, static_cast(sizeof(ShadowPushConstants))); diff --git a/src/render/renderer.cpp b/src/render/renderer.cpp index 99300564..f45681df 100644 --- a/src/render/renderer.cpp +++ b/src/render/renderer.cpp @@ -700,19 +700,22 @@ void Renderer::assignPointShadow(LightUBO& out, int packedSlot, const Lighting& faceAt(3), faceAt(4), faceAt(5)}; // Metrics are per LIGHT: a 90-degree face has tan(fov/2) == 1, so the axis scale is 2 / extent, // and the range is the light's own. + // The RANGE goes into the set with the faces. It is what the pass measures radial depth + // against, so it belongs beside the matrices rather than in an array the pass would have to + // find by arithmetic on a face slot — and the position it is measured from is already in each + // face's own projection descriptor, which the set checks agrees across all six. if (!shadowViews_.setPointLight( static_cast(shadowIndex), light.nodeId, ShadowViewMetrics::pointLight(2.0f / static_cast(kPointShadowMapExtent), far), - std::span{faces})) + far, std::span{faces})) { rejectedShadowView(std::format("point slot {}", shadowIndex)); } ++activePointCasters_; out.lights[packedSlot].cone[2] = static_cast(shadowIndex); - // Stash the effective range used for shadow projection so the shadow-pass - // push-constant and the main-shader compare value agree. + // The same effective range the faces were fitted with, so the main shader's compare value + // agrees with the ratio the shadow pass stored. out.lights[packedSlot].direction[3] = far; - pointCasters_[shadowIndex] = PointShadowCaster{light.worldPosition, far}; } void Renderer::writeIblAndDebugParams(LightUBO& out) const @@ -817,22 +820,64 @@ void Renderer::assignSelfShadowSlots(std::span drawCommands) // the mask it writes describes the families the pass will actually record. } -void Renderer::uploadFrameLighting() +void Renderer::prepareShadowPlan(const DrawBuckets& buckets) { - // The frame's map validity, decided ONCE from the completed view set — every producer has run: - // cascades and punctual views in updateFrameLighting, self layers in assignSelfShadowSlots, - // world-only last. Deriving it earlier would read a set that is still being written, and the - // two consumers (this upload and the pass's family gates) would then be answering different - // questions with the same name. - shadowMapValidity_ = shadowMapValidity(ShadowMapValidityInputs{ + // ELIGIBILITY, from the COMPLETED view set — every producer has run: cascades and punctual + // views in updateFrameLighting, self layers in assignSelfShadowSlots, world-only last. Taken + // BEFORE preparation, because preparation resolves casters and stages hysteresis: a family that + // will neither record nor be sampled must not leave decisions behind for the commit to adopt, + // and asking the finished plan would be too late to prevent that. + // + // The expected counts travel with it. Confirming against the plan alone would lose what matters + // for the variable-size families: two active spots of which one prepared leaves a plausible + // "some slot is sampleable" while the other light samples whatever its map last held. + const ShadowFamilyEligibility eligibility{ .shadowsDisabled = tunables_.noShadows, .primaryDirectionalLight = hasPrimaryDirectional_, - .activeCascadeViews = shadowViews_.activeCount(ShadowViewGroup::Cascade), - .activeWorldOnlyViews = shadowViews_.activeCount(ShadowViewGroup::WorldOnly), - .activeSelfViews = shadowViews_.activeCount(ShadowViewGroup::Self), - .activeSpotViews = shadowViews_.activeCount(ShadowViewGroup::Spot), - .activePointViews = shadowViews_.activeCount(ShadowViewGroup::Point), - }); + .activeViews = {shadowViews_.activeCount(ShadowViewGroup::Cascade), + shadowViews_.activeCount(ShadowViewGroup::WorldOnly), + shadowViews_.activeCount(ShadowViewGroup::Self), + shadowViews_.activeCount(ShadowViewGroup::Spot), + shadowViews_.activeCount(ShadowViewGroup::Point)}, + }; + + // The per-family raster parameters. They are CONTENT — the extent is the viewport the view is + // rasterised at and the biases are the depth-bias state — so they travel into the prepared view + // and are compared with the rest of it, rather than being read again at record time from + // constants the cache never saw. + ShadowPreparationInputs inputs{ + .shadowDraws = buckets.shadow, + .worldOnlyShadowDraws = buckets.worldShadow, + .selfShadowDraws = buckets.selfShadow, + .lodBudgetTexels = tunables_.shadowLodPixelBudget, + .hysteresis = ShadowLodHysteresis{.coarsenRatio = tunables_.shadowLodCoarsenRatio}, + .cullingEnabled = tunables_.cullingEnabled, + }; + const auto family = [&](ShadowViewGroup group) -> ShadowFamilyRaster& + { return inputs.raster[static_cast(group)]; }; + family(ShadowViewGroup::Cascade) = {kShadowMapExtent, kDirectionalShadowRasterBiasConstant, + kDirectionalShadowRasterBiasSlope}; + family(ShadowViewGroup::WorldOnly) = family(ShadowViewGroup::Cascade); + // The self-shadow layers carry NO raster bias: their dual-depth rejection compares two stored + // depths of the same surface, and biasing either one would move the gap the comparison is + // about. + family(ShadowViewGroup::Self) = {kSkinnedSelfShadowMapExtent, 0.0f, 0.0f}; + family(ShadowViewGroup::Spot) = {kSpotShadowMapExtent, kPunctualShadowRasterBiasConstant, + kPunctualShadowRasterBiasSlope}; + family(ShadowViewGroup::Point) = {kPointShadowMapExtent, kPunctualShadowRasterBiasConstant, + kPunctualShadowRasterBiasSlope}; + + prepareShadowFrame(inputs, shadowViews_, eligibility.eligible(), shadowLodResolver_, + shadowStatsRing_[currentFrame_], shadowPlan_); + + // CONFIRMATION, from the plan that was actually built and judged against the eligibility that + // authorised it. This is what the receiver is told, and what the pass records — one value, one + // derivation, as it has been since the validity mask replaced the family-by-family guesses. + shadowMapValidity_ = shadowMapValidityFromPlan(shadowPlan_, eligibility); +} + +void Renderer::uploadFrameLighting() +{ lightData_.shadowMapValidMask = shadowMapValidity_.packedMask(); // Into the frame ring beside the counters it explains: the diagnostics publish a slot a // ring-cycle later, and a report pairing this frame's decision with that frame's raster counts @@ -1131,10 +1176,6 @@ const Renderer::DrawBuckets& Renderer::collectDrawCommands(RenderableScene& scen const auto extent = swapchain_.extent(); const AlphaPipelines pipelines{forwardOpaqueHandle_, forwardBlendHandle_}; - // The shader-facing matrix array, derived from the view set rather than kept beside it. - // `FrameInfo` holds its own copy (the field is an array, not a span), and the shadow pass - // derives another from the same set when it records — the set stays the only stored authority. - const auto shadowMatrices = shadowMatrixArray(shadowViews_); const FrameInfo frame{.currentFrame = currentFrame_, .viewportWidth = extent.width, .viewportHeight = extent.height, @@ -1152,8 +1193,7 @@ const Renderer::DrawBuckets& Renderer::collectDrawCommands(RenderableScene& scen .lodMode = tunables_.lodMode, .vdpmGpuBackend = vdpmGpuActive, .vdpmRequestSink = vdpmGpuActive ? &vdpmRequestScratch_ : nullptr, - .shadowPipeline = shadows_.pipelineHandle(), - .shadowViewProjs = shadowMatrices}; + .shadowPipeline = shadows_.pipelineHandle()}; // Coarse pre-cull frustums: the camera plus every ACTIVE shadow view. The union is a superset // of what buildDrawBuckets / shadows_ keep per pass, so a node dropped by all of them is never @@ -1229,9 +1269,27 @@ const Renderer::DrawBuckets& Renderer::collectDrawCommands(RenderableScene& scen } } - // The view set is complete: derive this frame's map validity and upload the lighting block. + // SH-06 evidence, on the frames that sample it: where every caster sits relative to every + // cascade. It reads the caster bucket, so it belongs HERE beside the buckets rather than in the + // recording phase — the pass itself no longer sees a draw command. + if (logShadowPlacementThisFrame_) + { + logShadowCasterPlacement(drawBucketsScratch_.shadow); + } + + // The view set is complete: turn the frame's casters into the shadow plan (which resolves every + // view's LOD and decides what each view does), then upload the lighting block carrying the + // validity that plan confirmed. + prepareShadowPlan(drawBucketsScratch_); uploadFrameLighting(); + // Both consume what preparation just decided. The pending --shadow-focus is honoured first, + // against the fully populated view set, so the tint can be asked for a view that exists; the + // tint then patches the forward buckets with the levels the shadow views chose. Neither has to + // wait for the shadow pass any more — the levels exist as soon as the plan does. + resolveShadowFocusRequest(); + applyShadowLodTint(drawBucketsScratch_); + // GPU-driven VDPM (Stage B5b): distil the request sink down to the fronts that are actually // camera-visible this frame, and (B5b-2) resolve each visible forward draw's buffers to the GPU // output. Object appended a request for every front on a coarse-cull survivor (camera ∪ @@ -1336,28 +1394,14 @@ void Renderer::logShadowCasterPlacement(std::span shadowDraws } } -void Renderer::recordShadowPass(vk::CommandBuffer cmd, const DrawBuckets& buckets) +void Renderer::recordShadowPass(vk::CommandBuffer cmd) { - if (logShadowPlacementThisFrame_) - { - logShadowCasterPlacement(buckets.shadow); - } - std::span pointCasterSpan{ - pointCasters_.data(), static_cast(activePointCasters_)}; - // Self-shadow slots are assigned densely (assignSelfShadowSlots), so the - // scratch map's size is the number of slots the pass must render. - // WHICH FAMILIES RECORD is `shadowMapValidity_`, the same value the receiver was told about in - // `LightUBO::shadowMapValidMask` — including the world-only decision, which used to be re-read - // from the set here. `anySkinned` was only ever the request; the set's whole-family answer is - // what the validity law consumes, so the pass and the shader cannot disagree about which maps - // this frame's depth belongs to. - shadows_.recordPass(cmd, buckets.shadow, buckets.worldShadow, buckets.selfShadow, - static_cast(selfShadowSlotsScratch_.size()), activeSpotCasters_, - pointCasterSpan, shadowViews_, shadowLodResolver_, - tunables_.shadowLodPixelBudget, - ShadowLodHysteresis{.coarsenRatio = tunables_.shadowLodCoarsenRatio}, - tunables_.cullingEnabled, shadowMapValidity_, - shadowStatsRing_[currentFrame_], profiler_, currentFrame_); + // THE PLAN IS THE WHOLE INPUT (arc 2 #4). Which views record, what each rasterises with, and + // which draws each of its layers walks were all decided in `prepareShadowPlan` — the pass gets + // no draw spans, no view set and no resolver, so there is nothing left here that could reach a + // different answer than the one `LightUBO::shadowMapValidMask` already told the receiver. + shadows_.recordPass(cmd, shadowPlan_, shadowStatsRing_[currentFrame_], profiler_, + currentFrame_); } // What the shadow pass actually RECORDED, per family — the observable half of the validity @@ -1407,6 +1451,57 @@ void Renderer::logShadowRecordingSample() const familyLine(ShadowViewGroup::Point, recorded.point), recorded.none() ? " | no family recorded" : "", stats_.gpuValid() ? "" : " | timings unavailable on this device"); + + // PER ROW, and deliberately every counter rather than the pass count alone. Restructuring how + // the pass is fed — moving the filter and the LOD resolution out of recording and into a + // preparation phase — must not change what the frame decides or how much it draws, and a + // reference image cannot show a DUPLICATED observation: two walks of one caster produce the + // same pixels with twice the candidates. Self-shadow is the sharp case, since one selection is + // observed while two depth layers rasterise. GPU milliseconds are excluded on purpose — they + // vary run to run and would make an otherwise byte-comparable report useless as evidence. + for (std::size_t g = 0; g < kShadowViewGroupCount; ++g) + { + const auto group = static_cast(g); + for (std::size_t slot = 0; slot < shadowViewSlotCount(group); ++slot) + { + const ShadowViewStats& row = stats_.shadow.view(group, slot); + if (!row.claimed()) + { + continue; // a slot this frame's plan never named has nothing to report + } + // NOT "has passes or candidates". Every CLAIMED view reports, including one with + // neither: an empty map that was rendered is a finding, and once maps can be reused an + // untouched claimed row is the normal case — filtering on work would hide exactly the + // views the cache is about. + std::string histogram; + for (std::size_t bin = 0; bin < kShadowLodBinCount; ++bin) + { + histogram += std::format("{}{}", bin == 0 ? "" : ",", row.lodHistogram[bin]); + } + std::string reasons; + for (std::size_t reason = 0; reason < kShadowLodReasonCount; ++reason) + { + if (row.lodReasons[reason] != 0) + { + reasons += std::format("{}{}={}", reasons.empty() ? "" : ",", + toString(static_cast(reason)), + row.lodReasons[reason]); + } + } + // The LOGICAL identity, not just the physical slot. Spot, point and self slots are + // reassigned densely in gather order every frame, so a restructure could put the wrong + // view in a slot while every counter printed here stayed identical. Including the + // identity makes the baseline cover the plan's identity-to-slot mapping as well as its + // accounting. + log::debug(log::category::render, + "shadow row {}[{}] id={}:{}/face{}: passes={} cand={}/{}tri " + "drawn={}/{}tri levels=[{}] reasons=[{}]", + toString(group), slot, static_cast(row.logicalId.kind()), + row.logicalId.id(), row.logicalId.face(), row.rasterPasses, + row.candidateDraws, row.candidateTriangles, row.drawnDraws, + row.drawnTriangles, histogram, reasons.empty() ? "none" : reasons); + } + } } void Renderer::resolveShadowFocusRequest() @@ -1473,7 +1568,7 @@ void Renderer::applyShadowLodTint(DrawBuckets& buckets) const // selection: a second selection sees different history state, and the picture would // contradict the geometry it claims to describe. if (const ResolvedShadowDraw* resolved = - shadowLodResolver_.drawnResolution(tintGroup, key)) + shadowLodResolver_.contentResolution(tintGroup, key)) { dc.shadowLodLevel = static_cast(resolved->level); } @@ -1764,14 +1859,12 @@ void Renderer::drawFrame(Window& display, RenderableScene& scene, float dt) // boundaries inside recordPass, and an enclosing timer would overlap them (and would then have // to be excluded from the measured-pass sum to avoid double-counting, like the VDPM stage // rows). - recordShadowPass(cmd, buckets); + recordShadowPass(cmd); - // The levels the shadow views just chose are the tint's subject matter, and the forward draws - // that carry them into the shader have not been recorded yet — this is the one window where - // both are true. The pending --shadow-focus is honoured first, against the fully populated view - // set, so the tint below already follows the requested view on the very first frame. - resolveShadowFocusRequest(); - applyShadowLodTint(drawBucketsScratch_); + // (The --shadow-focus resolution and the ShadowLod tint used to sit here, in the one window + // where the levels existed and the forward draws had not been recorded yet. Preparation decides + // the levels before anything is recorded now, so both moved into collection beside the plan + // that produces their subject matter.) // GPU-driven VDPM (Stage B5b-2): the VDPM compute (recorded above, after collection) wrote each // visible front's emitted index stream (scatter) + indirect command (finalize). The depth diff --git a/src/render/shadows.cpp b/src/render/shadows.cpp index 5dcb6a24..c1052d31 100644 --- a/src/render/shadows.cpp +++ b/src/render/shadows.cpp @@ -1,12 +1,9 @@ #include -#include #include -#include #include #include -#include #include #include #include @@ -46,214 +43,44 @@ void imageLayerBarrier(vk::CommandBuffer cmd, vk::Image image, vk::ImageAspectFl vk::DependencyInfo{.imageMemoryBarrierCount = 1, .pImageMemoryBarriers = &b}); } -struct ShadowDrawFilter -{ - const Frustum* frustum{nullptr}; - int selfShadowSlot{-1}; - - [[nodiscard]] bool accepts(const DrawCommand& dc) const - { - if (selfShadowSlot >= 0 && dc.selfShadowSlot != selfShadowSlot) - { - return false; - } - if (frustum == nullptr) - { - return true; - } - // A caster whose bounds are STALE (cloth: a compute pass rewrites the vertices this box was - // measured from) cannot be rejected by them. The box says roughly where the caster was in - // its bind pose and nothing about where the drawn geometry is, so a frustum test against it - // can only produce false rejections — a cloth that is genuinely in this view, dropped. It - // is admitted until storage geometry carries a conservative envelope of its own. - if (dc.shadowBoundsKind != ShadowCasterBoundsKind::Exact) - { - return true; - } - return frustum->intersects(dc.shadowBounds); - } -}; - -// Which diagnostic view an iteration is rasterising (SH-01). MANDATORY on every recording call: -// an optional target would let a future shadow path silently skip instrumentation, and a missing -// row is indistinguishable from a view that legitimately drew nothing. -// -// `slot` is the PHYSICAL array slot — cascade index, self-shadow slot, spot slot, or -// `shadowPointViewSlot(p, face)`. Those slots are densely reassigned in scene-gather order each -// frame, so a row is a stable *array position*, NOT a stable light identity: if a light appears or -// disappears, later lights move rows. Panel labels must say "point slot 1, face 4" rather than -// naming a light. Cross-frame ownership would need a stable light ID, which fixed-capacity -// indexing cannot supply. -class ShadowViewTarget -{ -public: - // All four values are required and there is no default construction: `{}` would otherwise - // compile into a null sink pointed at Cascade 0, which either crashes or — worse — silently - // bills one view's work to another. The reference makes the sink's existence a type-level fact. - ShadowViewTarget(ShadowFrameStats& stats, ShadowViewGroup group, std::size_t slot, - bool countSelection) noexcept - : stats_{&stats}, - group_{group}, - slot_{slot}, - countSelection_{countSelection} - { - } - ShadowViewTarget() = delete; - - [[nodiscard]] ShadowViewStats& view() const noexcept - { - return stats_->view(group_, slot_); - } - [[nodiscard]] ShadowViewGroup group() const noexcept - { - return group_; - } - [[nodiscard]] std::string_view groupName() const noexcept - { - return toString(group_); - } - [[nodiscard]] std::size_t slot() const noexcept - { - return slot_; - } - // False only for the self-shadow SECOND depth layer: it re-rasterises the same logical view, so - // its cost counts but its LOD selection must not be counted twice. - [[nodiscard]] bool countSelection() const noexcept - { - return countSelection_; - } - -private: - ShadowFrameStats* stats_; // never null: bound from a reference - ShadowViewGroup group_; - std::size_t slot_; - bool countSelection_; -}; - -// A diagnostic row that two logical views tried to claim in one frame, or a view rasterising with -// no identity at all. Terminal because the alternative is a row whose counters are the sum of two -// unrelated views under one of their names — worse than no measurement, because it reads like one. +// A diagnostic row the recorder is about to rasterise into that the plan never claimed, or that +// holds a different logical view. Terminal because the alternative is a row whose counters are the +// sum of two unrelated views under one of their names — worse than no measurement, because it reads +// like one. [[noreturn]] void contradictoryShadowViewRow(std::string_view group, std::size_t slot) { throw std::runtime_error( - std::format("shadow view row {} slot {} was claimed by two different logical views in one " - "frame (or by a view with no identity)", + std::format("shadow view row {} slot {} was not claimed by this frame's plan, or is " + "claimed by a different logical view", group, slot)); } -// A shadow command that could not be resolved into geometry (SH-03). TERMINAL, on the same -// reasoning as the view set's rejections: the request is corrupt render input, and both ways of -// continuing are worse than stopping — dropping the draw leaves a caster missing from one shadow -// map with nothing to say so, and counting it as filtered corrupts the one metric the per-view -// diagnostics promise. In a Dev build the resolver's own assertion fires first, at the request that -// was malformed; under NDEBUG this throw carries the same refusal to main(). -[[noreturn]] void unresolvableShadowCaster(std::uint32_t objectId) -{ - throw std::runtime_error( - std::format("shadow caster (objectId {}) resolved to no geometry — its unresolved command " - "carries no drawable base mesh", - objectId)); -} - -// Everything one iteration needs to turn unresolved casters into its own draws (SH-03). -// -// REFERENCE-BOUND with no default: `{}` would otherwise compile into a context with no view and no -// resolver, which in release resolves every caster to an empty draw — a shadow map that renders -// nothing, reported as if it had. Making the two mandatory at construction is the same argument -// ShadowViewTarget above makes for its stats sink. -class ShadowLodContext -{ -public: - ShadowLodContext(const ShadowRenderView& view, ShadowLodResolver& resolver, float budgetTexels, - ShadowLodHysteresis hysteresis) noexcept - : view_{&view}, - resolver_{&resolver}, - budgetTexels_{budgetTexels}, - hysteresis_{hysteresis} - { - } - ShadowLodContext() = delete; - - [[nodiscard]] ResolvedShadowDraw resolve(const DrawCommand& dc) const noexcept - { - return resolver_->resolve(dc.shadowRequest, *view_, dc.shadowBounds, budgetTexels_, - hysteresis_); - } - // The identity this iteration is rasterising — the same one its resolutions are keyed on, so a - // diagnostic row and a hysteresis entry can never name different views. - [[nodiscard]] const ShadowLogicalViewId& logicalId() const noexcept - { - return view_->logicalId(); - } - // Records that this family drew this caster for this view. Called only where the draw is - // actually recorded, so membership means "rasterised", not "considered". - void noteDrawn(ShadowViewGroup group, const ShadowGeometryRequest& request) const noexcept - { - resolver_->noteDrawn(group, - ShadowLodStateKey{request.casterId, request.generation, logicalId()}); - } - -private: - const ShadowRenderView* view_; // never null: bound from a reference - ShadowLodResolver* resolver_; // never null: bound from a reference - float budgetTexels_; - ShadowLodHysteresis hysteresis_; -}; - -void recordShadowDrawBucket(vk::CommandBuffer cmd, std::span shadowDraws, +void recordShadowDrawBucket(vk::CommandBuffer cmd, const PreparedShadowLayer& layer, const Resources& resources, ShadowPipelinePair pipelines, - ShadowFaceCull cullPolicy, const ShadowPushConstants& viewConstants, - ShadowDrawFilter filter, const ShadowLodContext& lod, - const ShadowViewTarget& target) + const ShadowPushConstants& viewConstants, + const ShadowLogicalViewId& logicalId, ShadowViewGroup group, + std::size_t slot, ShadowFrameStats& stats) { - // Before walking the span, so a view that renders and clears with nothing to draw still - // reports as rasterised — that empty-but-rendered view is itself a finding. - ShadowViewStats& viewStats = target.view(); - if (!viewStats.beginRasterPass(lod.logicalId())) + // One raster pass, counted where the GPU work is: this call brackets a real depth image. The + // row was CLAIMED during preparation, and the identity is checked against that claim rather + // than re-claimed — otherwise "some view claimed this row" would be enough, and rasterising + // view B into view A's row would still read plausibly under A's name. + ShadowViewStats& viewStats = stats.view(group, slot); + if (!viewStats.beginRasterPass(logicalId)) { - // The row already belongs to a different logical view this frame, or the view arrived with - // no identity. Continuing would blend two views' counters into one row and label it with - // one of their names — evidence that looks like a measurement and is not. Terminal, like - // every other contradiction between what the renderer thinks it is drawing and what the - // shadow state says. - contradictoryShadowViewRow(target.groupName(), target.slot()); + contradictoryShadowViewRow(toString(group), slot); } // The pipeline currently bound, so the two fragment paths can interleave freely within one - // iteration: a family's casters are one span, and splitting it by material would either reorder + // iteration: a layer's draws are one list, and splitting it by material would either reorder // the draws or walk it twice. NullPipeline means "nothing bound yet in this iteration". PipelineHandle boundPipeline = NullPipeline; - for (const auto& dc : shadowDraws) + for (const PreparedShadowDraw& draw : layer.draws) { - // FILTER FIRST, resolve second. Selecting for a caster this view is about to drop would - // give it a dead band against a view it never appears in — a skinned caster would - // accumulate hysteresis against every other object's self-shadow map — and would evaluate - // wholly-rejected perspective casters outside the domain the projection model is good for. - const bool accepted = filter.accepts(dc); - const ResolvedShadowDraw resolved = accepted ? lod.resolve(dc) : ResolvedShadowDraw{}; - // TERMINAL, before anything is counted. The resolver returns drawable geometry for any - // request that carries base geometry — including every recoverable fallback — so a - // non-drawable result means the producer emitted a caster it could not describe. Skipping - // it would drop the caster from this shadow map silently, and folding it into the observed - // verdict would make it indistinguishable from a cull rejection, breaking the promise that - // `candidateDraws - drawnDraws` is exactly the filter's yield. - if (accepted && !resolved.drawable()) - { - unresolvableShadowCaster(dc.objectId); - } - // One observation per walked command, carrying the FILTER's verdict — nothing else. The - // full-detail count is what this view was OFFERED; the resolved count is what it will pay. - viewStats.observe(dc.shadowRequest.baseIndexCount / 3, accepted, resolved.indexCount / 3, - static_cast(resolved.level), resolved.reason, - target.countSelection()); - if (!accepted) - { - continue; - } - // SH-05: the fragment path this caster needs, from the classification on its REQUEST — the - // single place that fact is stored, and the same field the resolver just read to decide the - // level. Never from anything the producer could point at a pipeline with directly. - const PipelineHandle pipelineHandle = pipelines.forCaster(dc.shadowRequest.alpha); + // SH-05: the fragment path this caster needs, from the classification preparation recorded + // — the same value the comparison holds, so the path that rasterises a cached map and the + // path described by its content cannot differ. + const PipelineHandle pipelineHandle = pipelines.forCaster(draw.alpha); const bool pipelineChanged = pipelineHandle != boundPipeline; if (pipelineChanged) { @@ -263,22 +90,26 @@ void recordShadowDrawBucket(vk::CommandBuffer cmd, std::span } const vk::PipelineLayout layout = resources.vulkanPipelineLayout(pipelineHandle); // Cull mode is dynamic on every shadow pipeline (SH-05), so it is set per draw and not once - // per pass: within one iteration a single-sided and a double-sided caster need different - // answers, and the pipeline carries none. - cmd.setCullMode(shadowCullMode(cullPolicy, dc.doubleSided)); - if (dc.vertexBuffer != NullBuffer) + // per pass: within one layer a single-sided and a double-sided caster need different + // answers, and the pipeline carries none. The EFFECTIVE answer was resolved in preparation + // — this is only its Vulkan spelling. + cmd.setCullMode(shadowCullMode(draw.cull)); + if (draw.vertexBuffer != NullBuffer) { - cmd.bindVertexBuffers(0, resources.vulkanBuffer(dc.vertexBuffer), {vk::DeviceSize{0}}); + cmd.bindVertexBuffers(0, resources.vulkanBuffer(draw.vertexBuffer), + {vk::DeviceSize{0}}); } - vk::IndexType indexType = - dc.indexType == DrawIndexType::UInt32 ? vk::IndexType::eUint32 : vk::IndexType::eUint16; - // The RESOLVED buffer, never the command's: a shadow command carries none. - cmd.bindIndexBuffer(resources.vulkanBuffer(resolved.indexBuffer), 0, indexType); + vk::IndexType indexType = draw.indexType == DrawIndexType::UInt32 ? vk::IndexType::eUint32 + : vk::IndexType::eUint16; + // The RESOLVED buffer, never a command's: a shadow command carries none, and the level that + // chose this one was decided per view during preparation. + cmd.bindIndexBuffer(resources.vulkanBuffer(draw.indexBuffer), 0, indexType); // Shadow set 0 is pushed inline (core 1.4 push descriptors) — no allocated // per-object descriptor set, mirroring the forward pass. - pushShadowObjectDescriptors(cmd, resources, layout, dc); + pushShadowObjectDescriptors(cmd, resources, layout, draw.shadowUbo, draw.skinUbo, + draw.morphUbo, draw.morphSsbo); if (pipelineChanged) { // Bindless materials (set 2) for the masked fragment path. Bound AFTER the push @@ -301,13 +132,10 @@ void recordShadowDrawBucket(vk::CommandBuffer cmd, std::span // else. One struct assembled in one place, so the masked path cannot read a material index // that belongs to the previously recorded caster. ShadowPushConstants pc = viewConstants; - pc.materialIndex = dc.materialIndex; + pc.materialIndex = draw.materialIndex; cmd.pushConstants( layout, vk::ShaderStageFlagBits::eVertex | vk::ShaderStageFlagBits::eFragment, 0, pc); - cmd.drawIndexed(resolved.indexCount, 1, 0, 0, 0); - // Recorded HERE, beside the draw itself, so "this family drew this caster" cannot become - // true for a caster that was only considered. - lod.noteDrawn(target.group(), dc.shadowRequest); + cmd.drawIndexed(draw.indexCount, 1, 0, 0, 0); } } @@ -356,8 +184,8 @@ Shadows::Shadows(const Device& device, Resources& resources) spotShadowMapHandle_ = resources_->createShadowMap(kSpotShadowMapExtent, kMaxSpotShadowCasters); // Point casters: one cubemap-array depth image, kCubeFaceCount faces per caster. Layout - // `kCubeFaceCount * cube + face` matches Resources::vulkanPointShadowFaceView and the - // matrixIndex layout in ShadowUBO::lightViewProj. + // `kCubeFaceCount * cube + face` matches Resources::vulkanPointShadowFaceView and the flat + // point-view slot the diagnostics and the view set use (`shadowPointViewSlot`). pointShadowMapHandle_ = resources_->createPointShadowMap(kPointShadowMapExtent, kMaxPointShadowCasters); @@ -373,19 +201,72 @@ Shadows::Shadows(const Device& device, Resources& resources) shared.shadowDebugImage = shadowMapHandle_; } -void Shadows::recordPass(vk::CommandBuffer cmd, std::span shadowDraws, - std::span worldOnlyShadowDraws, - std::span selfShadowDraws, int activeSelfShadowCasters, - int activeSpotCasters, std::span pointCasters, - const ShadowRenderViewSet& views, ShadowLodResolver& resolver, - float lodBudgetTexels, ShadowLodHysteresis hysteresis, bool cullingEnabled, - ShadowMapValidity validity, ShadowFrameStats& stats, - const GpuProfiler& profiler, uint32_t frameIndex) const +// Which physical depth image a prepared layer fills, and which fragment paths rasterise it. +// +// The image and the pipeline pair are both functions of (family, layer kind), so they are resolved +// TOGETHER: a self-shadow second layer bound to the first layer's image would rasterise the +// dual-depth rejection into the map it is supposed to be sampling, and every counter would still +// read correctly. +Shadows::LayerTarget Shadows::layerTarget(ShadowViewGroup group, std::size_t slot, + ShadowLayerKind kind) const { - // Nothing to record at all — `--no-shadows`, or a scene with no light any family is fitted to. - // Returning here is what makes suppression OBSERVABLE: no draws, no clears, no timestamps, so - // every shadow row in the diagnostics and every shadow group in the GPU timings reads zero. - if (validity.none()) + const auto layer = static_cast(slot); + switch (group) + { + case ShadowViewGroup::Cascade: + return {resources_->vulkanImage(shadowMapHandle_), + resources_->vulkanShadowMapLayerView(shadowMapHandle_, layer), layer, + shadowPipelines_}; + case ShadowViewGroup::WorldOnly: + return {resources_->vulkanImage(worldShadowMapHandle_), + resources_->vulkanShadowMapLayerView(worldShadowMapHandle_, layer), layer, + shadowPipelines_}; + case ShadowViewGroup::Self: + // The FIRST layer captures whatever the light sees first (the main pair, recorded with an + // all-faces policy since SH-05 made cull mode dynamic); the SECOND samples that image and + // discards the surface it already recorded, which needs its own fragment shaders. + if (kind == ShadowLayerKind::SelfSecondDepth) + { + return {resources_->vulkanImage(selfShadowMapHandle_), + resources_->vulkanShadowMapLayerView(selfShadowMapHandle_, layer), layer, + selfShadowSecondPipelines_}; + } + return {resources_->vulkanImage(selfShadowFirstMapHandle_), + resources_->vulkanShadowMapLayerView(selfShadowFirstMapHandle_, layer), layer, + shadowPipelines_}; + case ShadowViewGroup::Spot: + return {resources_->vulkanImage(spotShadowMapHandle_), + resources_->vulkanShadowMapLayerView(spotShadowMapHandle_, layer), layer, + shadowPipelines_}; + case ShadowViewGroup::Point: + { + // ONE derivation feeding the attachment view and the depth layer: the point family's flat + // slot IS `6 * cube + face`, so the cube and the face come back out of it rather than being + // carried alongside and trusted to agree. + const auto faces = static_cast(kCubeFaceCount); + return {resources_->vulkanImage(pointShadowMapHandle_), + resources_->vulkanPointShadowFaceView(pointShadowMapHandle_, + static_cast(slot / faces), + static_cast(slot % faces)), + layer, shadowPipelines_}; + } + case ShadowViewGroup::Count: + break; + } + // Unreachable for a real group; the switch is exhaustive over the families the plan indexes. + throw std::runtime_error( + std::format("shadow layer target asked for group {}", static_cast(group))); +} + +void Shadows::recordPass(vk::CommandBuffer cmd, const ShadowFramePlan& plan, + ShadowFrameStats& stats, const GpuProfiler& profiler, + uint32_t frameIndex) const +{ + // Nothing to record at all — `--no-shadows`, a scene with no light any family is fitted to, or + // (once the residency store lands) a frame in which every view's map was reused. Returning here + // is what makes that OBSERVABLE: no draws, no clears, no timestamps, so every shadow row in the + // diagnostics and every shadow group in the GPU timings reads zero. + if (plan.recordsNothing()) { return; } @@ -394,13 +275,13 @@ void Shadows::recordPass(vk::CommandBuffer cmd, std::span sha // Every pass in the engine stamps this way now — begin() itself is bottom-of-pipe — so this is // no longer a shadow-only convention, just the convention. A group that records nothing leaves // its two stamps unwritten and reports 0, rather than an empty span that reads as a small real - // cost and inflates the frame total. An active family with zero candidate draws is still timed - // — its clears and layout barriers are real GPU work. + // cost and inflates the frame total. An active family with zero prepared draws is still timed — + // its clears and layout barriers are real GPU work. // // `recording` gates the body AND the stamps together, which is the point: a family this frame // does not record must draw nothing, clear nothing and time nothing, and one gate is what makes - // those three the same answer. It comes from `ShadowMapValidity`, the same value the receiver - // was told, so a skipped family's diagnostics read zero and its shader path reads "fully lit". + // those three the same answer. It is the PLAN's answer — `records()` is true only if some view + // of the family has work — so a family whose maps were all reused opens no span around nothing. const auto timeGroup = [&](ProfilePass pass, bool recording, auto&& body) { if (!recording) @@ -415,48 +296,23 @@ void Shadows::recordPass(vk::CommandBuffer cmd, std::span sha const vk::ClearValue depthClear{.depthStencil = vk::ClearDepthStencilValue{.depth = 1.0f, .stencil = 0}}; - // ONE lookup per iteration, from the frame's view set: the matrix that culls, the descriptor - // that selects, and the identity that keys hysteresis all come from the same entry, so they - // cannot describe different fits. A null return means the set says this physical view is not - // active — the iteration is skipped rather than rasterised from a matrix nobody vouched for. - const auto viewFor = [&](ShadowViewGroup group, std::size_t slot) -> const ShadowRenderView* - { return views.find(group, slot); }; - - // Culling frustum from the view's OWN matrix. Self-shadow layers pass everything through: they - // are already restricted to one caster by the slot filter, so a frustum test would only repeat - // it. Disabled culling passes everything through too. - const auto frustumFor = [&](const ShadowRenderView& view, bool cull) -> std::optional - { - if (!cull) - { - return std::nullopt; - } - return Frustum::fromViewProj(view.viewProj()); - }; - - const auto lodContextFor = [&](const ShadowRenderView& view) - { return ShadowLodContext{view, resolver, lodBudgetTexels, hysteresis}; }; - - // Renders one shadow layer with depth-only dynamic rendering. depthLayer is - // the array-layer subresource the barriers target; depthView is the matching - // single-layer attachment view. Depth rests in DepthStencilReadOnlyOptimal - // (the forward-sampler layout) between frames, so we cycle it - // ReadOnly → Attachment → ReadOnly. No colour attachment: current MoltenVK - // commits depth-only stores under dynamic rendering. - auto recordShadowIteration = - [&](vk::Image depthImage, uint32_t depthLayer, vk::ImageView depthView, uint32_t extent, - const ShadowPushConstants& pc, std::span draws, - ShadowDrawFilter filter, const ShadowLodContext& lod, ShadowPipelinePair pipelines, - ShadowFaceCull cullPolicy, float depthBiasConstant, float depthBiasSlope, - const ShadowViewTarget& target) + // Renders one prepared layer with depth-only dynamic rendering. Depth rests in + // DepthStencilReadOnlyOptimal (the forward-sampler layout) between frames, so we cycle it + // ReadOnly → Attachment → ReadOnly. No colour attachment: current MoltenVK commits depth-only + // stores under dynamic rendering. + const auto recordLayer = [&](ShadowViewGroup group, std::size_t slot, + const PreparedShadowView& view, const PreparedShadowLayer& layer, + const ShadowPushConstants& pc) { + const LayerTarget target = layerTarget(group, slot, layer.kind); + const uint32_t extent = view.extent(); vk::Viewport vp = makeFullViewport(static_cast(extent), static_cast(extent)); vk::Rect2D scissor{ .offset = vk::Offset2D{.x = 0, .y = 0}, .extent = vk::Extent2D{.width = extent, .height = extent}, }; - imageLayerBarrier(cmd, depthImage, vk::ImageAspectFlagBits::eDepth, depthLayer, + imageLayerBarrier(cmd, target.image, vk::ImageAspectFlagBits::eDepth, target.layer, vk::ImageLayout::eDepthStencilReadOnlyOptimal, vk::ImageLayout::eDepthStencilAttachmentOptimal, vk::PipelineStageFlagBits2::eFragmentShader, @@ -465,7 +321,7 @@ void Shadows::recordPass(vk::CommandBuffer cmd, std::span sha vk::AccessFlagBits2::eDepthStencilAttachmentWrite); vk::RenderingAttachmentInfo depth{ - .imageView = depthView, + .imageView = target.view, .imageLayout = vk::ImageLayout::eDepthStencilAttachmentOptimal, .loadOp = vk::AttachmentLoadOp::eClear, .storeOp = vk::AttachmentStoreOp::eStore, @@ -474,16 +330,18 @@ void Shadows::recordPass(vk::CommandBuffer cmd, std::span sha cmd.beginRendering(makeRenderingInfo(scissor, {}, &depth)); cmd.setViewport(0, vp); cmd.setScissor(0, scissor); - cmd.setDepthBias(depthBiasConstant, 0.0f, depthBiasSlope); + // From the PREPARED view, which is also what the comparison holds: a bias changed without + // the cache seeing it would keep a map whose depth was written under different rules. + cmd.setDepthBias(view.depthBiasConstant(), 0.0f, view.depthBiasSlope()); // The push constants are pushed PER DRAW inside the bucket (SH-05 added a per-draw // materialIndex to the block), so `pc` travels in as this view's part of them rather than // being pushed here — one struct, assembled in one place, instead of a view-level push a // per-draw push would then have to agree with. - recordShadowDrawBucket(cmd, draws, *resources_, pipelines, cullPolicy, pc, filter, lod, - target); + recordShadowDrawBucket(cmd, layer, *resources_, target.pipelines, pc, view.logicalId(), + group, slot, stats); cmd.endRendering(); - imageLayerBarrier(cmd, depthImage, vk::ImageAspectFlagBits::eDepth, depthLayer, + imageLayerBarrier(cmd, target.image, vk::ImageAspectFlagBits::eDepth, target.layer, vk::ImageLayout::eDepthStencilAttachmentOptimal, vk::ImageLayout::eDepthStencilReadOnlyOptimal, vk::PipelineStageFlagBits2::eLateFragmentTests, @@ -492,187 +350,62 @@ void Shadows::recordPass(vk::CommandBuffer cmd, std::span sha vk::AccessFlagBits2::eShaderRead); }; - // Layered maps (CSM/world/self): per-layer depth attachment view. - auto layeredIteration = [&](TextureHandle depthHandle, uint32_t layer, uint32_t extent, - const ShadowPushConstants& pc, std::span draws, - ShadowDrawFilter filter, const ShadowLodContext& lod, - ShadowPipelinePair pipelines, ShadowFaceCull cullPolicy, - float depthBiasConstant, float depthBiasSlope, - const ShadowViewTarget& target) + // One family: every slot the plan says RECORDS, in slot order, each of its layers in the order + // preparation built them. A slot that is reused or absent is not touched at all — no barrier, + // no clear, no draw — which is the whole point of the disposition. + const auto recordFamily = [&](ShadowViewGroup group) { - recordShadowIteration(resources_->vulkanImage(depthHandle), layer, - resources_->vulkanShadowMapLayerView(depthHandle, layer), extent, pc, - draws, filter, lod, pipelines, cullPolicy, depthBiasConstant, - depthBiasSlope, target); + for (std::size_t slot = 0; slot < shadowViewSlotCount(group); ++slot) + { + if (!shadowViewRecords(plan.disposition(group, slot))) + { + continue; + } + const PreparedShadowView* view = plan.view(group, slot); + if (view == nullptr) + { + // A slot that records must carry content — `ShadowFramePlan::add` refuses any + // other combination — so this is a contradiction inside the plan rather than a + // frame to degrade through. + throw std::runtime_error( + std::format("shadow view {} slot {} records but carries no prepared content", + toString(group), slot)); + } + // ONE assembly of the view's constants, shared by its layers. Every field comes from + // the prepared view: the matrix it rasterises with, how its fragments store depth, and + // — for a point face — the light that depth is measured against. `selfShadowSlot` is + // the physical slot itself, which is what the second depth layer samples the first + // layer's image with. + ShadowPushConstants pc{}; + pc.lightViewProj = view->viewProj(); + pc.radialDepth = shadowRadialDepthFlag(view->depthMode()); + pc.lightPosRange[0] = view->lightPosition().x(); + pc.lightPosRange[1] = view->lightPosition().y(); + pc.lightPosRange[2] = view->lightPosition().z(); + pc.lightPosRange[3] = view->lightRange(); + pc.selfShadowSlot = group == ShadowViewGroup::Self ? static_cast(slot) : -1; + + for (const PreparedShadowLayer& layer : view->layers()) + { + recordLayer(group, slot, *view, layer, pc); + } + } }; // The main CSM and the world-only CSM are recorded as CONTIGUOUS groups rather than interleaved // per cascade. Nothing depends on the interleaving (each layer is independently barriered), and // grouping them is what lets each family carry one bottom-to-bottom timestamp boundary in the // per-group GPU timing — interleaved, the two families' costs could not be separated at all. - timeGroup(ProfilePass::ShadowCascades, validity.cascades, - [&] - { - for (uint32_t cascade = 0; cascade < kShadowCascadeCount; ++cascade) - { - const ShadowRenderView* view = viewFor(ShadowViewGroup::Cascade, cascade); - if (view == nullptr) - { - continue; - } - ShadowPushConstants pc{}; - pc.matrixIndex = kShadowCascadeMatrixBase + static_cast(cascade); - const std::optional frustum = frustumFor(*view, cullingEnabled); - const ShadowDrawFilter filter{.frustum = frustum ? &*frustum : nullptr}; - layeredIteration( - shadowMapHandle_, cascade, kShadowMapExtent, pc, shadowDraws, filter, - lodContextFor(*view), shadowPipelines_, ShadowFaceCull::PerCaster, - kDirectionalShadowRasterBiasConstant, kDirectionalShadowRasterBiasSlope, - ShadowViewTarget{stats, ShadowViewGroup::Cascade, cascade, true}); - } - }); - + // // The world-only CSM exists so skinned receivers can sample a cascade without their own - // geometry; with no skinned draw this frame no world-only view is enabled, so the duplicate - // 4-cascade render is skipped entirely. Skipping leaves its diagnostic rows untouched, which is - // the honest report: the views were not rasterised. The receiver is told (the WORLD_ONLY bit) - // rather than left to rely on nothing sampling it. - timeGroup(ProfilePass::ShadowWorldOnly, validity.worldOnly, - [&] - { - for (uint32_t cascade = 0; cascade < kShadowCascadeCount; ++cascade) - { - // The set's world-only entry ALIASES the cascade's, so this iteration - // resolves against the same logical view — the resolver returns the cascade's - // cached answer, which is what makes the two CSMs agree for a rigid caster - // rather than agreeing by coincidence. - const ShadowRenderView* view = viewFor(ShadowViewGroup::WorldOnly, cascade); - if (view == nullptr) - { - continue; - } - ShadowPushConstants pc{}; - pc.matrixIndex = kShadowCascadeMatrixBase + static_cast(cascade); - const std::optional frustum = frustumFor(*view, cullingEnabled); - const ShadowDrawFilter filter{.frustum = frustum ? &*frustum : nullptr}; - layeredIteration( - worldShadowMapHandle_, cascade, kShadowMapExtent, pc, - worldOnlyShadowDraws, filter, lodContextFor(*view), shadowPipelines_, - ShadowFaceCull::PerCaster, kDirectionalShadowRasterBiasConstant, - kDirectionalShadowRasterBiasSlope, - ShadowViewTarget{stats, ShadowViewGroup::WorldOnly, cascade, true}); - } - }); - - // Only the densely-assigned slots render; an unassigned slot's layers are - // never sampled (no fragment carries its index), so they need no clear. An - // assigned slot whose caster produced no shadow draw still clears here — - // correctly reading "no occluder" (depth 1.0) for its forward fragments. - timeGroup( - ProfilePass::ShadowSelf, validity.self, - [&] - { - for (int slot = 0; - slot < activeSelfShadowCasters && slot < kMaxSkinnedSelfShadowCasters; ++slot) - { - const auto viewSlot = static_cast(slot); - const ShadowRenderView* view = viewFor(ShadowViewGroup::Self, viewSlot); - if (view == nullptr) - { - continue; - } - ShadowPushConstants pc{}; - pc.matrixIndex = -1; - pc.selfShadowSlot = slot; - // From the SET, not scanned out of the draw span: the slot's matrix is a property - // of the view, and searching the commands for it was a second place the same value - // lived. - pc.lightViewProj = view->viewProj(); - const ShadowDrawFilter filter{.selfShadowSlot = slot}; - // No frustum: the slot filter already restricts this layer to its one caster. - const ShadowLodContext lod = lodContextFor(*view); - // FIRST layer: the main pipeline pair with an all-faces policy — since SH-05 made - // cull mode dynamic, "capture whatever faces the light sees first" is recorded - // state rather than a pipeline that differed from the main one in nothing else. - layeredIteration(selfShadowFirstMapHandle_, static_cast(slot), - kSkinnedSelfShadowMapExtent, pc, selfShadowDraws, filter, lod, - shadowPipelines_, ShadowFaceCull::AllFaces, 0.0f, 0.0f, - ShadowViewTarget{stats, ShadowViewGroup::Self, viewSlot, true}); - // Second depth layer: same logical view re-rasterised, so it hits the resolver's - // frame cache — one decision, two layers — and its cost counts while its selection - // does not (that would double the histogram for one decision). - layeredIteration(selfShadowMapHandle_, static_cast(slot), - kSkinnedSelfShadowMapExtent, pc, selfShadowDraws, filter, lod, - selfShadowSecondPipelines_, ShadowFaceCull::BackFacesOnly, 0.0f, - 0.0f, - ShadowViewTarget{stats, ShadowViewGroup::Self, viewSlot, false}); - } - }); - - timeGroup(ProfilePass::ShadowSpot, validity.spot, - [&] - { - for (int s = 0; s < activeSpotCasters && s < kMaxSpotShadowCasters; ++s) - { - const auto viewSlot = static_cast(s); - const ShadowRenderView* view = viewFor(ShadowViewGroup::Spot, viewSlot); - if (view == nullptr) - { - continue; - } - ShadowPushConstants pc{}; - pc.matrixIndex = kShadowSpotMatrixBase + s; - const std::optional frustum = frustumFor(*view, cullingEnabled); - recordShadowIteration( - resources_->vulkanImage(spotShadowMapHandle_), static_cast(s), - resources_->vulkanShadowMapLayerView(spotShadowMapHandle_, - static_cast(s)), - kSpotShadowMapExtent, pc, shadowDraws, - ShadowDrawFilter{.frustum = frustum ? &*frustum : nullptr}, - lodContextFor(*view), shadowPipelines_, ShadowFaceCull::PerCaster, - kPunctualShadowRasterBiasConstant, kPunctualShadowRasterBiasSlope, - ShadowViewTarget{stats, ShadowViewGroup::Spot, viewSlot, true}); - } - }); - - timeGroup(ProfilePass::ShadowPoint, validity.point, - [&] - { - for (std::size_t p = 0; p < pointCasters.size() && - p < static_cast(kMaxPointShadowCasters); - ++p) - { - for (uint32_t face = 0; face < kCubeFaceCount; ++face) - { - // ONE derivation feeding the matrix slot, the attachment layer and the - // diagnostic row — the point family's matrix index, depth layer and view - // slot are all 6*p + face. - const std::size_t viewSlot = shadowPointViewSlot(p, face); - const ShadowRenderView* view = viewFor(ShadowViewGroup::Point, viewSlot); - if (view == nullptr) - { - continue; - } - ShadowPushConstants pc{}; - pc.matrixIndex = kShadowPointMatrixBase + static_cast(viewSlot); - pc.lightPosRange[0] = pointCasters[p].worldPosition.x(); - pc.lightPosRange[1] = pointCasters[p].worldPosition.y(); - pc.lightPosRange[2] = pointCasters[p].worldPosition.z(); - pc.lightPosRange[3] = pointCasters[p].range; - const std::optional frustum = frustumFor(*view, cullingEnabled); - recordShadowIteration( - resources_->vulkanImage(pointShadowMapHandle_), - static_cast(viewSlot), - resources_->vulkanPointShadowFaceView(pointShadowMapHandle_, - static_cast(p), face), - kPointShadowMapExtent, pc, shadowDraws, - ShadowDrawFilter{.frustum = frustum ? &*frustum : nullptr}, - lodContextFor(*view), shadowPipelines_, ShadowFaceCull::PerCaster, - kPunctualShadowRasterBiasConstant, kPunctualShadowRasterBiasSlope, - ShadowViewTarget{stats, ShadowViewGroup::Point, viewSlot, true}); - } - } - }); + // geometry; with no skinned draw this frame no world-only view is prepared, so the duplicate + // 4-cascade render is skipped entirely — and the receiver is TOLD (the WORLD_ONLY bit), rather + // than left to rely on nothing sampling it. + for (std::size_t g = 0; g < kShadowViewGroupCount; ++g) + { + const auto group = static_cast(g); + timeGroup(shadowProfilePass(group), plan.records(group), [&] { recordFamily(group); }); + } } } // namespace fire_engine diff --git a/tests/graphics/test_frame_info.cpp b/tests/graphics/test_frame_info.cpp index 793f947e..b893971d 100644 --- a/tests/graphics/test_frame_info.cpp +++ b/tests/graphics/test_frame_info.cpp @@ -56,15 +56,10 @@ TEST_CASE("FrameInfo.DefaultShadowPipelineIsNull", "[FrameInfo]") CHECK(info.shadowPipeline == NullPipeline); } -TEST_CASE("FrameInfo.DefaultShadowViewProjsAreZero", "[FrameInfo]") -{ - FrameInfo info; - Mat4 zero; - for (const Mat4& m : info.shadowViewProjs) - { - CHECK(m == zero); - } -} +// `shadowViewProjs` is gone: FrameInfo carried every shadow matrix in the frame so that +// Object::render could copy the table into each per-object ShadowUBO. Every shadow path now +// rasterises with the matrix in its view's push constants, so there is no table to carry, copy or +// index — and no second authority on the transform beside the view set. TEST_CASE("FrameInfo.AssignShadowPipelineRoundTrip", "[FrameInfo]") { @@ -73,20 +68,6 @@ TEST_CASE("FrameInfo.AssignShadowPipelineRoundTrip", "[FrameInfo]") CHECK(info.shadowPipeline == PipelineHandle{7}); } -TEST_CASE("FrameInfo.AssignShadowViewProjsRoundTrip", "[FrameInfo]") -{ - FrameInfo info; - Mat4 id = Mat4::identity(); - for (Mat4& m : info.shadowViewProjs) - { - m = id; - } - for (const Mat4& m : info.shadowViewProjs) - { - CHECK(m == id); - } -} - // --------------------------------------------------------------------------- // Aggregate initialization // --------------------------------------------------------------------------- diff --git a/tests/graphics/test_shadow_diagnostics.cpp b/tests/graphics/test_shadow_diagnostics.cpp index c33c4f2e..d640b34b 100644 --- a/tests/graphics/test_shadow_diagnostics.cpp +++ b/tests/graphics/test_shadow_diagnostics.cpp @@ -9,6 +9,20 @@ using namespace fire_engine; +namespace +{ + +// What the old single `beginRasterPass(identity)` did, now that engagement and raster-pass +// accounting are separate calls: claim the row for a logical view AND count one rasterised layer. +// Most cases below are about the identity rules, which are `claimView`'s; the ones that care about +// the split assert on `claimed()` / `touched()` / `rasterPasses` directly. +[[nodiscard]] bool engageRow(ShadowViewStats& row, ShadowLogicalViewId view) noexcept +{ + return row.claimView(view) && row.beginRasterPass(view); +} + +} // namespace + TEST_CASE("shadow view slots are a dense, collision-free flattening", "[ShadowDiagnostics]") { // Every (group, slot) must map to its own index, and the indices must exactly fill @@ -68,7 +82,7 @@ TEST_CASE("candidate and drawn are counted independently", "[ShadowDiagnostics]" // Three casters offered to cascade 0; the middle one is frustum-rejected. SH-03 split the two // triangle counts: the first is FULL DETAIL (what the view was offered, known without // resolving), the second is what this view's resolution actually draws. - REQUIRE(cascade0.beginRasterPass(ShadowLogicalViewId::cascade(0))); + REQUIRE(engageRow(cascade0, ShadowLogicalViewId::cascade(0))); cascade0.observe(100, true, 40, 0, ShadowLodReason::Selected, true); // Rejected before resolution, so it has no resolved count, level or reason to contribute. cascade0.observe(50, false, 0, 0, ShadowLodReason::Count, true); @@ -96,9 +110,9 @@ TEST_CASE("a twice-rasterised self-shadow view doubles cost but not selection", ShadowFrameStats stats; ShadowViewStats& self = stats.view(ShadowViewGroup::Self, 2); - REQUIRE(self.beginRasterPass(ShadowLogicalViewId::self(9))); + REQUIRE(engageRow(self, ShadowLogicalViewId::self(9))); self.observe(64, true, 30, 1, ShadowLodReason::Selected, true); // first layer: counts selection - REQUIRE(self.beginRasterPass(ShadowLogicalViewId::self(9))); + REQUIRE(engageRow(self, ShadowLogicalViewId::self(9))); self.observe(64, true, 30, 1, ShadowLodReason::Selected, false); // second layer: cost only CHECK(self.rasterPasses == 2); @@ -117,7 +131,7 @@ TEST_CASE("a rasterised view with no candidates stays visible", "[ShadowDiagnost // the panel. ShadowFrameStats stats; ShadowViewStats& cascade2 = stats.view(ShadowViewGroup::Cascade, 2); - REQUIRE(cascade2.beginRasterPass(ShadowLogicalViewId::cascade(2))); + REQUIRE(engageRow(cascade2, ShadowLogicalViewId::cascade(2))); CHECK(cascade2.rasterPasses == 1); CHECK(cascade2.candidateDraws == 0); @@ -133,7 +147,7 @@ TEST_CASE("drawn can never exceed candidate", "[ShadowDiagnostics]") // promised metric, and this is what makes the promise structural rather than conventional. ShadowFrameStats stats; ShadowViewStats& spot = stats.view(ShadowViewGroup::Spot, 1); - REQUIRE(spot.beginRasterPass(ShadowLogicalViewId::spot(static_cast(4)))); + REQUIRE(engageRow(spot, ShadowLogicalViewId::spot(static_cast(4)))); for (std::uint32_t i = 0; i < 5; ++i) { spot.observe(10, i % 2 == 0, 4, i, ShadowLodReason::Selected, true); @@ -149,12 +163,11 @@ TEST_CASE("group and scene rollups sum their slots", "[ShadowDiagnostics]") ShadowFrameStats stats; for (const std::size_t slot : {std::size_t{0}, std::size_t{3}}) { - REQUIRE( - stats.view(ShadowViewGroup::Cascade, slot) - .beginRasterPass(ShadowLogicalViewId::cascade(static_cast(slot)))); + REQUIRE(engageRow(stats.view(ShadowViewGroup::Cascade, slot), + ShadowLogicalViewId::cascade(static_cast(slot)))); } - REQUIRE(stats.view(ShadowViewGroup::Point, shadowPointViewSlot(1, 4)) - .beginRasterPass(ShadowLogicalViewId::point(static_cast(6), 4))); + REQUIRE(engageRow(stats.view(ShadowViewGroup::Point, shadowPointViewSlot(1, 4)), + ShadowLogicalViewId::point(static_cast(6), 4))); stats.view(ShadowViewGroup::Cascade, 0) .observe(10, true, 10, 0, ShadowLodReason::Selected, true); @@ -185,16 +198,16 @@ TEST_CASE("activeViewCount reports rasterised slots", "[ShadowDiagnostics]") ShadowFrameStats stats; CHECK(stats.activeViewCount(ShadowViewGroup::Spot) == 0); - REQUIRE(stats.view(ShadowViewGroup::Spot, 0) - .beginRasterPass(ShadowLogicalViewId::spot(static_cast(1)))); - REQUIRE(stats.view(ShadowViewGroup::Spot, 2) - .beginRasterPass(ShadowLogicalViewId::spot(static_cast(2)))); + REQUIRE(engageRow(stats.view(ShadowViewGroup::Spot, 0), + ShadowLogicalViewId::spot(static_cast(1)))); + REQUIRE(engageRow(stats.view(ShadowViewGroup::Spot, 2), + ShadowLogicalViewId::spot(static_cast(2)))); CHECK(stats.activeViewCount(ShadowViewGroup::Spot) == 2); // A view whose every candidate was culled is still ACTIVE — it was rasterised (cleared), and // hiding it would hide "this map ran and drew nothing", which is the interesting case. - REQUIRE(stats.view(ShadowViewGroup::Spot, 3) - .beginRasterPass(ShadowLogicalViewId::spot(static_cast(3)))); + REQUIRE(engageRow(stats.view(ShadowViewGroup::Spot, 3), + ShadowLogicalViewId::spot(static_cast(3)))); stats.view(ShadowViewGroup::Spot, 3).observe(5, false, 0, 0, ShadowLodReason::Count, true); CHECK(stats.activeViewCount(ShadowViewGroup::Spot) == 3); CHECK(stats.view(ShadowViewGroup::Spot, 3).drawnDraws == 0); @@ -209,8 +222,8 @@ TEST_CASE("LOD reasons are recorded per view, level 0 distinguishable from force ShadowFrameStats stats; ShadowViewStats& cascade = stats.view(ShadowViewGroup::Cascade, 0); ShadowViewStats& spot = stats.view(ShadowViewGroup::Spot, 0); - REQUIRE(cascade.beginRasterPass(ShadowLogicalViewId::cascade(0))); - REQUIRE(spot.beginRasterPass(ShadowLogicalViewId::spot(static_cast(5)))); + REQUIRE(engageRow(cascade, ShadowLogicalViewId::cascade(0))); + REQUIRE(engageRow(spot, ShadowLogicalViewId::spot(static_cast(5)))); cascade.observe(10, true, 10, 0, ShadowLodReason::Selected, true); // level 0, within budget cascade.observe(10, true, 2, 2, ShadowLodReason::Selected, true); // level 2 @@ -234,7 +247,7 @@ TEST_CASE("a focused view distinguishes 'ran and drew nothing' from 'never ran'" // has nothing measured at all, and reporting zeros for it would state that finding falsely. ShadowFrameStats stats; const auto lit = ShadowLogicalViewId::spot(static_cast(11)); - REQUIRE(stats.view(ShadowViewGroup::Spot, 1).beginRasterPass(lit)); + REQUIRE(engageRow(stats.view(ShadowViewGroup::Spot, 1), lit)); const FocusedShadowView ran = stats.focused( ShadowViewFocus{.perView = true, .group = ShadowViewGroup::Spot, .view = lit}); @@ -261,32 +274,32 @@ TEST_CASE("one diagnostic row belongs to one logical view", "[ShadowDiagnostics] ShadowViewStats& slot = stats.view(ShadowViewGroup::Spot, 0); const auto first = ShadowLogicalViewId::spot(static_cast(31)); - REQUIRE(slot.beginRasterPass(first)); + REQUIRE(engageRow(slot, first)); slot.observe(10, true, 10, 0, ShadowLodReason::Selected, true); // The SAME identity again is the normal case — a self-shadow slot's two depth layers — and is // accepted in every build. - CHECK(slot.beginRasterPass(first)); + CHECK(engageRow(slot, first)); CHECK(slot.rasterPasses == 2); #ifdef NDEBUG // Dev asserts at the contradiction (and the renderer's call site throws either way); this is // the release behaviour, which must leave the row exactly as it was. const auto second = ShadowLogicalViewId::spot(static_cast(32)); - CHECK_FALSE(slot.beginRasterPass(second)); + CHECK_FALSE(engageRow(slot, second)); CHECK(slot.rasterPasses == 2); CHECK(slot.logicalId == first); CHECK(slot.drawnDraws == 1); // An invalid identity is refused before it can even count the pass. - CHECK_FALSE(slot.beginRasterPass(ShadowLogicalViewId{})); + CHECK_FALSE(engageRow(slot, ShadowLogicalViewId{})); CHECK(slot.rasterPasses == 2); CHECK(slot.logicalId == first); // On a fresh row an invalid identity leaves it untouched, rather than "rasterised but unnamed" // — a row nothing could ever select. ShadowViewStats& fresh = stats.view(ShadowViewGroup::Spot, 1); - CHECK_FALSE(fresh.beginRasterPass(ShadowLogicalViewId{})); + CHECK_FALSE(engageRow(fresh, ShadowLogicalViewId{})); CHECK_FALSE(fresh.touched()); #endif } @@ -306,8 +319,8 @@ TEST_CASE("a focus must pair its group with a compatible identity kind", "[Shado STATIC_REQUIRE(shadowViewKindFor(ShadowViewGroup::Count) == ShadowLogicalViewKind::Invalid); ShadowFrameStats stats; - REQUIRE(stats.view(ShadowViewGroup::Spot, 0) - .beginRasterPass(ShadowLogicalViewId::spot(static_cast(41)))); + REQUIRE(engageRow(stats.view(ShadowViewGroup::Spot, 0), + ShadowLogicalViewId::spot(static_cast(41)))); const ShadowViewFocus mismatched{ .perView = true, .group = ShadowViewGroup::Spot, .view = ShadowLogicalViewId::cascade(0)}; @@ -334,8 +347,7 @@ TEST_CASE("an unaddressable focus is a different state from an inactive view", // (structurally malformed — no frame can satisfy it, so re-select) versus a well-formed focus // simply "not present in this frame" (which says nothing about whether it returns). ShadowFrameStats stats; - REQUIRE( - stats.view(ShadowViewGroup::Cascade, 0).beginRasterPass(ShadowLogicalViewId::cascade(0))); + REQUIRE(engageRow(stats.view(ShadowViewGroup::Cascade, 0), ShadowLogicalViewId::cascade(0))); // The scene rollup names no view at all — and is not addressable, so the panel takes its own // branch rather than being handed one view's numbers. @@ -365,8 +377,8 @@ TEST_CASE("focusing follows the view, not the slot's occupant", "[ShadowDiagnost const auto second = ShadowLogicalViewId::spot(static_cast(22)); ShadowFrameStats before; - REQUIRE(before.view(ShadowViewGroup::Spot, 0).beginRasterPass(first)); - REQUIRE(before.view(ShadowViewGroup::Spot, 1).beginRasterPass(second)); + REQUIRE(engageRow(before.view(ShadowViewGroup::Spot, 0), first)); + REQUIRE(engageRow(before.view(ShadowViewGroup::Spot, 1), second)); before.view(ShadowViewGroup::Spot, 1).observe(30, true, 12, 1, ShadowLodReason::Selected, true); const ShadowViewFocus focus{.perView = true, .group = ShadowViewGroup::Spot, .view = second}; @@ -378,7 +390,7 @@ TEST_CASE("focusing follows the view, not the slot's occupant", "[ShadowDiagnost // Next frame the first light is gone, so `second` compacts down into slot 0 and draws // something different. The focus must follow the LIGHT. ShadowFrameStats after; - REQUIRE(after.view(ShadowViewGroup::Spot, 0).beginRasterPass(second)); + REQUIRE(engageRow(after.view(ShadowViewGroup::Spot, 0), second)); after.view(ShadowViewGroup::Spot, 0).observe(30, true, 7, 2, ShadowLodReason::Selected, true); const FocusedShadowView moved = after.focused(focus); @@ -401,11 +413,11 @@ TEST_CASE("a cascade and its world-only twin share an identity but not a row", // and an identity alone could not tell them apart. ShadowFrameStats stats; const auto shared = ShadowLogicalViewId::cascade(2); - REQUIRE(stats.view(ShadowViewGroup::Cascade, 2).beginRasterPass(shared)); + REQUIRE(engageRow(stats.view(ShadowViewGroup::Cascade, 2), shared)); stats.view(ShadowViewGroup::Cascade, 2) .observe(50, true, 50, 0, ShadowLodReason::Selected, true); - REQUIRE(stats.view(ShadowViewGroup::WorldOnly, 2) - .beginRasterPass(ShadowLogicalViewId::worldOnly(2))); + REQUIRE( + engageRow(stats.view(ShadowViewGroup::WorldOnly, 2), ShadowLogicalViewId::worldOnly(2))); stats.view(ShadowViewGroup::WorldOnly, 2) .observe(20, true, 20, 0, ShadowLodReason::Selected, true); @@ -513,8 +525,7 @@ TEST_CASE("every reason and group has a name", "[ShadowDiagnostics]") TEST_CASE("reset clears every counter", "[ShadowDiagnostics]") { ShadowFrameStats stats; - REQUIRE( - stats.view(ShadowViewGroup::Cascade, 1).beginRasterPass(ShadowLogicalViewId::cascade(1))); + REQUIRE(engageRow(stats.view(ShadowViewGroup::Cascade, 1), ShadowLogicalViewId::cascade(1))); stats.view(ShadowViewGroup::Cascade, 1) .observe(99, true, 40, 2, ShadowLodReason::Selected, true); @@ -527,3 +538,98 @@ TEST_CASE("reset clears every counter", "[ShadowDiagnostics]") CHECK(stats.sceneTotal().rasterPasses == 0); CHECK(stats.activeViewCount(ShadowViewGroup::Cascade) == 0); } + +TEST_CASE("claiming a view and rasterising a layer are separate facts", "[ShadowDiagnostics]") +{ + // The split the shadow cache needs. A view whose map is REUSED is claimed and observed while + // rasterising nothing, so a row forced to count a raster pass in order to be observable would + // report intended work as performed work — and reuse would be unobservable. + ShadowFrameStats stats; + ShadowViewStats& cascade = stats.view(ShadowViewGroup::Cascade, 1); + + CHECK_FALSE(cascade.claimed()); + CHECK_FALSE(cascade.touched()); + + const ShadowLogicalViewId view = ShadowLogicalViewId::cascade(1); + REQUIRE(cascade.claimView(view)); + CHECK(cascade.claimed()); + // Claimed, no raster pass recorded. That is all this state says on its own — it also describes + // a view that will be recorded later this frame, and (once caching lands) a recorder bug that + // omitted its work. Proving REUSE needs the plan's disposition, which the cache will record. + CHECK_FALSE(cascade.touched()); + CHECK(cascade.rasterPasses == 0); + + // Observation belongs to the CLAIM, not to a raster pass: the counters describe what the map + // holds, which is knowable before (and without) any recording. + cascade.observe(100, true, 40, 0, ShadowLodReason::Selected, true); + CHECK(cascade.candidateDraws == 1); + CHECK(cascade.drawnDraws == 1); + CHECK(cascade.rasterPasses == 0); + + // And the recorder's call is what turns it into GPU work. + REQUIRE(cascade.beginRasterPass(view)); + CHECK(cascade.touched()); + CHECK(cascade.rasterPasses == 1); + REQUIRE(cascade.beginRasterPass(view)); // a second layer of the same view + CHECK(cascade.rasterPasses == 2); +} + +TEST_CASE("a raster pass cannot be counted for an unclaimed or mismatched row", + "[ShadowDiagnostics]") +{ +#ifdef NDEBUG + // Two refusals. GPU work attributed to NO view carries a cost with nothing to name it; work + // attributed to the WRONG view is worse, because the row stays plausible under the identity + // that claimed it. Dev builds assert at the source; this is the release contract. + ShadowFrameStats stats; + ShadowViewStats& unclaimed = stats.view(ShadowViewGroup::Spot, 0); + CHECK_FALSE(unclaimed.beginRasterPass(ShadowLogicalViewId::spot(static_cast(1)))); + CHECK(unclaimed.rasterPasses == 0); + CHECK_FALSE(unclaimed.claimed()); + + // A claimed, B rasterises: refused, and the pass count stays at zero. + ShadowViewStats& row = stats.view(ShadowViewGroup::Spot, 1); + const ShadowLogicalViewId a = ShadowLogicalViewId::spot(static_cast(7)); + const ShadowLogicalViewId b = ShadowLogicalViewId::spot(static_cast(8)); + REQUIRE(row.claimView(a)); + CHECK_FALSE(row.beginRasterPass(b)); + CHECK(row.rasterPasses == 0); + // The claim is untouched — a rejected raster pass must not re-point the row at B. + CHECK(row.logicalId == a); + // And A can still record. + CHECK(row.beginRasterPass(a)); + CHECK(row.rasterPasses == 1); +#else + SUCCEED( + "Dev builds assert inside beginRasterPass; the release contract is tested under NDEBUG"); +#endif +} + +TEST_CASE("a claimed view can be focused even with no raster pass", "[ShadowDiagnostics]") +{ + // PRESENCE IS THE CLAIM, not the work. A view whose map is reused records nothing, and keying + // focus off rasterisation would make the panel's selection — and the ShadowLod tint that + // follows it — vanish the moment a view became free. That is the case the cache exists to + // produce, so it must be the case focus handles. + ShadowFrameStats stats; + const ShadowLogicalViewId spot = ShadowLogicalViewId::spot(static_cast(11)); + ShadowViewStats& row = stats.view(ShadowViewGroup::Spot, 2); + REQUIRE(row.claimView(spot)); + row.observe(90, true, 30, 1, ShadowLodReason::Selected, true); + REQUIRE_FALSE(row.touched()); // claimed, no raster pass recorded + + const FocusedShadowView found = stats.focused( + ShadowViewFocus{.perView = true, .group = ShadowViewGroup::Spot, .view = spot}); + REQUIRE(found.stats != nullptr); + CHECK(found.slot == 2); + CHECK(found.stats->candidateDraws == 1); + CHECK(found.stats->rasterPasses == 0); + + // An UNCLAIMED row is still absent: its identity is whatever the slot last held, possibly + // frames ago, so matching against it would resurrect a stale view. + const FocusedShadowView stale = + stats.focused(ShadowViewFocus{.perView = true, + .group = ShadowViewGroup::Spot, + .view = ShadowLogicalViewId::spot(static_cast(12))}); + CHECK(stale.stats == nullptr); +} diff --git a/tests/graphics/test_shadow_lod_resolver.cpp b/tests/graphics/test_shadow_lod_resolver.cpp index b6dcac2a..0480f64d 100644 --- a/tests/graphics/test_shadow_lod_resolver.cpp +++ b/tests/graphics/test_shadow_lod_resolver.cpp @@ -72,7 +72,7 @@ ShadowGeometryRequest request(const std::vector& lods, ShadowCaster .lods = lods, .baseIndexBuffer = buffer(10), .baseIndexCount = 900, - .worldScale = 1.0f, + .pose = ShadowCasterPose::fromModel(Mat4::identity()), .casterId = caster, .generation = generation, .lodEnabled = true, @@ -118,7 +118,7 @@ ShadowRenderViewSet populatedViews() // are passed once. const std::array cube{ pointFace(0), pointFace(1), pointFace(2), pointFace(3), pointFace(4), pointFace(5)}; - REQUIRE(views.setPointLight(0, light, somePointMetrics(), + REQUIRE(views.setPointLight(0, light, somePointMetrics(), 30.0f, std::span{cube})); return views; } @@ -451,7 +451,7 @@ TEST_CASE("ShadowLodResolver.AnUnsetWorldScaleForcesAFallback", "[ShadowLodResol resolver.beginFrame(); auto unset = request(lods, static_cast(15)); - unset.worldScale = ShadowGeometryRequest{}.worldScale; // i.e. never assigned + unset.pose = ShadowCasterPose{}; // i.e. never stated const ResolvedShadowDraw forced = resolver.resolve( unset, view(views, ShadowViewGroup::Cascade, 0), someBounds(), kBudget, kNoHysteresis); CHECK(forced.reason == ShadowLodReason::InvalidCaster); @@ -460,7 +460,7 @@ TEST_CASE("ShadowLodResolver.AnUnsetWorldScaleForcesAFallback", "[ShadowLodResol // An explicitly computed zero still selects — it is a real answer about a real transform. resolver.beginFrame(); auto singular = request(lods, static_cast(16)); - singular.worldScale = 0.0f; + singular.pose = ShadowCasterPose::fromModel(Mat4::scale(Vec3{0.0f, 0.0f, 0.0f})); const ResolvedShadowDraw flattened = resolver.resolve( singular, view(views, ShadowViewGroup::Cascade, 0), someBounds(), kBudget, kNoHysteresis); CHECK(flattened.reason == ShadowLodReason::Selected); @@ -469,10 +469,10 @@ TEST_CASE("ShadowLodResolver.AnUnsetWorldScaleForcesAFallback", "[ShadowLodResol TEST_CASE("ShadowLodResolver.FrameResolutionExposesTheSharedDecision", "[ShadowLodResolver]") { - // The DECISION, independent of which families acted on it — no `noteDrawn` here on purpose. + // The DECISION, independent of which families acted on it — no `noteContent` here on purpose. // This is the entry every view with that identity is handed, and the one a consumer reasoning // about the decision itself wants. Attribution is a separate question with a separate query - // (`drawnResolution`, covered below), because the shared decision alone cannot say which pass + // (`contentResolution`, covered below), because the shared decision alone cannot say which pass // drew what. // // What both share: it is the entry the pass drew from, never a fresh selection — one would see @@ -536,31 +536,31 @@ TEST_CASE("ShadowLodResolver.ProvenanceIsPerFamilyEvenWhenTheResolutionIsShared" const ResolvedShadowDraw drawn = resolver.resolve(request(lods, skinned), view(views, ShadowViewGroup::Cascade, 0), someBounds(), kBudget, kNoHysteresis); - resolver.noteDrawn(ShadowViewGroup::Cascade, key); + resolver.noteContent(ShadowViewGroup::Cascade, key); // The DECISION is shared — asking for it plainly still finds the level ... REQUIRE(resolver.frameResolution(key) != nullptr); CHECK(resolver.frameResolution(key)->level == drawn.level); // ... but attributing it to a pass is a different question, and the one consumers must ask. - REQUIRE(resolver.drawnResolution(ShadowViewGroup::Cascade, key) != nullptr); - CHECK(resolver.drawnResolution(ShadowViewGroup::Cascade, key)->level == drawn.level); - CHECK(resolver.drawnResolution(ShadowViewGroup::WorldOnly, key) == nullptr); + REQUIRE(resolver.contentResolution(ShadowViewGroup::Cascade, key) != nullptr); + CHECK(resolver.contentResolution(ShadowViewGroup::Cascade, key)->level == drawn.level); + CHECK(resolver.contentResolution(ShadowViewGroup::WorldOnly, key) == nullptr); // Once world-only does draw it, both attribute the same shared decision. - resolver.noteDrawn(ShadowViewGroup::WorldOnly, key); - REQUIRE(resolver.drawnResolution(ShadowViewGroup::WorldOnly, key) != nullptr); - CHECK(resolver.drawnResolution(ShadowViewGroup::WorldOnly, key)->level == drawn.level); - CHECK(resolver.drawnResolution(ShadowViewGroup::Cascade, key) != nullptr); + resolver.noteContent(ShadowViewGroup::WorldOnly, key); + REQUIRE(resolver.contentResolution(ShadowViewGroup::WorldOnly, key) != nullptr); + CHECK(resolver.contentResolution(ShadowViewGroup::WorldOnly, key)->level == drawn.level); + CHECK(resolver.contentResolution(ShadowViewGroup::Cascade, key) != nullptr); // A caster nobody drew has no attribution anywhere. const ShadowLodStateKey unseen{static_cast(21), ShadowCasterGeneration::First, ShadowLogicalViewId::cascade(0)}; - CHECK(resolver.drawnResolution(ShadowViewGroup::Cascade, unseen) == nullptr); + CHECK(resolver.contentResolution(ShadowViewGroup::Cascade, unseen) == nullptr); // Provenance is per FRAME: a view that stops drawing a caster must stop attributing it. resolver.commitFrame(); resolver.beginFrame(); - CHECK(resolver.drawnResolution(ShadowViewGroup::Cascade, key) == nullptr); + CHECK(resolver.contentResolution(ShadowViewGroup::Cascade, key) == nullptr); } TEST_CASE("ShadowLodResolver.MarkingAnUnresolvedCasterDrawnChangesNothing", "[ShadowLodResolver]") @@ -575,17 +575,17 @@ TEST_CASE("ShadowLodResolver.MarkingAnUnresolvedCasterDrawnChangesNothing", "[Sh const ShadowLodStateKey neverResolved{static_cast(22), ShadowCasterGeneration::First, ShadowLogicalViewId::cascade(0)}; - resolver.noteDrawn(ShadowViewGroup::Cascade, neverResolved); + resolver.noteContent(ShadowViewGroup::Cascade, neverResolved); CHECK(resolver.frameCacheSize() == 0); CHECK(resolver.frameResolution(neverResolved) == nullptr); - CHECK(resolver.drawnResolution(ShadowViewGroup::Cascade, neverResolved) == nullptr); + CHECK(resolver.contentResolution(ShadowViewGroup::Cascade, neverResolved) == nullptr); // An unkeyable caster is in no store at all, so it likewise gains nothing. const ShadowLodStateKey unkeyable{ShadowCasterId::Invalid, ShadowCasterGeneration::First, ShadowLogicalViewId::cascade(0)}; - resolver.noteDrawn(ShadowViewGroup::Cascade, unkeyable); + resolver.noteContent(ShadowViewGroup::Cascade, unkeyable); CHECK(resolver.frameCacheSize() == 0); - CHECK(resolver.drawnResolution(ShadowViewGroup::Cascade, unkeyable) == nullptr); + CHECK(resolver.contentResolution(ShadowViewGroup::Cascade, unkeyable) == nullptr); #endif } @@ -623,15 +623,15 @@ TEST_CASE("ShadowLodResolver.OneCasterTintsDifferentlyPerFocusedView", "[ShadowL return ShadowLodStateKey{caster, ShadowCasterGeneration::First, ShadowLogicalViewId::cascade(cascade)}; }; - resolver.noteDrawn(ShadowViewGroup::Cascade, keyFor(0)); - resolver.noteDrawn(ShadowViewGroup::Cascade, keyFor(1)); + resolver.noteContent(ShadowViewGroup::Cascade, keyFor(0)); + resolver.noteContent(ShadowViewGroup::Cascade, keyFor(1)); // Read back through the TINT's query, per view: focusing one must not change what the other // reports. This is the SH-03 fix in the one place a person can see it. const auto tintLevelFor = [&](std::uint32_t cascade) { const ResolvedShadowDraw* r = - resolver.drawnResolution(ShadowViewGroup::Cascade, keyFor(cascade)); + resolver.contentResolution(ShadowViewGroup::Cascade, keyFor(cascade)); REQUIRE(r != nullptr); return r->level; }; @@ -947,7 +947,7 @@ TEST_CASE("ShadowLodResolver.ARequestThatOmitsDeformationDoesNotSelect", "[Shado req.lods = lods; req.baseIndexBuffer = buffer(10); req.baseIndexCount = 900; - req.worldScale = 1.0f; + req.pose = ShadowCasterPose::fromModel(Mat4::identity()); req.casterId = static_cast(7); const ResolvedShadowDraw resolved = @@ -1084,7 +1084,7 @@ TEST_CASE("ShadowLodResolver.ARequestThatOmitsTheAlphaModeDoesNotSelect", "[Shad req.lods = lods; req.baseIndexBuffer = buffer(10); req.baseIndexCount = 900; - req.worldScale = 1.0f; + req.pose = ShadowCasterPose::fromModel(Mat4::identity()); req.casterId = static_cast(37); // Deformation stated, so this case isolates the ALPHA default rather than tripping SH-04's. req.deformation = ShadowCasterDeformation::Rigid; diff --git a/tests/graphics/test_shadow_pass_plan.cpp b/tests/graphics/test_shadow_pass_plan.cpp new file mode 100644 index 00000000..9226ec88 --- /dev/null +++ b/tests/graphics/test_shadow_pass_plan.cpp @@ -0,0 +1,651 @@ +#include + +#include + +using namespace fire_engine; + +namespace +{ + +constexpr auto kCaster = static_cast(7); +constexpr auto kOtherCaster = static_cast(8); + +Mat4 translation(float x) +{ + return Mat4::translate(Vec3{x, 0.0f, 0.0f}); +} + +PreparedShadowDraw rigidDraw() +{ + return PreparedShadowDraw{ + .casterId = kCaster, + .generation = ShadowCasterGeneration::First, + .model = translation(1.0f), + .vertexBuffer = static_cast(11), + .indexBuffer = static_cast(12), + .indexCount = 300, + .indexType = DrawIndexType::UInt16, + .alpha = ShadowCasterAlpha::Opaque, + .materialIndex = 3, + .cull = ShadowEffectiveCull::FrontFaces, + .deformable = false, + .level = 1, + .reason = ShadowLodReason::Selected, + }; +} + +PreparedShadowView cascadeView() +{ + return PreparedShadowView::projected(ShadowLogicalViewId::cascade(1), translation(4.0f), 2048, + 0.0f, 2.0f); +} + +PreparedShadowView viewWith(const PreparedShadowDraw& draw) +{ + PreparedShadowView view = cascadeView(); + REQUIRE(view.addDraw(draw)); + return view; +} + +ShadowViewResidency residentFrom(const PreparedShadowView& view) +{ + ShadowViewResidency residency{}; + residency.commit(view); + return residency; +} + +// A point face: the depth it stores is a distance/range ratio taken against the light, so the light +// itself is part of the content. +PreparedShadowView pointFace(Vec3 lightPosition = Vec3{2.0f, 3.0f, 4.0f}, float range = 25.0f) +{ + return PreparedShadowView::pointFace(ShadowLogicalViewId::point(static_cast(1), 3), + translation(4.0f), 1024, 0.0f, 2.0f, lightPosition, range); +} + +PreparedShadowView pointFaceWith(const PreparedShadowDraw& draw, + Vec3 lightPosition = Vec3{2.0f, 3.0f, 4.0f}, float range = 25.0f) +{ + PreparedShadowView view = pointFace(lightPosition, range); + REQUIRE(view.addDraw(draw)); + return view; +} + +} // namespace + +TEST_CASE("identical prepared content reuses the resident map", "[ShadowPassPlan]") +{ + const PreparedShadowView prepared = viewWith(rigidDraw()); + const ShadowViewResidency resident = residentFrom(prepared); + CHECK(shadowViewDisposition(true, prepared, resident) == ShadowViewDisposition::Reused); + CHECK(shadowViewSampleable(ShadowViewDisposition::Reused)); + CHECK_FALSE(shadowViewRecords(ShadowViewDisposition::Reused)); +} + +TEST_CASE("an inactive view is invalid whatever its image holds", "[ShadowPassPlan]") +{ + const PreparedShadowView prepared = viewWith(rigidDraw()); + const ShadowViewResidency resident = residentFrom(prepared); + // The view set is the authority on engagement (SH-03). Content that matches is irrelevant if + // nothing this frame vouches for the matrix behind it. + const ShadowViewDisposition disposition = shadowViewDisposition(false, prepared, resident); + CHECK(disposition == ShadowViewDisposition::Invalid); + CHECK_FALSE(shadowViewSampleable(disposition)); + CHECK_FALSE(shadowViewRecords(disposition)); +} + +TEST_CASE("first use must record even with matching content", "[ShadowPassPlan]") +{ + // Creation transitions the image to the read-only layout but leaves its depth undefined, so an + // uncommitted slot has no answer to reuse — the one case where the right layout is not enough. + const PreparedShadowView prepared = viewWith(rigidDraw()); + const ShadowViewResidency empty{}; + CHECK_FALSE(empty.hasContent()); + CHECK(empty.content() == nullptr); + CHECK(shadowViewDisposition(true, prepared, empty) == ShadowViewDisposition::Recorded); + + // And once it IS committed, the same prepared work reuses — then `invalidate()` (an image + // recreated under it) takes it back to recording. + ShadowViewResidency resident = residentFrom(prepared); + CHECK(shadowViewDisposition(true, prepared, resident) == ShadowViewDisposition::Reused); + resident.invalidate(); + CHECK(shadowViewDisposition(true, prepared, resident) == ShadowViewDisposition::Recorded); +} + +TEST_CASE("a point face's light position and range are part of its content", "[ShadowPassPlan]") +{ + // The depth a point face stores is `length(worldPos - lightPos) / range`, written to + // gl_FragDepth from the push constants — NOT a consequence of the face matrix. A light that + // moves or re-ranges with an unchanged matrix changes every texel, so an equal viewProj is not + // enough to reuse. + const PreparedShadowView resident = pointFaceWith(rigidDraw()); + CHECK(shadowViewDisposition(true, pointFaceWith(rigidDraw()), residentFrom(resident)) == + ShadowViewDisposition::Reused); + + const PreparedShadowView moved = pointFaceWith(rigidDraw(), Vec3{2.0f, 3.0f, 4.01f}); + CHECK(moved.viewProj() == resident.viewProj()); // the hole this closes: matrices agree + CHECK(shadowViewDisposition(true, moved, residentFrom(resident)) == + ShadowViewDisposition::Recorded); + + const PreparedShadowView reranged = pointFaceWith(rigidDraw(), Vec3{2.0f, 3.0f, 4.0f}, 26.0f); + CHECK(reranged.viewProj() == resident.viewProj()); + CHECK(shadowViewDisposition(true, reranged, residentFrom(resident)) == + ShadowViewDisposition::Recorded); +} + +TEST_CASE("the depth mode follows the identity and cannot be set against it", "[ShadowPassPlan]") +{ + // Projected hardware depth and a radial ratio are different numbers in the same texels, so a + // point face prepared as projected would compare without its light while the shader still took + // the radial branch. That state is unrepresentable: the mode is derived from the identity, and + // each factory refuses the identity it cannot serve. + CHECK(cascadeView().depthMode() == ShadowDepthMode::Projected); + CHECK(pointFace().depthMode() == ShadowDepthMode::RadialRatio); + + const PreparedShadowView pointAsProjected = PreparedShadowView::projected( + ShadowLogicalViewId::point(static_cast(1), 3), Mat4::identity(), 1024, 0.0f, 0.0f); + CHECK_FALSE(pointAsProjected.valid()); + + const PreparedShadowView cascadeAsPoint = PreparedShadowView::pointFace( + ShadowLogicalViewId::cascade(0), Mat4::identity(), 2048, 0.0f, 0.0f, Vec3{}, 10.0f); + CHECK_FALSE(cascadeAsPoint.valid()); + + // And a default-constructed view is invalid rather than "cascade 0 with no draws". + CHECK_FALSE(PreparedShadowView{}.valid()); + CHECK(cascadeView().valid()); +} + +TEST_CASE("a projected view carries no light to compare", "[ShadowPassPlan]") +{ + // Cascade, spot and self views write fixed-function depth and carry no light position in their + // push constants — there is no setter that could give one a stray light, and the comparison + // would ignore it anyway. + const PreparedShadowView view = cascadeView(); + CHECK(view.lightRange() == 0.0f); + CHECK(view.lightPosition().x() == 0.0f); + CHECK(shadowViewDisposition(true, viewWith(rigidDraw()), residentFrom(viewWith(rigidDraw()))) == + ShadowViewDisposition::Reused); +} + +TEST_CASE("a moved caster records even though its buffers and bounds are unchanged", + "[ShadowPassPlan]") +{ + // THE case the review flagged: bounds and geometry identity say nothing about the matrix, and + // two transforms can share an AABB while rasterising different pixels. + const PreparedShadowView resident = viewWith(rigidDraw()); + PreparedShadowDraw moved = rigidDraw(); + moved.model = translation(1.5f); + CHECK(shadowViewDisposition(true, viewWith(moved), residentFrom(resident)) == + ShadowViewDisposition::Recorded); +} + +TEST_CASE("a re-fitted view records — the matrix is compared, not the fit that explains it", + "[ShadowPassPlan]") +{ + const PreparedShadowView resident = viewWith(rigidDraw()); + PreparedShadowView refitted = PreparedShadowView::projected( + ShadowLogicalViewId::cascade(1), translation(4.5f), 2048, 0.0f, 2.0f); + REQUIRE(refitted.addDraw(rigidDraw())); + CHECK(shadowViewDisposition(true, refitted, residentFrom(resident)) == + ShadowViewDisposition::Recorded); +} + +TEST_CASE("a swapped LOD carrier records even at the same level", "[ShadowPassPlan]") +{ + // The resolved carrier is the geometry; the level only names the decision. A chain rebuilt + // behind the same level number is different pixels. + const PreparedShadowView resident = viewWith(rigidDraw()); + PreparedShadowDraw swapped = rigidDraw(); + swapped.indexBuffer = static_cast(99); + CHECK(shadowViewDisposition(true, viewWith(swapped), residentFrom(resident)) == + ShadowViewDisposition::Recorded); + + PreparedShadowDraw coarser = rigidDraw(); + coarser.indexCount = 150; + CHECK(shadowViewDisposition(true, viewWith(coarser), residentFrom(resident)) == + ShadowViewDisposition::Recorded); +} + +TEST_CASE("the level and reason are diagnostics and do not force a re-record", "[ShadowPassPlan]") +{ + // Two levels resolving to one carrier is genuinely the same image. Comparing the level would + // cost a re-record for no pixel difference — correctness-neutral, but a real loss. + const PreparedShadowView resident = viewWith(rigidDraw()); + PreparedShadowDraw relabelled = rigidDraw(); + relabelled.level = 2; + relabelled.reason = ShadowLodReason::SingleLevel; + CHECK(shadowViewDisposition(true, viewWith(relabelled), residentFrom(resident)) == + ShadowViewDisposition::Reused); +} + +TEST_CASE("every pixel-producing field forces a re-record when it changes", "[ShadowPassPlan]") +{ + const PreparedShadowView resident = viewWith(rigidDraw()); + const auto records = [&](const PreparedShadowDraw& draw) + { + return shadowViewDisposition(true, viewWith(draw), residentFrom(resident)) == + ShadowViewDisposition::Recorded; + }; + + PreparedShadowDraw d = rigidDraw(); + d.casterId = kOtherCaster; + CHECK(records(d)); + + d = rigidDraw(); + d.generation = nextShadowCasterGeneration(ShadowCasterGeneration::First); + CHECK(records(d)); + + d = rigidDraw(); + d.vertexBuffer = static_cast(77); + CHECK(records(d)); + + d = rigidDraw(); + d.indexType = DrawIndexType::UInt32; + CHECK(records(d)); + + d = rigidDraw(); + d.alpha = ShadowCasterAlpha::Masked; + CHECK(records(d)); + + d = rigidDraw(); + d.cull = ShadowEffectiveCull::None; + CHECK(records(d)); +} + +TEST_CASE("the material index is content for a masked caster only", "[ShadowPassPlan]") +{ + // The masked fragment path samples the material to decide what it discards, so its index is a + // raster input there. The opaque path reads no material data at all — two opaque variants of + // one mesh differing only in material store identical depth and must reuse. + PreparedShadowDraw masked = rigidDraw(); + masked.alpha = ShadowCasterAlpha::Masked; + PreparedShadowDraw maskedOther = masked; + maskedOther.materialIndex = masked.materialIndex + 1; + CHECK(shadowViewDisposition(true, viewWith(maskedOther), residentFrom(viewWith(masked))) == + ShadowViewDisposition::Recorded); + + PreparedShadowDraw opaqueOther = rigidDraw(); + opaqueOther.materialIndex = rigidDraw().materialIndex + 1; + CHECK(shadowViewDisposition(true, viewWith(opaqueOther), residentFrom(viewWith(rigidDraw()))) == + ShadowViewDisposition::Reused); +} + +TEST_CASE("per-view raster state changes force a re-record", "[ShadowPassPlan]") +{ + const PreparedShadowView resident = viewWith(rigidDraw()); + const auto records = [&](const PreparedShadowView& view) + { + return shadowViewDisposition(true, view, residentFrom(resident)) == + ShadowViewDisposition::Recorded; + }; + + const auto cascadeVariant = + [](std::uint32_t extent, float biasConstant, float biasSlope, std::uint32_t cascadeIndex) + { + PreparedShadowView view = + PreparedShadowView::projected(ShadowLogicalViewId::cascade(cascadeIndex), + translation(4.0f), extent, biasConstant, biasSlope); + REQUIRE(view.addDraw(rigidDraw())); + return view; + }; + + CHECK(records(cascadeVariant(1024, 0.0f, 2.0f, 1))); + CHECK(records(cascadeVariant(2048, 1.0f, 2.0f, 1))); + CHECK(records(cascadeVariant(2048, 0.0f, 3.0f, 1))); + // A physical slot is reassigned between frames, so identity is part of the content: without it, + // one light's resident depth could be matched against another light's prepared work. + CHECK(records(cascadeVariant(2048, 0.0f, 2.0f, 2))); + // The unchanged variant is the control: the four above differ in exactly one field each. + CHECK_FALSE(records(cascadeVariant(2048, 0.0f, 2.0f, 1))); +} + +TEST_CASE("a deformable caster poisons the whole view, in both directions", "[ShadowPassPlan]") +{ + // A skinned caster rewrites its vertices with the same buffers and the same matrix, so nothing + // in the descriptor can see the change. Until arc 2 #5 supplies a deformation revision, the + // honest answer is that such a view is never cacheable — and neither is content recorded while + // it held one, since that content describes geometry that has since moved. + PreparedShadowDraw deforming = rigidDraw(); + deforming.deformable = true; + + const PreparedShadowView withDeformable = viewWith(deforming); + CHECK_FALSE(withDeformable.cacheable()); + CHECK(shadowViewDisposition(true, withDeformable, residentFrom(withDeformable)) == + ShadowViewDisposition::Recorded); + + // Prepared is rigid now, but the resident content was captured with a deformable in the set. + CHECK(shadowViewDisposition(true, viewWith(rigidDraw()), residentFrom(withDeformable)) == + ShadowViewDisposition::Recorded); + + CHECK(viewWith(rigidDraw()).cacheable()); +} + +TEST_CASE("a changed draw set forces a re-record", "[ShadowPassPlan]") +{ + const PreparedShadowView resident = viewWith(rigidDraw()); + + PreparedShadowView extra = viewWith(rigidDraw()); + PreparedShadowDraw second = rigidDraw(); + second.casterId = kOtherCaster; + REQUIRE(extra.addDraw(second)); + CHECK(shadowViewDisposition(true, extra, residentFrom(resident)) == + ShadowViewDisposition::Recorded); + + // Same view, no draws: a caster that left the frame. + const PreparedShadowView empty = cascadeView(); + CHECK(shadowViewDisposition(true, empty, residentFrom(resident)) == + ShadowViewDisposition::Recorded); + + // An empty view is cacheable and reusable against empty resident content: a cleared map that + // stays cleared is a legitimate answer, and it is the case that makes an unchanged empty + // cascade free rather than a clear plus two barriers every frame. + CHECK(empty.cacheable()); + CHECK(shadowViewDisposition(true, empty, residentFrom(empty)) == ShadowViewDisposition::Reused); +} + +TEST_CASE("draw order is compared, conservatively", "[ShadowPassPlan]") +{ + // Depth-only output is order-independent, so a reorder is a FALSE miss — one wasted re-record, + // never a wrong image. That trade is deliberate: order-insensitive comparison would mean + // sorting or hashing every frame to save a case the stable gather order does not produce. + PreparedShadowDraw first = rigidDraw(); + PreparedShadowDraw second = rigidDraw(); + second.casterId = kOtherCaster; + + PreparedShadowView forwards = viewWith(first); + REQUIRE(forwards.addDraw(second)); + PreparedShadowView backwards = viewWith(second); + REQUIRE(backwards.addDraw(first)); + + CHECK(shadowViewDisposition(true, backwards, residentFrom(forwards)) == + ShadowViewDisposition::Recorded); +} + +TEST_CASE("disposition names are distinct and non-empty", "[ShadowPassPlan]") +{ + CHECK(toString(ShadowViewDisposition::Invalid) == "invalid"); + CHECK(toString(ShadowViewDisposition::Reused) == "reused"); + CHECK(toString(ShadowViewDisposition::Recorded) == "recorded"); +} + +TEST_CASE("the plan refuses an invalid prepared view before it can be dispositioned", + "[ShadowPassPlan]") +{ + // A factory handed a mismatched identity yields an invalid view. Admitting one would record + // from a matrix nobody vouched for and cache content that describes nothing, so the plan + // rejects it and the caller must treat that as terminal — the same discipline as a rejected + // view-set write. + ShadowFramePlan plan{}; + const PreparedShadowView bad = PreparedShadowView::projected( + ShadowLogicalViewId::point(static_cast(1), 0), Mat4::identity(), 1024, 0.0f, 0.0f); + CHECK_FALSE(plan.add(ShadowViewGroup::Cascade, 0, bad, ShadowViewDisposition::Recorded)); + CHECK(plan.view(ShadowViewGroup::Cascade, 0) == nullptr); + CHECK(plan.disposition(ShadowViewGroup::Cascade, 0) == ShadowViewDisposition::Invalid); + + // Out-of-range slots are refused the same way, rather than wrapping into another family's row. + CHECK_FALSE(plan.add(ShadowViewGroup::Cascade, shadowViewSlotCount(ShadowViewGroup::Cascade), + cascadeView(), ShadowViewDisposition::Recorded)); +} + +TEST_CASE("a mixed CSM stays family-valid while timing only the recorded subset", + "[ShadowPassPlan]") +{ + // THE case caching exists to produce: a camera that moved slightly re-fits the near cascades + // while the far ones are untouched. All four are sampleable, so the family is valid and the + // shader keeps sampling every layer; only the two that changed cost anything. + ShadowFramePlan plan{}; + const std::size_t cascades = shadowViewSlotCount(ShadowViewGroup::Cascade); + for (std::size_t slot = 0; slot < cascades; ++slot) + { + PreparedShadowView view = PreparedShadowView::projected( + ShadowLogicalViewId::cascade(static_cast(slot)), Mat4::identity(), 2048, + 0.0f, 2.0f); + REQUIRE(view.addDraw(rigidDraw())); + const ShadowViewDisposition disposition = + slot < 2 ? ShadowViewDisposition::Recorded : ShadowViewDisposition::Reused; + REQUIRE(plan.add(ShadowViewGroup::Cascade, slot, view, disposition)); + } + + CHECK(plan.sampleableCount(ShadowViewGroup::Cascade) == cascades); + CHECK(plan.records(ShadowViewGroup::Cascade)); + CHECK_FALSE(plan.recordsNothing()); + // The families nobody prepared are neither sampleable nor timed. + CHECK(plan.sampleableCount(ShadowViewGroup::Spot) == 0); + CHECK_FALSE(plan.records(ShadowViewGroup::Spot)); +} + +TEST_CASE("a fully reused frame is sampleable and records nothing", "[ShadowPassPlan]") +{ + ShadowFramePlan plan{}; + for (std::size_t slot = 0; slot < shadowViewSlotCount(ShadowViewGroup::Cascade); ++slot) + { + PreparedShadowView view = PreparedShadowView::projected( + ShadowLogicalViewId::cascade(static_cast(slot)), Mat4::identity(), 2048, + 0.0f, 2.0f); + REQUIRE(plan.add(ShadowViewGroup::Cascade, slot, view, ShadowViewDisposition::Reused)); + } + CHECK(plan.sampleableCount(ShadowViewGroup::Cascade) == + shadowViewSlotCount(ShadowViewGroup::Cascade)); + CHECK_FALSE(plan.records(ShadowViewGroup::Cascade)); + CHECK(plan.recordsNothing()); // the pass returns without a single bracket + + // An Invalid entry keeps no content: "engaged but unusable" must not read as a description of + // the image. + ShadowFramePlan invalidated{}; + REQUIRE(invalidated.add(ShadowViewGroup::Cascade, 1, viewWith(rigidDraw()), + ShadowViewDisposition::Invalid)); + CHECK(invalidated.view(ShadowViewGroup::Cascade, 1) == nullptr); + CHECK(invalidated.sampleableCount(ShadowViewGroup::Cascade) == 0); + CHECK(invalidated.recordsNothing()); +} + +TEST_CASE("both self-shadow layers are content", "[ShadowPassPlan]") +{ + // A self view rasterises two depth images with different fragment paths and different cull. A + // comparison that stopped at the first layer would reuse a view whose second had changed. + const auto selfView = [](ShadowEffectiveCull secondCull) + { + PreparedShadowView view = PreparedShadowView::projected( + ShadowLogicalViewId::self(42), translation(2.0f), 1024, 0.0f, 0.0f); + // Both layers exist already — a self identity is born with its dual-depth pair. + PreparedShadowDraw firstDraw = rigidDraw(); + firstDraw.cull = ShadowEffectiveCull::None; + REQUIRE(view.addDraw(ShadowLayerKind::Depth, firstDraw)); + + PreparedShadowDraw secondDraw = rigidDraw(); + secondDraw.cull = secondCull; + REQUIRE(view.addDraw(ShadowLayerKind::SelfSecondDepth, secondDraw)); + return view; + }; + + const PreparedShadowView resident = selfView(ShadowEffectiveCull::FrontFaces); + CHECK(resident.layers().size() == 2); + CHECK(shadowViewDisposition(true, selfView(ShadowEffectiveCull::FrontFaces), + residentFrom(resident)) == ShadowViewDisposition::Reused); + CHECK(shadowViewDisposition(true, selfView(ShadowEffectiveCull::None), + residentFrom(resident)) == ShadowViewDisposition::Recorded); +} + +TEST_CASE("a view is born with its layers, so an empty one still clears", "[ShadowPassPlan]") +{ + // A first-use empty cascade is Recorded and sampleable. If its layers only appeared when a draw + // did, the recorder would walk nothing, clear nothing, and leave undefined depth behind a map + // the plan called usable — so topology comes from the identity, not from what happened to be + // appended. + const PreparedShadowView emptyCascade = cascadeView(); + REQUIRE(emptyCascade.layers().size() == 1); + CHECK(emptyCascade.layers().front().kind == ShadowLayerKind::Depth); + CHECK(emptyCascade.layers().front().draws.empty()); + + const PreparedShadowView emptySelf = PreparedShadowView::projected( + ShadowLogicalViewId::self(7), Mat4::identity(), 1024, 0.0f, 0.0f); + REQUIRE(emptySelf.layers().size() == 2); + CHECK(emptySelf.layers()[0].kind == ShadowLayerKind::Depth); + CHECK(emptySelf.layers()[1].kind == ShadowLayerKind::SelfSecondDepth); + + // And a layer a view does not have cannot be drawn into: asking a cascade for a second + // self-shadow depth is a producer bug, not a draw to drop. + PreparedShadowView cascade = cascadeView(); + CHECK_FALSE(cascade.addDraw(ShadowLayerKind::SelfSecondDepth, rigidDraw())); + CHECK(cascade.addDraw(ShadowLayerKind::Depth, rigidDraw())); +} + +TEST_CASE("a slot may be claimed once per frame", "[ShadowPassPlan]") +{ + // Two producers preparing one physical view means two views are being prepared as one: the plan + // would keep the last writer's work under the other's identity. Refused, like a duplicate + // view-set write or a duplicate caster-bounds key. + ShadowFramePlan plan{}; + REQUIRE(plan.add(ShadowViewGroup::Cascade, 1, viewWith(rigidDraw()), + ShadowViewDisposition::Recorded)); + CHECK_FALSE(plan.add(ShadowViewGroup::Cascade, 1, viewWith(rigidDraw()), + ShadowViewDisposition::Reused)); + CHECK(plan.disposition(ShadowViewGroup::Cascade, 1) == ShadowViewDisposition::Recorded); + + // An INVALID claim is still a claim — otherwise a second producer could overwrite it and the + // duplicate would go unreported. + ShadowFramePlan invalidFirst{}; + const PreparedShadowView spot = PreparedShadowView::projected( + ShadowLogicalViewId::spot(static_cast(61)), translation(1.0f), 1024, 0.0f, 1.0f); + REQUIRE(invalidFirst.add(ShadowViewGroup::Spot, 1, spot, ShadowViewDisposition::Invalid)); + CHECK_FALSE(invalidFirst.add(ShadowViewGroup::Spot, 1, spot, ShadowViewDisposition::Recorded)); + + // reset() releases every claim. + plan.reset(); + CHECK(plan.add(ShadowViewGroup::Cascade, 1, viewWith(rigidDraw()), + ShadowViewDisposition::Recorded)); +} + +namespace +{ + +ShadowFamilyEligibility eligibilityFor(ShadowViewGroup group, std::size_t activeViews) +{ + ShadowFamilyEligibility eligibility{}; + eligibility.primaryDirectionalLight = true; + eligibility.activeViews[static_cast(group)] = activeViews; + return eligibility; +} + +PreparedShadowView spotView(std::size_t slot, std::uint64_t lightId) +{ + return PreparedShadowView::projected(ShadowLogicalViewId::spot(static_cast(lightId)), + translation(static_cast(slot)), 1024, 0.0f, 1.0f); +} + +PreparedShadowView pointFaceView(std::uint8_t face, std::uint64_t lightId) +{ + return PreparedShadowView::pointFace( + ShadowLogicalViewId::point(static_cast(lightId), face), translation(1.0f), 1024, + 0.0f, 1.0f, Vec3{1.0f, 2.0f, 3.0f}, 20.0f); +} + +} // namespace + +TEST_CASE("confirmation requires every eligible view, not a plausible count", "[ShadowPassPlan]") +{ + // TWO SPOTS, ONE PREPARES. The eligibility law alone is satisfied by "any active slot", so the + // family would stay valid and the light that failed to prepare would sample whatever its map + // held last. Completeness against the EXPECTED count is what catches it. + ShadowFramePlan partial{}; + REQUIRE( + partial.add(ShadowViewGroup::Spot, 0, spotView(0, 11), ShadowViewDisposition::Recorded)); + const ShadowFamilyEligibility twoSpots = eligibilityFor(ShadowViewGroup::Spot, 2); + CHECK(twoSpots.eligible().spot); // eligible: two active spots + CHECK_FALSE(shadowMapValidityFromPlan(partial, twoSpots).spot); + + // Both prepared — including one REUSED, which counts as arrived because it is sampleable. + ShadowFramePlan both{}; + REQUIRE(both.add(ShadowViewGroup::Spot, 0, spotView(0, 11), ShadowViewDisposition::Recorded)); + REQUIRE(both.add(ShadowViewGroup::Spot, 1, spotView(1, 12), ShadowViewDisposition::Reused)); + CHECK(shadowMapValidityFromPlan(both, twoSpots).spot); +} + +TEST_CASE("twelve eligible point faces are not confirmed by six", "[ShadowPassPlan]") +{ + // Six of twelve is a whole number of cubes by arithmetic, so the modulo rule passes; the second + // light is simply missing. Completeness catches the count, and pointCubesWhole catches the + // shape — a half-prepared cube beside a whole one, which no count can see. + const auto faces = static_cast(kCubeFaceCount); + const ShadowFamilyEligibility twoCubes = eligibilityFor(ShadowViewGroup::Point, 2 * faces); + CHECK(twoCubes.eligible().point); + + ShadowFramePlan oneCube{}; + for (std::size_t face = 0; face < faces; ++face) + { + REQUIRE(oneCube.add(ShadowViewGroup::Point, face, + pointFaceView(static_cast(face), 21), + ShadowViewDisposition::Recorded)); + } + CHECK(oneCube.sampleableCount(ShadowViewGroup::Point) == faces); // a "whole number of cubes" + CHECK(oneCube.pointCubesWhole()); + CHECK_FALSE(shadowMapValidityFromPlan(oneCube, twoCubes).point); + + // A half-prepared cube fails the shape check even when the total count matches. + ShadowFramePlan ragged{}; + for (std::size_t face = 0; face < faces; ++face) + { + const bool firstCube = face < faces / 2; + const std::size_t slot = firstCube ? face : faces + face; + const std::uint8_t logicalFace = static_cast(slot % faces); + REQUIRE(ragged.add(ShadowViewGroup::Point, slot, + pointFaceView(logicalFace, firstCube ? 21 : 22), + ShadowViewDisposition::Recorded)); + } + CHECK(ragged.sampleableCount(ShadowViewGroup::Point) == faces); + CHECK_FALSE(ragged.pointCubesWhole()); + CHECK_FALSE(shadowMapValidityFromPlan(ragged, twoCubes).point); +} + +TEST_CASE("the plan refuses a view in the wrong physical slot", "[ShadowPassPlan]") +{ + // The recorder trusts this object exclusively — there is no view set left to cross-check + // against — so a misplaced identity has to be refused here. Each of these renders plausibly and + // reports plausibly while drawing the wrong thing into the wrong image. + ShadowFramePlan plan{}; + + // A cascade identity in a spot slot. + CHECK_FALSE(plan.add(ShadowViewGroup::Spot, 0, cascadeView(), ShadowViewDisposition::Recorded)); + // A cascade whose index is not its slot: it would rasterise cascade 1's matrix into layer 2. + CHECK_FALSE( + plan.add(ShadowViewGroup::Cascade, 2, cascadeView(), ShadowViewDisposition::Recorded)); + // World-only shares the cascade identity, and the same index rule. + CHECK(plan.add(ShadowViewGroup::WorldOnly, 1, cascadeView(), ShadowViewDisposition::Recorded)); + // A point face in the wrong face slot of the right cube. + CHECK_FALSE( + plan.add(ShadowViewGroup::Point, 0, pointFaceView(3, 31), ShadowViewDisposition::Recorded)); + // A spot identity in a self slot. + CHECK_FALSE( + plan.add(ShadowViewGroup::Self, 0, spotView(0, 41), ShadowViewDisposition::Recorded)); +} + +TEST_CASE("one physical point cube belongs to one light", "[ShadowPassPlan]") +{ + // Two lights sharing a cube would each render half of it and both sample all of it. The view + // set's atomic installation prevents it upstream; the plan is a second producer of the same + // arrangement, so it checks rather than assuming. + ShadowFramePlan plan{}; + REQUIRE( + plan.add(ShadowViewGroup::Point, 0, pointFaceView(0, 51), ShadowViewDisposition::Recorded)); + CHECK( + plan.add(ShadowViewGroup::Point, 1, pointFaceView(1, 51), ShadowViewDisposition::Recorded)); + CHECK_FALSE( + plan.add(ShadowViewGroup::Point, 2, pointFaceView(2, 52), ShadowViewDisposition::Recorded)); + // A different cube may of course hold a different light. + CHECK(plan.add(ShadowViewGroup::Point, kCubeFaceCount, pointFaceView(0, 52), + ShadowViewDisposition::Recorded)); +} + +TEST_CASE("a suppressed frame confirms nothing", "[ShadowPassPlan]") +{ + // --no-shadows: nothing eligible, so preparation produces no rows, and confirmation agrees. + ShadowFamilyEligibility suppressed{}; + suppressed.shadowsDisabled = true; + suppressed.primaryDirectionalLight = true; + suppressed.activeViews[static_cast(ShadowViewGroup::Cascade)] = + shadowViewSlotCount(ShadowViewGroup::Cascade); + CHECK(suppressed.eligible().none()); + + const ShadowFramePlan empty{}; + CHECK(shadowMapValidityFromPlan(empty, suppressed).none()); + CHECK(empty.recordsNothing()); +} diff --git a/tests/graphics/test_shadow_pass_prepare.cpp b/tests/graphics/test_shadow_pass_prepare.cpp new file mode 100644 index 00000000..8ef6001a --- /dev/null +++ b/tests/graphics/test_shadow_pass_prepare.cpp @@ -0,0 +1,444 @@ +#include + +#include + +using namespace fire_engine; + +namespace +{ + +constexpr auto kLight = static_cast(21); +constexpr float kBudget = 4.0f; +constexpr ShadowLodHysteresis kNoHysteresis{.coarsenRatio = 1.0f}; +constexpr float kPointRange = 30.0f; +const Vec3 kPointPosition{2.0f, 3.0f, 4.0f}; + +// --- view-set fixtures ------------------------------------------------------------------------- + +[[nodiscard]] ShadowView someOrtho() +{ + return ShadowView::orthographic(0.05f); +} +[[nodiscard]] ShadowViewMetrics someOrthoMetrics() +{ + return ShadowViewMetrics::orthographic(0.05f, 100.0f); +} +[[nodiscard]] ShadowView somePerspective(const Vec3& forward, const Vec3& position) +{ + return ShadowView::perspective(position, forward, 1.5708f, 512, 0.1f); +} + +// An identity-ish matrix with a mark, so each view's transform is trivially identifiable and the +// frustum it produces admits geometry near the origin. +[[nodiscard]] Mat4 markedMatrix(float mark) +{ + Mat4 m = Mat4::identity(); + m[0, 3] = mark; + return m; +} + +// Every family populated at once, so a test can assert what a family did AND what its neighbours +// did — the accounting rules are per family, and most of the ways to break them show up as one +// family's work landing on another's row. +[[nodiscard]] ShadowRenderViewSet populatedViews() +{ + ShadowRenderViewSet views; + REQUIRE(views.setCascade(0, markedMatrix(0.0f), someOrtho(), someOrthoMetrics())); + REQUIRE(views.enableWorldOnly(0)); + REQUIRE(views.setSelf(0, 55, markedMatrix(0.0f), someOrtho(), someOrthoMetrics())); + REQUIRE(views.setSpot(0, kLight, markedMatrix(0.0f), + somePerspective(Vec3{0.0f, 0.0f, -1.0f}, Vec3{0.0f, 0.0f, 5.0f}), + ShadowViewMetrics::spot(0.002f, 0.1f, 50.0f))); + + const std::array forwards{Vec3{1, 0, 0}, Vec3{-1, 0, 0}, Vec3{0, 1, 0}, + Vec3{0, -1, 0}, Vec3{0, 0, 1}, Vec3{0, 0, -1}}; + const auto face = [&](std::uint8_t f) + { return ShadowPointFace{markedMatrix(0.0f), somePerspective(forwards[f], kPointPosition)}; }; + const std::array cube{face(0), face(1), face(2), + face(3), face(4), face(5)}; + REQUIRE(views.setPointLight(0, kLight, ShadowViewMetrics::pointLight(0.004f, kPointRange), + kPointRange, + std::span{cube})); + return views; +} + +// --- caster fixtures --------------------------------------------------------------------------- + +// A single-level rigid caster. Single-level so no test depends on the selector's arithmetic: what +// these tests pin is the plumbing around it, and a fixture whose level moved with an unrelated +// tuning change would fail for the wrong reason. +const std::vector kSingleLevel{GeometryLod{ + .indexBuffer = static_cast(3), .indexCount = 900, .shadowDeviation = 0.0f}}; + +[[nodiscard]] DrawCommand +caster(std::uint32_t objectId, Bounds3 bounds, + ShadowCasterDeformation deformation = ShadowCasterDeformation::Rigid) +{ + DrawCommand dc{}; + dc.objectId = objectId; + dc.vertexBuffer = static_cast(objectId + 100); + dc.indexType = DrawIndexType::UInt16; + dc.materialIndex = objectId; + dc.shadowUbo = static_cast(objectId + 200); + dc.skinUbo = static_cast(objectId + 300); + dc.morphUbo = static_cast(objectId + 400); + dc.morphSsbo = static_cast(objectId + 500); + dc.shadowBounds = bounds; + // EXACT, so the frustum filter is allowed to reject it — `Stale` bounds are admitted whatever + // the frustum says, which would make the filter tests vacuous. + dc.shadowBoundsKind = ShadowCasterBoundsKind::Exact; + dc.shadowRequest = ShadowGeometryRequest{ + .lods = kSingleLevel, + .baseIndexBuffer = static_cast(3), + .baseIndexCount = 900, + .pose = + ShadowCasterPose::fromModel(Mat4::translate(Vec3{static_cast(objectId), 0, 0})), + .casterId = static_cast(objectId), + .generation = ShadowCasterGeneration::First, + .lodEnabled = true, + .deformation = deformation, + .alpha = ShadowCasterAlpha::Opaque, + }; + return dc; +} + +[[nodiscard]] Bounds3 boundsAt(Vec3 centre, float halfSize = 0.25f) +{ + Bounds3 b{}; + b.expand(centre - Vec3{halfSize, halfSize, halfSize}); + b.expand(centre + Vec3{halfSize, halfSize, halfSize}); + return b; +} + +// Inside every fixture view's frustum (the marked matrices are identity-like, so the unit cube +// about the origin is in view). +[[nodiscard]] DrawCommand nearCaster(std::uint32_t objectId = 1) +{ + return caster(objectId, boundsAt(Vec3{0.0f, 0.0f, 0.0f})); +} + +// Far outside every fixture view's frustum, so the filter rejects it. +[[nodiscard]] DrawCommand distantCaster(std::uint32_t objectId = 2) +{ + return caster(objectId, boundsAt(Vec3{5000.0f, 5000.0f, 5000.0f})); +} + +[[nodiscard]] ShadowMapValidity allFamilies() +{ + return ShadowMapValidity{ + .cascades = true, .worldOnly = true, .self = true, .spot = true, .point = true}; +} + +struct Prepared +{ + ShadowFramePlan plan{}; + ShadowFrameStats stats{}; + ShadowLodResolver resolver{}; +}; + +// Runs a preparation over the standard view set. Returned by value so each test owns its own +// resolver and stats — the hysteresis history is cross-frame state, and a shared one would make +// tests order-dependent. +void prepare(Prepared& out, const ShadowPreparationInputs& inputs, const ShadowRenderViewSet& views, + ShadowMapValidity eligible) +{ + out.resolver.beginFrame(); + prepareShadowFrame(inputs, views, eligible, out.resolver, out.stats, out.plan); +} + +[[nodiscard]] ShadowPreparationInputs inputsFor(std::span shadowDraws, + std::span worldOnlyDraws = {}, + std::span selfDraws = {}) +{ + ShadowPreparationInputs inputs{ + .shadowDraws = shadowDraws, + .worldOnlyShadowDraws = worldOnlyDraws, + .selfShadowDraws = selfDraws, + .lodBudgetTexels = kBudget, + .hysteresis = kNoHysteresis, + .cullingEnabled = true, + }; + for (ShadowFamilyRaster& raster : inputs.raster) + { + raster = + ShadowFamilyRaster{.extent = 1024, .depthBiasConstant = 1.0f, .depthBiasSlope = 2.0f}; + } + return inputs; +} + +} // namespace + +TEST_CASE("every active view is recorded while nothing is resident", "[ShadowPassPrepare]") +{ + // The reuse half of the item has no residency store yet, so every view is a first use — and a + // first use RECORDS whatever its prepared content looks like, because image creation + // transitions the layout but leaves the depth undefined. + const std::vector draws{nearCaster()}; + const ShadowRenderViewSet views = populatedViews(); + Prepared out{}; + prepare(out, inputsFor(draws, draws, draws), views, allFamilies()); + + CHECK(out.plan.disposition(ShadowViewGroup::Cascade, 0) == ShadowViewDisposition::Recorded); + CHECK(out.plan.disposition(ShadowViewGroup::WorldOnly, 0) == ShadowViewDisposition::Recorded); + CHECK(out.plan.disposition(ShadowViewGroup::Spot, 0) == ShadowViewDisposition::Recorded); + for (std::size_t f = 0; f < static_cast(kCubeFaceCount); ++f) + { + CHECK(out.plan.disposition(ShadowViewGroup::Point, shadowPointViewSlot(0, f)) == + ShadowViewDisposition::Recorded); + } + // An inactive slot is not claimed at all, which is a different answer from "recorded nothing". + CHECK(out.plan.disposition(ShadowViewGroup::Cascade, 1) == ShadowViewDisposition::Invalid); + CHECK(out.plan.view(ShadowViewGroup::Cascade, 1) == nullptr); + CHECK_FALSE(out.stats.view(ShadowViewGroup::Cascade, 1).claimed()); +} + +TEST_CASE("a suppressed family is never resolved", "[ShadowPassPrepare]") +{ + // Not merely "not recorded". Resolution STAGES hysteresis, so a family that will be neither + // recorded nor sampled must not leave a dead band behind for the commit to adopt — which means + // the resolver may not be asked about its casters at all. + const std::vector draws{nearCaster()}; + const ShadowRenderViewSet views = populatedViews(); + + Prepared out{}; + prepare( + out, inputsFor(draws, draws, draws), views, + ShadowMapValidity{ + .cascades = false, .worldOnly = false, .self = false, .spot = true, .point = false}); + + // The spot family was eligible and resolved; nothing else reached the resolver, so the only + // frame entry is the spot view's. + CHECK(out.resolver.frameCacheSize() == 1); + CHECK(out.resolver.frameResolution( + ShadowLodStateKey{static_cast(1), ShadowCasterGeneration::First, + ShadowLogicalViewId::spot(kLight)}) != nullptr); + CHECK(out.resolver.frameResolution( + ShadowLodStateKey{static_cast(1), ShadowCasterGeneration::First, + ShadowLogicalViewId::cascade(0)}) == nullptr); + + // And a suppressed family leaves no diagnostic row: it was not claimed, so nothing reports a + // view that did not happen. + CHECK_FALSE(out.stats.view(ShadowViewGroup::Cascade, 0).claimed()); + CHECK(out.stats.view(ShadowViewGroup::Spot, 0).claimed()); + CHECK(out.plan.sampleableCount(ShadowViewGroup::Cascade) == 0); + CHECK(out.plan.sampleableCount(ShadowViewGroup::Spot) == 1); +} + +TEST_CASE("the filter runs before resolution", "[ShadowPassPrepare]") +{ + // A caster this view drops must acquire NO history against it: a skinned caster would otherwise + // accumulate a dead band against every other object's self-shadow map, and a wholly-rejected + // perspective caster would be evaluated outside the domain the projection model is good for. + const std::vector draws{distantCaster()}; + const ShadowRenderViewSet views = populatedViews(); + Prepared out{}; + prepare(out, inputsFor(draws), views, allFamilies()); + + CHECK(out.resolver.frameCacheSize() == 0); + // The row still reports the caster as a CANDIDATE — it was offered and rejected, which is + // exactly what `candidateDraws - drawnDraws` is supposed to measure. + const ShadowViewStats& row = out.stats.view(ShadowViewGroup::Cascade, 0); + CHECK(row.claimed()); + CHECK(row.candidateDraws == 1); + CHECK(row.drawnDraws == 0); + CHECK(row.drawnTriangles == 0); + // Rejected means the view prepares no draw for it, not that the view is absent. + const PreparedShadowView* view = out.plan.view(ShadowViewGroup::Cascade, 0); + REQUIRE(view != nullptr); + CHECK(view->draws().empty()); +} + +TEST_CASE("a self view prepares two layers and counts one selection", "[ShadowPassPrepare]") +{ + // The self family rasterises ONE logical view into TWO depth images. Both layers' costs are + // real (each walks the caster set, each is a raster pass), but they share one LOD decision — + // counting it twice would double a distribution that describes one choice. + std::vector draws{nearCaster()}; + draws[0].selfShadowSlot = 0; + const ShadowRenderViewSet views = populatedViews(); + Prepared out{}; + prepare(out, inputsFor({}, {}, draws), views, allFamilies()); + + const PreparedShadowView* view = out.plan.view(ShadowViewGroup::Self, 0); + REQUIRE(view != nullptr); + REQUIRE(view->layers().size() == 2); + CHECK(view->layers()[0].kind == ShadowLayerKind::Depth); + CHECK(view->layers()[1].kind == ShadowLayerKind::SelfSecondDepth); + CHECK(view->layers()[0].draws.size() == 1); + CHECK(view->layers()[1].draws.size() == 1); + // SH-05: the first layer keeps every face (whatever the light sees first), the second keeps + // only back faces so the dual-depth rejection is well-founded. + CHECK(view->layers()[0].draws.front().cull == ShadowEffectiveCull::None); + CHECK(view->layers()[1].draws.front().cull == ShadowEffectiveCull::FrontFaces); + + const ShadowViewStats& row = out.stats.view(ShadowViewGroup::Self, 0); + CHECK(row.candidateDraws == 2); // one per layer walked + CHECK(row.drawnDraws == 2); + std::uint64_t selections = 0; + for (const std::uint64_t bin : row.lodHistogram) + { + selections += bin; + } + CHECK(selections == 1); // one decision, however many layers rasterise it +} + +TEST_CASE("a self view keeps only the caster holding its slot", "[ShadowPassPrepare]") +{ + std::vector draws{nearCaster(1), nearCaster(2)}; + draws[0].selfShadowSlot = 0; + draws[1].selfShadowSlot = 1; + const ShadowRenderViewSet views = populatedViews(); + Prepared out{}; + prepare(out, inputsFor({}, {}, draws), views, allFamilies()); + + const PreparedShadowView* view = out.plan.view(ShadowViewGroup::Self, 0); + REQUIRE(view != nullptr); + REQUIRE(view->draws().size() == 1); + CHECK(view->draws().front().casterId == static_cast(1)); +} + +TEST_CASE("a point face carries the light its depth is measured against", "[ShadowPassPrepare]") +{ + // The stored depth is `distance / range`, so the light is a shader input rather than a + // consequence of the face matrix — and it has to arrive in the prepared view EXACTLY, or a + // moved light would keep a cube whose every texel is wrong. + const std::vector draws{nearCaster()}; + const ShadowRenderViewSet views = populatedViews(); + Prepared out{}; + prepare(out, inputsFor(draws), views, allFamilies()); + + const PreparedShadowView* face = + out.plan.view(ShadowViewGroup::Point, shadowPointViewSlot(0, 3)); + REQUIRE(face != nullptr); + CHECK(face->depthMode() == ShadowDepthMode::RadialRatio); + CHECK(face->lightPosition().x() == kPointPosition.x()); + CHECK(face->lightPosition().y() == kPointPosition.y()); + CHECK(face->lightPosition().z() == kPointPosition.z()); + CHECK(face->lightRange() == kPointRange); + + // Every other family stores projected depth and carries no light at all. + const PreparedShadowView* cascade = out.plan.view(ShadowViewGroup::Cascade, 0); + REQUIRE(cascade != nullptr); + CHECK(cascade->depthMode() == ShadowDepthMode::Projected); + CHECK(cascade->lightRange() == 0.0f); +} + +TEST_CASE("the raster parameters a family is prepared with reach its view", "[ShadowPassPrepare]") +{ + // Extent and depth bias are raster CONTENT: a map rendered at another extent, or with another + // bias, holds different depth. They travel in the prepared view rather than being re-read from + // constants at record time, so the comparison sees what the rasteriser will. + const std::vector draws{nearCaster()}; + const ShadowRenderViewSet views = populatedViews(); + ShadowPreparationInputs inputs = inputsFor(draws); + inputs.raster[static_cast(ShadowViewGroup::Cascade)] = + ShadowFamilyRaster{.extent = 2048, .depthBiasConstant = 3.0f, .depthBiasSlope = 4.0f}; + Prepared out{}; + prepare(out, inputs, views, allFamilies()); + + const PreparedShadowView* view = out.plan.view(ShadowViewGroup::Cascade, 0); + REQUIRE(view != nullptr); + CHECK(view->extent() == 2048); + CHECK(view->depthBiasConstant() == 3.0f); + CHECK(view->depthBiasSlope() == 4.0f); +} + +TEST_CASE("a prepared draw carries what the rasteriser reads", "[ShadowPassPrepare]") +{ + const std::vector draws{nearCaster(7)}; + const ShadowRenderViewSet views = populatedViews(); + Prepared out{}; + prepare(out, inputsFor(draws), views, allFamilies()); + + const PreparedShadowView* view = out.plan.view(ShadowViewGroup::Cascade, 0); + REQUIRE(view != nullptr); + REQUIRE(view->draws().size() == 1); + const PreparedShadowDraw& draw = view->draws().front(); + // The POSE's matrix — the same value the caster's ShadowUBO holds, not a re-derivation. + CHECK(draw.model == draws.front().shadowRequest.pose.model()); + CHECK(draw.vertexBuffer == draws.front().vertexBuffer); + CHECK(draw.materialIndex == draws.front().materialIndex); + // The RESOLVED carrier, not the command's (a shadow command carries none). + CHECK(draw.indexBuffer == static_cast(3)); + CHECK(draw.indexCount == 900); + // The recording payload: per-frame ring handles, deliberately excluded from the comparison but + // needed to record at all. + CHECK(draw.shadowUbo == draws.front().shadowUbo); + CHECK(draw.skinUbo == draws.front().skinUbo); + CHECK(draw.morphUbo == draws.front().morphUbo); + CHECK(draw.morphSsbo == draws.front().morphSsbo); +} + +TEST_CASE("a deformable caster poisons its view's reuse", "[ShadowPassPrepare]") +{ + // SH-04's classification IS the cacheability question: skinned, morph-capable and + // storage-vertex casters rewrite their vertices with no revision the comparison can see. + const std::vector draws{ + caster(1, boundsAt(Vec3{0.0f, 0.0f, 0.0f}), ShadowCasterDeformation::Deformable)}; + const ShadowRenderViewSet views = populatedViews(); + Prepared out{}; + prepare(out, inputsFor(draws), views, allFamilies()); + + const PreparedShadowView* view = out.plan.view(ShadowViewGroup::Cascade, 0); + REQUIRE(view != nullptr); + REQUIRE(view->draws().size() == 1); + CHECK(view->draws().front().deformable); + CHECK_FALSE(view->cacheable()); +} + +TEST_CASE("the cascade and its world-only twin share one decision", "[ShadowPassPrepare]") +{ + // They deliberately carry ONE logical identity, so the resolver's frame cache hands the second + // the first's answer — which is what makes the two maps agree for a rigid caster rather than + // agreeing by coincidence. They do NOT contain the same casters, which is why content is + // attributed per family. + const std::vector shadowDraws{nearCaster(1), nearCaster(2)}; + const std::vector worldOnlyDraws{shadowDraws.front()}; + const ShadowRenderViewSet views = populatedViews(); + Prepared out{}; + prepare(out, inputsFor(shadowDraws, worldOnlyDraws), views, allFamilies()); + + const ShadowLodStateKey shared{static_cast(1), ShadowCasterGeneration::First, + ShadowLogicalViewId::cascade(0)}; + const ShadowLodStateKey cascadeOnly{static_cast(2), + ShadowCasterGeneration::First, + ShadowLogicalViewId::cascade(0)}; + CHECK(out.resolver.contentResolution(ShadowViewGroup::Cascade, shared) != nullptr); + CHECK(out.resolver.contentResolution(ShadowViewGroup::WorldOnly, shared) != nullptr); + CHECK(out.resolver.contentResolution(ShadowViewGroup::Cascade, cascadeOnly) != nullptr); + // The world-only map never held caster 2, so it reports nothing for it — a level from the + // cascade would be one view's answer presented as another's. + CHECK(out.resolver.contentResolution(ShadowViewGroup::WorldOnly, cascadeOnly) == nullptr); +} + +TEST_CASE("preparing twice into one plan is refused", "[ShadowPassPrepare]") +{ + // `reset()` at the top of preparation is what makes this a fresh frame rather than two frames + // accumulated into one plan — the second run must land the same claims, not collide with the + // first's. The STATS are the other half: they are reset per frame by the renderer, so claiming + // a row twice with the same identity is legal and claiming it with another is not. + const std::vector draws{nearCaster()}; + const ShadowRenderViewSet views = populatedViews(); + Prepared out{}; + prepare(out, inputsFor(draws), views, allFamilies()); + const std::size_t firstCount = out.plan.sampleableCount(ShadowViewGroup::Cascade); + + out.stats.reset(); + prepare(out, inputsFor(draws), views, allFamilies()); + CHECK(out.plan.sampleableCount(ShadowViewGroup::Cascade) == firstCount); +} + +TEST_CASE("a caster with no stated pose stops the frame", "[ShadowPassPrepare]") +{ + // The selector survives an unusable transform (InvalidCaster, full detail). The CACHE cannot: + // an unstated pose is a default matrix, identical every frame, so the comparison would find the + // view unchanged while the GPU rasterised the caster's real transform — a map reused forever + // for something that is moving, with no symptom anywhere. + std::vector draws{nearCaster()}; + draws[0].shadowRequest.pose = ShadowCasterPose{}; + const ShadowRenderViewSet views = populatedViews(); + Prepared out{}; + out.resolver.beginFrame(); + CHECK_THROWS(prepareShadowFrame(inputsFor(draws), views, allFamilies(), out.resolver, out.stats, + out.plan)); +} diff --git a/tests/graphics/test_shadow_render_view.cpp b/tests/graphics/test_shadow_render_view.cpp index 328ab4ef..1969d965 100644 --- a/tests/graphics/test_shadow_render_view.cpp +++ b/tests/graphics/test_shadow_render_view.cpp @@ -28,6 +28,11 @@ namespace return ShadowViewMetrics::pointLight(0.004f, 25.0f); } +// The effective range a point cube's faces store depth against. Any positive finite value will do +// for the fixtures that only care that the cube was accepted; the tests that care about the value +// itself pass their own. +constexpr float kPointRange = 25.0f; + Mat4 markedMatrix(float mark) { Mat4 m = Mat4::identity(); @@ -109,7 +114,8 @@ TEST_CASE("ShadowRenderViewSet.EachWriterStampsItsOwnSlotsIdentity", "[ShadowRen REQUIRE(views.setCascade(2, markedMatrix(3.0f), someOrtho(), someOrthoMetrics())); REQUIRE(views.setSelf(1, 77, markedMatrix(20.0f), someOrtho(), someOrthoMetrics())); REQUIRE(views.setSpot(3, light, markedMatrix(30.0f), somePerspective(), someSpotMetrics())); - REQUIRE(views.setPointLight(1, light, somePointMetrics(), cubeSpan(someCube(40.0f)))); + REQUIRE( + views.setPointLight(1, light, somePointMetrics(), kPointRange, cubeSpan(someCube(40.0f)))); REQUIRE(views.find(ShadowViewGroup::Cascade, 2) != nullptr); CHECK(views.find(ShadowViewGroup::Cascade, 2)->logicalId() == ShadowLogicalViewId::cascade(2)); @@ -136,7 +142,8 @@ TEST_CASE("ShadowRenderViewSet.WritersRejectTheWrongProjectionKind", "[ShadowRen const auto orthoFace = ShadowPointFace{markedMatrix(4.0f), someOrtho()}; const std::array orthoCube{orthoFace, orthoFace, orthoFace, orthoFace, orthoFace, orthoFace}; - CHECK_FALSE(views.setPointLight(0, light, somePointMetrics(), cubeSpan(orthoCube))); + CHECK_FALSE( + views.setPointLight(0, light, somePointMetrics(), kPointRange, cubeSpan(orthoCube))); CHECK(views.activeCount(ShadowViewGroup::Cascade) == 0); CHECK(views.activeCount(ShadowViewGroup::Self) == 0); @@ -145,6 +152,63 @@ TEST_CASE("ShadowRenderViewSet.WritersRejectTheWrongProjectionKind", "[ShadowRen #endif } +TEST_CASE("ShadowRenderViewSet.PointFacesCarryTheLightTheirDepthIsMeasuredAgainst", + "[ShadowRenderView]") +{ + // A point face stores `distance / range` rather than projected depth, so the light's position + // and its effective range are shader inputs — raster content, not consequences of the matrix. + // They live on the view because a cached cube is only reusable while they are unchanged, and + // the position comes from the face's own projection descriptor, which is also what LOD + // selection measures depth from. + ShadowRenderViewSet views; + const auto light = static_cast(12); + REQUIRE(views.setPointLight(0, light, somePointMetrics(), 42.0f, cubeSpan(someCube(1.0f)))); + + const ShadowRenderView* face = views.find(ShadowViewGroup::Point, shadowPointViewSlot(0, 2)); + REQUIRE(face != nullptr); + const std::optional depth = face->pointLightDepth(); + REQUIRE(depth.has_value()); + CHECK(depth->range == 42.0f); + CHECK(depth->position.x() == face->projection().lightPosition().x()); + CHECK(depth->position.y() == face->projection().lightPosition().y()); + CHECK(depth->position.z() == face->projection().lightPosition().z()); + + // Nothing else has one. A cascade carries no light at all, and a zero position with a zero + // range is a value a caller could read and push — so the answer is absence, not zeroes. + REQUIRE(views.setCascade(0, markedMatrix(1.0f), someOrtho(), someOrthoMetrics())); + REQUIRE(views.setSpot(0, light, markedMatrix(2.0f), somePerspective(), someSpotMetrics())); + CHECK_FALSE(views.find(ShadowViewGroup::Cascade, 0)->pointLightDepth().has_value()); + CHECK_FALSE(views.find(ShadowViewGroup::Spot, 0)->pointLightDepth().has_value()); +} + +TEST_CASE("ShadowRenderViewSet.APointCubeNeedsOneLightAndAUsableRange", "[ShadowRenderView]") +{ + // Both halves of the stored ratio are checked as strictly as the matrices. A zero or non-finite + // range makes every texel of all six faces meaningless; six faces about DIFFERENT positions + // mean the caller assembled the cube from more than one light, and half of it would then be + // measured from the wrong origin while every matrix still looked fine. Dev asserts; these + // expectations describe the release behaviour. +#ifdef NDEBUG + ShadowRenderViewSet views; + const auto light = static_cast(13); + + CHECK_FALSE(views.setPointLight(0, light, somePointMetrics(), 0.0f, cubeSpan(someCube(1.0f)))); + CHECK_FALSE(views.setPointLight(0, light, somePointMetrics(), + std::numeric_limits::infinity(), + cubeSpan(someCube(1.0f)))); + CHECK(views.activeCount(ShadowViewGroup::Point) == 0); + + // Five faces about one light and a sixth about another. + std::array mixed = someCube(1.0f); + mixed[4] = ShadowPointFace{markedMatrix(9.0f), ShadowView::perspective(Vec3{40.0f, 0.0f, 0.0f}, + Vec3{0.0f, 0.0f, 1.0f}, + 1.5708f, 512, 0.05f)}; + CHECK_FALSE(views.setPointLight(0, light, somePointMetrics(), kPointRange, cubeSpan(mixed))); + // ALL SIX cleared, not five installed: a cube is accepted or refused whole. + CHECK(views.activeCount(ShadowViewGroup::Point) == 0); +#endif +} + TEST_CASE("ShadowRenderViewSet.WritersRejectAnUnkeyableIdentity", "[ShadowRenderView]") { // An engaged entry must always be keyable: hysteresis keys on the identity, so an invalid one @@ -157,8 +221,8 @@ TEST_CASE("ShadowRenderViewSet.WritersRejectAnUnkeyableIdentity", "[ShadowRender someSpotMetrics())); // A face index out of range is no longer expressible — `setPointLight` takes a fixed span of // six — so what is left to reject here is the light's own identity. - CHECK_FALSE( - views.setPointLight(0, NodeId::Invalid, somePointMetrics(), cubeSpan(someCube(3.0f)))); + CHECK_FALSE(views.setPointLight(0, NodeId::Invalid, somePointMetrics(), kPointRange, + cubeSpan(someCube(3.0f)))); CHECK(views.activeCount(ShadowViewGroup::Self) == 0); CHECK(views.activeCount(ShadowViewGroup::Spot) == 0); @@ -295,7 +359,7 @@ TEST_CASE("ShadowRenderViewSet.PointFacesOccupyDistinctFlatSlotsWithDistinctForw }; const std::array cube{at(0), at(1), at(2), at(3), at(4), at(5)}; - REQUIRE(views.setPointLight(lightSlot, light, somePointMetrics(), cubeSpan(cube))); + REQUIRE(views.setPointLight(lightSlot, light, somePointMetrics(), kPointRange, cubeSpan(cube))); CHECK(views.activeCount(ShadowViewGroup::Point) == kCubeFaceCount); for (std::uint8_t face = 0; face < kCubeFaceCount; ++face) @@ -330,9 +394,10 @@ TEST_CASE("ShadowRenderViewSet.PointLightSlotIsValidatedBeforeFlattening", "[Sha STATIC_REQUIRE(kCubeFaceCount % 2 == 0); // what makes the product wrap to zero REQUIRE(shadowPointViewSlot(wrapping, 1) == 1); // it really does land on light 0, face 1 - REQUIRE(views.setPointLight(0, real, somePointMetrics(), cubeSpan(someCube(11.0f)))); - CHECK_FALSE( - views.setPointLight(wrapping, impostor, somePointMetrics(), cubeSpan(someCube(99.0f)))); + REQUIRE( + views.setPointLight(0, real, somePointMetrics(), kPointRange, cubeSpan(someCube(11.0f)))); + CHECK_FALSE(views.setPointLight(wrapping, impostor, somePointMetrics(), kPointRange, + cubeSpan(someCube(99.0f)))); const ShadowRenderView* view = views.find(ShadowViewGroup::Point, shadowPointViewSlot(0, 1)); REQUIRE(view != nullptr); @@ -417,12 +482,12 @@ TEST_CASE("ShadowRenderViewSet.ARejectedReplacementClearsTheSlotItAddressed", "[ // the wrapping point slot, which is rejected before it can be flattened onto a live face. Both // families are seeded first, so each rejection has a live entry it could have damaged. REQUIRE(views.setCascade(1, markedMatrix(8.0f), someOrtho(), someOrthoMetrics())); - REQUIRE(views.setPointLight(0, static_cast(5), somePointMetrics(), + REQUIRE(views.setPointLight(0, static_cast(5), somePointMetrics(), kPointRange, cubeSpan(someCube(5.0f)))); constexpr std::size_t wrapping = std::size_t{1} << (std::numeric_limits::digits - 1); CHECK_FALSE(views.setPointLight(wrapping, static_cast(6), somePointMetrics(), - cubeSpan(someCube(6.0f)))); + kPointRange, cubeSpan(someCube(6.0f)))); CHECK_FALSE( views.setCascade(kShadowCascadeCount, markedMatrix(7.0f), someOrtho(), someOrthoMetrics())); @@ -464,44 +529,11 @@ ShadowRenderViewSet withAllCascades() } } // namespace -TEST_CASE("ShadowRenderView.MatrixArrayPlacesEachFamilyAtItsOwnBase", "[ShadowRenderView]") -{ - ShadowRenderViewSet views = withAllCascades(); - const auto light = static_cast(4); - REQUIRE(views.setSpot(2, light, markedMatrix(50.0f), somePerspective(), someSpotMetrics())); - REQUIRE(views.setPointLight(1, light, somePointMetrics(), cubeSpan(someCube(103.0f)))); - - const auto matrices = shadowMatrixArray(views); - - CHECK(matrices[static_cast(kShadowCascadeMatrixBase) + 1][0, 3] == 2.0f); - CHECK(matrices[static_cast(kShadowSpotMatrixBase) + 2][0, 3] == 50.0f); - // someCube marks face f with seed + f, so light 1's face 3 carries 106 — checking a face other - // than 0 is the point: the flat slot must be the light's base plus the face, not the light's. - CHECK(matrices[static_cast(kShadowPointMatrixBase) + shadowPointViewSlot(1, 3)] - [0, 3] == 106.0f); - // Inactive slots are identity, not stale content from another family. - CHECK(matrices[static_cast(kShadowSpotMatrixBase)][0, 3] == 0.0f); - CHECK(matrices[static_cast(kShadowPointMatrixBase)][0, 3] == 0.0f); -} - -TEST_CASE("ShadowRenderView.WorldOnlyWritesNoShaderSlot", "[ShadowRenderView]") -{ - // World-only rasterises with its cascade's matrix, so enabling it must change nothing in the - // shader array. - ShadowRenderViewSet withWorld = withAllCascades(); - for (std::uint32_t cascade = 0; cascade < kShadowCascadeCount; ++cascade) - { - REQUIRE(withWorld.enableWorldOnly(cascade)); - } - const auto withWorldOnly = shadowMatrixArray(withWorld); - const auto withoutWorldOnly = shadowMatrixArray(withAllCascades()); - - for (std::size_t i = 0; i < withWorldOnly.size(); ++i) - { - CAPTURE(i); - CHECK(withWorldOnly[i][0, 3] == withoutWorldOnly[i][0, 3]); - } -} +// The combined matrix ARRAY is gone (arc 2 #4 step 1). It existed to fill +// `ShadowUBO::lightViewProj[32]`, which every shadow draw carried so a push constant could index +// one row; every path now rasterises with `pc.lightViewProj`, taken from the view being recorded. +// The per-family LightUBO extractors below are unaffected — those feed the RECEIVER, which still +// needs each family's matrices to project a fragment into light space. TEST_CASE("ShadowRenderView.LightUboArraysExtractTheirOwnFamily", "[ShadowRenderView]") { @@ -590,7 +622,8 @@ TEST_CASE("ShadowRenderViewSet.WritersRejectMetricsOfTheWrongKind", "[ShadowRend CHECK_FALSE(views.setCascade(0, markedMatrix(1.0f), someOrtho(), someSpotMetrics())); CHECK_FALSE(views.setSelf(0, 5, markedMatrix(2.0f), someOrtho(), somePointMetrics())); CHECK_FALSE(views.setSpot(0, light, markedMatrix(3.0f), somePerspective(), someOrthoMetrics())); - CHECK_FALSE(views.setPointLight(0, light, someSpotMetrics(), cubeSpan(someCube(4.0f)))); + CHECK_FALSE( + views.setPointLight(0, light, someSpotMetrics(), kPointRange, cubeSpan(someCube(4.0f)))); CHECK(views.activeCount(ShadowViewGroup::Cascade) == 0); CHECK(views.activeCount(ShadowViewGroup::Self) == 0); @@ -607,7 +640,7 @@ TEST_CASE("ShadowRenderViewSet.APointCubeCarriesOneMetricForTheWholeLight", "[Sh ShadowRenderViewSet views; const auto light = static_cast(21); const auto metrics = ShadowViewMetrics::pointLight(0.008f, 40.0f); - REQUIRE(views.setPointLight(2, light, metrics, cubeSpan(someCube(7.0f)))); + REQUIRE(views.setPointLight(2, light, metrics, kPointRange, cubeSpan(someCube(7.0f)))); for (std::uint8_t face = 0; face < kCubeFaceCount; ++face) { @@ -628,14 +661,15 @@ TEST_CASE("ShadowRenderViewSet.ARejectedCubeLeavesNoFaceBehind", "[ShadowRenderV #ifdef NDEBUG ShadowRenderViewSet views; const auto light = static_cast(33); - REQUIRE(views.setPointLight(0, light, somePointMetrics(), cubeSpan(someCube(10.0f)))); + REQUIRE( + views.setPointLight(0, light, somePointMetrics(), kPointRange, cubeSpan(someCube(10.0f)))); REQUIRE(views.activeCount(ShadowViewGroup::Point) == kCubeFaceCount); // One bad face rejects the whole cube AND clears the previously good one. auto cube = someCube(20.0f); cube[4] = ShadowPointFace{markedMatrix(std::numeric_limits::quiet_NaN()), somePerspective()}; - CHECK_FALSE(views.setPointLight(0, light, somePointMetrics(), cubeSpan(cube))); + CHECK_FALSE(views.setPointLight(0, light, somePointMetrics(), kPointRange, cubeSpan(cube))); CHECK(views.activeCount(ShadowViewGroup::Point) == 0); #endif } @@ -653,7 +687,7 @@ TEST_CASE("ShadowRenderView.BiasMetricArraysFollowTheirFamilies", "[ShadowRender ShadowViewMetrics::orthographic(0.125f, 4.0f))); REQUIRE(views.setSpot(3, light, markedMatrix(3.0f), somePerspective(), ShadowViewMetrics::spot(0.01f, 0.2f, 30.0f))); - REQUIRE(views.setPointLight(1, light, ShadowViewMetrics::pointLight(0.02f, 10.0f), + REQUIRE(views.setPointLight(1, light, ShadowViewMetrics::pointLight(0.02f, 10.0f), kPointRange, cubeSpan(someCube(4.0f)))); const auto cascades = cascadeBiasMetricsArray(views); diff --git a/tests/render/test_ubo.cpp b/tests/render/test_ubo.cpp index 3d124943..6225900e 100644 --- a/tests/render/test_ubo.cpp +++ b/tests/render/test_ubo.cpp @@ -389,7 +389,9 @@ TEST_CASE("UBO.ForwardPushConstantsDefaultsToNoSelfShadowSlot", "[UBO]") TEST_CASE("UBO.ShadowPushConstantsCanCarryInlineLightMatrix", "[UBO]") { ShadowPushConstants pc{}; - CHECK(pc.matrixIndex == 0); + // Default is PROJECTED depth: the radial ratio is the point family's special case, and a + // default that opted into it would make every unset view write distance/range. + CHECK(pc.radialDepth == 0); CHECK(pc.selfShadowSlot == -1); CHECK(pc.selfShadowDepthEpsilon == Catch::Approx(fire_engine::kSkinnedSelfShadowDepthEpsilon).margin(1e-5f)); @@ -421,10 +423,15 @@ TEST_CASE("UBO.ShadowUBOHasSkinCanBeSet", "[UBO]") TEST_CASE("UBO.ShadowUBOFieldOrder", "[UBO]") { - static_assert(offsetof(ShadowUBO, model) < offsetof(ShadowUBO, lightViewProj), - "model must precede lightViewProj to match shader layout"); - static_assert(offsetof(ShadowUBO, lightViewProj) < offsetof(ShadowUBO, hasSkin), - "lightViewProj must precede hasSkin to match shader layout"); + // The block is now just the object's world matrix and its skin flag: the 32-matrix table that + // used to sit between them is gone, and with it every object's copy of every shadow matrix in + // the frame. Anything that still needs a light-space transform reads it from the push block. + // + // The EXACT offsets live beside the type in ubo.hpp, where they fail the build rather than a + // test run; this pins the same values from the consumer's side. + static_assert(offsetof(ShadowUBO, model) == 0); + static_assert(offsetof(ShadowUBO, hasSkin) == 64); + static_assert(sizeof(ShadowUBO) == 80); SUCCEED(); } @@ -432,7 +439,5 @@ TEST_CASE("UBO.ShadowUBOMatricesAligned16", "[UBO]") { static_assert(offsetof(ShadowUBO, model) % 16 == 0, "model must be 16-byte aligned for std140 mat4"); - static_assert(offsetof(ShadowUBO, lightViewProj) % 16 == 0, - "lightViewProj must be 16-byte aligned for std140 mat4"); SUCCEED(); }