utils/shadowCameraFit.tstypescript
import type { DepthCamera } from "../control/depthCamera/DepthCamera";
import type { QuadTreeStrategy } from "../quadTree/QuadTreeStrategy";
import type { Planet } from "../scene/Planet";
import type { CameraFootprint } from "./cameraFootprint";
import { Vec3 } from "../math/Vec3";
import { EPS12 } from "../math";

/**
 * How high above the footprint a caster may stand and still reach the shadow map.
 * Raising it is cheap. It only moves the camera sunward.
 */
const SHADOW_CASTER_RELIEF_FACTOR = 1.25;
const SHADOW_CASTER_HEIGHT_FACTOR = 0.25;
const MIN_SHADOW_CASTER_HEIGHT = 100;
const MAX_SHADOW_CASTER_HEIGHT = 10000;

/**
 * Controls shadow camera height snapping.
 * A value of 1 uses 128, 256, 512 m, etc., preventing small movements every frame.
 */
const SHADOW_CASTER_HEIGHT_STEPS = 1;

/**
 * Minimum far-plane margin for terrain below the fitted receivers.
 * fit() adds the measured terrain descent to this value.
 */
const SHADOW_RECEIVER_DEPTH_PADDING = 500;

/**
 * Extra border around the fitted bounds, in texels.
 * Keeps soft-shadow samples inside the map and prevents edge artifacts.
 */
const SHADOW_ORTHO_TEXEL_PADDING = 3;

/**
 * Snaps the fitted extent to four size levels per doubling.
 * Slack delays shrinking, preventing texel-grid movement and shadow shimmer.
 * See _quantizeOrthoTexelSize().
 */
const ORTHO_TEXEL_QUANTIZATION_STEPS = 4;
const ORTHO_TEXEL_QUANTIZATION_RATIO = Math.pow(2.0, 1.0 / ORTHO_TEXEL_QUANTIZATION_STEPS);
const ORTHO_TEXEL_RELEASE_SLACK = 0.15;

/** Smallest bounds padding and far-near gap, in world units. */
const MIN_SHADOW_ORTHO_SIZE = 1.0;

export interface ILightSpaceBounds {
    minX: number;
    maxX: number;
    minY: number;
    maxY: number;
    minZ: number;
    maxZ: number;
}

export interface IOrthoBounds {
    left: number;
    right: number;
    bottom: number;
    top: number;
}

interface ITerrainRelief {
    up: number;
    down: number;
    depthUp: number;
    depthDown: number;
    cornerSpread: number;
}

export interface IShadowCameraFitParams {
    /**
     * Adds proportional padding to each side of the fitted bounds.
     * 0.01 adds 1% per side. This keeps curved ground between the four
     * sampled screen corners inside the shadow map.
     */
    orthoMarginFactor?: number;

    /**
     * Extends caster coverage toward the Sun:
     * max(casterClearance, casterHeight * casterClearanceFactor)
     */
    casterClearance?: number;
    casterClearanceFactor?: number;

    /**
     * Minimum view-ray slope used when adjusting the footprint for terrain relief.
     * 0.1 limits sideways movement to 10 metres per metre of relief.
     * This prevents near-horizontal rays from greatly expanding the shadow map.
     */
    minFootprintRaySlope?: number;

    /**
     * Limits sideways footprint expansion caused by terrain below the reference level.
     * 0.5 allows expansion up to half the footprint radius.
     * Higher values improve downhill coverage but reduce shadow resolution.
     */
    reliefLateralFactor?: number;

    /** Upper limits on the terrain relief the fit takes from the quad tree, in world units. */
    maxReliefUp?: number;
    maxReliefDown?: number;

    /**
     * Aligns the fitted bounds to a texel grid fixed in the world, so that shadow edges stop crawling while
     * the camera moves. See _snapOrthographicBounds.
     */
    snapToTexelGrid?: boolean;

    /**
     * How the shadow rectangle is turned around the sun direction: along the local horizon under the
     * footprint, or along the world axes. The camera aims at the sun either way, only the roll changes.
     * Horizon aligned wastes less of the map, but then the rectangle turns as the camera moves and its
     * texel grid stops holding still. See getStableLightUp.
     */
    horizonAlignedLightUp?: boolean;

