Source: scene/perspective-camera.js

import * as MathConstants from '../constants/math.js';
import { Camera }         from './camera.js';
import { Matrix4 }        from '../math/matrix4.js';

/**
 * Perspective camera with a vertical field of view, viewport aspect ratio and near/far clipping planes.
 *
 * The projection matrix is computed on demand, cached and recomputed after the aspect ratio changes.
 */
export class PerspectiveCamera extends Camera {

    /**
     * Vertical field of view in radians.
     *
     * @type {number}
     * @private
     */
    #fieldOfViewRadians;

    /**
     * Viewport aspect ratio (width / height).
     *
     * @type {number}
     * @private
     */
    #aspectRatio;

    /**
     * Distance to the near clipping plane.
     *
     * @type {number}
     * @private
     */
    #near;

    /**
     * Distance to the far clipping plane.
     *
     * @type {number}
     * @private
     */
    #far;

    /**
     * Cached perspective projection matrix.
     *
     * Reused between calls to avoid allocations.
     *
     * @type {Float32Array}
     * @private
     */
    #projectionMatrix;

    /**
     * Indicates that the cached projection matrix must be rebuilt.
     *
     * @type {boolean}
     * @private
     */
    #isProjectionMatrixDirty = true;

    /**
     * Creates a perspective camera.
     *
     * @param {number} fieldOfViewRadians - Vertical field of view in radians.
     * @param {number} aspectRatio        - Viewport aspect ratio (width / height).
     * @param {number} near               - Distance to the near clipping plane, must be greater than 0.
     * @param {number} far                - Distance to the far clipping plane, must be greater than near.
     * @throws {TypeError}                - If any argument is not a finite number.
     * @throws {RangeError}               - If the field of view, aspect ratio or clipping distances are invalid.
     */
    constructor(fieldOfViewRadians, aspectRatio, near, far) {
        super();

        if (typeof fieldOfViewRadians !== 'number' || !Number.isFinite(fieldOfViewRadians)) {
            throw new TypeError('`PerspectiveCamera` expects `fieldOfViewRadians` as a number.');
        }

        if (typeof aspectRatio !== 'number' || !Number.isFinite(aspectRatio)) {
            throw new TypeError('`PerspectiveCamera` expects `aspectRatio` as a number.');
        }

        if (typeof near !== 'number' || !Number.isFinite(near)) {
            throw new TypeError('`PerspectiveCamera` expects `near` as a number.');
        }

        if (typeof far !== 'number' || !Number.isFinite(far)) {
            throw new TypeError('`PerspectiveCamera` expects `far` as a number.');
        }

        if (fieldOfViewRadians <= MathConstants.MATH_CAMERA_LIMITS.MINIMUM_FIELD_OF_VIEW_RADIANS ||
            fieldOfViewRadians >= MathConstants.MATH_CAMERA_LIMITS.MAXIMUM_FIELD_OF_VIEW_RADIANS) {
            throw new RangeError('`PerspectiveCamera` expects `0 < fieldOfViewRadians < Math.PI`.');
        }

        if (aspectRatio <= MathConstants.MATH_CAMERA_LIMITS.MINIMUM_ASPECT_RATIO) {
            throw new RangeError('`PerspectiveCamera` expects a positive `aspect ratio`.');
        }

        if (near <= MathConstants.MATH_CAMERA_LIMITS.MINIMUM_NEAR_CLIP_DISTANCE || far <= near) {
            throw new RangeError('`PerspectiveCamera` expects `0 < near < far`.');
        }

        this.#fieldOfViewRadians = fieldOfViewRadians;
        this.#aspectRatio        = aspectRatio;
        this.#near               = near;
        this.#far                = far;
        this.#projectionMatrix   = new Float32Array(MathConstants.MATH_LAYOUT.MATRIX_4X4_ELEMENT_COUNT);
    }

    /**
     * Updates the viewport aspect ratio and invalidates the projection cache, when the value changes.
     *
     * @param {number} aspectRatio - New viewport aspect ratio (canvas width divided by canvas height).
     * @returns {void}
     * @throws {TypeError}  - If the aspect ratio is not a finite number.
     * @throws {RangeError} - If the aspect ratio is not positive.
     */
    setAspectRatio(aspectRatio) {
        if (typeof aspectRatio !== 'number' || !Number.isFinite(aspectRatio)) {
            throw new TypeError('`PerspectiveCamera.setAspectRatio` expects `aspectRatio` as a number.');
        }

        if (aspectRatio <= MathConstants.MATH_CAMERA_LIMITS.MINIMUM_ASPECT_RATIO) {
            throw new RangeError('`PerspectiveCamera.setAspectRatio` expects a positive number.');
        }

        if (aspectRatio === this.#aspectRatio) {
            return;
        }

        this.#aspectRatio             = aspectRatio;
        this.#isProjectionMatrixDirty = true;
    }

    /**
     * Returns the cached projection matrix, rebuilding it when needed.
     *
     * @returns {Float32Array} - Cached projection matrix.
     */
    getProjectionMatrix() {
        if (this.#isProjectionMatrixDirty === true) {
            Matrix4.writePerspectiveTo(
                this.#projectionMatrix,
                this.#fieldOfViewRadians,
                this.#aspectRatio,
                this.#near,
                this.#far
            );

            this.#isProjectionMatrixDirty = false;
        }

        return this.#projectionMatrix;
    }
}