Repository navigation
[Bugfix] Stop CSM shadow cascades from crawling as the camera rotates - #1315
Closed
untoldengine wants to merge 4 commits into
Closed
untoldengine wants to merge 4 commits into
untoldengine wants to merge 4 commits into
Conversation
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.
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.
Summary
computeCSMShadowinLightShader.metal) used forward-axis-projected view depth, which isn't rotation-invariant for a stationary point; switched to true Euclidean distance from the camera.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.cascadeFrustumCornersWorldSpace,cascadeNearDistance, and the latter's dedicated test class) and updated stale doc comments.Fixes #1310.
Test plan
swift build./buildkernels.sh(required after.metaledits)swift test --filter RendererTests— all cascade/shadow tests pass, including PSNR image-comparison render tests and scene-root scale/rotation invariance guardsswift test --filter LightSystemTest— all passswift test --filter "ShadowSystem|CsmCascadeCountTests"— all pass🤖 Generated with Claude Code