renderer_projectors_ProjectorManager.ts

import { Mat4 } from "../../math/Mat4";
import { cons } from "../../cons";
import type { ShaderProgram } from "../../webgl/ShaderProgram";
import type { DepthCamera } from "../../control/depthCamera/DepthCamera";
import type { Renderer } from "../Renderer";
import { Projector } from "./Projector";

export type { ProjectorSourceType, ProjectorRenderMode, IProjectorParams } from "./Projector";
export { Projector } from "./Projector";

/**
 * Maximum number of depth layers allocated in manager-owned projector array texture.
 * Defines how many projectors can be added to the manager at once.
 */
export const MAX_PROJECTOR_LAYERS = 64;
const INITIAL_PROJECTOR_LAYERS = 8;

/**
 * Maximum number of projectors processed in a single shader invocation.
 * Used by forward / WOIT paths as top-K by priority per draw call.
 * Deferred projector pass binds one projector per draw (`chunkSize = 1`).
 */
export const MAX_FORWARD_PROJECTORS = 8;

/** Default texture unit where the depth array sampler is bound. */
export const DEFAULT_PROJECTOR_TEXTURE_UNIT_START = 6;

export class ProjectorManager {
    protected _renderer: Renderer;
    protected _projectors: Projector[];
    protected _activeProjectors: Projector[];

    protected _viewProjData: Float32Array;
    protected _invViewProjData: Float32Array;
    protected _eyeRelData: Float32Array;
    protected _colorIntensityData: Float32Array;
    protected _paramsData: Float32Array;
    protected _layerData: Int32Array;
    protected _updateActiveProjectors: boolean;

    protected _depthArrayTexture: WebGLTexture | null;
    /** Layer size of `_depthArrayTexture`. Determined by the first projector added. */
    protected _depthSize: number;
    protected _depthCapacity: number;
    protected _freeSlots: number[];

    protected _tmpInverse: Mat4;

    constructor(renderer: Renderer) {
        this._renderer = renderer;
        this._projectors = [];
        this._activeProjectors = [];

        this._viewProjData = new Float32Array(MAX_FORWARD_PROJECTORS * 16);
        this._invViewProjData = new Float32Array(MAX_FORWARD_PROJECTORS * 16);
        this._eyeRelData = new Float32Array(MAX_FORWARD_PROJECTORS * 3);
        this._colorIntensityData = new Float32Array(MAX_FORWARD_PROJECTORS * 4);
        this._paramsData = new Float32Array(MAX_FORWARD_PROJECTORS * 4);
        this._layerData = new Int32Array(MAX_FORWARD_PROJECTORS);
        this._updateActiveProjectors = true;

        this._depthArrayTexture = null;
        this._depthSize = 0;
        this._depthCapacity = 0;
        this._freeSlots = [];

        this._tmpInverse = new Mat4();
    }

    /** Total active projectors count (used by consumers to choose _proj / _noproj programs). */
    public get activeCount(): number {
        return this._getActiveProjectors().length;
    }

    /** Manager-owned TEXTURE_2D_ARRAY containing depth maps for all projectors. */
    public get depthArrayTexture(): WebGLTexture | null {
        return this._depthArrayTexture;
    }

    /** Snapshot of currently active projectors (sorted by priority desc). */
    public get active(): Projector[] {
        return this._getActiveProjectors().slice();
    }

    public add(projector: Projector): number {
        if (projector._slot !== -1) return projector.id;

        if (this._freeSlots.length === 0 && this._depthCapacity >= MAX_PROJECTOR_LAYERS) {
            console.warn(`ProjectorManager.add(): max projector layers (${MAX_PROJECTOR_LAYERS}) reached`);
            return -1;
        }

        const framebuffer = projector.depthCamera.framebuffer;
        if (!framebuffer._fbo) {
            console.warn("ProjectorManager.add(): projector.depthCamera.framebuffer must be initialized before add()");
            return -1;
        }

        const fbW = framebuffer.width;
        const fbH = framebuffer.height;
        if (fbW !== fbH) {
            console.warn(`ProjectorManager.add(): projector framebuffer must be square (${fbW}x${fbH})`);
            return -1;
        }

        if (!this._ensureDepthArrayTexture(fbW, this._depthCapacity || INITIAL_PROJECTOR_LAYERS)) return -1;
        if (this._freeSlots.length === 0 && !this._growDepthArrayTexture()) return -1;

        projector._slot = this._freeSlots.pop()!;
        projector._manager = this;

        if (!this._rebindFramebufferToLayer(projector)) {
            this._restoreFramebufferAttachment(projector);
            this._freeSlots.push(projector._slot);
            projector._slot = -1;
            projector._manager = null;
            return -1;
        }

        this._projectors.push(projector);
        this._updateActiveProjectors = true;
        return projector.id;
    }

