Source: render/renderer.js

import { Matrix4 }                  from '../math/matrix4.js';
import { Object3D }                 from '../scene/object3d.js';
import { Mesh }                     from '../scene/mesh.js';
import { Scene }                    from '../scene/scene.js';
import { Camera }                   from '../scene/camera.js';
import { WebGLContext }             from '../webgl-context.js';
import { DirectionalLightMaterial } from '../material/directional-light-material.js';
import { DirectionalLight }         from '../light/directional-light.js';
import { AmbientLight }             from '../light/ambient-light.js';
import {
    PRIMITIVE_TRIANGLES,
    PRIMITIVE_LINES,
    PRIMITIVE_LINE_STRIP,
    PRIMITIVE_LINE_LOOP,
    PRIMITIVE_POINTS
} from '../geometry/geometry.js';

/**
 * Byte offset passed to `webglRenderingContext.drawElements()` call.
 * Indices always start at the beginning of the currently bound index buffer.
 *
 * @type {number}
 */
const INDEX_BUFFER_OFFSET_BYTES = 0;

/**
 * Element count for a 4x4 matrix stored in a flat array.
 * Used for allocating reusable `Float32Array(16)` buffers.
 *
 * @type {number}
 */
const MATRIX_4x4_ELEMENT_COUNT = 16;

/**
 * Element count for a 3D vector stored in a flat array (x, y, z).
 * Used for allocating reusable `Float32Array(3)` buffers (e.g. camera position).
 *
 * @type {number}
 */
const VECTOR3_ELEMENT_COUNT = 3;

/**
 * Opacity value, that is considered fully opaque.
 *
 * @type {number}
 */
const OPAQUE_OPACITY = 1.0;

/**
 * `Material.apply()` parameter count, when it expects: finalMatrix, worldMatrix.
 *
 * @type {number}
 */
const MATERIAL_APPLY_WORLD_MATRIX_PARAM_COUNT = 2;

/**
 * `Material.apply()` parameter count, when it expects: finalMatrix, worldMatrix, worldInverseTransposeMatrix.
 *
 * @type {number}
 */
const MATERIAL_APPLY_WORLD_INVERSE_TRANSPOSE_PARAM_COUNT = 3;

/**
 * `Material.apply()` parameter count when it expects:
 * finalMatrix, worldMatrix, worldInverseTransposeMatrix, cameraPosition.
 *
 * @type {number}
 */
const MATERIAL_APPLY_CAMERA_POSITION_PARAM_COUNT = 4;

/**
 * Error message used, when geometry primitive is unknown.
 *
 * @type {string}
 */
const ERROR_UNKNOWN_PRIMITIVE = 'Renderer received an unknown geometry primitive.';

/**
 * Stack empty length, used for traversal loops.
 *
 * @type {number}
 */
const STACK_EMPTY_LENGTH = 0;

/**
 * Start index for the loop iterations.
 *
 * @type {number}
 */
const LOOP_START_INDEX = 0;

/**
 * Loop increment step.
 *
 * @type {number}
 */
const LOOP_INCREMENT = 1;

/**
 * Canvas resize options for `WebGLContext.resizeToDisplaySize()`.
 *
 * @typedef {Object} ResizeToDisplaySizeOptions
 * @property {boolean} [fitToWindow] - If true, resizes the canvas to match the window size.
 */

/**
 * High-level renderer, that draws a scene from the perspective of a camera.
 * Keeps per-frame allocations minimal (reuse the matrices, reuse the traversal callback).
 */
export class Renderer {

    /**
     * Wrapper around the underlying WebGL2 rendering context.
     * @type {WebGLContext}
     * @private
     */
    #contextWrapper;

    /**
     * Raw WebGL2 rendering context.
     * @type {WebGL2RenderingContext}
     * @private
     */
    #webglRenderingContext;

    /**
     * Reused buffer for the view-projection matrix.
     * @type {Float32Array}
     * @private
     */
    #viewProjectionMatrix;

    /**
     * Reused buffer for the per-mesh final matrix (viewProjection * world).
     * @type {Float32Array}
     * @private
     */
    #finalMatrix;

    /**
     * Reused buffer for per-mesh inverse world matrix (world ^ -1).
     * Only computed when the current material requires normal-matrix support.
     *
     * @type {Float32Array}
     * @private
     */
    #worldMatrixInverse;

    /**
     * Reused buffer for per-mesh world inverse transpose matrix ((world ^ -1) ^ T).
     * Used for correct normal transformation under non-uniform scale.
     * Only computed when the current material requires normal-matrix support.
     *
     * @type {Float32Array}
     * @private
     */
    #worldInverseTransposeMatrix;

    /**
     * Reused buffer for the camera position of the current frame.
     *
     * @type {Float32Array}
     * @private
     */
    #cameraPosition;

