renderer_cascadeShadows_CascadeShadowMap.ts

import { Sphere } from "../../bv/Sphere";
import { Camera } from "../../camera/Camera";
import { PlanetCamera } from "../../camera/PlanetCamera";
import { Entity } from "../../entity/Entity";
import { Vector } from "../../layer/Vector";
import { RADIANS_HALF } from "../../math";
import { Vec3 } from "../../math/Vec3";
import { Object3d } from "../../Object3d";
import { QuadTreeStrategy } from "../../quadTree";
import type { Node } from "../../quadTree/Node";
import type { Renderer } from "../../renderer/Renderer";
import type { Planet } from "../../scene/Planet";
import type { Segment } from "../../segment/Segment";
import { Framebuffer } from "../../webgl";
import type { CascadeShadowManager } from "./CascadeShadowManager";

const DEFAULT_CASCADE_SHADOW_SIZE = 1024;
const DEFAULT_CASCADE_COUNT = 4;
const DEFAULT_CASCADE_MAX_DISTANCE = 10000000;
const DEFAULT_CASCADE_SPLIT_LAMBDA = 0.65;
const DEFAULT_VERTICAL_VIEW_ANGLE = 45;
const DEFAULT_CASCADE_BIAS = 10000;
const DEFAULT_CASCADE_NORMAL_BIAS = 100;
const DEFAULT_CASCADE_DEPTH_EPSILON = 10000;
const DEFAULT_CASCADE_ORTHOGRAPHIC_MARGIN_FACTOR = 0.02;
const DEFAULT_CASCADE_CASTER_MARGIN = 0.0;
const DEFAULT_CASCADE_CASTER_MARGIN_FACTOR = 0.25;
const RENDER_SKIRTS_SLOPE = 0.3;
const CAMERA_FRUSTUM_LENGTH = 2.5;
const MIN_CASCADE_SPLIT_DISTANCE = 1e-6;
const MIN_CASCADE_LIGHT_DISTANCE = 1e-3;
const MIN_CASCADE_LIGHT_SIZE = 1e-3;

const cascadeShadowCameraFrustumObj = Object3d.createFrustum();

/**
 * Cascade shadow map split configuration.
 * @property enabled - Enables the cascade.
 * @property splitNear - Near split distance.
 * @property splitFar - Far split distance.
 * @property bias - Shadow depth bias. Converted to normalized depth by CascadeShadowManager.
 * @property normalBias - Surface normal offset in RTC/world units.
 * @property depthEpsilon - Shadow depth transition width. Converted to normalized depth by CascadeShadowManager.
 */
export interface CascadeParams {
    enabled: boolean;
    splitNear: number;
    splitFar: number;
    bias: number;
    normalBias: number;
    depthEpsilon: number;
}

/**
 * Cascade shadow map configuration options.
 * @property enabled - Enables cascade shadow rendering.
 * @property size - Shadow map texture size in pixels.
 * @property cascadeCount - Number of generated cascade splits when cascades is not provided.
 * @property maxDistance - Maximum camera distance covered by all cascade splits.
 * @property splitLambda - Blend factor between uniform and logarithmic split distribution.
 * @property verticalViewAngle - Vertical view angle for the orthographic cascade camera.
 * @property casterMargin - Minimum light-space depth margin for shadow casters in world units.
 * @property excludeLayers - Vector layers excluded from cascade shadow rendering.
 * @property cascades - Per-cascade parameter overrides. When provided, its length defines cascade count.
 */
export interface ICascadeShadowMapParams {
    enabled?: boolean;
    size?: number;
    cascadeCount?: number;
    maxDistance?: number;
    splitLambda?: number;
    verticalViewAngle?: number;
    casterMargin?: number;
    excludeLayers?: Vector[];
    cascades?: Partial<CascadeParams>[];
}

export class CascadeShadowMap {
    protected static __counter__ = 0;

    public readonly id: number;
    public readonly size: number;
    public readonly maxDistance: number;
    public readonly splitLambda: number;
    public readonly verticalViewAngle: number;
    public readonly casterMargin: number;
    public readonly excludeLayers: Vector[];

