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 { useFrame } from '@react-three/fiber'
import { useMemo, useRef } from 'react' import { useMemo, useRef } from 'react'
import type { import type {
@@ -26,15 +27,29 @@ 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 // Shadow frustum framing. The frustum is fit to the BUILDING geometry (not the
// along its (theme-defined) direction from the focus point, and the ortho // camera): we union the bounds of all registered scene nodes, fit a sphere, and
// shadow camera's depth is centred on that focus. Theme offsets are only // size the directional light's ortho shadow camera to that sphere plus a margin.
// ~1732 units long, so without a fixed distance the focus sits near the front // This keeps shadows anchored to the building and a bit of surrounding ground no
// of a long frustum and the far end swings around as the focus moves. Keeping // matter how the user zooms or pans — fixing the previous camera-following
// the light far away and the focus centred keeps the frustum hugging the view. // behaviour that fell apart when zoomed out (frustum too small) or zoomed into
const SHADOW_DISTANCE = 120 // an empty corner (frustum centred on nothing).
const SHADOW_NEAR = 20 //
const SHADOW_FAR = 220 // `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() { export function Lights() {
const sceneTheme = useViewer((state) => state.sceneTheme) const sceneTheme = useViewer((state) => state.sceneTheme)
@@ -43,14 +58,16 @@ export function Lights() {
const lightRefs = useRef<Array<DirectionalLight | null>>([]) const lightRefs = useRef<Array<DirectionalLight | null>>([])
const shadowCamera = useRef<OrthographicCamera>(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 // Building bounds the shadow frustum is fit to, recomputed on an interval.
// ortho shadow camera only covers ±shadowCameraSize around the light target, const shadowFocus = useRef(new THREE.Vector3()) // sphere centre
// so it has to track the view or anything far from origin gets no shadows. const shadowRadius = useRef(SHADOW_FALLBACK_RADIUS) // sphere radius
const shadowFocus = useRef(new THREE.Vector3()) const shadowDir = useRef(new THREE.Vector3()) // scratch: per-light direction
// Scratch vector for the per-light direction, reused to avoid per-frame allocs. const boundsBox = useRef(new THREE.Box3()) // scratch: union AABB
const shadowDir = useRef(new THREE.Vector3()) const boundsSphere = useRef(new THREE.Sphere()) // scratch: fitted sphere
const lastBoundsTime = useRef(-1) // last refresh timestamp (-1 = never)
const hemiRef = useRef<HemisphereLight>(null) const hemiRef = useRef<HemisphereLight>(null)
const ambientRef = useRef<AmbientLight>(null) const ambientRef = useRef<AmbientLight>(null)
@@ -71,33 +88,66 @@ export function Lights() {
// clamp delta to avoid huge jumps on tab switch // clamp delta to avoid huge jumps on tab switch
const dt = Math.min(delta, 0.1) * 4 const dt = Math.min(delta, 0.1) * 4
// Recentre each shadow-casting light's frustum on what the viewer is looking // Fit each shadow-casting light's frustum to the BUILDING geometry rather
// at (orbit target if available, else the camera's ground projection), moving // than the camera. We refresh the union bounds on an interval (cheap enough,
// the light and its target together so the light DIRECTION is preserved and // and bounds only change while editing), fit a sphere, and size + place the
// only the shadow camera slides. Without this the frustum stays at the origin // ortho shadow camera so the building (plus a margin) is fully covered from
// and zones far from it never receive shadows. // the light's direction. The light DIRECTION stays exactly as the theme
// specifies; only its position/distance and the frustum extents change.
if (shadows) { if (shadows) {
const focus = shadowFocus.current const now = state.clock.elapsedTime
const controls = state.controls as { getTarget?: (out: THREE.Vector3) => void } | null if (now - lastBoundsTime.current >= BOUNDS_REFRESH_INTERVAL) {
if (controls?.getTarget) { lastBoundsTime.current = now
controls.getTarget(focus) const box = boundsBox.current.makeEmpty()
} else { for (const [id, obj] of sceneRegistry.nodes) {
focus.set(state.camera.position.x, 0, state.camera.position.z) 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++) { for (let index = 0; index < theme.lights.length; index++) {
const config = theme.lights[index] const config = theme.lights[index]
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
// 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) const dir = shadowDir.current.set(ox, oy, oz)
if (dir.lengthSq() === 0) dir.set(0, 1, 0) 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.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()
// 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 <orthographicCamera
attach="shadow-camera" attach="shadow-camera"
bottom={-shadowCameraSize} bottom={-shadowCameraSize}
far={SHADOW_FAR} far={400}
left={-shadowCameraSize} left={-shadowCameraSize}
near={SHADOW_NEAR} near={1}
ref={shadowCamera} ref={shadowCamera}
right={shadowCameraSize} right={shadowCameraSize}
top={shadowCameraSize} top={shadowCameraSize}