Skip to content

Shadow-LOD selection needs a number the existing LOD data cannot supp… - #124

Merged
nnewson merged 1 commit into
mainfrom
shadow-view-lod-model
Jul 27, 2026
Merged

Shadow-LOD selection needs a number the existing LOD data cannot supp…#124
nnewson merged 1 commit into
mainfrom
shadow-view-lod-model

Conversation

@nnewson

@nnewson nnewson commented Jul 27, 2026

Copy link
Copy Markdown
Owner

…ly. GeometryLod::error is an RMS quadric value — an average, which a locally bad region hides inside: measured on a UV sphere it read 0.0085 against a true surface movement of 0.0170, half the real deviation. Selecting a shadow level against it would silently exceed any budget it was given.

Two existing channels were measured and rejected before adding a third. MeshCollapse::deviationRadius is point-to-PLANE, so it reads ~0 for a coplanar collapse whose removed vertex lands outside every surviving face — exactly the in-plane silhouette movement a shadow shows and a camera view barely does. supportRadius is safe but measured 12x loose on a sphere and 21,000x on a near-flat plate, where it grows toward the object's own size; it would pin every planar caster to LOD0 forever.

So the simplifier records a dedicated Euclidean channel, from a distance it already computed and discarded: CollapseDeviation::shadow accumulates into MeshCollapse::shadowDeviationRadius, reduces per cut through the pure shadowDeviationForCut (max over the exact collapseCount prefix) onto ProgressiveLod::shadowDeviation, and is carried — validated — to GeometryLod::shadowDeviation.

It is deliberately an ESTIMATE, not a bound, and the docs say so: it measures the tangential displacement of the removed sample and is one-sided. On a sphere at LOD2 the simplified surface sat 0.069 from the original while original->simplified sampling reported 0.044; the accumulated estimate covered both at 2.27x, but that is empirical cushion rather than a guarantee. A certified symmetric bound stays a swappable future metric instead of hidden unfinished work here.

Invalid data is rejected uniformly. A deviation is a magnitude, so negative, NaN and non-finite all mean invalid, never a cheap collapse, and each becomes infinity to force LOD0. std::max(0.0f, x) is precisely the wrong idiom for that (it returns 0 for a NaN and for a negative) and is used nowhere in the chain. A prefix longer than the recorded stream asserts in debug and returns infinity in release rather than clamping into a plausible under-estimate.

The model itself (graphics/shadow_view.hpp) is Vulkan-free and headless:

  • ShadowView is encapsulated — private state, static factories — so an invalid view cannot be assembled by bypassing a convention, and the perspective factory normalises its own forward. Orthographic covers cascades, world-only cascades and self-shadow layers; each point face is its own perspective view.
  • Depth is the minimum signed forward projection over all eight bounds corners. A centre distance hides a caster straddling the light; a radial distance overstates depth off-axis (40 units to the side reads 9 deep, not 41).
  • projectShadowErrorTexels is radial texel displacement, stated as such. The perspective factor sqrt(1 + 2*tanHalfFov^2) is the Jacobian's largest singular value at a frustum corner, evaluated at depth minus the world error because the displacement is finite and the projection steepens toward the light — a frustum-corner test measured an actual 7.4256 texels against a 7.4021 first-order bound.
  • selectShadowLod validates the LOD chain before selecting, so an invalid deviation reports InvalidCaster rather than a plausible-looking Selected LOD0. Refinement may jump multiple levels to restore the budget; coarsening reaches the coarsest level clearing the stricter threshold. previousLevel is valid only within one draw and one logical view — out of range is reported, never clamped, so a punctual slot reassigned to another light cannot apply one caster's hysteresis to another's geometry.

largestSingularValue moves to math/singular_value.hpp, shared with VDPM rather than duplicated, and now rejects non-finite elements up front: std::max(finite, NaN) keeps the finite operand, which would have handed back a plausible scale.

No selection behaviour changes: nothing outside tests calls selectShadowLod yet. What does change at runtime is that the simplifier computes one more channel per collapse, and the overlay renders shadow LOD reasons by iterating the enum (a two-column table, since the new fallback names would clip a single line).

SH-03 owns the tuning this deliberately refuses to guess: kShadowLodPixelBudget and the coarsening ratio in render/constants.hpp threaded explicitly into the selector, retiring kShadowLodBias, and calibrating both against SH-01's captures and diagnostics. The hysteresis default is an invalid sentinel so no caller inherits an unmeasured value.

