Source: scene/third-person-camera.js

import { Camera }     from './camera.js';
import { CameraMath } from '../math/camera-math.js';

/**
 * Default camera field of view divisor used for computing FOV in radians.
 *
 * @type {number}
 */
const DEFAULT_FIELD_OF_VIEW_DIVISOR = 4;

/**
 * Default camera field of view in radians.
 *
 * @type {number}
 */
const DEFAULT_FIELD_OF_VIEW_RADIANS = Math.PI / DEFAULT_FIELD_OF_VIEW_DIVISOR;

/**
 * Default viewport aspect ratio.
 *
 * @type {number}
 */
const DEFAULT_ASPECT_RATIO = 1.0;

/**
 * Default near clipping plane distance.
 *
 * @type {number}
 */
const DEFAULT_NEAR = 0.1;

/**
 * Default far clipping plane distance.
 *
 * @type {number}
 */
const DEFAULT_FAR = 200.0;

/**
 * Minimum allowed aspect ratio.
 *
 * @type {number}
 */
const MINIMUM_ASPECT_RATIO = 0.0;

/**
 * Minimum allowed near clipping plane distance.
 *
 * @type {number}
 */
const MINIMUM_NEAR_CLIP_DISTANCE = 0.0;

/**
 * Element count for a 4x4 matrix stored in a flat array.
 *
 * @type {number}
 */
const MATRIX_4x4_ELEMENT_COUNT = 16;

/**
 * Third-person camera mode - normal (no bobbing).
 *
 * @type {string}
 */
const THIRD_PERSON_CAMERA_MODE_NORMAL = 'NORMAL';

/**
 * Third-person camera mode - bobbing (walking sway).
 *
 * @type {string}
 */
const THIRD_PERSON_CAMERA_MODE_BOBBING = 'BOBBING';

/**
 * Enum-like dictionary of supported third-person camera modes.
 *
 * @type {{ NORMAL: string, BOBBING: string }}
 */
const THIRD_PERSON_CAMERA_MODES = Object.freeze({
    NORMAL  : THIRD_PERSON_CAMERA_MODE_NORMAL,
    BOBBING : THIRD_PERSON_CAMERA_MODE_BOBBING
});

/**
 * Supported third-person camera mode values.
 *
 * @type {Set<string>}
 */
const THIRD_PERSON_CAMERA_MODE_SET = new Set(Object.values(THIRD_PERSON_CAMERA_MODES));

/**
 * Options used by {@link ThirdPersonCamera}.
 *
 * @typedef {Object} ThirdPersonCameraOptions
 * @property {number} [fieldOfViewRadians = Math.PI / 4] - Vertical field of view in radians.
 * @property {number} [aspectRatio = 1.0]                - Viewport aspect ratio (width / height).
 * @property {number} [near = 0.1]                       - Distance to the near clipping plane.
 * @property {number} [far = 200.0]                      - Distance to the far clipping plane.
 * @property {string} [mode = 'NORMAL']                  - Camera mode, see `ThirdPersonCamera.Modes`.
 */

/**
 * Perspective third-person camera with adjustable mode for camera bobbing.
 */
export class ThirdPersonCamera extends Camera {

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

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

    /**
     * Near clipping plane distance.
     *
     * @type {number}
     * @private
     */
    #near;

    /**
     * Far clipping plane distance.
     *
     * @type {number}
     * @private
     */
    #far;

    /**
     * Cached projection matrix buffer.
     * The buffer is reused between frames to avoid allocations.
     *
     * @type {Float32Array}
     * @private
     */
    #projectionMatrix;

    /**
     * When true, projection matrix must be recomputed.
     *
     * @type {boolean}
     * @private
     */
    #isProjectionMatrixDirty = true;

    /**
     * Current third-person camera mode.
     *
     * @type {string}
     * @private
     */
    #mode = THIRD_PERSON_CAMERA_MODE_NORMAL;

    /**
     * @param {ThirdPersonCameraOptions} [options = {}] - Camera options.
     */
    constructor(options = {}) {
        super();

        if (options === null || typeof options !== 'object' || Array.isArray(options)) {
            throw new TypeError('`ThirdPersonCamera` expects `options` as a plain object.');
        }

        const {
            fieldOfViewRadians = DEFAULT_FIELD_OF_VIEW_RADIANS,
            aspectRatio        = DEFAULT_ASPECT_RATIO,
            near               = DEFAULT_NEAR,
            far                = DEFAULT_FAR,
            mode               = THIRD_PERSON_CAMERA_MODE_NORMAL
        } = options;

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

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

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

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

        if (aspectRatio <= MINIMUM_ASPECT_RATIO) {
            throw new RangeError('`ThirdPersonCamera` expects `aspectRatio` to be a positive number.');
        }

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

        if (!THIRD_PERSON_CAMERA_MODE_SET.has(mode)) {
            throw new RangeError('`ThirdPersonCamera` expects `mode` to be a valid value from `ThirdPersonCamera.Modes`.');
        }

        this.#fieldOfViewRadians = fieldOfViewRadians;
        this.#aspectRatio        = aspectRatio;
        this.#near               = near;
        this.#far                = far;
        this.#projectionMatrix   = new Float32Array(MATRIX_4x4_ELEMENT_COUNT);
        this.#mode               = mode;
    }

    /**
     * Supported third-person camera modes.
     *
     * @returns {{ NORMAL: string, BOBBING: string }} - Supported mode labels.
     */
    static get Modes() {
        return THIRD_PERSON_CAMERA_MODES;
    }

    /**
     * Current camera mode.
     *
     * @returns {string}
     */
    get mode() {
        return this.#mode;
    }

    /**
     * Updates the camera mode.
     *
     * @param {string} mode - New camera mode.
     */
    setMode(mode) {
        if (!THIRD_PERSON_CAMERA_MODE_SET.has(mode)) {
            throw new RangeError('`ThirdPersonCamera.setMode` expects a valid mode from `ThirdPersonCamera.Modes`.');
        }

        this.#mode = mode;
    }

    /**
     * Updates the aspect ratio and marks projection cache as dirty.
     *
     * @param {number} aspectRatio - New viewport aspect ratio (canvas width divided by canvas height).
     */
    setAspectRatio(aspectRatio) {
        if (typeof aspectRatio !== 'number') {
            throw new TypeError('`ThirdPersonCamera.setAspectRatio` expects `aspectRatio` as a number.');
        }

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

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

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

    /**
     * Returns the projection matrix for this camera. The returned matrix is cached and reused between calls.
     *
     * @returns {Float32Array} - Cached projection matrix.
     */
    getProjectionMatrix() {
        if (this.#isProjectionMatrixDirty === true) {
            CameraMath.writePerspectiveMatrixTo(
                this.#projectionMatrix,
                this.#fieldOfViewRadians,
                this.#aspectRatio,
                this.#near,
                this.#far
            );

            this.#isProjectionMatrixDirty = false;
        }

        return this.#projectionMatrix;
    }
}