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: fitCascade takes an eye param, uses it directly as center (radius loop unchanged in form — now measures from eye, which is exactly the correct worst-case-over-orientation radius, no new trig needed). computeCascades computes eye once from the inverse view matrix. Rename Cascade.far_distance → select_radius since it now means true distance-from-camera, not distance-along-view-axis.
  • scene.frag.glsl: shadowFactor()'s cam_dist becomes length(world_pos - camera_pos) instead of a dot product with cam_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_radius strictly 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).