Skip to content

Verify punctual shadow reuse, and measure what it is worth - #145

Merged
nnewson merged 1 commit into
mainfrom
shadow-punctual-change-detection
Sep 12, 2026
Merged

nnewson merged 1 commit into
mainfrom
shadow-punctual-change-detection

Conversation

@nnewson

@nnewson nnewson commented Sep 12, 2026

Copy link
Copy Markdown
Owner

Arc 2 #15 asked for punctual-shadow change detection. Arc 2 #4 had already built it: the residency mechanism is family-agnostic, a point face's light position and range are in the content descriptor, and per-face frustum filtering predates both. So this branch changes no engine code. What it owes instead is evidence, because "we believe the mechanism covers this family" is not the same claim as "it does".

THE AUDIT. Every shadow SAMPLING input — spotViewProj, cascadeViewProj, selfShadowViewProj and all four *BiasMetrics arrays — is copied wholesale from the completed view set every frame, unconditionally, never from the plan and never gated on whether a family recorded. Every RASTER input is in the content descriptor. Nothing sits outside both, which is what makes a reused map unable to go stale through a sampling parameter. Shadow draws are also VDPM-free by construction (object.cpp clears the indirect handle and the GPU front), so a per-frame GPU-emitted index buffer — whose handle repeats out of a two-deep ring while its contents change — can never enter the descriptor.

WHAT IS PINNED. Eight headless cases, each a way a convincing stale map could have survived. A RANGE-only change re-records all six faces: every matrix is identical across the two frames and every texel still differs, because the stored value is a ratio against that range — the case a transform-only descriptor would miss. A moving light re-records all six, since its position is an input to the radial depth each face stores. A slot inherited by another light re-records rather than lighting one light with its predecessor's shadows, pinned for a single spot view and for a whole cube, because a law that held for one entry could still leave five faces of a departed light's map in place. Bias metrics reuse, on both paths, which is the audit's boundary stated as a test.

PER-FACE GRANULARITY is where the punctual saving lives, and it is caster-driven: a cube is compared face by face, but a light's own movement invalidates every face through the content law, so only a caster can change one face and not another.
ShadowResidencyCasterMotionTest moves one rigid caster inside a single face and holds recorded=1 reused=5 at a median 0.007 ms, against 0.141 ms forced on the same scene — about twenty times less for an identical image. ShadowResidencyLightMotionTest is the ceiling: recorded=6 reused=0 at 0.172 ms, which is what punctual reuse cannot save. Two files rather than two phases of one animation, because the evidence is a per-family count and a scene that changed regime part-way would make every aggregate a mixture of two answers.

SLOT CHURN is parked, not solved. Punctual slots are assigned per frame in gather order, so a light leaving compacts the ones after it and every inherited slot re-records a cube it could in principle have kept. That is safe — identity is part of the comparison, and two tests pin it — and its cost is currently unreachable: there is no runtime light removal or enable/disable path and gatherLights() walks a stable node order, so churn frequency in production is zero. A stable-assignment scheme would be machinery guarding against nothing. Trigger for revisiting it is recorded in the roadmap.

Two traps found in the existing test fixture along the way, both now commented where the next person will hit them: populatedViews() gives all six point faces the same marked matrix, so their frusta are identical and every face admits every caster; and candidateDraws counts draws OFFERED while drawnDraws counts those the filter ACCEPTED, so a premise checked against the wrong counter reports six faces seeing an on-axis caster.

Arc 2 #15 asked for punctual-shadow change detection. Arc 2 #4 had
already built it: the residency mechanism is family-agnostic, a point
face's light position and range are in the content descriptor, and
per-face frustum filtering predates both. So this branch changes no
engine code. What it owes instead is evidence, because "we believe the
mechanism covers this family" is not the same claim as "it does".

THE AUDIT. Every shadow SAMPLING input — spotViewProj, cascadeViewProj,
selfShadowViewProj and all four *BiasMetrics arrays — is copied
wholesale from the completed view set every frame, unconditionally,
never from the plan and never gated on whether a family recorded. Every
RASTER input is in the content descriptor. Nothing sits outside both,
which is what makes a reused map unable to go stale through a sampling
parameter. Shadow draws are also VDPM-free by construction (object.cpp
clears the indirect handle and the GPU front), so a per-frame
GPU-emitted index buffer — whose handle repeats out of a two-deep ring
while its contents change — can never enter the descriptor.

WHAT IS PINNED. Eight headless cases, each a way a convincing stale map
could have survived. A RANGE-only change re-records all six faces: every
matrix is identical across the two frames and every texel still differs,
because the stored value is a ratio against that range — the case a
transform-only descriptor would miss. A moving light re-records all six,
since its position is an input to the radial depth each face stores. A
slot inherited by another light re-records rather than lighting one
light with its predecessor's shadows, pinned for a single spot view and
for a whole cube, because a law that held for one entry could still
leave five faces of a departed light's map in place. Bias metrics reuse,
on both paths, which is the audit's boundary stated as a test.

PER-FACE GRANULARITY is where the punctual saving lives, and it is
caster-driven: a cube is compared face by face, but a light's own
movement invalidates every face through the content law, so only a
caster can change one face and not another.
ShadowResidencyCasterMotionTest moves one rigid caster inside a single
face and holds recorded=1 reused=5 at a median 0.007 ms, against 0.141
ms forced on the same scene — about twenty times less for an identical
image. ShadowResidencyLightMotionTest is the ceiling: recorded=6
reused=0 at 0.172 ms, which is what punctual reuse cannot save. Two
files rather than two phases of one animation, because the evidence is a
per-family count and a scene that changed regime part-way would make
every aggregate a mixture of two answers.

SLOT CHURN is parked, not solved. Punctual slots are assigned per frame
in gather order, so a light leaving compacts the ones after it and every
inherited slot re-records a cube it could in principle have kept. That
is safe — identity is part of the comparison, and two tests pin it — and
its cost is currently unreachable: there is no runtime light removal or
enable/disable path and gatherLights() walks a stable node order, so
churn frequency in production is zero. A stable-assignment scheme would
be machinery guarding against nothing. Trigger for revisiting it is
recorded in the roadmap.

Two traps found in the existing test fixture along the way, both now
commented where the next person will hit them: populatedViews() gives
all six point faces the same marked matrix, so their frusta are
identical and every face admits every caster; and candidateDraws counts
draws OFFERED while drawnDraws counts those the filter ACCEPTED, so a
premise checked against the wrong counter reports six faces seeing an
on-axis caster.
@nnewson
nnewson merged commit dc6a161 into main Sep 12, 2026
9 of 10 checks passed
@nnewson
nnewson deleted the shadow-punctual-change-detection branch September 12, 2026 14:24
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