    /**
     * Rebinds projector.depthCamera.framebuffer COLOR_ATTACHMENT0 from its own TEXTURE_2D
     * to (this._depthArrayTexture, projector._slot) so renders go directly into
     * the array layer without any per-frame copy. The original TEXTURE_2D stays
     * referenced inside framebuffer.textures[0] so framebuffer.destroy() can free it
     * normally — we never overwrite that slot with the shared array texture.
     */
    protected _rebindFramebufferToLayer(projector: Projector): boolean {
        const gl = this._renderer.handler.gl;
        if (!gl) return false;

        const fb = projector.depthCamera.framebuffer;
        if (!fb._fbo) return false;

        const status = fb.attachLayer(this._depthArrayTexture, projector._slot);

        if (status !== gl.FRAMEBUFFER_COMPLETE) {
            console.warn(`ProjectorManager._rebindFramebufferToLayer(): framebuffer incomplete after framebufferTextureLayer
                (status=${fb.statusToText(status)}, slot=${projector._slot}). Check float color-buffer support for R32F.`);
            return false;
        }

        return true;
    }

    /**
     * Restores projector.depthCamera.framebuffer COLOR_ATTACHMENT0 back to its original TEXTURE_2D
     * so subsequent depth renders no longer touch the freed array layer.
     */
    protected _restoreFramebufferAttachment(projector: Projector): void {
        const gl = this._renderer.handler.gl;
        if (!gl) return;

        const fb = projector.depthCamera.framebuffer;
        if (!fb._fbo) return;

        const orig = projector.depthTexture;
        gl.bindFramebuffer(gl.FRAMEBUFFER, fb._fbo);
        if (orig) {
            gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, orig, 0);
        } else {
            // No original recorded — detach the array layer.
            gl.framebufferTextureLayer(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, null, 0, 0);
        }
        gl.bindFramebuffer(gl.FRAMEBUFFER, null);
    }

    public update(projector: Projector): boolean {
        const index = this._projectors.findIndex((p) => p.id === projector.id);
        if (index === -1) return false;
        this._projectors[index] = projector;
        this._updateActiveProjectors = true;
        return true;
    }

    public getByDepthCamera(depthCamera: DepthCamera): Projector | null {
        for (let i = 0; i < this._projectors.length; i++) {
            if (this._projectors[i].depthCamera === depthCamera) {
                return this._projectors[i];
            }
        }

        return null;
    }

    public remove(projector: Projector): boolean {
        const index = this._projectors.findIndex((p) => p.id === projector.id);
        if (index === -1) return false;

        this._restoreFramebufferAttachment(projector);

        if (projector._slot !== -1) {
            this._freeSlots.push(projector._slot);
            projector._slot = -1;
            projector._manager = null;
        }

        this._projectors.splice(index, 1);
        this._updateActiveProjectors = true;
        return true;
    }

    public clear(): void {
        for (let i = 0; i < this._projectors.length; i++) {
            const p = this._projectors[i];
            this._restoreFramebufferAttachment(p);
            p._slot = -1;
            p._manager = null;
        }
        this._projectors.length = 0;
        this._activeProjectors.length = 0;
        this._updateActiveProjectors = false;

        this._freeSlots.length = 0;
        for (let i = this._depthCapacity - 1; i >= 0; i--) {
            this._freeSlots.push(i);
        }
    }

    public dispose(): void {
        this.clear();
        const gl = this._renderer.handler.gl;
        if (gl && this._depthArrayTexture) {
            gl.deleteTexture(this._depthArrayTexture);
        }
        this._depthArrayTexture = null;
        this._depthSize = 0;
        this._depthCapacity = 0;
        this._freeSlots.length = 0;
    }

    /**
     * Binds forward/WOIT projectors to the given shader program.
     * Uses top-K projectors by priority for current draw call.
     *
     * @returns Actual projector count uploaded (0..MAX_FORWARD_PROJECTORS).
     */
    public bindForward(
        program: ShaderProgram,
        textureUnitStart: number = DEFAULT_PROJECTOR_TEXTURE_UNIT_START
    ): number {
        const gl = this._renderer.handler.gl;
        if (!gl) return 0;

        const u = program.uniforms!;
        const active = this._getActiveProjectors();
        const total = active.length;

        if (total === 0) {
            gl.uniform1i(u.u_projectorCount!, 0);
            // Keep sampler2DArray on its dedicated unit even when projectors are disabled.
            // Prevents sampler type conflicts with sampler2D bound to texture unit 0.
            gl.uniform1i(u.u_projectorDepthArray!, textureUnitStart);
            return 0;
        }

        const size = total > MAX_FORWARD_PROJECTORS ? MAX_FORWARD_PROJECTORS : total;
        const activeCameraEye = this._renderer.activeCamera.eye;

        for (let i = 0; i < size; i++) {
            const pi = active[i];
            const camera = pi.depthCamera.camera;
            const mOffset = i * 16;
            const eOffset = i * 3;
            const vOffset = i * 4;

            const pvRTE = camera.getProjectionViewRTEMatrix();
            this._viewProjData.set(pvRTE, mOffset);

            this._tmpInverse.set(pvRTE).inverseTo(this._tmpInverse);
            this._invViewProjData.set(this._tmpInverse._m, mOffset);

            this._eyeRelData[eOffset] = camera.eye.x - activeCameraEye.x;
            this._eyeRelData[eOffset + 1] = camera.eye.y - activeCameraEye.y;
            this._eyeRelData[eOffset + 2] = camera.eye.z - activeCameraEye.z;

            const color = pi.color;
            this._colorIntensityData[vOffset] = color[0] ?? 1.0;
            this._colorIntensityData[vOffset + 1] = color[1] ?? 1.0;
            this._colorIntensityData[vOffset + 2] = color[2] ?? 1.0;
            this._colorIntensityData[vOffset + 3] = color[3] ?? 1.0;

            this._paramsData[vOffset] = pi.depthCamera.bias;
            this._paramsData[vOffset + 1] = pi.depthCamera.normalBias;
            this._paramsData[vOffset + 2] = pi.renderMode;
            this._paramsData[vOffset + 3] = pi.depthCamera.depthEpsilon;

            this._layerData[i] = pi._slot;
        }

        gl.uniform1i(u.u_projectorCount!, size);
        gl.uniform1iv(u.u_projectorLayer!, this._layerData);
        gl.uniformMatrix4fv(u.u_projectorViewProjRTE!, false, this._viewProjData);
        gl.uniform3fv(u.u_projectorEyeRel!, this._eyeRelData);
        gl.uniform4fv(u.u_projectorColor!, this._colorIntensityData);
        gl.uniform4fv(u.u_projectorParams!, this._paramsData);

        gl.activeTexture(gl.TEXTURE0 + textureUnitStart);
        gl.bindTexture(gl.TEXTURE_2D_ARRAY, this._depthArrayTexture);
        gl.uniform1i(u.u_projectorDepthArray!, textureUnitStart);
        gl.activeTexture(gl.TEXTURE0);

        return size;
    }

    /**
     * Binds exactly one projector for deferred frustum-geometry draw call.
     *
     * @param projectorIndex - Active projector index in priority-sorted array.
     * @returns 1 if projector was bound, 0 if index is out of range.
     */
    public bindDeferred(
        program: ShaderProgram,
        textureUnitStart: number = DEFAULT_PROJECTOR_TEXTURE_UNIT_START,
        projectorIndex: number = 0
    ): number {
        const gl = this._renderer.handler.gl;
        if (!gl) return 0;

        const u = program.uniforms!;
        const active = this._getActiveProjectors();
        const total = active.length;

        if (total === 0 || projectorIndex < 0 || projectorIndex >= total) {
            gl.uniform1i(u.u_projectorCount!, 0);
            gl.uniform1i(u.u_projectorDepthArray!, textureUnitStart);
            return 0;
        }

        const pi = active[projectorIndex];
        const camera = pi.depthCamera.camera;
        const activeCameraEye = this._renderer.activeCamera.eye;
        const pvRTE = camera.getProjectionViewRTEMatrix();

        this._tmpInverse.set(pvRTE).inverseTo(this._tmpInverse);

        const color = pi.color;

        gl.uniform1i(u.u_projectorCount, 1);
        gl.uniform1i(u.u_projectorLayer, pi._slot);
        gl.uniformMatrix4fv(u.u_projectorViewProjRTE, false, pvRTE);
        gl.uniform3f(
            u.u_projectorEyeRel,
            camera.eye.x - activeCameraEye.x,
            camera.eye.y - activeCameraEye.y,
            camera.eye.z - activeCameraEye.z
        );
        gl.uniform4f(u.u_projectorColor, color[0], color[1], color[2], color[3]);
        gl.uniform4f(
            u.u_projectorParams,
            pi.depthCamera.bias,
            pi.depthCamera.normalBias,
            pi.renderMode,
            pi.depthCamera.depthEpsilon
        );
        gl.uniformMatrix4fv(u.u_projectorInvViewProjRTE, false, this._tmpInverse._m);

        gl.activeTexture(gl.TEXTURE0 + textureUnitStart);
        gl.bindTexture(gl.TEXTURE_2D_ARRAY, this._depthArrayTexture);
        gl.uniform1i(u.u_projectorDepthArray!, textureUnitStart);
        gl.activeTexture(gl.TEXTURE0);

        return 1;
    }

    /**
     * Lazily allocates the manager-owned TEXTURE_2D_ARRAY at the size of the first
     * projector to be added. All subsequent projectors must use the same size.
     * Returns false if a size mismatch is detected.
     */
    protected _ensureDepthArrayTexture(size: number, capacity: number): boolean {
        if (this._depthArrayTexture) {
            if (this._depthSize !== size) {
                cons.logWrn(
                    `ProjectorManager: depth array texture size mismatch (have ${this._depthSize}, got ${size}). ` +
                        `All projectors must share the same framebuffer size.`
                );
                return false;
            }
            return true;
        }

        return this._createDepthArrayTexture(size, Math.min(capacity, MAX_PROJECTOR_LAYERS));
    }

    protected _createDepthArrayTexture(size: number, capacity: number): boolean {
        const gl = this._renderer.handler.gl as WebGL2RenderingContext;
        if (!gl) return false;

        const tex = this._renderer.handler.createEmptyTexture2DArrayExt(
            size,
            size,
            capacity,
            "NEAREST",
            "R32F",
            "CLAMP_TO_EDGE",
            1
        );
        if (!tex) return false;

        this._depthArrayTexture = tex;
        this._depthSize = size;
        this._depthCapacity = capacity;
        this._freeSlots.length = 0;
        for (let i = capacity - 1; i >= this._projectors.length; i--) {
            this._freeSlots.push(i);
        }
        return true;
    }

    protected _growDepthArrayTexture(): boolean {
        if (!this._depthArrayTexture || !this._depthSize || this._depthCapacity >= MAX_PROJECTOR_LAYERS) {
            return false;
        }

        const gl = this._renderer.handler.gl as WebGL2RenderingContext;
        if (!gl) return false;

        const oldTexture = this._depthArrayTexture;
        const oldCapacity = this._depthCapacity;
        const nextCapacity = Math.min(oldCapacity * 2, MAX_PROJECTOR_LAYERS);

        if (!this._createDepthArrayTexture(this._depthSize, nextCapacity)) {
            this._depthArrayTexture = oldTexture;
            this._depthCapacity = oldCapacity;
            return false;
        }

        for (let i = 0; i < this._projectors.length; i++) {
            if (!this._rebindFramebufferToLayer(this._projectors[i])) {
                gl.deleteTexture(this._depthArrayTexture);
                this._depthArrayTexture = oldTexture;
                this._depthCapacity = oldCapacity;
                this._freeSlots.length = 0;
                for (let j = 0; j < this._projectors.length; j++) {
                    this._rebindFramebufferToLayer(this._projectors[j]);
                }
                return false;
            }
        }

        gl.deleteTexture(oldTexture);

        this._freeSlots.length = 0;
        for (let i = nextCapacity - 1; i >= oldCapacity; i--) {
            this._freeSlots.push(i);
        }

        return true;
    }

    protected _collectActiveProjectors(): Projector[] {
        const active: Projector[] = [];

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

            active.push(projector);
        }

        active.sort((a, b) => b.priority - a.priority);

        return active;
    }

    protected _getActiveProjectors(): Projector[] {
        if (!this._updateActiveProjectors) return this._activeProjectors;

        this._activeProjectors = this._collectActiveProjectors();
        this._updateActiveProjectors = false;

        return this._activeProjectors;
    }
}