…ly. GeometryLod::error is an RMS quadric value — an average, which a locally bad region hides inside: measured on a UV sphere it read 0.0085 against a true surface movement of 0.0170, half the real deviation. Selecting a shadow level against it would silently exceed any budget it was given.

Two existing channels were measured and rejected before adding a third.
MeshCollapse::deviationRadius is point-to-PLANE, so it reads ~0 for a
coplanar collapse whose removed vertex lands outside every surviving
face — exactly the in-plane silhouette movement a shadow shows and a
camera view barely does. supportRadius is safe but measured 12x loose on
a sphere and 21,000x on a near-flat plate, where it grows toward the
object's own size; it would pin every planar caster to LOD0 forever.

So the simplifier records a dedicated Euclidean channel, from a distance
it already computed and discarded: CollapseDeviation::shadow accumulates
into MeshCollapse::shadowDeviationRadius, reduces per cut through the
pure shadowDeviationForCut (max over the exact collapseCount prefix)
onto ProgressiveLod::shadowDeviation, and is carried — validated — to
GeometryLod::shadowDeviation.

It is deliberately an ESTIMATE, not a bound, and the docs say so: it
measures the tangential displacement of the removed sample and is
one-sided. On a sphere at LOD2 the simplified surface sat 0.069 from the
original while original->simplified sampling reported 0.044; the
accumulated estimate covered both at 2.27x, but that is empirical
cushion rather than a guarantee. A certified symmetric bound stays a
swappable future metric instead of hidden unfinished work here.

Invalid data is rejected uniformly. A deviation is a magnitude, so
negative, NaN and non-finite all mean invalid, never a cheap collapse,
and each becomes infinity to force LOD0. std::max(0.0f, x) is precisely
the wrong idiom for that (it returns 0 for a NaN and for a negative) and
is used nowhere in the chain. A prefix longer than the recorded stream
asserts in debug and returns infinity in release rather than clamping
into a plausible under-estimate.

The model itself (graphics/shadow_view.hpp) is Vulkan-free and headless:

- ShadowView is encapsulated — private state, static factories — so an
invalid view cannot be assembled by bypassing a convention, and the
perspective factory normalises its own forward. Orthographic covers
cascades, world-only cascades and self-shadow layers; each point face is
its own perspective view.
- Depth is the minimum signed forward projection over all eight bounds
corners. A centre distance hides a caster straddling the light; a radial
distance overstates depth off-axis (40 units to the side reads 9 deep,
not 41).
- projectShadowErrorTexels is radial texel displacement, stated as such.
The perspective factor sqrt(1 + 2*tanHalfFov^2) is the Jacobian's
largest singular value at a frustum corner, evaluated at depth minus the
world error because the displacement is finite and the projection
steepens toward the light — a frustum-corner test measured an actual
7.4256 texels against a 7.4021 first-order bound.
- selectShadowLod validates the LOD chain before selecting, so an
invalid deviation reports InvalidCaster rather than a plausible-looking
Selected LOD0. Refinement may jump multiple levels to restore the
budget; coarsening reaches the coarsest level clearing the stricter
threshold. previousLevel is valid only within one draw and one logical
view — out of range is reported, never clamped, so a punctual slot
reassigned to another light cannot apply one caster's hysteresis to
another's geometry.

largestSingularValue moves to math/singular_value.hpp, shared with VDPM
rather than duplicated, and now rejects non-finite elements up front:
std::max(finite, NaN) keeps the finite operand, which would have handed
back a plausible scale.

No selection behaviour changes: nothing outside tests calls
selectShadowLod yet. What does change at runtime is that the simplifier
computes one more channel per collapse, and the overlay renders shadow
LOD reasons by iterating the enum (a two-column table, since the new
fallback names would clip a single line).

SH-03 owns the tuning this deliberately refuses to guess:
kShadowLodPixelBudget and the coarsening ratio in render/constants.hpp
threaded explicitly into the selector, retiring kShadowLodBias, and
calibrating both against SH-01's captures and diagnostics. The
hysteresis default is an invalid sentinel so no caller inherits an
unmeasured value.
@nnewson
nnewson merged commit 55b076a into main Jul 27, 2026
4 checks passed
@nnewson
nnewson deleted the shadow-view-lod-model branch July 27, 2026 19:40
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