    /**
     * Pads each side of the shadow map by the distance the camera travels in one frame, times this number.
     *
     * The fit always runs a frame behind the view, so while flying fast the ground right in front of the
     * camera can fall outside the map and lose its shadow - this is what covers it. In exchange the map size
     * follows the camera speed and the shadow edges start crawling, so it is off by default.
     */
    motionMarginFrames?: number;

    /**
     * Shadow depth bias, counted in shadow map texels so that it follows its resolution, plus a flat
     * offset in metres:
     *
     *     depthBiasWorld = texelWorldSize * depthBiasTexels + depthBiasOffset
     *
     * Too little and surfaces shadow themselves, too much and the shadow comes away from the foot of its
     * caster by depthBiasWorld * cos(sunElevation). The shader adds a slope term of its own, also
     * counted in texels, see SHADOW_MAP_SLOPE_DEPTH_BIAS.
     */
    depthBiasTexels?: number;
    depthBiasOffset?: number;

    /**
     * Renders the depth pass from the depth camera's own quad tree traversal rather than from the planet's.
     * The planet's one covers only the near band of the view and drops tiles while they fade. Above a main
     * camera height of about 136 km DepthCamera takes the planet's traversal whatever this says.
     */
    pinOwnQuadTreeTraversal?: boolean;
}

/** Last fit measurements, for readouts and debugging. */
export interface IShadowCameraFitStats {
    /** Shadow texel size in world units, the way ShadowManager measures it. */
    texelWorldSize: number;
    footprintRadius: number;
    /** Spread of the footprint ground points in geocentric radius. */
    cornerHeightSpread: number;
    /** Terrain relief above and below the footprint corners, as the main camera traversal reports it. */
    reliefUp: number;
    reliefDown: number;
    /** The same, with the depth camera traversal taken into account. Only the down side is used, by the far plane. */
    depthReliefUp: number;
    depthReliefDown: number;
    casterHeight: number;
    /** Main camera displacement since the previous fit, in world units. */
    cameraStep: number;
    orthoWidth: number;
    orthoHeight: number;
    orthoBounds: IOrthoBounds;
    near: number;
    far: number;
}

function projPerp(vector: Vec3, axis: Vec3): Vec3 {
    return vector.sub(axis.scaleTo(vector.dot(axis)));
}

/**
 * Fallback seed for the light space up vector, for when
 * the preferred one turns out to be parallel to the light.
 */
function getLeastAlignedAxis(direction: Vec3): Vec3 {
    let x = Math.abs(direction.x);
    let y = Math.abs(direction.y);
    let z = Math.abs(direction.z);

    if (x <= y && x <= z) {
        return Vec3.UNIT_X;
    }

    return y <= z ? Vec3.UNIT_Y : Vec3.UNIT_Z;
}

/**
 * Up vector of the light space basis, that is, how the shadow rectangle
 * is turned around the sun direction.
 */
function getStableLightUp(footprint: CameraFootprint, lightDirection: Vec3, horizonAligned: boolean): Vec3 {
    let seed = horizonAligned ? footprint.ellipsoid!.getSurfaceNormal3v(footprint.center) : Vec3.NORTH;
    let projected = projPerp(seed, lightDirection);

    if (projected.length2() <= EPS12) {
        projected = projPerp(getLeastAlignedAxis(lightDirection), lightDirection);
    }

    return projected.normalize();
}

/**
 * How high the rendered terrain rises above a reference radius, and how deep it drops below it, as two
 * positive heights.
 */
function getStrategyRelief(
    quadTreeStrategy: QuadTreeStrategy | undefined,
    upFrom: number,
    downFrom: number
): { up: number; down: number } {
    if (!quadTreeStrategy || quadTreeStrategy.maxTerrainRadius <= 0.0) {
        return { up: 0.0, down: 0.0 };
    }

    return {
        up: Math.max(0.0, quadTreeStrategy.maxTerrainRadius - upFrom),
        down: Math.max(0.0, downFrom - quadTreeStrategy.minTerrainRadius)
    };
}

function getRadiusRange(points: Vec3[]): { min: number; max: number } {
    let min = Infinity;
    let max = -Infinity;

    for (let i = 0; i < points.length; i++) {
        let radius = points[i].length();

        min = Math.min(min, radius);
        max = Math.max(max, radius);
    }

    return { min, max };
}

