viewer: fit shadow frustum to building geometry instead of the camera (#348)

Following the camera look-at broke when zoomed out (fixed ±50 frustum
too small to cover the scene) and when zoomed into an empty corner
(frustum centred on nothing). Instead, fit the directional light's ortho
shadow camera to the building: union the registered scene-node bounds
(excluding the site/ground plane), fit a sphere, and size + place the
shadow camera to cover that sphere plus a margin. Bounds are refreshed on
a short interval since they only change while editing.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Wassim SAMAD
2026-05-29 14:11:34 -04:00
committed by GitHub
co-authored by Claude Opus 4.7
parent 37a3aba672
commit 986d75026f
@@ -1,3 +1,4 @@
import { sceneRegistry } from '@pascal-app/core'
import { useFrame } from '@react-three/fiber'
import { useMemo, useRef } from 'react'
import type {
@@ -26,15 +27,29 @@ const SHADOWS_DISABLED =
// deliberate middle ground — present, but not the heavy contact shadow there.
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
// Shadow frustum framing. The frustum is fit to the BUILDING geometry (not the
// camera): we union the bounds of all registered scene nodes, fit a sphere, and
// size the directional light's ortho shadow camera to that sphere plus a margin.
// This keeps shadows anchored to the building and a bit of surrounding ground no
// matter how the user zooms or pans — fixing the previous camera-following
// behaviour that fell apart when zoomed out (frustum too small) or zoomed into
// an empty corner (frustum centred on nothing).
//
// `site` nodes (the ground/site plane, which can be arbitrarily large) are
// excluded so they don't blow the frustum up to cover the whole lot.
const SHADOW_EXCLUDED_TYPES = ['site'] as const
// How often (seconds) to recompute building bounds. Bounds only change while
// editing, so we throttle the (subtree-walking) union instead of doing it every
// frame.
const BOUNDS_REFRESH_INTERVAL = 0.4
// Extra coverage around the building bounds — the "and a bit nearby" margin so
// shadows don't get clipped right at the walls. Scales with building size.
const SHADOW_MARGIN_SCALE = 1.15
const SHADOW_MARGIN = 3
// Gap between the building bounds sphere and the light / near plane.
const SHADOW_BACKOFF = 10
// Fallback radius when the scene has no building geometry yet (empty scene).
const SHADOW_FALLBACK_RADIUS = 30
export function Lights() {
const sceneTheme = useViewer((state) => state.sceneTheme)
@@ -43,14 +58,16 @@ export function Lights() {
const lightRefs = useRef<Array<DirectionalLight | null>>([])
const shadowCamera = useRef<OrthographicCamera>(null)
const shadowCameraSize = 50 // The "area" around the camera to shadow
// Initial ortho half-size; overridden each refresh to fit the building.
const shadowCameraSize = 50
// Where the shadow frustum is centred each frame. The directional light's
// 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.
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())
// Building bounds the shadow frustum is fit to, recomputed on an interval.
const shadowFocus = useRef(new THREE.Vector3()) // sphere centre
const shadowRadius = useRef(SHADOW_FALLBACK_RADIUS) // sphere radius
const shadowDir = useRef(new THREE.Vector3()) // scratch: per-light direction
const boundsBox = useRef(new THREE.Box3()) // scratch: union AABB
const boundsSphere = useRef(new THREE.Sphere()) // scratch: fitted sphere
const lastBoundsTime = useRef(-1) // last refresh timestamp (-1 = never)
const hemiRef = useRef<HemisphereLight>(null)
const ambientRef = useRef<AmbientLight>(null)
@@ -71,33 +88,66 @@ export function Lights() {
// clamp delta to avoid huge jumps on tab switch
const dt = Math.min(delta, 0.1) * 4
// Recentre each shadow-casting light's frustum on what the viewer is looking
// at (orbit target if available, else the camera's ground projection), moving
// the light and its target together so the light DIRECTION is preserved and
// only the shadow camera slides. Without this the frustum stays at the origin
// and zones far from it never receive shadows.
// Fit each shadow-casting light's frustum to the BUILDING geometry rather
// than the camera. We refresh the union bounds on an interval (cheap enough,
// and bounds only change while editing), fit a sphere, and size + place the
// ortho shadow camera so the building (plus a margin) is fully covered from
// the light's direction. The light DIRECTION stays exactly as the theme
// specifies; only its position/distance and the frustum extents change.
if (shadows) {
const focus = shadowFocus.current
const controls = state.controls as { getTarget?: (out: THREE.Vector3) => void } | null
if (controls?.getTarget) {
controls.getTarget(focus)
} else {
focus.set(state.camera.position.x, 0, state.camera.position.z)
const now = state.clock.elapsedTime
if (now - lastBoundsTime.current >= BOUNDS_REFRESH_INTERVAL) {
lastBoundsTime.current = now
const box = boundsBox.current.makeEmpty()
for (const [id, obj] of sceneRegistry.nodes) {
if (SHADOW_EXCLUDED_TYPES.some((t) => sceneRegistry.byType[t]!.has(id))) continue
box.expandByObject(obj)
}
if (box.isEmpty()) {
// Empty scene: fall back to the origin with a default radius so the
// ground still receives a sensible shadow region.
shadowFocus.current.set(0, 0, 0)
shadowRadius.current = SHADOW_FALLBACK_RADIUS
} else {
box.getBoundingSphere(boundsSphere.current)
shadowFocus.current.copy(boundsSphere.current.center)
shadowRadius.current = boundsSphere.current.radius
}
}
const focus = shadowFocus.current
// Ortho half-extent: the building sphere plus a proportional margin.
const size = shadowRadius.current * SHADOW_MARGIN_SCALE + SHADOW_MARGIN
// Park the light just outside the sphere so the near plane stays positive
// and the whole building fits between near and far along the light axis.
const distance = size + SHADOW_BACKOFF
const near = SHADOW_BACKOFF
const far = distance + size
for (let index = 0; index < theme.lights.length; index++) {
const config = theme.lights[index]
const light = lightRefs.current[index]
if (!(config?.castShadow && light)) continue
const [ox, oy, oz] = config.position
// 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)
dir.normalize().multiplyScalar(distance)
light.position.set(focus.x + dir.x, focus.y + dir.y, focus.z + dir.z)
light.target.position.copy(focus)
light.target.updateMatrixWorld()
// Resize the ortho frustum to the fitted bounds. The shadow camera is
// the <orthographicCamera attach="shadow-camera"> below.
const cam = light.shadow?.camera as THREE.OrthographicCamera | undefined
if (cam) {
cam.left = -size
cam.right = size
cam.top = size
cam.bottom = -size
cam.near = near
cam.far = far
cam.updateProjectionMatrix()
}
}
}
@@ -193,9 +243,9 @@ export function Lights() {
<orthographicCamera
attach="shadow-camera"
bottom={-shadowCameraSize}
far={SHADOW_FAR}
far={400}
left={-shadowCameraSize}
near={SHADOW_NEAR}
near={1}
ref={shadowCamera}
right={shadowCameraSize}
top={shadowCameraSize}