Skip to content

Shadow cascade caster depth fit - #133

Merged
nnewson merged 3 commits into
mainfrom
shadow-cascade-caster-depth-fit
Aug 3, 2026
Merged

Shadow cascade caster depth fit#133
nnewson merged 3 commits into
mainfrom
shadow-cascade-caster-depth-fit

Conversation

@nnewson

@nnewson nnewson commented Aug 3, 2026

Copy link
Copy Markdown
Owner

No description provided.

nnewson added 3 commits August 1, 2026 16:15
Groundwork for the caster-aware depth fit, and the measurement that
justifies it. No production behaviour changes here: the cascade matrices
are untouched, and everything added is diagnostics, a test fixture, and
a classification the candidate query will consume.

placeCaster (render/cascade_fit) answers where one caster sits relative
to one cascade in that cascade's light space: its U/V/W bounds, whether
the fitted near/far planes clip it, and how its footprint relates to the
cascade rectangle. It is pure and headless, and it is deliberately not a
verdict — it separates a caster CLIPPED in depth from one whose
footprint sits outside the cascade, which a picture cannot.

CascadeFootprintRelation is Invalid / Outside / Inside / Straddles, with
edge-touching classified conservatively as Straddles: the candidate
query must reject Outside and only Outside, because over-including a
caster costs a draw while wrongly excluding one costs a shadow nobody
can trace. Straddling is ordinary, not defective — light rays in an
orthographic directional map preserve U and V, so the part of a caster
outside the rectangle cannot shadow any receiver inside it.

The renderer retains each frame's two fit carriers and logs, on the same
frames as the cascade fit (periodic plus the --capture-frame frame),
where every shadow caster sits in every cascade — keyed by
ShadowCasterId and generation, since one object can hold several caster
bindings. The log states its own limit: it runs before ShadowDrawFilter,
so it proves placement, never rasterisation.

ShadowDepthClipDemo's caster is repositioned, and this is the substance
of the branch. The first placement — a sphere centred ON cascade 2's
legacy near plane — produced a complete ellipse, which is correct rather
than a null result: the shadow pass culls FRONT faces, so the surface a
caster records is its far side, and a sphere centred on the plane has
that entire surface downstream of it. Moving the caster one metre
upstream along the sun does not move its shadow (it stays on the same
light ray) but makes the recorded surface straddle the plane. The trace
then reports clippedNear with W [-45.723, -38.953] against depth W
[-41.340, 33.535], and the shadow shrinks from 35253 to 26166 pixels,
306x152 to 263x131 — 14% linearly, 26% by area, against an analytic
prediction of 13.4% / 25% for the cap the plane removes from a sphere.

That is now SH-06's acceptance gate, recorded pre-fix: the caster-aware
depth fit must restore the full-size ellipse.

Scope the shape result carefully, and the comments do. A plane
perpendicular to the light removes a cap symmetric about the light axis,
so THIS fixture's sphere shrinks concentrically rather than acquiring a
straight edge; asymmetric geometry can present a straight projected
boundary under the same clip. What generalises is the mechanism, not the
silhouette.

The half-ellipse originally reported against ShadowLodMotionDemo is
filed as a separate open question rather than SH-06's motivation.
Sweeping the caster's whole animation range finds no pose where it is
depth-clipped (closest approach 20.7 m under the fallback sun the scene
used to render with, 30.8 m under the authored one), and a 676-row live
trace over a ~25 s run reports zero clippedNear events for any caster.
Neither null is vacuous: the sweep reproduces the engine's own logged
cascade-0 fit to the printed digit, and the trace flags the probe scene.
Diagnosing it needs the symptom re-confirmed under the repaired sun, the
pass's own drawn verdict beside the placement, and per-pixel cascade /
blend factor / projected shadow U/V at the affected receivers — none of
which exist yet.
…distance

The directional cascade depth range was `centre ± (radius +
kShadowDepthBackExtend)`: a fixed 20-world-unit extension in both
directions, standing in for "how far behind the slice a caster might
be". No constant can answer that for an arbitrary scene. Too small clips
a caster; too large spends depth precision on empty space.