/**
 * Light space depth of the closest point of the caster volume, which is the footprint raised by the caster
 * height. Only the near plane has to clear it: a caster whose shadow lands on the footprint shares the
 * light space XY of that shadow, so it is already inside the fitted bounds sideways.
 */
function getCasterMinZ(footprint: CameraFootprint, forward: Vec3, casterHeight: number): number {
    let minZ = Infinity;

    for (let i = 0; i < footprint.points.length; i++) {
        let casterPoint = footprint.points[i].add(footprint.normals[i].scaleTo(casterHeight));

        minZ = Math.min(minZ, casterPoint.sub(footprint.center).dot(forward));
    }

    return minZ;
}

function getLightSpaceBounds(origin: Vec3, right: Vec3, up: Vec3, forward: Vec3, points: Vec3[]): ILightSpaceBounds {
    let bounds: ILightSpaceBounds = {
        minX: Infinity,
        maxX: -Infinity,
        minY: Infinity,
        maxY: -Infinity,
        minZ: Infinity,
        maxZ: -Infinity
    };

    for (let i = 0; i < points.length; i++) {
        let relativePoint = points[i].sub(origin);
        let x = relativePoint.dot(right);
        let y = relativePoint.dot(up);
        let z = relativePoint.dot(forward);

        bounds.minX = Math.min(bounds.minX, x);
        bounds.maxX = Math.max(bounds.maxX, x);
        bounds.minY = Math.min(bounds.minY, y);
        bounds.maxY = Math.max(bounds.maxY, y);
        bounds.minZ = Math.min(bounds.minZ, z);
        bounds.maxZ = Math.max(bounds.maxZ, z);
    }

    return bounds;
}

function isFittableBounds(bounds: IOrthoBounds): boolean {
    return (
        Number.isFinite(bounds.left) &&
        Number.isFinite(bounds.right) &&
        Number.isFinite(bounds.bottom) &&
        Number.isFinite(bounds.top) &&
        bounds.right > bounds.left &&
        bounds.top > bounds.bottom
    );
}

function quantizeUp(value: number, stepsPerOctave: number): number {
    if (!(value > 0.0)) {
        return 0.0;
    }

    return Math.pow(2.0, Math.ceil(Math.log2(value) * stepsPerOctave) / stepsPerOctave);
}

/**
 * Places and sizes an orthographic depth camera over a camera footprint, so that the shadow map covers the
 * ground the main camera sees, and everything that casts onto it, at the tightest texel the footprint allows.
 *
 * The fit holds state between frames - the texel grid step and the camera displacement - so one instance
 * belongs to one depth camera.
 */
export class ShadowCameraFit {
    public orthoMarginFactor: number;
    public casterClearance: number;
    public casterClearanceFactor: number;
    public minFootprintRaySlope: number;
    public reliefLateralFactor: number;
    public maxReliefUp: number;
    public maxReliefDown: number;
    public snapToTexelGrid: boolean;
    public horizonAlignedLightUp: boolean;
    public motionMarginFrames: number;
    public depthBiasTexels: number;
    public depthBiasOffset: number;
    public pinOwnQuadTreeTraversal: boolean;

    public readonly stats: IShadowCameraFitStats;

    protected _lastCameraEye: Vec3;
    protected _cameraStep: number;

    protected _orthoTexelSizeX: number;
    protected _orthoTexelSizeY: number;

    constructor(params: IShadowCameraFitParams = {}) {
        this.orthoMarginFactor = params.orthoMarginFactor ?? 0.01;
        this.casterClearance = params.casterClearance ?? 100000;
        this.casterClearanceFactor = params.casterClearanceFactor ?? 6.0;
        this.minFootprintRaySlope = params.minFootprintRaySlope ?? 0.1;
        this.reliefLateralFactor = params.reliefLateralFactor ?? 0.5;
        this.maxReliefUp = params.maxReliefUp ?? 12000;
        this.maxReliefDown = params.maxReliefDown ?? 2000;
        this.snapToTexelGrid = params.snapToTexelGrid ?? true;
        this.horizonAlignedLightUp = params.horizonAlignedLightUp ?? false;
        this.motionMarginFrames = params.motionMarginFrames ?? 0.0;
        this.depthBiasTexels = params.depthBiasTexels ?? 1.0;
        this.depthBiasOffset = params.depthBiasOffset ?? 100;
        this.pinOwnQuadTreeTraversal = params.pinOwnQuadTreeTraversal ?? true;

        this.stats = {
            texelWorldSize: 0.0,
            footprintRadius: 0.0,
            cornerHeightSpread: 0.0,
            reliefUp: 0.0,
            reliefDown: 0.0,
            depthReliefUp: 0.0,
            depthReliefDown: 0.0,
            casterHeight: 0.0,
            cameraStep: 0.0,
            orthoWidth: 0.0,
            orthoHeight: 0.0,
            orthoBounds: { left: 0.0, right: 0.0, bottom: 0.0, top: 0.0 },
            near: 0.0,
            far: 0.0
        };

        this._lastCameraEye = new Vec3();
        this._cameraStep = 0.0;
        this._orthoTexelSizeX = 0.0;
        this._orthoTexelSizeY = 0.0;
    }

    /** Drops the state carried between frames, for a camera that has been teleported. */
    public reset(): void {
        this._lastCameraEye.set(0.0, 0.0, 0.0);
        this._cameraStep = 0.0;
        this._orthoTexelSizeX = 0.0;
        this._orthoTexelSizeY = 0.0;
    }

    /**
     * Places the depth camera over the footprint and sets its orthographic projection and depth biases.
     *
     * @param {DepthCamera} depthCamera - Orthographic depth camera to fit.
     * @param {CameraFootprint} footprint - Ground area to cover, taken of the main camera this frame.
     * @param {Vec3} sunPos - Sun position.
     * @returns {boolean} - False when the footprint is incomplete or gives no usable bounds.
     */
    public fit(depthCamera: DepthCamera, footprint: CameraFootprint, sunPos: Vec3): boolean {
        let camera = footprint.camera;

        if (!footprint.isValid || !camera || !depthCamera.initialized) {
            return false;
        }

        let shadowCamera = depthCamera.camera;
        let textureWidth = depthCamera.framebuffer.width;
        let textureHeight = depthCamera.framebuffer.height;

        this._cameraStep = this._lastCameraEye.isZero() ? 0.0 : camera.eye.distance(this._lastCameraEye);
        this._lastCameraEye.copy(camera.eye);

        let lightDirection = sunPos.normal().scale(-1.0);
        let lightUp = getStableLightUp(footprint, lightDirection, this.horizonAlignedLightUp);

        // 1. Aim the camera along the sunlight. Only the roll is a choice here, the eye is placed in step 4.
        shadowCamera.set(footprint.center.sub(lightDirection), footprint.center, lightUp);

        let forward = shadowCamera.getForward();
        let right = shadowCamera.getRight();
        let up = shadowCamera.getUp();

        // 2. Box the ground the view sees, with the terrain relief around it, along the camera axes.
        let relief = this._getTerrainRelief(depthCamera, footprint);
        let bounds = getLightSpaceBounds(
            footprint.center,
            right,
            up,
            forward,
            this._getReceiverBoundsPoints(footprint, relief)
        );

        // 3. Pad that box and round it onto the world texel grid.
        let orthoBounds = this._snapOrthographicBounds(
            this._expandOrthographicBounds(bounds, textureWidth, textureHeight),
            right,
            up,
            footprint.center,
            textureWidth,
            textureHeight
        );

        if (!isFittableBounds(orthoBounds)) {
            return false;
        }

        // 4. Move the eye sunward, until the whole caster volume stands in front of the near plane. Along
        // forward only, so the box stays exactly as it was fitted. The height comes from the view relief,
        // not the depth one - that one depends on the bounds this very step places.
        let casterHeight = this._getCasterHeight(camera.planet, footprint.radius, relief.up);
        let near = depthCamera.near;
        let casterMinZ = getCasterMinZ(footprint, forward, casterHeight);
        let casterClearance = Math.max(this.casterClearance, casterHeight * this.casterClearanceFactor);
        let eyeOffset = casterMinZ - near - casterClearance;
        let eye = footprint.center.add(forward.scaleTo(eyeOffset));

        // 5. Push the far plane past the lowest receiver.
        let receiverDepthPadding = SHADOW_RECEIVER_DEPTH_PADDING + relief.depthDown;
        let far = Math.max(near + MIN_SHADOW_ORTHO_SIZE, bounds.maxZ + receiverDepthPadding - eyeOffset);

        // 6. Apply, then hand the depth pass its biases and its own traversal.
        shadowCamera.set(eye, eye.add(forward), lightUp);
        shadowCamera.frustum.setOrthoProjection(
            orthoBounds.left,
            orthoBounds.right,
            orthoBounds.bottom,
            orthoBounds.top,
            near,
            far
        );
        shadowCamera.update();

        this._updateDepthBiases(depthCamera, orthoBounds, textureWidth);

        if (this.pinOwnQuadTreeTraversal) {
            depthCamera._forceOwnQuadTreeStrategyPass = true;
        }

        let stats = this.stats;

        stats.footprintRadius = footprint.radius;
        stats.cornerHeightSpread = relief.cornerSpread;
        stats.reliefUp = relief.up;
        stats.reliefDown = relief.down;
        stats.depthReliefUp = relief.depthUp;
        stats.depthReliefDown = relief.depthDown;
        stats.casterHeight = casterHeight;
        stats.cameraStep = this._cameraStep;
        stats.orthoWidth = orthoBounds.right - orthoBounds.left;
        stats.orthoHeight = orthoBounds.top - orthoBounds.bottom;
        stats.orthoBounds = orthoBounds;
        stats.near = near;
        stats.far = far;

        return true;
    }

