SH-06 slices 1-2: extract the cascade fit into receiver/depth carriers - #131
Merged
Conversation
Splits Renderer::computeShadowCascades' fitCascade lambda into two pure,
Vulkan-free carriers in render/cascade_fit.{hpp,cpp}, because the
pipeline is receiver slice -> stable XY fit -> candidate query -> depth
fit -> render matrix and the candidate query consumes the first. Baking
Z into a single fit made the query depend on its own output, which is
why a fixed kShadowDepthBackExtend was needed at all.
CascadeReceiverFit::fit produces the slice's light-space footprint:
normalised basis, frustum and snapped centre, radius, snapped U/V
bounds, exact receiver min/max W from the eight corners (not centreW +/-
radius — the caster-aware far plane must reach the receiver volume, and
the bounding sphere is generally looser in depth), and worldPerTexel.
fitLegacyCascadeDepth turns that into the light position and
view-projection using today's fixed back-extension, and is the one
function SH-06 replaces with the caster-aware policy.
The extraction is proven to be a no-op:
tests/render/test_cascade_fit.cpp holds a verbatim copy of the
pre-extraction lambda and asserts the matrices are bit-identical across
four camera/sun poses x the four shipped splits, on both macOS/arm64 and
Linux/x86_64, with a second case proving that reference still responds
to its own inputs so the equality cannot pass vacuously.
That equality forced one contract rather than confirming one:
lightDirection must arrive unit length and is rejected, not normalised.
Vec3::normalise of an already-unit vector moves it by an ulp whenever
its squared length lands just under one, which changed two of the four
poses. Tolerance is 8 * FLT_EPSILON on squared length — sized in float
rounding, so a 1.0001 scale is caught while real normalise output is
not.
CascadeReceiverFit is encapsulated like ShadowView (private constructor,
static factory, read-only getters). As a public aggregate it had a hole
no field-wise validator closes cheaply: lightUp set equal to
lightDirection is finite, passes every check worth writing, and sends
Mat4::lookAt to its own fallback up — manufacturing exactly the
plausible basis this API refuses. A shared full-carrier validator would
only hold while every future consumer remembered to call it. The
invariant is pinned with STATIC_REQUIRE_FALSE on
is_default_constructible/is_aggregate, since there is no longer a
runtime state to test. CascadeDepthFit stays a plain aggregate: nothing
consumes one, so no policy has to trust it.
fitLegacyCascadeDepth therefore validates only backExtend — the one
input still arriving from outside — and its own output. Negative
extensions are the instructive case, verified against the raw
expressions: a small one keeps the range ordered but pulls both planes
inside the fitted sphere, and one past -radius reverses it so every
depth comparison inverts. Both produce fully finite matrices, which is
precisely what the view set's non-finite validation waves through. Both
rejections are terminal in the renderer via rejectedCascadeFit.
Adds per-cascade fit diagnostics at FE_LOG=render:debug, sampled every
120 frames starting with the first plus unconditionally on the frame
--capture-frame selects, reading only values back from the two carriers.
A periodic sample alone cannot describe a capture: at a 120-frame
stride, frame 300's image would be explained by the fit from frame 241.
Later caster-bound and cascade-blend diagnostics should share that
capture-triggered condition so all evidence describes one submitted
frame.
Adds ShadowDepthClipDemo to assets/shadow_lod/generate.py as a
depth-clip probe, with a dedicated validator whose eight claims were
each negative-tested (two vacuous assertions were found and fixed that
way). Both existing generated scenes remain byte-identical. The fit log
confirms the placement is exact — cascade 2 reports depth W [-41.340,
33.535], the predicted plane, with the caster centre on it — but the
probe is not discriminating, because the shadow pass front-culls and the
downstream hemisphere still projects a complete silhouette. It stays a
probe; the acceptance fixture will be the frozen ShadowLodMotionDemo
failing pose.
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.
Splits Renderer::computeShadowCascades' fitCascade lambda into two pure, Vulkan-free carriers in render/cascade_fit.{hpp,cpp}, because the pipeline is receiver slice -> stable XY fit -> candidate query -> depth fit -> render matrix and the candidate query consumes the first. Baking Z into a single fit made the query depend on its own output, which is why a fixed kShadowDepthBackExtend was needed at all.
CascadeReceiverFit::fit produces the slice's light-space footprint: normalised basis, frustum and snapped centre, radius, snapped U/V bounds, exact receiver min/max W from the eight corners (not centreW +/- radius — the caster-aware far plane must reach the receiver volume, and the bounding sphere is generally looser in depth), and worldPerTexel. fitLegacyCascadeDepth turns that into the light position and view-projection using today's fixed back-extension, and is the one function SH-06 replaces with the caster-aware policy.
The extraction is proven to be a no-op:
tests/render/test_cascade_fit.cpp holds a verbatim copy of the pre-extraction lambda and asserts the matrices are bit-identical across four camera/sun poses x the four shipped splits, on both macOS/arm64 and Linux/x86_64, with a second case proving that reference still responds to its own inputs so the equality cannot pass vacuously.
That equality forced one contract rather than confirming one: lightDirection must arrive unit length and is rejected, not normalised. Vec3::normalise of an already-unit vector moves it by an ulp whenever its squared length lands just under one, which changed two of the four poses. Tolerance is 8 * FLT_EPSILON on squared length — sized in float rounding, so a 1.0001 scale is caught while real normalise output is not.
CascadeReceiverFit is encapsulated like ShadowView (private constructor, static factory, read-only getters). As a public aggregate it had a hole no field-wise validator closes cheaply: lightUp set equal to lightDirection is finite, passes every check worth writing, and sends Mat4::lookAt to its own fallback up — manufacturing exactly the plausible basis this API refuses. A shared full-carrier validator would only hold while every future consumer remembered to call it. The invariant is pinned with STATIC_REQUIRE_FALSE on
is_default_constructible/is_aggregate, since there is no longer a runtime state to test. CascadeDepthFit stays a plain aggregate: nothing consumes one, so no policy has to trust it.
fitLegacyCascadeDepth therefore validates only backExtend — the one input still arriving from outside — and its own output. Negative extensions are the instructive case, verified against the raw expressions: a small one keeps the range ordered but pulls both planes inside the fitted sphere, and one past -radius reverses it so every depth comparison inverts. Both produce fully finite matrices, which is precisely what the view set's non-finite validation waves through. Both rejections are terminal in the renderer via rejectedCascadeFit.
Adds per-cascade fit diagnostics at FE_LOG=render:debug, sampled every 120 frames starting with the first plus unconditionally on the frame --capture-frame selects, reading only values back from the two carriers. A periodic sample alone cannot describe a capture: at a 120-frame stride, frame 300's image would be explained by the fit from frame 241. Later caster-bound and cascade-blend diagnostics should share that capture-triggered condition so all evidence describes one submitted frame.
Adds ShadowDepthClipDemo to assets/shadow_lod/generate.py as a depth-clip probe, with a dedicated validator whose eight claims were each negative-tested (two vacuous assertions were found and fixed that way). Both existing generated scenes remain byte-identical. The fit log confirms the placement is exact — cascade 2 reports depth W [-41.340, 33.535], the predicted plane, with the caster centre on it — but the probe is not discriminating, because the shadow pass front-culls and the downstream hemisphere still projects a complete silhouette. It stays a probe; the acceptance fixture will be the frozen ShadowLodMotionDemo failing pose.