    public depthCamera: Camera;
    public cameraFrustumEntity: Entity;
    public framebuffer: Framebuffer | null;
    public quadTreeStrategy: QuadTreeStrategy | null;
    public readonly cascades: CascadeParams[];

    public _manager: CascadeShadowManager | null;

    protected _planet: Planet | null;
    protected _renderer: Renderer | null;
    protected _initialized: boolean;
    protected _enabled: boolean;
    protected _depthArrayTexture: WebGLTexture | null;
    protected _lastPlanetHeightFactor: number;
    protected _cascadeBoundingSpheres: Sphere[];
    protected _depthCameraBoundingSphere: Sphere;
    protected _useCameraFrustumEntityPose: boolean;
    protected _prevCameraPos: Vec3;
    protected _prevCameraPitch: number;
    protected _prevCameraYaw: number;
    protected _prevCameraRoll: number;
    protected _prevCameraFrustumEntityPos: Vec3;
    protected _prevCameraFrustumEntityPitch: number;
    protected _prevCameraFrustumEntityYaw: number;
    protected _prevCameraFrustumEntityRoll: number;

    constructor(params: ICascadeShadowMapParams = {}) {
        this.id = CascadeShadowMap.__counter__++;

        this._enabled = params.enabled ?? true;

        this.size = params.size || DEFAULT_CASCADE_SHADOW_SIZE;

        const cascadeCount =
            params.cascades && params.cascades.length > 0
                ? params.cascades.length
                : params.cascadeCount || DEFAULT_CASCADE_COUNT;

        this.maxDistance = params.maxDistance || DEFAULT_CASCADE_MAX_DISTANCE;
        this.splitLambda = Math.max(0.0, Math.min(params.splitLambda ?? DEFAULT_CASCADE_SPLIT_LAMBDA, 1.0));
        this.verticalViewAngle = params.verticalViewAngle || DEFAULT_VERTICAL_VIEW_ANGLE;
        this.casterMargin = params.casterMargin ?? DEFAULT_CASCADE_CASTER_MARGIN;
        this.excludeLayers = params.excludeLayers ? [...params.excludeLayers] : [];
        this.cascades = this._createCascadeParams(params, cascadeCount);
        this.depthCamera = this._createDepthCamera();
        this.cameraFrustumEntity = this._createCameraFrustumEntity();
        this.framebuffer = null;

        this._cascadeBoundingSpheres = this._createCascadeBoundingSpheres();
        this._depthCameraBoundingSphere = new Sphere();

        this._manager = null;
        this._planet = null;
        this._renderer = null;
        this._initialized = false;
        this._depthArrayTexture = null;
        this._lastPlanetHeightFactor = 1.0;
        this.quadTreeStrategy = null;

        this._useCameraFrustumEntityPose = false;
        this._prevCameraPos = new Vec3();
        this._prevCameraPitch = 0;
        this._prevCameraYaw = 0;
        this._prevCameraRoll = 0;
        this._prevCameraFrustumEntityPos = new Vec3();
        this._prevCameraFrustumEntityPitch = 0;
        this._prevCameraFrustumEntityYaw = 0;
        this._prevCameraFrustumEntityRoll = 0;
    }

    public get initialized(): boolean {
        return this._initialized;
    }

    public get enabled(): boolean {
        return this._enabled;
    }

    public set enabled(enabled: boolean) {
        if (this._enabled === enabled) return;

        this._enabled = enabled;
        this._manager?.update(this);
    }

    public get depthArrayTexture(): WebGLTexture | null {
        return this._depthArrayTexture;
    }

    public get frustumScale(): Vec3 {
        return Object3d.getFrustumScaleByCameraAngles(
            CAMERA_FRUSTUM_LENGTH,
            this.depthCamera.horizontalViewAngle,
            this.depthCamera.verticalViewAngle
        );
    }

