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