Source: engine/engine.js

import { WebGLContext }        from '../webgl-context.js';
import { Renderer }            from '../render/renderer.js';
import { Scene }               from '../scene/scene.js';
import { Camera }              from '../scene/camera.js';
import { PerspectiveCamera }   from '../scene/perspective-camera.js';
import { Mesh }                from '../scene/mesh.js';
import { BoxGeometry }         from '../geometry/box-geometry.js';
import { Material }            from '../material/material.js';
import { VertexColorMaterial } from '../material/vertex-color-material.js';

/**
 * Default camera vertical field of view, in radians.
 * Used when `EngineOptions.fieldOfViewRadians` is not provided.
 *
 * @type {number}
 */
const DEFAULT_FIELD_OF_VIEW_RADIANS = Math.PI / 4;

/**
 * Default near clipping plane distance.
 * Used when `EngineOptions.near` is not provided.
 *
 * @type {number}
 */
const DEFAULT_NEAR = 0.1;

/**
 * Default far clipping plane distance.
 * Used when `EngineOptions.far` is not provided.
 *
 * @type {number}
 */
const DEFAULT_FAR = 100.0;

/**
 * Default initial camera position on the Z axis.
 * Used when `EngineOptions.initialCameraZ` is not provided.
 *
 * @type {number}
 */
const DEFAULT_INITIAL_CAMERA_Z = 5.0;

/**
 * Converts milliseconds to seconds. Used to compute time values.
 *
 * @type {number}
 */
const MILLISECONDS_TO_SECONDS = 0.001;

/**
 * Default box size used by `Engine.createBoxMesh()`.
 * Used when `createBoxMesh options.size` is not provided.
 *
 * @type {number}
 */
const DEFAULT_BOX_SIZE = 1.0;

/**
 * Minimal allowed box size for `Engine.createBoxMesh()`.
 *
 * @type {number}
 */
const MIN_BOX_SIZE = 0;

/**
 * `requestAnimationFrame` id reset value.
 * Zero means - no frame scheduled.
 *
 * @type {number}
 */
const ENGINE_ANIMATION_FRAME_ID_RESET_VALUE = 0;

/**
 * Engine time fields reset/uninitialized value (seconds).
 * Used as a sentinel to detect the first frame.
 *
 * @type {number}
 */
const ENGINE_TIME_SECONDS_RESET_VALUE = 0;

/**
 * Initial camera aspect ratio used during `Engine` construction.
 * Real aspect ratio is updated on first render based on canvas size.
 *
 * @type {number}
 */
const INITIAL_CAMERA_ASPECT_RATIO = 1.0;

/**
 * Exclusive lower bound for numeric parameters.
 *
 * @type {number}
 */
const MIN_EXCLUSIVE_NUMBER = 0;

/**
 * Options used by `createEngine` and `Engine`.
 *
 * @typedef {Object} EngineOptions
 * @property {number}  [fieldOfViewRadians=Math.PI / 4] - Vertical field of view in radians.
 * @property {number}  [near = 0.1]                     - Near clipping plane.
 * @property {number}  [far  = 100.0]                   - Far clipping plane.
 * @property {number}  [initialCameraZ = 5.0]           - Initial camera position on the Z axis.
 * @property {boolean} [fitToWindow    = false]         - When true, the engine will render using `window.innerWidth/innerHeight` as the canvas size source.
 */

/**
 * Per-frame callback invoked by `Engine.start`.
 *
 * @callback EngineFrameCallback
 * @param {number} deltaTimeSeconds - Time passed since previous frame, in seconds.
 * @param {number} timeSeconds      - Time since engine start, in seconds.
 * @param {Engine} engine           - Current engine instance.
 */

/**
 * Options used by `Engine.createBoxMesh`.
 *
 * Ownership rule: if `material` is provided by the user, created `Mesh` must NOT own it.
 *
 * @typedef {Object} CreateBoxMeshOptions
 * @property {number}   [size = 1.0] - Edge length of the box.
 * @property {Material} [material]   - Optional material instance (shared).
 */

/**
 * High-level convenience wrapper, that bundles the most common building blocks.
 */
export class Engine {

    /**
     * WebGL context wrapper used by the engine.
     *
     * @type {WebGLContext}
     * @private
     */
    #contextWrapper;

    /**
     * Renderer instance used to draw the scene.
     *
     * @type {Renderer}
     * @private
     */
    #renderer;

    /**
     * Root scene node used by the engine.
     *
     * @type {Scene}
     * @private
     */
    #scene;

    /**
     * Active camera used by the engine renderer.
     *
     * @type {Camera}
     * @private
     */
    #camera;

    /**
     * When true, the engine uses the browser window size as the render target size source.
     * This is passed to the renderer via resize options on each frame.
     *
     * @type {boolean}
     * @private
     */
    #fitToWindow;

    /**
     * Indicates whether the `requestAnimationFrame` loop is currently running.
     *
     * @type {boolean}
     * @private
     */
    #isRunning = false;

    /**
     * Stores the active requestAnimationFrame id.
     * A reset value (usually `0`) indicates, that no frame is currently scheduled.
     *
     * @type {number}
     * @private
     */
    #requestAnimationFrameId = ENGINE_ANIMATION_FRAME_ID_RESET_VALUE;

    /**
     * Timestamp (in seconds) of the previous frame.
     * Used to compute deltaTimeSeconds.
     *
     * @type {number}
     * @private
     */
    #lastTimeSeconds = ENGINE_TIME_SECONDS_RESET_VALUE;

    /**
     * Start timestamp (in seconds) of the engine loop.
     * Used to compute `engineTimeSeconds`.
     *
     * @type {number}
     * @private
     */
    #startTimeSeconds = ENGINE_TIME_SECONDS_RESET_VALUE;

    /**
     * Optional per-frame callback invoked by `Engine.start(callback)`.
     *
     * @type {EngineFrameCallback | null}
     * @private
     */
    #frameCallback = null;

    /**
     * Cached resize options object passed to the renderer.
     * Reused between frames to avoid unnecessary allocations.
     *
     * @type {{ fitToWindow: boolean }}
     * @private
     */
    #resizeOptions = { fitToWindow: false };

    /**
     * @param {HTMLCanvasElement} canvas - Canvas used for rendering.
     * @param {EngineOptions} [options]  - Engine options.
     */
    constructor(canvas, options = {}) {
        if (!(canvas instanceof HTMLCanvasElement)) {
            throw new TypeError('Engine expects an HTMLCanvasElement.');
        }

        if (options === null || typeof options !== 'object' || Array.isArray(options)) {
            throw new TypeError('Engine expects an options object (plain object).');
        }

        const {
            fieldOfViewRadians = DEFAULT_FIELD_OF_VIEW_RADIANS,
            near               = DEFAULT_NEAR,
            far                = DEFAULT_FAR,
            initialCameraZ     = DEFAULT_INITIAL_CAMERA_Z,
            fitToWindow        = false
        } = options;

        if (typeof fieldOfViewRadians !== 'number' || fieldOfViewRadians <= MIN_EXCLUSIVE_NUMBER) {
            throw new RangeError('Engine option `fieldOfViewRadians` must be a positive number.');
        }

        if (typeof near   !== 'number'
            || typeof far !== 'number'
            || near <= MIN_EXCLUSIVE_NUMBER
            || far  <= MIN_EXCLUSIVE_NUMBER
            || near >= far) {
            throw new RangeError('Engine options `near` and `far` must be positive numbers and near < far.');
        }

        if (typeof initialCameraZ !== 'number') {
            throw new TypeError('Engine option `initialCameraZ` must be a number.');
        }

        if (typeof fitToWindow !== 'boolean') {
            throw new TypeError('Engine option `fitToWindow` must be a boolean.');
        }

        this.#fitToWindow       = fitToWindow;
        this.#contextWrapper    = new WebGLContext(canvas);
        this.#renderer          = new Renderer(this.#contextWrapper);
        this.#scene             = new Scene();
        this.#camera            = new PerspectiveCamera(fieldOfViewRadians, INITIAL_CAMERA_ASPECT_RATIO, near, far); // default camera type
        this.#camera.position.z = initialCameraZ;
    }

    /** @returns {WebGLContext} */
    get context() {
        return this.#contextWrapper;
    }

    /** @returns {WebGL2RenderingContext} */
    get webglRenderingContext() {
        return this.#contextWrapper.context;
    }

    /** @returns {Renderer} */
    get renderer() {
        return this.#renderer;
    }

    /** @returns {Scene} */
    get scene() {
        return this.#scene;
    }

    /** @returns {Camera} */
    get camera() {
        return this.#camera;
    }