    public init(renderer: Renderer): void {
        if (this._initialized) return;

        this._renderer = renderer;
        this.depthCamera = this._createDepthCamera(this._planet);
        this.framebuffer = this._createFramebuffer(renderer);
        this.framebuffer.init();
        this._depthArrayTexture = this._createDepthArrayTexture(renderer);
        this._validateFramebufferLayers();

        if (this._planet) {
            this._initPlanetSource(this._planet);
        }

        this._initialized = true;
    }

    public bindPlanet(planet: Planet): void {
        if (this._planet === planet && this.quadTreeStrategy) return;

        if (this._planet && this._planet !== planet) {
            this.unbindPlanet();
        }

        this._planet = planet;

        if (this._initialized) {
            this._initPlanetSource(planet);
        }
    }

    public unbindPlanet(): void {
        if (this.quadTreeStrategy) {
            this.quadTreeStrategy.clear();
            this.quadTreeStrategy = null;
        }

        this._planet = null;
        this._useCameraFrustumEntityPose = false;

        if (this._initialized) {
            this.depthCamera = this._createDepthCamera();
        }
    }

    public destroy(): void {
        if (!this._initialized) return;

        const gl = this._renderer?.handler.gl;

        if (this._depthArrayTexture && this.framebuffer?._fbo) {
            this.framebuffer.activate();
            this.framebuffer.bindOutputTextureLayer(null, 0);
            this.framebuffer.deactivate();
        }

        if (this.quadTreeStrategy) {
            this.quadTreeStrategy.clear();
            this.quadTreeStrategy = null;
        }

        if (this.framebuffer) {
            this.framebuffer.destroy();
            this.framebuffer = null;
        }

        if (gl && this._depthArrayTexture) {
            gl.deleteTexture(this._depthArrayTexture);
        }

        this._depthArrayTexture = null;
        this._planet = null;
        this._renderer = null;
        this._useCameraFrustumEntityPose = false;
        this._initialized = false;
    }

    public frame(): void {
        if (!this.enabled || !this._initialized) return;

        const r = this._renderer!;

        this._prepareFrame();
        r.applyDepthForCamera(this.depthCamera);

        this._drawFrame();

        r.applyDepthForCamera(r.activeCamera);
        this._finishFrame();
    }

    protected _prepareFrame(): void {
        this._syncPlanetHeightFactor();
        const useCameraFrustumEntityPose = this._prepareCameraFrustumEntitySync();
        this._updateCascadeBounds();
        this._updateDepthCamera(useCameraFrustumEntityPose);
    }

    protected _finishFrame(): void {
        this._finishCameraFrustumEntitySync();
    }

    protected _createCascadeParams(params: ICascadeShadowMapParams, cascadeCount: number): CascadeParams[] {
        const cascades: CascadeParams[] = [];
        const splitDistributionNear =
            params.cascades && params.cascades.length > 0 ? (params.cascades[0].splitNear ?? 1.0) : 1.0;

        for (let i = 0; i < cascadeCount; i++) {
            const splitNear = this._computeCascadeSplitDistance(i, splitDistributionNear, cascadeCount);
            const splitFar = this._computeCascadeSplitDistance(i + 1, splitDistributionNear, cascadeCount);
            const cascade: CascadeParams = {
                enabled: true,
                splitNear,
                splitFar,
                bias: DEFAULT_CASCADE_BIAS,
                normalBias: DEFAULT_CASCADE_NORMAL_BIAS,
                depthEpsilon: DEFAULT_CASCADE_DEPTH_EPSILON,
                ...params.cascades?.[i]
            };

            cascades.push(cascade);
        }

        return cascades;
    }

    protected _computeCascadeSplitDistance(
        splitIndex: number,
        splitDistributionNear: number,
        cascadeCount: number
    ): number {
        if (splitIndex <= 0) {
            return splitDistributionNear;
        }

        if (splitIndex >= cascadeCount) {
            return this.maxDistance;
        }

        const t = splitIndex / cascadeCount;
        const near = splitDistributionNear;
        const far = this.maxDistance;
        const uniform = near + (far - near) * t;
        const logarithmic = near * Math.pow(far / near, t);
        const distance = uniform * (1.0 - this.splitLambda) + logarithmic * this.splitLambda;

        return Math.max(0.0, Math.min(distance, this.maxDistance));
    }

