Skip to content

Shadow pass: decide in preparation, record from the plan - #141

Merged
nnewson merged 1 commit into
mainfrom
shadow-static-cascade-cache
Aug 19, 2026
Merged

Shadow pass: decide in preparation, record from the plan#141
nnewson merged 1 commit into
mainfrom
shadow-static-cascade-cache

Conversation

@nnewson

@nnewson nnewson commented Aug 19, 2026

Copy link
Copy Markdown
Owner

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.

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.
@nnewson
nnewson merged commit 0dc4ebd into main Aug 19, 2026
4 checks passed
@nnewson
nnewson deleted the shadow-static-cascade-cache branch August 19, 2026 19:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant