Source: scene/camera.js

import * as MathConstants from '../constants/math.js';
import { Object3D }       from './object3d.js';

/**
 * Base camera class. Responsibilities:
 * - Caches view matrix (inverse of local TRS transform).
 * - Defines a common camera contract for derived camera types.
 */
export class Camera extends Object3D {

    /**
     * Cached view matrix buffer. Reused between frames to avoid allocations.
     *
     * @type {Float32Array}
     * @private
     */
    #viewMatrix;

    /**
     * Cached local `position X` component used to detect transform changes.
     *
     * @type {number}
     * @private
     */
    #cachedPositionX = Number.NaN;

    /**
     * Cached local `position Y` component used to detect transform changes.
     *
     * @type {number}
     * @private
     */
    #cachedPositionY = Number.NaN;

    /**
     * Cached local `position Z` component used to detect transform changes.
     *
     * @type {number}
     * @private
     */
    #cachedPositionZ = Number.NaN;

    /**
     * Cached local `rotation X` component (radians) used to detect transform changes.
     *
     * @type {number}
     * @private
     */
    #cachedRotationX = Number.NaN;

    /**
     * Cached local `rotation Y` component (radians) used to detect transform changes.
     *
     * @type {number}
     * @private
     */
    #cachedRotationY = Number.NaN;

    /**
     * Cached local `rotation Z` component (radians) used to detect transform changes.
     *
     * @type {number}
     * @private
     */
    #cachedRotationZ = Number.NaN;

    /**
     * Cached local `scale X` component used to detect transform changes.
     *
     * @type {number}
     * @private
     */
    #cachedScaleX = Number.NaN;

    /**
     * Cached local `scale Y` component used to detect transform changes.
     *
     * @type {number}
     * @private
     */
    #cachedScaleY = Number.NaN;

    /**
     * Cached local `scale Z` component used to detect transform changes.
     *
     * @type {number}
     * @private
     */
    #cachedScaleZ = Number.NaN;

    constructor() {
        super();
        this.#viewMatrix = new Float32Array(MathConstants.MATH_LAYOUT.MATRIX_4X4_ELEMENT_COUNT);
    }

    /**
     * Returns the view matrix (inverse of camera local TRS transform).
     * The returned matrix is cached and reused between calls.
     *
     * @returns {Float32Array} - Cached view matrix.
     */
    getViewMatrix() {
        this.#updateViewMatrixIfRequired();
        return this.#viewMatrix;
    }

    /**
     * Returns the projection matrix for this camera.
     * Derived camera classes must implement this method.
     *
     * @throws {Error} Always throws in the base class.
     * @returns {Float32Array} - Projection matrix.
     */
    getProjectionMatrix() {
        throw new Error('`Camera.getProjectionMatrix` must be implemented in a derived camera class.');
    }

    /**
     * Updates the camera aspect ratio (width/height).
     * Base camera class does not define, how aspect ratio affects the projection.
     *
     * @param {number} aspectRatio - New viewport aspect ratio.
     */
    setAspectRatio(aspectRatio) {
        if (typeof aspectRatio !== 'number') {
            throw new TypeError('`Camera.setAspectRatio` expects `aspectRatio` as a number.');
        }

        throw new Error('`Camera.setAspectRatio` must be implemented in a derived camera class.');
    }

    /**
     * Recomputes the view matrix only, when local transform changes since the last call.
     *
     * @private
     */
    #updateViewMatrixIfRequired() {
        const position    = this.position;
        const rotation    = this.rotation;
        const scale       = this.scale;
        const isViewDirty = this.#isTransformChanged(position, rotation, scale);