    protected _createCascadeBoundingSpheres(): Sphere[] {
        const spheres: Sphere[] = [];

        for (let i = 0; i < this.cascades.length; i++) {
            spheres.push(new Sphere());
        }

        return spheres;
    }

    protected _createCameraFrustumEntity(): Entity {
        return new Entity({
            visibility: true,
            scale: new Vec3(1, 1, 1),
            geoObject: {
                tag: "cascade-shadow-camera-frustum",
                color: "rgba(255, 214, 0, 0.88)",
                object3d: cascadeShadowCameraFrustumObj
            },
            properties: {
                cascadeShadowMap: this
            }
        });
    }

    protected _createDepthCamera(planet?: Planet | null): Camera {
        const frustums: [number, number][] = [];

        for (let i = 0; i < this.cascades.length; i++) {
            const cascade = this.cascades[i];
            frustums.push([cascade.splitNear, cascade.splitFar]);
        }

        const params = {
            frustums,
            width: this.size,
            height: this.size,
            viewAngle: this.verticalViewAngle,
            isOrthographic: true,
            focusDistance: this.maxDistance,
            maxAltitude: planet
                ? Math.max(this.maxDistance * 4.0, planet.ellipsoid.getEquatorialSize() * 8.0)
                : undefined,
            reverseDepth: false
        };

        return planet ? new PlanetCamera(planet, params) : new Camera(params);
    }

    protected _createFramebuffer(renderer: Renderer): Framebuffer {
        return new Framebuffer(renderer.handler, {
            width: this.size,
            height: this.size,
            depthComponent: "DEPTH_COMPONENT32F",
            targets: [
                {
                    internalFormat: "R32F",
                    attachment: "COLOR_ATTACHMENT"
                }
            ],
            useDepth: true
        });
    }

    protected _createDepthArrayTexture(renderer: Renderer): WebGLTexture | null {
        return renderer.handler.createEmptyTexture2DArrayExt(
            this.size,
            this.size,
            this.cascades.length,
            "LINEAR",
            "R32F",
            "CLAMP_TO_EDGE",
            1
        );
    }

    protected _validateFramebufferLayers(): void {
        const framebuffer = this.framebuffer;
        if (!framebuffer) {
            return;
        }

        const gl = framebuffer.handler.gl;

        if (!gl || !this._depthArrayTexture || !framebuffer._fbo) {
            return;
        }

        framebuffer.activate();
        for (let i = 0; i < this.cascades.length; i++) {
            framebuffer.bindOutputTextureLayer(this._depthArrayTexture, i);
            if (
                !framebuffer.validateComplete(`CascadeShadowMap._validateFramebufferLayers(): framebuffer incomplete
                (layer=${i}). Check float color-buffer support for R32F.`)
            ) {
                break;
            }
        }
        framebuffer.deactivate();
    }

    protected _createQuadTreeStrategy(planet: Planet, camera: PlanetCamera): QuadTreeStrategy {
        const quadTreeStrategy = new planet.quadTreeStrategyPrototype({
            planet,
            maxEqualZoomAltitude: planet.quadTreeStrategy.maxEqualZoomAltitude,
            minEqualZoomAltitude: planet.quadTreeStrategy.minEqualZoomAltitude,
            minEqualZoomCameraSlope: planet.quadTreeStrategy.minEqualZoomCameraSlope,
            transitionOpacityEnabled: false
        });

        quadTreeStrategy.init(camera);
        quadTreeStrategy.lodCamera = planet.camera;
        quadTreeStrategy.preRender();
        quadTreeStrategy.clearRenderedNodes();
        quadTreeStrategy.preLoad();

        return quadTreeStrategy;
    }

