viewer: center shadow frustum on focus so it tracks the view (#347)

The directional shadow light was placed at focus + theme offset (only
~17-32 units), while the ortho shadow camera ran near=1/far=100. That
left the focus near the front of a long frustum whose far end swung
around as the look-at moved, so shadows appeared not to follow the view.

Park the light at a fixed distance along its (preserved) direction and
bracket near/far around that distance so the focus stays centered in the
frustum depth.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Wassim SAMAD
2026-05-29 13:34:51 -04:00
committed by GitHub
co-authored by Claude Opus 4.7
parent b8d94a6436
commit 37a3aba672
@@ -26,6 +26,16 @@ const SHADOWS_DISABLED =
// deliberate middle ground — present, but not the heavy contact shadow there. // deliberate middle ground — present, but not the heavy contact shadow there.
const MAX_SHADOW_INTENSITY = 0.55 const MAX_SHADOW_INTENSITY = 0.55
// Shadow frustum framing. The directional light is parked at a fixed distance
// along its (theme-defined) direction from the focus point, and the ortho
// shadow camera's depth is centred on that focus. Theme offsets are only
// ~1732 units long, so without a fixed distance the focus sits near the front
// of a long frustum and the far end swings around as the focus moves. Keeping
// the light far away and the focus centred keeps the frustum hugging the view.
const SHADOW_DISTANCE = 120
const SHADOW_NEAR = 20
const SHADOW_FAR = 220
export function Lights() { export function Lights() {
const sceneTheme = useViewer((state) => state.sceneTheme) const sceneTheme = useViewer((state) => state.sceneTheme)
const theme = getSceneTheme(sceneTheme) const theme = getSceneTheme(sceneTheme)
@@ -39,6 +49,8 @@ export function Lights() {
// ortho shadow camera only covers ±shadowCameraSize around the light target, // ortho shadow camera only covers ±shadowCameraSize around the light target,
// so it has to track the view or anything far from origin gets no shadows. // so it has to track the view or anything far from origin gets no shadows.
const shadowFocus = useRef(new THREE.Vector3()) const shadowFocus = useRef(new THREE.Vector3())
// Scratch vector for the per-light direction, reused to avoid per-frame allocs.
const shadowDir = useRef(new THREE.Vector3())
const hemiRef = useRef<HemisphereLight>(null) const hemiRef = useRef<HemisphereLight>(null)
const ambientRef = useRef<AmbientLight>(null) const ambientRef = useRef<AmbientLight>(null)
@@ -77,7 +89,13 @@ export function Lights() {
const light = lightRefs.current[index] const light = lightRefs.current[index]
if (!(config?.castShadow && light)) continue if (!(config?.castShadow && light)) continue
const [ox, oy, oz] = config.position const [ox, oy, oz] = config.position
light.position.set(focus.x + ox, focus.y + oy, focus.z + oz) // Preserve the theme's light DIRECTION but park the light at a fixed
// distance, so the ortho frustum (depth centred via SHADOW_NEAR/FAR)
// stays centred on the focus instead of dangling far past it.
const dir = shadowDir.current.set(ox, oy, oz)
if (dir.lengthSq() === 0) dir.set(0, 1, 0)
dir.normalize().multiplyScalar(SHADOW_DISTANCE)
light.position.set(focus.x + dir.x, focus.y + dir.y, focus.z + dir.z)
light.target.position.copy(focus) light.target.position.copy(focus)
light.target.updateMatrixWorld() light.target.updateMatrixWorld()
} }
@@ -175,9 +193,9 @@ export function Lights() {
<orthographicCamera <orthographicCamera
attach="shadow-camera" attach="shadow-camera"
bottom={-shadowCameraSize} bottom={-shadowCameraSize}
far={100} far={SHADOW_FAR}
left={-shadowCameraSize} left={-shadowCameraSize}
near={1} near={SHADOW_NEAR}
ref={shadowCamera} ref={shadowCamera}
right={shadowCameraSize} right={shadowCameraSize}
top={shadowCameraSize} top={shadowCameraSize}