    /** Returns how much higher and how much lower the terrain goes than the four footprint corners. */
    protected _getTerrainRelief(depthCamera: DepthCamera, footprint: CameraFootprint): ITerrainRelief {
        let camera = footprint.camera!;
        let corners = getRadiusRange(footprint.points);

        let view = getStrategyRelief(camera.planet.quadTreeStrategy, corners.max, corners.min);
        let referenceRadius = camera.eye.length() - camera.getHeight() + footprint.ellipsoidHeight;
        let map = getStrategyRelief(depthCamera.quadTreeStrategy, referenceRadius, referenceRadius);

        return {
            up: Math.min(this.maxReliefUp, view.up),
            down: Math.min(this.maxReliefDown, view.down),
            depthUp: Math.min(this.maxReliefUp, Math.max(view.up, map.up)),
            depthDown: Math.min(this.maxReliefDown, Math.max(view.down, map.down)),
            cornerSpread: corners.max - corners.min
        };
    }

    /**
     * Returns the points the bounds are fitted to: the four footprint corners, plus the relief they hide,
     * reached by walking each corner along its own view ray. Along the ray, because that is where the hidden
     * terrain lies - pushing the corners sideways instead would widen the bounds for relief that is only ever
     * an estimate.
     */
    protected _getReceiverBoundsPoints(footprint: CameraFootprint, relief: ITerrainRelief): Vec3[] {
        let points = footprint.points.slice();

        if (relief.up <= 0.0 && relief.down <= 0.0) {
            return points;
        }

        let eye = footprint.camera!.eye;
        let lateralLimit = footprint.radius * this.reliefLateralFactor;

        for (let i = 0; i < footprint.points.length; i++) {
            let point = footprint.points[i];
            let toPoint = point.sub(eye);
            let distance = toPoint.length();

            if (distance <= 0.0) {
                continue;
            }

            let direction = toPoint.scale(1.0 / distance);
            let slope = Math.max(-direction.dot(footprint.normals[i]), this.minFootprintRaySlope);

            if (relief.up > 0.0) {
                points.push(point.add(direction.scaleTo(-Math.min(relief.up / slope, distance))));
            }

            if (relief.down > 0.0) {
                let tangential = Math.sqrt(Math.max(0.0, 1.0 - slope * slope));
                let limit = tangential > 0.0 ? lateralLimit / tangential : Infinity;

                points.push(point.add(direction.scaleTo(Math.min(relief.down / slope, limit))));
            }
        }

        return points;
    }