    protected _initPlanetSource(planet: Planet): void {
        if (!(this.depthCamera instanceof PlanetCamera) || this.depthCamera.planet !== planet) {
            this.depthCamera = this._createDepthCamera(planet);
        }

        this.quadTreeStrategy = this._createQuadTreeStrategy(planet, this.depthCamera as PlanetCamera);
        this._lastPlanetHeightFactor = planet._heightFactor;
    }

    protected _syncPlanetHeightFactor(): void {
        if (!this._planet || this._lastPlanetHeightFactor === this._planet._heightFactor) {
            return;
        }

        this._lastPlanetHeightFactor = this._planet._heightFactor;

        if (this.quadTreeStrategy) {
            this.quadTreeStrategy.destroyBranches();
            this.quadTreeStrategy.clearRenderedNodes();
        }
    }

    protected _updateCascadeBounds(): void {
        for (let i = 0; i < this.cascades.length; i++) {
            this._computeCascadeBoundingSphere(this.cascades[i], this._cascadeBoundingSpheres[i]);
        }
    }

    protected _prepareCameraFrustumEntitySync(): boolean {
        const cameraFrustumEntity = this.cameraFrustumEntity;
        const cam = this.depthCamera;
        const cameraFrustumEntityPos = cameraFrustumEntity.getAbsoluteCartesian();
        const cameraFrustumEntityPitch = cameraFrustumEntity.getPitch();
        const cameraFrustumEntityYaw = cameraFrustumEntity.getYaw();
        const cameraFrustumEntityRoll = cameraFrustumEntity.getRoll();
        let cameraUpdated = false;

        if (this._prevCameraPos.equal(cam.eye) && !this._prevCameraFrustumEntityPos.equal(cameraFrustumEntityPos)) {
            cam.eye.copy(cameraFrustumEntityPos);
            cameraUpdated = true;
        }

        const cameraPitch = cam.getPitch();
        const cameraYaw = cam.getYaw();
        const cameraRoll = cam.getRoll();

        if (
            this._prevCameraPitch === cameraPitch &&
            this._prevCameraYaw === cameraYaw &&
            this._prevCameraRoll === cameraRoll &&
            (this._prevCameraFrustumEntityPitch !== cameraFrustumEntityPitch ||
                this._prevCameraFrustumEntityYaw !== cameraFrustumEntityYaw ||
                this._prevCameraFrustumEntityRoll !== cameraFrustumEntityRoll)
        ) {
            cam.setPitchYawRoll(cameraFrustumEntityPitch, cameraFrustumEntityYaw, cameraFrustumEntityRoll);
            cameraUpdated = true;
        }

        if (cameraUpdated) {
            this._useCameraFrustumEntityPose = true;
            cam.update();
        }

        return this._useCameraFrustumEntityPose;
    }

    protected _finishCameraFrustumEntitySync(): void {
        const cameraFrustumEntity = this.cameraFrustumEntity;
        const cam = this.depthCamera;

        cameraFrustumEntity.setScale3v(this.frustumScale);
        cameraFrustumEntity.setCartesian3v(cam.eye);
        cameraFrustumEntity.setAbsolutePitch(cam.getPitch());
        cameraFrustumEntity.setAbsoluteYaw(cam.getYaw());
        cameraFrustumEntity.setAbsoluteRoll(cam.getRoll());

        this._prevCameraPitch = cam.getPitch();
        this._prevCameraYaw = cam.getYaw();
        this._prevCameraRoll = cam.getRoll();
        this._prevCameraPos.copy(cam.eye);

        this._prevCameraFrustumEntityPos.copy(cameraFrustumEntity.getAbsoluteCartesian());
        this._prevCameraFrustumEntityPitch = cameraFrustumEntity.getPitch();
        this._prevCameraFrustumEntityYaw = cameraFrustumEntity.getYaw();
        this._prevCameraFrustumEntityRoll = cameraFrustumEntity.getRoll();

        if (this._planet && this._planet.ellipsoid) {
            cameraFrustumEntity._lonLat.copy(
                this._planet.ellipsoid.cartesianToLonLat(cameraFrustumEntity.getAbsoluteCartesian())
            );
        }
    }

