Shadow pass: decide in preparation, record from the plan - #141
Merged
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.
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.