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:
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
|
||||
// ~17–32 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}
|
||||
|
||||
Reference in New Issue
Block a user