    protected _updateDepthCamera(useCameraFrustumEntityPose: boolean = false): void {
        const maxCascadeRadius = this._computeDepthCameraBoundingSphere(this._depthCameraBoundingSphere);
        if (maxCascadeRadius < 0.0) {
            this.depthCamera.update();
            return;
        }

        const lightPosition = this._renderer!._lightPosition;
        const lightLenSq =
            lightPosition[0] * lightPosition[0] +
            lightPosition[1] * lightPosition[1] +
            lightPosition[2] * lightPosition[2];

        let lightDirection: Vec3;
        if (lightLenSq > 0.0) {
            const lightLen = 1.0 / Math.sqrt(lightLenSq);
            lightDirection = new Vec3(
                lightPosition[0] * lightLen,
                lightPosition[1] * lightLen,
                lightPosition[2] * lightLen
            );
        } else {
            lightDirection = new Vec3(1.0, 1.0, 1.0).normalize();
        }

        const bounds = this._depthCameraBoundingSphere;
        const maxCasterMargin = Math.max(maxCascadeRadius * DEFAULT_CASCADE_CASTER_MARGIN_FACTOR, this.casterMargin);
        const lightDistance = bounds.radius + maxCascadeRadius + maxCasterMargin + MIN_CASCADE_LIGHT_DISTANCE;
        const target = bounds.center;
        const eye = target.add(lightDirection.scaleTo(lightDistance));
        const up = this._getDepthCameraUp(lightDirection);

        if (!useCameraFrustumEntityPose) {
            this.depthCamera.set(eye, target, up);
        }

        for (let i = 0; i < this.cascades.length; i++) {
            const sphere = this._cascadeBoundingSpheres[i];
            const radius = Math.max(sphere.radius, MIN_CASCADE_LIGHT_SIZE);
            const xyMargin = Math.max(radius * DEFAULT_CASCADE_ORTHOGRAPHIC_MARGIN_FACTOR, MIN_CASCADE_LIGHT_SIZE);
            const zMargin = Math.max(
                radius * DEFAULT_CASCADE_CASTER_MARGIN_FACTOR,
                this.casterMargin,
                MIN_CASCADE_LIGHT_SIZE
            );
            const rel = sphere.center.sub(this.depthCamera.eye);
            const centerX = rel.dot(this.depthCamera._r);
            const centerY = rel.dot(this.depthCamera._u);
            const centerDistance = -rel.dot(this.depthCamera._b);
            const near = Math.max(MIN_CASCADE_SPLIT_DISTANCE, centerDistance - radius - zMargin);
            const far = Math.max(near + MIN_CASCADE_LIGHT_DISTANCE, centerDistance + radius + zMargin);

            this.depthCamera.frustums[i].setOrthoProjection(
                centerX - radius - xyMargin,
                centerX + radius + xyMargin,
                centerY - radius - xyMargin,
                centerY + radius + xyMargin,
                near,
                far
            );
        }

        this.depthCamera.update();
    }

    protected _computeDepthCameraBoundingSphere(outSphere: Sphere): number {
        let hasBounds = false;
        let maxRadius = 0.0;

        for (let i = 0; i < this.cascades.length; i++) {
            const cascade = this.cascades[i];
            if (!cascade.enabled) {
                continue;
            }

            const sphere = this._cascadeBoundingSpheres[i];
            maxRadius = Math.max(maxRadius, sphere.radius);

            if (!hasBounds) {
                outSphere.center.copy(sphere.center);
                outSphere.radius = sphere.radius;
                hasBounds = true;
            } else {
                this._extendSphereBySphere(outSphere, sphere);
            }
        }

        return hasBounds ? maxRadius : -1.0;
    }

