From 1e196336e3389b0cb79531b4b2d3612876e4ff2e Mon Sep 17 00:00:00 2001 From: Victor Giers Date: Wed, 22 Apr 2026 14:50:57 +0200 Subject: [PATCH] auto-git: [add] src/rendering/celestial-shadows.ts --- src/rendering/celestial-shadows.ts | 489 +++++++++++++++++++++++++++++ 1 file changed, 489 insertions(+) create mode 100644 src/rendering/celestial-shadows.ts diff --git a/src/rendering/celestial-shadows.ts b/src/rendering/celestial-shadows.ts new file mode 100644 index 00000000..c6014aec --- /dev/null +++ b/src/rendering/celestial-shadows.ts @@ -0,0 +1,489 @@ +import { + OrthographicCamera, + PerspectiveCamera, + Vector3 +} from "three"; + +import type { Vec3 } from "../core/vector"; +import type { WorldSunLightSettings } from "../document/world-settings"; + +const MIN_CELESTIAL_SHADOW_INTENSITY = 1e-4; +const MIN_SHADOW_FOCUS_RADIUS = 0.5; +const MIN_CELESTIAL_SHADOW_DISTANCE = 24; +const MAX_CELESTIAL_SHADOW_DISTANCE = 160; +const MIN_CELESTIAL_SHADOW_EXTENT = 4; +const MIN_CELESTIAL_SHADOW_MARGIN = 2; +const MIN_CELESTIAL_SHADOW_DEPTH_PADDING = 6; +const WORLD_UP = new Vector3(0, 1, 0); +const FALLBACK_UP = new Vector3(0, 0, 1); + +export interface CelestialShadowFocusTarget { + center: Vec3; + radius: number; +} + +export interface CelestialShadowSceneBounds { + min: Vec3; + max: Vec3; +} + +export interface CelestialShadowCaster { + key: "sun" | "moon"; + light: WorldSunLightSettings; +} + +export interface CelestialShadowFit { + coverageDistance: number; + normalBias: number; + lightPosition: Vec3; + targetPosition: Vec3; + cameraBounds: { + left: number; + right: number; + top: number; + bottom: number; + near: number; + far: number; + }; +} + +export interface FitCelestialDirectionalShadowOptions { + activeCamera: PerspectiveCamera | OrthographicCamera; + focusTarget: CelestialShadowFocusTarget; + lightDirection: Vec3; + mapSize: number; + sceneBounds?: CelestialShadowSceneBounds | null; +} + +function clamp(value: number, min: number, max: number) { + return Math.min(max, Math.max(min, value)); +} + +function isFiniteVec3(vector: Vec3 | null): vector is Vec3 { + return ( + vector !== null && + Number.isFinite(vector.x) && + Number.isFinite(vector.y) && + Number.isFinite(vector.z) + ); +} + +function vec3ToVector3(vector: Vec3): Vector3 { + return new Vector3(vector.x, vector.y, vector.z); +} + +function vector3ToVec3(vector: Vector3): Vec3 { + return { + x: vector.x, + y: vector.y, + z: vector.z + }; +} + +function getLightDirection(light: WorldSunLightSettings | null): Vector3 | null { + if (light === null || light.intensity <= MIN_CELESTIAL_SHADOW_INTENSITY) { + return null; + } + + const direction = vec3ToVector3(light.direction); + + if (direction.lengthSq() <= 1e-8) { + return null; + } + + return direction.normalize(); +} + +export function resolveDominantCelestialShadowCaster( + sunLight: WorldSunLightSettings, + moonLight: WorldSunLightSettings | null +): CelestialShadowCaster | null { + const sunDirection = getLightDirection(sunLight); + const moonDirection = getLightDirection(moonLight); + + if (sunDirection !== null && moonDirection !== null) { + return sunLight.intensity >= (moonLight?.intensity ?? 0) + ? { + key: "sun", + light: sunLight + } + : { + key: "moon", + light: moonLight as WorldSunLightSettings + }; + } + + if (sunDirection !== null) { + return { + key: "sun", + light: sunLight + }; + } + + if (moonDirection !== null && moonLight !== null) { + return { + key: "moon", + light: moonLight + }; + } + + return null; +} + +function pushBoxCorners( + points: Vector3[], + center: Vector3, + halfExtents: Vector3 +) { + for (const xSign of [-1, 1]) { + for (const ySign of [-1, 1]) { + for (const zSign of [-1, 1]) { + points.push( + new Vector3( + center.x + halfExtents.x * xSign, + center.y + halfExtents.y * ySign, + center.z + halfExtents.z * zSign + ) + ); + } + } + } +} + +function pushClippedBoundsCorners( + points: Vector3[], + sceneBounds: CelestialShadowSceneBounds | null, + focusCenter: Vector3, + coverageDistance: number +) { + if (sceneBounds === null) { + return; + } + + const clippedMin = { + x: Math.max(sceneBounds.min.x, focusCenter.x - coverageDistance), + y: Math.max(sceneBounds.min.y, focusCenter.y - coverageDistance), + z: Math.max(sceneBounds.min.z, focusCenter.z - coverageDistance) + }; + const clippedMax = { + x: Math.min(sceneBounds.max.x, focusCenter.x + coverageDistance), + y: Math.min(sceneBounds.max.y, focusCenter.y + coverageDistance), + z: Math.min(sceneBounds.max.z, focusCenter.z + coverageDistance) + }; + + if ( + clippedMin.x > clippedMax.x || + clippedMin.y > clippedMax.y || + clippedMin.z > clippedMax.z + ) { + return; + } + + const center = new Vector3( + (clippedMin.x + clippedMax.x) * 0.5, + (clippedMin.y + clippedMax.y) * 0.5, + (clippedMin.z + clippedMax.z) * 0.5 + ); + const halfExtents = new Vector3( + (clippedMax.x - clippedMin.x) * 0.5, + (clippedMax.y - clippedMin.y) * 0.5, + (clippedMax.z - clippedMin.z) * 0.5 + ); + + points.push(center.clone()); + pushBoxCorners(points, center, halfExtents); +} + +function resolveCameraBasis( + camera: PerspectiveCamera | OrthographicCamera +) { + const forward = new Vector3(0, 0, -1) + .applyQuaternion(camera.quaternion) + .normalize(); + const up = new Vector3(0, 1, 0) + .applyQuaternion(camera.quaternion) + .normalize(); + const right = new Vector3(1, 0, 0) + .applyQuaternion(camera.quaternion) + .normalize(); + + return { + forward, + up, + right + }; +} + +function collectPerspectiveCoveragePoints( + points: Vector3[], + camera: PerspectiveCamera, + focusCenter: Vector3, + coverageDistance: number +) { + const { forward, up, right } = resolveCameraBasis(camera); + const focusDistance = camera.position.distanceTo(focusCenter); + const distances = Array.from( + new Set( + [ + clamp(focusDistance, 2, coverageDistance), + clamp(Math.max(camera.near * 8, coverageDistance * 0.35), 2, coverageDistance), + coverageDistance + ].map((value) => Math.max(0.1, value)) + ) + ).sort((left, rightValue) => left - rightValue); + + for (const distance of distances) { + const halfHeight = + Math.tan((camera.fov * Math.PI) / 360) * distance; + const halfWidth = halfHeight * Math.max(camera.aspect, 0.0001); + const planeCenter = camera.position + .clone() + .add(forward.clone().multiplyScalar(distance)); + const scaledUp = up.clone().multiplyScalar(halfHeight); + const scaledRight = right.clone().multiplyScalar(halfWidth); + + points.push(planeCenter.clone()); + + for (const xSign of [-1, 1]) { + for (const ySign of [-1, 1]) { + points.push( + planeCenter + .clone() + .addScaledVector(scaledRight, xSign) + .addScaledVector(scaledUp, ySign) + ); + } + } + } +} + +function collectOrthographicCoveragePoints( + points: Vector3[], + camera: OrthographicCamera, + focusCenter: Vector3, + coverageDistance: number +) { + const { forward, up, right } = resolveCameraBasis(camera); + const halfWidth = + Math.abs(camera.right - camera.left) / + Math.max(camera.zoom, 0.0001) * + 0.5; + const halfHeight = + Math.abs(camera.top - camera.bottom) / + Math.max(camera.zoom, 0.0001) * + 0.5; + const depthHalf = coverageDistance * 0.5; + const scaledUp = up.clone().multiplyScalar(halfHeight); + const scaledRight = right.clone().multiplyScalar(halfWidth); + + for (const depthOffset of [-depthHalf, 0, depthHalf]) { + const planeCenter = focusCenter + .clone() + .add(forward.clone().multiplyScalar(depthOffset)); + + points.push(planeCenter.clone()); + + for (const xSign of [-1, 1]) { + for (const ySign of [-1, 1]) { + points.push( + planeCenter + .clone() + .addScaledVector(scaledRight, xSign) + .addScaledVector(scaledUp, ySign) + ); + } + } + } +} + +function resolveShadowCoverageDistance( + camera: PerspectiveCamera | OrthographicCamera, + focusCenter: Vector3, + focusRadius: number +) { + if (camera instanceof PerspectiveCamera) { + const distanceToFocus = camera.position.distanceTo(focusCenter); + return clamp( + Math.max(distanceToFocus * 1.35, focusRadius * 4), + MIN_CELESTIAL_SHADOW_DISTANCE, + MAX_CELESTIAL_SHADOW_DISTANCE + ); + } + + const visibleWidth = + Math.abs(camera.right - camera.left) / Math.max(camera.zoom, 0.0001); + const visibleHeight = + Math.abs(camera.top - camera.bottom) / Math.max(camera.zoom, 0.0001); + + return clamp( + Math.max(Math.hypot(visibleWidth, visibleHeight) * 1.8, focusRadius * 3), + MIN_CELESTIAL_SHADOW_DISTANCE, + MAX_CELESTIAL_SHADOW_DISTANCE + ); +} + +export function fitCelestialDirectionalShadow( + options: FitCelestialDirectionalShadowOptions +): CelestialShadowFit | null { + if (!isFiniteVec3(options.focusTarget.center)) { + return null; + } + + const directionToLight = vec3ToVector3(options.lightDirection); + + if (directionToLight.lengthSq() <= 1e-8) { + return null; + } + + const focusCenter = vec3ToVector3(options.focusTarget.center); + const focusRadius = Math.max( + MIN_SHADOW_FOCUS_RADIUS, + options.focusTarget.radius + ); + const coverageDistance = resolveShadowCoverageDistance( + options.activeCamera, + focusCenter, + focusRadius + ); + const points: Vector3[] = [focusCenter.clone()]; + + pushBoxCorners( + points, + focusCenter, + new Vector3(focusRadius, focusRadius, focusRadius) + ); + + if (options.activeCamera instanceof PerspectiveCamera) { + collectPerspectiveCoveragePoints( + points, + options.activeCamera, + focusCenter, + coverageDistance + ); + } else { + collectOrthographicCoveragePoints( + points, + options.activeCamera, + focusCenter, + coverageDistance + ); + } + + pushClippedBoundsCorners( + points, + options.sceneBounds ?? null, + focusCenter, + coverageDistance + ); + + const lightForward = directionToLight.normalize().negate(); + const upReference = + Math.abs(lightForward.dot(WORLD_UP)) > 0.92 ? FALLBACK_UP : WORLD_UP; + const lightRight = new Vector3() + .crossVectors(upReference, lightForward) + .normalize(); + const lightUp = new Vector3() + .crossVectors(lightForward, lightRight) + .normalize(); + + let minX = Number.POSITIVE_INFINITY; + let maxX = Number.NEGATIVE_INFINITY; + let minY = Number.POSITIVE_INFINITY; + let maxY = Number.NEGATIVE_INFINITY; + let minZ = Number.POSITIVE_INFINITY; + let maxZ = Number.NEGATIVE_INFINITY; + + for (const point of points) { + const offset = point.clone().sub(focusCenter); + const x = offset.dot(lightRight); + const y = offset.dot(lightUp); + const z = offset.dot(lightForward); + + minX = Math.min(minX, x); + maxX = Math.max(maxX, x); + minY = Math.min(minY, y); + maxY = Math.max(maxY, y); + minZ = Math.min(minZ, z); + maxZ = Math.max(maxZ, z); + } + + if ( + !Number.isFinite(minX) || + !Number.isFinite(maxX) || + !Number.isFinite(minY) || + !Number.isFinite(maxY) || + !Number.isFinite(minZ) || + !Number.isFinite(maxZ) + ) { + return null; + } + + const margin = Math.max( + MIN_CELESTIAL_SHADOW_MARGIN, + focusRadius * 0.35, + coverageDistance * 0.08 + ); + minX -= margin; + maxX += margin; + minY -= margin; + maxY += margin; + + let centerX = (minX + maxX) * 0.5; + let centerY = (minY + maxY) * 0.5; + const centerZ = (minZ + maxZ) * 0.5; + const halfWidth = Math.max( + MIN_CELESTIAL_SHADOW_EXTENT, + (maxX - minX) * 0.5 + ); + const halfHeight = Math.max( + MIN_CELESTIAL_SHADOW_EXTENT, + (maxY - minY) * 0.5 + ); + + const texelWidth = (halfWidth * 2) / Math.max(1, options.mapSize); + const texelHeight = (halfHeight * 2) / Math.max(1, options.mapSize); + if (texelWidth > 0) { + centerX = Math.round(centerX / texelWidth) * texelWidth; + } + if (texelHeight > 0) { + centerY = Math.round(centerY / texelHeight) * texelHeight; + } + + const depthPadding = Math.max( + MIN_CELESTIAL_SHADOW_DEPTH_PADDING, + focusRadius, + coverageDistance * 0.12 + ); + const halfDepth = Math.max( + MIN_CELESTIAL_SHADOW_EXTENT, + (maxZ - minZ) * 0.5 + depthPadding + ); + const targetPosition = focusCenter + .clone() + .addScaledVector(lightRight, centerX) + .addScaledVector(lightUp, centerY) + .addScaledVector(lightForward, centerZ); + const lightPosition = targetPosition + .clone() + .addScaledVector(lightForward, -(halfDepth + depthPadding)); + const normalBias = clamp( + Math.max(texelWidth, texelHeight) * 0.75, + 0.001, + 0.08 + ); + + return { + coverageDistance, + normalBias, + lightPosition: vector3ToVec3(lightPosition), + targetPosition: vector3ToVec3(targetPosition), + cameraBounds: { + left: -halfWidth, + right: halfWidth, + top: halfHeight, + bottom: -halfHeight, + near: Math.max(0.5, depthPadding * 0.5), + far: halfDepth * 2 + depthPadding * 2 + } + }; +}