auto-git:
[add] src/rendering/celestial-shadows.ts
This commit is contained in:
489
src/rendering/celestial-shadows.ts
Normal file
489
src/rendering/celestial-shadows.ts
Normal 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
|
||||
}
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user