Cascaded shadows drift on static geometry under pure camera rotation
Problem
In the Bistro sample, a building's cast shadow visibly shifts when the camera only rotates in place (no translation) — found via a screen recording, confirmed algebraically from the code, not just observed.
subsystems/render/shadow.zig, fitCascade (118-159): for a symmetric frustum, averaging the 8 view-frustum-slice corners collapses to center = eye + forward_axis * (near_d+far_d)/2 — exactly the camera eye offset along the rotating forward axis. radius (max corner distance from that center) is genuinely orientation-invariant, matching ADR-0010's "stable under camera yaw" claim — but that claim only holds for the radius, not the center. Existing texel-snapping (137-147) removes only sub-texel jitter; a real rotation moves the pre-snap center by far more than one texel, so each cascade becomes a different render of the same static geometry frame to frame.
Second, independent bug: scene.frag.glsl shadowFactor() (204-230) selects a cascade via cam_dist = dot(world_pos - camera_pos, cam_forward) — projecting onto the camera's current forward axis, so a static point's assigned cascade flips as the camera pans, even without the drift above.
Scope
Anchor cascade fitting at the camera eye instead of the rotating frustum-slice centroid (standard "stable CSM" technique), and converge the two current uses of the split distance (frustum-corner sizing vs. shader-side cascade selection) onto one Euclidean scalar:
shadow.zig:fitCascadetakes aneyeparam, uses it directly ascenter(radius loop unchanged in form — now measures from eye, which is exactly the correct worst-case-over-orientation radius, no new trig needed).computeCascadescomputeseyeonce from the inverse view matrix. RenameCascade.far_distance→select_radiussince it now means true distance-from-camera, not distance-along-view-axis.scene.frag.glsl:shadowFactor()'scam_distbecomeslength(world_pos - camera_pos)instead of a dot product withcam_forward.ADR-0010-lighting-and-shadows.md: correct the yaw-stability claim (radius-only, not center).
Known, accepted trade-off: cascade 0 is unaffected (near_d≈0, already eye-adjacent). Cascades 1-3 lose roughly 1.5-2x effective texel density (a sphere covering all orientations is bigger than the old tight frustum-slice fit) — real but bounded and back-loaded onto the least-visually-important cascades. Secondary risk: the larger radius grows far_p, shrinking depth_scale, so the existing world-unit shadow bias buys less NDC headroom — watch for acne/peter-panning during verification rather than pre-tuning bias speculatively.
Relationship to other work
- Should land before #165's temporal cascade caching — #165 currently assumes today's per-frame refit is already visually stable under a static camera, which this issue shows isn't quite true (rotation, not just position, currently invalidates visual correctness). This fix also strengthens #165's case: cache validity only needs eye position + light direction unchanged, camera rotation no longer matters.
- Not part of epic #158 (that's the performance epic) — this is a correctness/visual-quality bug, part of the broader Bistro epic #132.
Acceptance
- Unit tests in
shadow.zig: identical camera position + different rotation → bit-identical cascades (within float epsilon); different position + same rotation → cascades legitimately differ;select_radiusstrictly increasing across cascades. - Fixed-position, rotating-camera screenshot pair on Bistro (
Camera - Test Light) shows no visible shadow shift on the target building. - Translating-camera screenshot pair shows shadows still refit correctly (no regression to the legitimate case).