    protected _extendSphereBySphere(outSphere: Sphere, sphere: Sphere): void {
        const dx = sphere.center.x - outSphere.center.x;
        const dy = sphere.center.y - outSphere.center.y;
        const dz = sphere.center.z - outSphere.center.z;
        const distSq = dx * dx + dy * dy + dz * dz;
        const radius = sphere.radius;

        if (distSq <= 0.0) {
            outSphere.radius = Math.max(outSphere.radius, radius);
            return;
        }

        const dist = Math.sqrt(distSq);

        if (outSphere.radius >= dist + radius) {
            return;
        }

        if (radius >= dist + outSphere.radius) {
            outSphere.center.copy(sphere.center);
            outSphere.radius = radius;
            return;
        }

        const newRadius = 0.5 * (dist + outSphere.radius + radius);
        const centerOffset = (newRadius - outSphere.radius) / dist;

        outSphere.center.x += dx * centerOffset;
        outSphere.center.y += dy * centerOffset;
        outSphere.center.z += dz * centerOffset;
        outSphere.radius = newRadius;
    }

    protected _getDepthCameraUp(lightDirection: Vec3): Vec3 {
        const activeCamera = this._renderer!.activeCamera;
        const candidates = [activeCamera.getUp(), activeCamera.getRight(), Vec3.NORTH, Vec3.UNIT_Y, Vec3.UNIT_X];

        for (let i = 0; i < candidates.length; i++) {
            const projected = candidates[i].sub(lightDirection.scaleTo(candidates[i].dot(lightDirection)));
            if (projected.length2() > 1e-12) {
                return projected.normalize();
            }
        }

        return Vec3.UNIT_Y;
    }

    /**
     * Computes a minimum bounding sphere for the main camera perspective frustum slice.
     * Cascade split distances are linear distances from the main camera eye.
     */
    protected _computeCascadeBoundingSphere(cascade: CascadeParams, outSphere: Sphere): void {
        const mainCamera = this._renderer!.activeCamera;
        const eye = mainCamera.eye;
        const direction = mainCamera._f;
        const mainNear = mainCamera.frustums[0].near || MIN_CASCADE_SPLIT_DISTANCE;
        const dMin = Math.max(cascade.splitNear, mainNear, MIN_CASCADE_SPLIT_DISTANCE);
        const dMax = cascade.splitFar;

        const dirLenSq = direction.x * direction.x + direction.y * direction.y + direction.z * direction.z;

        const invDirLen = 1.0 / Math.sqrt(dirLenSq);
        const dirX = direction.x * invDirLen;
        const dirY = direction.y * invDirLen;
        const dirZ = direction.z * invDirLen;

        const halfHeightAtNear = dMin * Math.tan(mainCamera.viewAngle * RADIANS_HALF);
        const halfWidthAtNear = halfHeightAtNear * mainCamera.getAspectRatio();
        const invDMin = 1.0 / dMin;
        const r = halfWidthAtNear * invDMin;
        const u = halfHeightAtNear * invDMin;
        const slopeSq = r * r + u * u;

        let sphereCenterDistance = 0.5 * (dMin + dMax) * (1.0 + slopeSq);
        let radius: number;

        if (sphereCenterDistance >= dMax) {
            sphereCenterDistance = dMax;
            radius = dMax * Math.sqrt(slopeSq);
        } else {
            const diff = 1.0 - sphereCenterDistance / dMax;
            radius = dMax * Math.sqrt(diff * diff + slopeSq);
        }

        outSphere.center.set(
            eye.x + sphereCenterDistance * dirX,
            eye.y + sphereCenterDistance * dirY,
            eye.z + sphereCenterDistance * dirZ
        );
        outSphere.radius = radius;
    }

    protected _collectRenderNodes(): QuadTreeStrategy | null {
        const planet = this._planet;
        const camera = this.depthCamera;

        if (!planet || !this.quadTreeStrategy || !(camera instanceof PlanetCamera)) {
            return null;
        }

        camera.updateCameraSlope();

        const quadTreeStrategy = this.quadTreeStrategy;
        quadTreeStrategy.maxZoomLimit = planet.quadTreeStrategy.maxCurrZoom;
        quadTreeStrategy.lodCamera = planet.camera;
        quadTreeStrategy.collectRenderNodes(camera);

        return quadTreeStrategy;
    }