    /**
     * Reference to the view-projection matrix of the current frame.
     * This is a pointer to a reused `Float32Array`.
     *
     * @type {Float32Array}
     * @private
     */
    #frameViewProjectionMatrix;

    /**
     * Reference to the camera position of the current frame.
     * This is a pointer to a reused `Float32Array`.
     *
     * @type {Float32Array}
     * @private
     */
    #frameCameraPosition;

    /**
     * Cached traversal callback to avoid allocating an inline function every frame.
     *
     * @type {function(Object3D): void}
     * @private
     */
    #traverseCallback;

    /**
     * Reused stack for the light search traversal.
     *
     * @type {Object3D[]}
     * @private
     */
    #lightSearchStack;

    /**
     * Cached active directional light for the current frame.
     *
     * @type {DirectionalLight | null}
     * @private
     */
    #activeDirectionalLight = null;

    /**
     * Cached active ambient light for the current frame.
     *
     * @type {AmbientLight | null}
     * @private
     */
    #activeAmbientLight = null;

    /**
     * @param {WebGLContext} webglContext - Wrapper around the underlying WebGL2 rendering context.
     */
    constructor(webglContext) {
        if (!(webglContext instanceof WebGLContext)) {
            throw new TypeError('Renderer expects a WebGLContext instance.');
        }

        this.#contextWrapper              = webglContext;
        this.#webglRenderingContext       = webglContext.context;
        this.#viewProjectionMatrix        = new Float32Array(MATRIX_4x4_ELEMENT_COUNT);
        this.#finalMatrix                 = new Float32Array(MATRIX_4x4_ELEMENT_COUNT);
        this.#worldMatrixInverse          = new Float32Array(MATRIX_4x4_ELEMENT_COUNT);
        this.#worldInverseTransposeMatrix = new Float32Array(MATRIX_4x4_ELEMENT_COUNT);
        this.#cameraPosition              = new Float32Array(VECTOR3_ELEMENT_COUNT);
        this.#frameViewProjectionMatrix   = this.#viewProjectionMatrix;
        this.#frameCameraPosition         = this.#cameraPosition;
        this.#lightSearchStack            = [];

        // Allocate the traverse callback once (no per-frame function allocations):
        this.#traverseCallback = (x) => this.#renderVisitedObject(x);
    }

    /**
     * Renders the given scene from the point of view of the given camera.
     *
     * @param {Scene} scene                                - Scene graph containing all objects that should be rendered.
     * @param {Camera} camera                              - Camera defining view and projection used for rendering.
     * @param {ResizeToDisplaySizeOptions} [resizeOptions] - Optional canvas resize options.
     */
    render(scene, camera, resizeOptions) {
        if (!(scene instanceof Scene)) {
            throw new TypeError('`Renderer.render` expects a `Scene` instance.');
        }

        if (!(camera instanceof Camera)) {
            throw new TypeError('`Renderer.render` expects a `Camera` derived-instance.');
        }

        const renderingContext = this.#webglRenderingContext;
        this.#contextWrapper.resizeToDisplaySize(resizeOptions);
        this.#contextWrapper.clear();

        const canvas      = renderingContext.canvas;
        const aspectRatio = canvas.width / canvas.height;
        camera.setAspectRatio(aspectRatio);

        const projectionMatrix = camera.getProjectionMatrix();
        const viewMatrix       = camera.getViewMatrix();

        this.#frameViewProjectionMatrix = Matrix4.multiplyTo(
            this.#viewProjectionMatrix,
            projectionMatrix,
            viewMatrix
        );

        const cameraPosition      = camera.position;
        this.#cameraPosition[0]   = cameraPosition.x;
        this.#cameraPosition[1]   = cameraPosition.y;
        this.#cameraPosition[2]   = cameraPosition.z;
        this.#frameCameraPosition = this.#cameraPosition;

        scene.updateWorldMatrix({ parentWorldMatrix: null });
        this.#findActiveLights(scene);
        scene.traverse(this.#traverseCallback);
    }

    /**
     * Renders a single visited scene node during traversal.
     *
     * @param {Object3D} visitedObject - Visited scene node (only `Mesh` instances are rendered, they're childs from `Object3D`).
     * @private
     */
    #renderVisitedObject(visitedObject) {
        if (!(visitedObject instanceof Object3D)) {
            return;
        }

        if (!(visitedObject instanceof Mesh)) {
            return;
        }

        const mesh = visitedObject;

        if (mesh.isDisposed) {
            return;
        }

        const renderingContext = this.#webglRenderingContext;
        const geometry         = mesh.geometry;
        const material         = mesh.material;
        const worldMatrix      = mesh.worldMatrix;
        Matrix4.multiplyTo(
            this.#finalMatrix,
            this.#frameViewProjectionMatrix,
            worldMatrix
        );