    /**
     * Returns the caster height, rounded to a coarse step. It decides where the shadow camera stands, and a
     * camera that jumps every time the terrain range is revised makes the whole map blink.
     */
    protected _getCasterHeight(planet: Planet, footprintRadius: number, terrainRelief: number): number {
        let height = Math.max(
            footprintRadius * SHADOW_CASTER_HEIGHT_FACTOR * (planet._heightFactor || 1.0),
            terrainRelief * SHADOW_CASTER_RELIEF_FACTOR
        );
        let clamped = Math.min(MAX_SHADOW_CASTER_HEIGHT, Math.max(MIN_SHADOW_CASTER_HEIGHT, height));

        return Math.min(MAX_SHADOW_CASTER_HEIGHT, quantizeUp(clamped, SHADOW_CASTER_HEIGHT_STEPS));
    }

    protected _expandOrthographicBounds(
        bounds: ILightSpaceBounds,
        textureWidth: number,
        textureHeight: number
    ): IOrthoBounds {
        let width = bounds.maxX - bounds.minX;
        let height = bounds.maxY - bounds.minY;
        let motionPadding = this._cameraStep * this.motionMarginFrames;
        let paddingX = Math.max(
            width * this.orthoMarginFactor + (width / textureWidth) * SHADOW_ORTHO_TEXEL_PADDING + motionPadding,
            MIN_SHADOW_ORTHO_SIZE
        );
        let paddingY = Math.max(
            height * this.orthoMarginFactor + (height / textureHeight) * SHADOW_ORTHO_TEXEL_PADDING + motionPadding,
            MIN_SHADOW_ORTHO_SIZE
        );

        return {
            left: bounds.minX - paddingX,
            right: bounds.maxX + paddingX,
            bottom: bounds.minY - paddingY,
            top: bounds.maxY + paddingY
        };
    }

    /**
     * Returns the texel size to fit the extent onto, keeping the previous one while it is still large enough.
     */
    protected _quantizeOrthoTexelSize(extent: number, resolution: number, prevTexelSize: number): number {
        let texelSize = extent / resolution;

        if (!Number.isFinite(texelSize) || texelSize <= 0.0) {
            return 0.0;
        }

        let releaseBelow = prevTexelSize / (ORTHO_TEXEL_QUANTIZATION_RATIO * (1.0 + ORTHO_TEXEL_RELEASE_SLACK));

        if (prevTexelSize > 0.0 && texelSize <= prevTexelSize && texelSize > releaseBelow) {
            return prevTexelSize;
        }

        return quantizeUp(texelSize, ORTHO_TEXEL_QUANTIZATION_STEPS);
    }

    protected _snapOrthographicBounds(
        bounds: IOrthoBounds,
        right: Vec3,
        up: Vec3,
        anchor: Vec3,
        resolutionX: number,
        resolutionY: number
    ): IOrthoBounds {
        if (!this.snapToTexelGrid) {
            return bounds;
        }

        let texelSizeX = this._quantizeOrthoTexelSize(bounds.right - bounds.left, resolutionX, this._orthoTexelSizeX);
        let texelSizeY = this._quantizeOrthoTexelSize(bounds.top - bounds.bottom, resolutionY, this._orthoTexelSizeY);

        if (texelSizeX <= 0.0 || texelSizeY <= 0.0) {
            return bounds;
        }

        this._orthoTexelSizeX = texelSizeX;
        this._orthoTexelSizeY = texelSizeY;

        let width = texelSizeX * resolutionX;
        let height = texelSizeY * resolutionY;
        let anchorX = anchor.dot(right);
        let anchorY = anchor.dot(up);
        let centerX = (bounds.left + bounds.right) * 0.5;
        let centerY = (bounds.bottom + bounds.top) * 0.5;
        let left = Math.floor((anchorX + centerX - width * 0.5) / texelSizeX) * texelSizeX - anchorX;
        let bottom = Math.floor((anchorY + centerY - height * 0.5) / texelSizeY) * texelSizeY - anchorY;

        return {
            left,
            right: left + width,
            bottom,
            top: bottom + height
        };
    }

    protected _updateDepthBiases(depthCamera: DepthCamera, orthoBounds: IOrthoBounds, textureWidth: number): void {
        let texelWorldSize =
            Math.max(orthoBounds.right - orthoBounds.left, orthoBounds.top - orthoBounds.bottom) / textureWidth;

        this.stats.texelWorldSize = texelWorldSize;

        depthCamera.depthBiasWorld = texelWorldSize * this.depthBiasTexels + this.depthBiasOffset;
    }
}