`fitCasterAwareCascadeDepth` fits the planes to the frame's actual
geometry. The near plane reaches back to the furthest-upstream CANDIDATE
caster — footprint not `Outside`, since light rays preserve U and V in
an orthographic map, so a caster outside the rectangle cannot shadow a
receiver inside it. The far plane covers the receiver volume and no
further: geometry behind every receiver in the slice cannot shadow one,
and reaching it would spend precision on nothing. Both planes get one
`worldPerTexel` of slack — the fit's own unit rather than an invented
epsilon — which widens the depth span by an XY texel's world size and
has nothing to do with depth resolution. The matrix is built with the
same `lookAt` / `ortho` calls as the legacy fit, so the only difference
between the policies is where the planes sit.

The result carries a `CascadeDepthFitMode`: `LegacyFixedExtension` for
`fitLegacyCascadeDepth` asked for directly, `CasterAware` for a fitted
range, and `LegacyStaleFallback` where the policy declined. Three
values, not two, because the legacy function still has its own callers
and its direct result is neither caster-aware nor a fallback from
anything. `CascadeDepthFit` became an encapsulated class for the same
reason `CascadeReceiverFit` is one: a mode has to describe THIS matrix,
and a public aggregate would let a caller pair either with the other.

Cloth is the unresolved case and is marked as such rather than papered
over. A storage-vertex caster's bounds are its bind pose — a compute
pass rewrites the vertices — so the box neither bounds the geometry nor
establishes which cascade that geometry affects. Ignoring stale entries
and fitting the rest is not the safe reading: it can produce a range
NARROWER than one covering the cloth, which clips it, arriving at the
same defect from the other side. So a single stale caster anywhere in
the frame makes every directional cascade fall back to the legacy range,
marked `LegacyStaleFallback`. Validation of every Exact bound completes
BEFORE that choice, so a frame containing both cloth and a corrupt
caster still reports the corruption rather than silently taking the
fallback.

Cascade blending became a fitting constraint. The shader cross-fades
into cascade i+1 over the last tenth of cascade i's view-depth range, so
those receivers sample i+1's map — and a cascade fitted tightly to its
own slice would not cover them. Each cascade is now fitted from the
start of its predecessor's blend band, and the fraction lives in
`LightUBO::cascadeParams.x` so the value that decides the band and the
value that fits for it are one C++ constant, not a constant plus a
shader literal.

Corrupt bounds are terminal end to end. `Bounds3::expandChecked` refuses
a non-finite point instead of letting std::min/std::max drop it (they
return the other operand against NaN, so the old path produced a finite
box that did not contain its geometry); the prepass reports such a
caster as valid-but-non-finite, which is what it is — this caster has an
extent and the engine cannot state it — and the policy fails rather than
skipping it, because a range tightened around a caster nobody accounted
for is the exact defect being fixed.

ACCEPTANCE, on `ShadowDepthClipDemo`, whose caster the legacy fit
clipped: 26166 -> 35324 shadow pixels, bounding box 263x131 -> 305x152.
Against the geometrically unclipped baseline (35253 px, 306x152) the two
differ by 253 pixels grouped into 185 horizontal scanline runs, longest
5 px, median 1 — a one-pixel fringe following the silhouette with no
clip-sized interior region. Residual clipping leaves a chunk; the legacy
row is exactly that, a concentric 26% loss of area.

Evidence re-run on an idle machine: the SH-03 budget table moves
slightly with the changed matrices (budget 2 0.243% -> 0.289%, budget 4
0.356% -> 0.374%) and the 0.1% threshold still selects budget 1 by a
wide margin; the dead-band sweep again shows zero reversals at every
ratio. A sweep that overlapped a Docker CI build reported a 0.12% noise
floor where an idle one reports 0.0000%, and eight identical-flag
capture pairs differed by zero pixels — that sample was contention, and
`constants.hpp` now says to discard any sweep whose noise-floor line is
not zero.
@nnewson
nnewson merged commit 3efd938 into main Aug 3, 2026
4 checks passed
@nnewson
nnewson deleted the shadow-cascade-caster-depth-fit branch August 3, 2026 19:17
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