Skip to content

[Bugfix] Stop CSM shadow cascades from crawling as the camera rotates - #1315

Closed
untoldengine wants to merge 4 commits into
developfrom
bugfix/shadow_crawling
Closed

untoldengine wants to merge 4 commits into
developfrom
bugfix/shadow_crawling

Conversation

@untoldengine

Copy link
Copy Markdown
Owner

Summary

  • Cascaded shadow maps were fitted to the camera's current view cone every frame, so each cascade's shadow-map box continuously re-centered and reshaped as the camera rotated in place. This was most visible when looking toward the light's own direction (e.g. straight up at an overhead stadium sun) — the classic degenerate case for frustum-fitted CSM. In a headset, where view direction changes constantly, this showed up as shadows visibly crawling on fine geometry (a stadium roof's cable lattice).
  • Fixes two compounding issues:
    • Per-pixel cascade selection (computeCSMShadow in LightShader.metal) used forward-axis-projected view depth, which isn't rotation-invariant for a stationary point; switched to true Euclidean distance from the camera.
    • Each cascade's coverage (ShadowSystem.updateCascades()) was fitted to the camera's view cone (orientation-dependent, 8 frustum corners derived from the camera's current rotation); switched to a sphere of fixed radius centered on camera position only, so rotating the camera never moves or reshapes a cascade's coverage — only translating does.
  • Removed now-dead code this obsoleted (cascadeFrustumCornersWorldSpace, cascadeNearDistance, and the latter's dedicated test class) and updated stale doc comments.

Fixes #1310.

Test plan

  • swift build
  • ./buildkernels.sh (required after .metal edits)
  • swift test --filter RendererTests — all cascade/shadow tests pass, including PSNR image-comparison render tests and scene-root scale/rotation invariance guards
  • swift test --filter LightSystemTest — all pass
  • swift test --filter "ShadowSystem|CsmCascadeCountTests" — all pass
  • On-device verification on Vision Pro (rotate headset while looking up at a large overhead structure) — reported fixed by the original reporter during this investigation

🤖 Generated with Claude Code

Cascaded shadow maps were fitted to the camera's current view cone each
frame, so the shadow-map box continuously re-centered and reshaped as the
camera rotated in place -- most visibly when looking toward the light's
own direction (e.g. straight up at an overhead sun), the classic
degenerate case for frustum-fitted CSM. In a headset, where the view
direction changes constantly, this showed up as shadows crawling on fine
geometry like a stadium roof's cable lattice.

Fixes two compounding issues:
- Per-pixel cascade selection used forward-axis-projected view depth,
  which isn't rotation-invariant for a stationary point; switched to true
  distance from the camera.
- Each cascade's coverage was fitted to the camera's view cone
  (orientation-dependent); switched to a sphere of fixed radius centered
  on camera position only, so rotating the camera never moves or reshapes
  a cascade's coverage -- only translating does.

Fixes #1310.
computeCSMShadow picked a cascade by comparing distance-from-camera
against csm.cascadeSplits, but the camera position it used
(SceneRootTransform.shared.effectiveCameraPosition) and the position
cascadeSplits/cascadeWorldRadii were fit around on the CPU (the
camera's raw, root-uncorrected position) diverge whenever the scene
root has non-identity scale -- e.g. AR tabletop placement. That
silently broke cascade selection (and shadow visibility) even though
nothing in the scene actually moved.

Move the camera reference point into CSMUniforms itself, sourced
directly from cascadeWorldCenters (the same value used to fit the
cascades), instead of threading a separately-sourced cameraPosition
through three call sites where it can drift into a different space.
Sizing each CSM cascade's coverage sphere to exactly its selection
split (cascadeSplitDistances[i]) under-covers content at the edge of
the camera's FOV relative to the old view-frustum-fitted wedge, which
always reached that far in the look direction (a FOV-edge point at
forward distance d is at true distance d * sqrt(1 + tan^2(halfFovX) +
tan^2(halfFovY)) from the camera -- up to ~1.5x d at a typical FOV).
For wide/large scenes (e.g. a stadium) this showed up as a hard,
FOV-shaped shadow horizon sweeping across the ground well within the
visible frustum, as if CSM only covered part of the scene.

Inflate the coverage radius by that same factor so it covers
everything the old wedge did. Cascade *selection* stays pure
Euclidean distance against the uninflated split, so rotation
invariance (the original crawl fix) is unaffected -- the factor
depends only on the camera's own fixed FOV, never on orientation.
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.

[Bug] CSM shadows crawl/move on headset rotation in large scenes (stadium)

1 participant