    /**
     * Creates a box mesh using: `BoxGeometry` + `VertexColorMaterial` by default.
     *
     * Ownership rules: geometry is created internally => mesh owns geometry.
     * Material: if not provided then Mesh owns created `VertexColorMaterial`,
     * if provided then Mesh does NOT own the material (shared user resource).
     *
     * @param {CreateBoxMeshOptions} [options] - Box mesh options.
     * @returns {Mesh}
     */
    createBoxMesh(options = {}) {
        if (options === null || typeof options !== 'object' || Array.isArray(options)) {
            throw new TypeError('`Engine.createBoxMesh` expects an options object (plain object).');
        }

        const { size = DEFAULT_BOX_SIZE, material } = options;

        if (typeof size !== 'number' || size <= MIN_BOX_SIZE) {
            throw new RangeError('`Engine.createBoxMesh` option `size` must be a positive number.');
        }

        if (material !== undefined && !(material instanceof Material)) {
            throw new TypeError('`Engine.createBoxMesh` option `material` must be a `Material` instance.');
        }

        const geometry           = new BoxGeometry(this.webglRenderingContext, {size});
        const isUserMaterial     = material !== undefined;
        const usedMaterial       = isUserMaterial ? material : new VertexColorMaterial(this.webglRenderingContext);
        const meshOwnershipFlags = { ownsGeometry: true, ownsMaterial: !isUserMaterial };
        return new Mesh(geometry, usedMaterial, meshOwnershipFlags);
    }

    /**
     * Renders a single frame.
     */
    render() {
        this.#resizeOptions.fitToWindow = this.#fitToWindow;
        this.#renderer.render(this.#scene, this.#camera, this.#resizeOptions);
    }

    /**
     * Starts the `requestAnimationFrame` loop.
     *
     * @param {EngineFrameCallback} [frameCallback] - Optional per-frame callback.
     */
    start(frameCallback) {
        if (frameCallback !== undefined && typeof frameCallback !== 'function') {
            throw new TypeError('Engine.start expects a function callback or undefined.');
        }

        if (this.#isRunning) {
            return;
        }

        this.#isRunning               = true;
        this.#frameCallback           = frameCallback || null;
        this.#lastTimeSeconds         = ENGINE_TIME_SECONDS_RESET_VALUE;
        this.#startTimeSeconds        = ENGINE_TIME_SECONDS_RESET_VALUE;
        this.#requestAnimationFrameId = window.requestAnimationFrame((timeMs) => this.#renderFrame(timeMs));
    }

    /**
     * Stops the `requestAnimationFrame` loop.
     */
    stop() {
        if (!this.#isRunning) {
            return;
        }

        window.cancelAnimationFrame(this.#requestAnimationFrameId);
        this.#requestAnimationFrameId = ENGINE_ANIMATION_FRAME_ID_RESET_VALUE;
        this.#isRunning               = false;
        this.#frameCallback           = null;
    }

    /**
     * Sets the active camera used by the engine renderer.
     *
     * @param {Camera} camera - New active camera instance.
     */
    setCamera(camera) {
        if (!(camera instanceof Camera)) {
            throw new TypeError('`Engine.setCamera` expects a `Camera` instance (including the derived types).');
        }

        this.#camera = camera;
    }

    /**
     * @param {number} timeMs - `requestAnimationFrame` timestamp in milliseconds.
     * @private
     */
    #renderFrame(timeMs) {
        const timeSeconds = timeMs * MILLISECONDS_TO_SECONDS;

        if (this.#startTimeSeconds === ENGINE_TIME_SECONDS_RESET_VALUE) {
            this.#startTimeSeconds = timeSeconds;
            this.#lastTimeSeconds  = timeSeconds;
        }

        const engineTimeSeconds = timeSeconds - this.#startTimeSeconds;
        const deltaTimeSeconds  = timeSeconds - this.#lastTimeSeconds;
        this.#lastTimeSeconds   = timeSeconds;

        if (this.#frameCallback) {
            this.#frameCallback(deltaTimeSeconds, engineTimeSeconds, this);
        }

        if (!this.#isRunning) {
            return;
        }

        this.render();
        this.#requestAnimationFrameId = window.requestAnimationFrame((nextTimeMs) => this.#renderFrame(nextTimeMs));
    }
}

/**
 * Factory function for `Engine`.
 *
 * @param {HTMLCanvasElement} canvas - Canvas used for rendering.
 * @param {EngineOptions} [options]  - Engine options.
 * @returns {Engine}
 */
export function createEngine(canvas, options) {
    return new Engine(canvas, options);
}