        if (isViewDirty === true) {
            this.#writeViewMatrixTo(this.#viewMatrix, position, rotation, scale);
            this.#cacheTransform(position, rotation, scale);
        }
    }

    /**
     * Writes a view matrix into an existing output matrix.
     *
     * A view matrix is the inverse of the camera transform.
     * It moves the world-space coordinates into the camera-relative view-space.
     *
     * 'Object3D' builds local transforms in this order:
     *
     * local = T * (Rz * Ry * Rx) * S
     *
     * Therefore the inverse view transform is applied in reverse order:
     *
     * view = inv(S) * inv(R) * inv(T)
     *
     * The inverse of the rotation block is its transpose, because the rotation
     * matrix is orthonormal. The inverse scale is applied to the rows of that
     * transposed rotation block, forming matrix:
     *
     * A = inv(S) * R^T
     *
     * Camera translation is then inverted as '-A * position', because the world must be
     * shifted by the opposite camera position after the inverse rotation/scale basis is known.
     *
     * Scale components must be non-zero. A zero scale would have no reciprocal,
     * so the camera transform could not be inverted into a valid view matrix.
     *
     * The method writes the matrix into 'out' and returns that same 'out' buffer.
     *
     * @private
     * @param {Float32Array} out      - Output 4x4 matrix (length 16), that will receive the view matrix.
     * @param {Vector3}      position - Camera position.
     * @param {Vector3}      rotation - Camera rotation in radians.
     * @param {Vector3}      scale    - Camera scale (must be non-zero on all axes).
     * @returns {Float32Array}        - The output matrix (out).
     */
    #writeViewMatrixTo(out, position, rotation, scale) {
        if (scale.x === MathConstants.MATH_MATRIX_VALUES.ZERO
            || scale.y === MathConstants.MATH_MATRIX_VALUES.ZERO
            || scale.z === MathConstants.MATH_MATRIX_VALUES.ZERO) {
            throw new RangeError('Private method `Camera.#writeViewMatrixTo` cannot invert a zero scale.');
        }

        const positionX = position.x;
        const positionY = position.y;
        const positionZ = position.z;

        const rotationX = rotation.x;
        const rotationY = rotation.y;
        const rotationZ = rotation.z;

        const inverseScaleX = MathConstants.MATH_VIEW_MATRIX.SCALE_INVERSE_NUMERATOR / scale.x;
        const inverseScaleY = MathConstants.MATH_VIEW_MATRIX.SCALE_INVERSE_NUMERATOR / scale.y;
        const inverseScaleZ = MathConstants.MATH_VIEW_MATRIX.SCALE_INVERSE_NUMERATOR / scale.z;

        const cosX = Math.cos(rotationX);
        const sinX = Math.sin(rotationX);
        const cosY = Math.cos(rotationY);
        const sinY = Math.sin(rotationY);
        const cosZ = Math.cos(rotationZ);
        const sinZ = Math.sin(rotationZ);

        // Rotation matrix, R = Rz * Ry * Rx
        const rot00 = cosZ * cosY;
        const rot01 = (cosZ * sinY * sinX) - (sinZ * cosX);
        const rot02 = (cosZ * sinY * cosX) + (sinZ * sinX);

        const rot10 = sinZ * cosY;
        const rot11 = (sinZ * sinY * sinX) + (cosZ * cosX);
        const rot12 = (sinZ * sinY * cosX) - (cosZ * sinX);

        const rot20 = -sinY;
        const rot21 = cosY * sinX;
        const rot22 = cosY * cosX;

        // view = invS * R^T * invT
        // A    = invS * R^T (invS scales rows of R^T)
        const a00 = rot00 * inverseScaleX;
        const a01 = rot10 * inverseScaleX;
        const a02 = rot20 * inverseScaleX;

        const a10 = rot01 * inverseScaleY;
        const a11 = rot11 * inverseScaleY;
        const a12 = rot21 * inverseScaleY;

        const a20 = rot02 * inverseScaleZ;
        const a21 = rot12 * inverseScaleZ;
        const a22 = rot22 * inverseScaleZ;

        // t' = -A * position
        const translateX = -(a00 * positionX + a01 * positionY + a02 * positionZ);
        const translateY = -(a10 * positionX + a11 * positionY + a12 * positionZ);
        const translateZ = -(a20 * positionX + a21 * positionY + a22 * positionZ);

        out[0]  = a00;
        out[1]  = a10;
        out[2]  = a20;
        out[3]  = MathConstants.MATH_MATRIX_VALUES.ZERO;

        out[4]  = a01;
        out[5]  = a11;
        out[6]  = a21;
        out[7]  = MathConstants.MATH_MATRIX_VALUES.ZERO;

        out[8]  = a02;
        out[9]  = a12;
        out[10] = a22;
        out[11] = MathConstants.MATH_MATRIX_VALUES.ZERO;
        out[12] = translateX;
        out[13] = translateY;
        out[14] = translateZ;
        out[15] = MathConstants.MATH_MATRIX_VALUES.UNIT;

        return out;
    }

    /**
     * Checks whether the local transform has changed, since the last cached snapshot.
     *
     * @param {Object} position - Camera position vector.
     * @param {Object} rotation - Camera rotation vector in radians.
     * @param {Object} scale    - Camera scale vector.
     * @returns {boolean}       - True, when local transform differs from cached snapshot.
     * @private
     */
    #isTransformChanged(position, rotation, scale) {
        if (this.#isPositionChanged(position) === true) {
            return true;
        }

        if (this.#isRotationChanged(rotation) === true) {
            return true;
        }

        if (this.#isScaleChanged(scale) === true) {
            return true;
        }

        return false;
    }

    /* eslint-disable indent */

    /**
     * @param {Object} position - Position vector.
     * @returns {boolean}       - True, when local position differs from the cached snapshot.
     * @private
     */
    #isPositionChanged(position) {
        return (
               position.x !== this.#cachedPositionX
            || position.y !== this.#cachedPositionY
            || position.z !== this.#cachedPositionZ
        );
    }

    /**
     * @param {Object} rotation - Rotation vector.
     * @returns {boolean}       - True, when local rotation differs from the cached snapshot.
     * @private
     */
    #isRotationChanged(rotation) {
        return (
               rotation.x !== this.#cachedRotationX
            || rotation.y !== this.#cachedRotationY
            || rotation.z !== this.#cachedRotationZ
        );
    }

    /**
     * @param {Object} scale - Scale vector.
     * @returns {boolean}    - True, when local scale differs from the cached snapshot.
     * @private
     */
    #isScaleChanged(scale) {
        return (
               scale.x !== this.#cachedScaleX
            || scale.y !== this.#cachedScaleY
            || scale.z !== this.#cachedScaleZ
        );
    }

    /* eslint-enable indent */

    /**
     * Stores the current local transform components as a cached snapshot for future comparisons.
     *
     * @param {Object} position - Position vector.
     * @param {Object} rotation - Rotation vector.
     * @param {Object} scale    - Scale vector.
     * @private
     */
    #cacheTransform(position, rotation, scale) {
        this.#cachedPositionX = position.x;
        this.#cachedPositionY = position.y;
        this.#cachedPositionZ = position.z;

        this.#cachedRotationX = rotation.x;
        this.#cachedRotationY = rotation.y;
        this.#cachedRotationZ = rotation.z;

        this.#cachedScaleX = scale.x;
        this.#cachedScaleY = scale.y;
        this.#cachedScaleZ = scale.z;
    }
}