490 lines
12 KiB
TypeScript
490 lines
12 KiB
TypeScript
|
|
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
|
||
|
|
}
|
||
|
|
};
|
||
|
|
}
|