        if (material instanceof DirectionalLightMaterial) {
            if (this.#activeDirectionalLight) {
                material.setLightDirection(this.#activeDirectionalLight.getDirection());
                material.setDirectionalStrength(this.#activeDirectionalLight.getStrength());
            }

            if (this.#activeAmbientLight) {
                material.setAmbientStrength(this.#activeAmbientLight.getStrength());
            }
        }

        material.use();
        const materialOpacity = material.opacity;
        const isTransparent   = materialOpacity < OPAQUE_OPACITY;

        if (isTransparent) {
            renderingContext.enable(renderingContext.BLEND);
            renderingContext.blendFunc(renderingContext.SRC_ALPHA, renderingContext.ONE_MINUS_SRC_ALPHA);
            renderingContext.depthMask(false);
        } else {
            renderingContext.disable(renderingContext.BLEND);
            renderingContext.depthMask(true);
        }

        const applyParameterCount = material.apply.length;
        const wantsWorldMatrix    = applyParameterCount >= MATERIAL_APPLY_WORLD_MATRIX_PARAM_COUNT;
        const wantsNormalMatrix   = applyParameterCount >= MATERIAL_APPLY_WORLD_INVERSE_TRANSPOSE_PARAM_COUNT;
        const wantsCameraPosition = applyParameterCount >= MATERIAL_APPLY_CAMERA_POSITION_PARAM_COUNT;

        if (!wantsWorldMatrix) {
            material.apply(this.#finalMatrix);
        } else if (!wantsNormalMatrix) {
            material.apply(this.#finalMatrix, worldMatrix);
        } else {
            Matrix4.invertTo(this.#worldMatrixInverse, worldMatrix);
            Matrix4.transposeTo(this.#worldInverseTransposeMatrix, this.#worldMatrixInverse);

            if (!wantsCameraPosition) {
                material.apply(this.#finalMatrix, worldMatrix, this.#worldInverseTransposeMatrix);
            } else {
                material.apply(this.#finalMatrix, worldMatrix, this.#worldInverseTransposeMatrix, this.#frameCameraPosition);
            }
        }

        geometry.bind();
        const isWireframeEnabled = material.isWireframeEnabled();
        geometry.bindIndexBuffer(isWireframeEnabled);

        const primitive  = geometry.getPrimitive(isWireframeEnabled);
        const mode       = resolvePrimitiveMode(renderingContext, primitive);
        const indexCount = geometry.getIndexCount(isWireframeEnabled);

        renderingContext.drawElements(
            mode,
            indexCount,
            geometry.getIndexComponentType(isWireframeEnabled),
            INDEX_BUFFER_OFFSET_BYTES
        );
    }

    /**
     * Finds the active lights for the current frame.
     *
     * @param {Scene} scene - Scene to search.
     * @returns {void}
     * @private
     */
    #findActiveLights(scene) {
        this.#activeDirectionalLight = null;
        this.#activeAmbientLight     = null;

        const stack  = this.#lightSearchStack;
        stack.length = STACK_EMPTY_LENGTH;
        stack.push(scene);

        while (stack.length > STACK_EMPTY_LENGTH && (this.#activeDirectionalLight === null || this.#activeAmbientLight === null)) {
            const node = stack.pop();

            if (this.#activeDirectionalLight === null
                && node instanceof DirectionalLight
                && node.isEnabled()) {
                this.#activeDirectionalLight = node;
            } else if (this.#activeAmbientLight === null
                && node instanceof AmbientLight
                && node.isEnabled()) {
                this.#activeAmbientLight = node;
            }

            const children = node.children;

            for (let index = LOOP_START_INDEX; index < children.length; index += LOOP_INCREMENT) {
                stack.push(children[index]);
            }
        }
    }
}

/**
 * Maps engine primitive constants to the WebGL draw modes.
 *
 * @param {WebGL2RenderingContext} renderingContext - WebGL2 rendering context.
 * @param {string} primitive                        - Geometry primitive name.
 * @returns {number}                                - WebGL draw mode constant.
 */
function resolvePrimitiveMode(renderingContext, primitive) {
    switch (primitive) {
        case PRIMITIVE_TRIANGLES:
            return renderingContext.TRIANGLES;
        case PRIMITIVE_LINES:
            return renderingContext.LINES;
        case PRIMITIVE_LINE_STRIP:
            return renderingContext.LINE_STRIP;
        case PRIMITIVE_LINE_LOOP:
            return renderingContext.LINE_LOOP;
        case PRIMITIVE_POINTS:
            return renderingContext.POINTS;
        default:
            throw new Error(ERROR_UNKNOWN_PRIMITIVE);
    }
}