    protected _drawFrame(): void {
        const quadTreeStrategy = this._collectRenderNodes();

        for (let i = 0; i < this.cascades.length; i++) {
            const cascade = this.cascades[i];
            if (!cascade.enabled) continue;

            let renderedNodes: Node[] = [];
            let fadingOpaqueSegments: Segment[] = [];

            if (quadTreeStrategy) {
                renderedNodes = quadTreeStrategy._renderedNodesInFrustum[i] || [];
                fadingOpaqueSegments = quadTreeStrategy._fadingOpaqueSegments;
            }

            this.depthCamera.setCurrentFrustum(i);
            this._renderCascade(cascade, i, renderedNodes, fadingOpaqueSegments);
        }
    }

    protected _renderCascade(
        cascade: CascadeParams,
        cascadeIndex: number,
        renderedNodes: Node[],
        fadingOpaqueSegments: Segment[]
    ): void {
        if (!this._depthArrayTexture) return;

        const framebuffer = this.framebuffer;
        if (!framebuffer) return;

        const gl = framebuffer.handler.gl!;

        framebuffer.activate();
        framebuffer.bindOutputTextureLayer(this._depthArrayTexture, cascadeIndex);

        gl.clearColor(0.0, 0.0, 0.0, 0.0);
        gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
        gl.disable(gl.BLEND);

        this._segmentsPass(this.depthCamera, renderedNodes, fadingOpaqueSegments);
        this._geoObjectsPass(this.depthCamera);

        gl.enable(gl.BLEND);

        framebuffer.deactivate();
    }

    protected _segmentsPass(camera: Camera, renderedNodes: Node[], fadingOpaqueSegments: Segment[]): void {
        const h = this._renderer!.handler;
        const gl = h.gl!;
        const planet = this._planet;

        if (!planet || !(camera instanceof PlanetCamera)) return;

        const checkSlope = camera.isOrthographic && camera.slope < RENDER_SKIRTS_SLOPE;

        h.programs.cascade_shadow_depth.activate();
        const sh = h.programs.cascade_shadow_depth;
        const shu = sh.uniforms;

        gl.uniformMatrix4fv(shu.viewMatrix, false, camera.getViewMatrix());
        gl.uniformMatrix4fv(shu.projectionMatrix, false, camera.getProjectionMatrix());

        gl.enable(gl.CULL_FACE);

        const isEq = planet.terrain!.equalizeVertices;
        const baseLayerSlice = planet.visibleTileLayers.length ? [planet.visibleTileLayers[0]] : undefined;
        const renderSkirts = checkSlope;

        let i = renderedNodes.length;
        while (i--) {
            const s = renderedNodes[i].segment;
            if (!s.node) continue;
            if (s._transitionOpacity >= 1) {
                isEq && s.equalize();
                s.readyToEngage && s.engage();
                s.ensureIndexBuffer();
                s.updateRTCEyePosition(camera);
                s.depthRendering(sh, baseLayerSlice, renderSkirts);
            }
        }

        for (let j = 0; j < fadingOpaqueSegments.length; ++j) {
            const s = fadingOpaqueSegments[j];
            if (!s.node) continue;
            isEq && s.equalize();
            s.readyToEngage && s.engage();
            s.ensureIndexBuffer();
            s.updateRTCEyePosition(camera);
            s.depthRendering(sh, baseLayerSlice, renderSkirts);
        }

        gl.enable(gl.CULL_FACE);
    }

    protected _geoObjectsPass(camera: Camera): void {
        const planet = this._planet;
        if (!planet) return;

        const layers = planet.layers;

        for (let i = 0; i < layers.length; i++) {
            const layer = layers[i];
            if (!(layer instanceof Vector) || !layer.getVisibility()) {
                continue;
            }

            if (this.excludeLayers.includes(layer)) {
                continue;
            }

            layer._geoObjectEntityCollection.geoObjectHandler.drawDepthCameraPass(camera);
        }
    }
}