auto-git:

[add] src/rendering/celestial-shadows.ts
This commit is contained in:
2026-04-22 14:50:57 +02:00
parent 1b58f5a72f
commit 1e196336e3

View File

@@ -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
}
};
}