Source: render/renderer.js

import * as MathConstants             from '../constants/math.js';
import * as RendererConstants         from '../constants/renderer.js';
import * as RendererExceptionMessages from '../exception-messages/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';

/**
 * 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 by reusing matrices and 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       = this.#contextWrapper.context;
        this.#viewProjectionMatrix        = new Float32Array(MathConstants.MATH_LAYOUT.MATRIX_4X4_ELEMENT_COUNT);
        this.#finalMatrix                 = new Float32Array(MathConstants.MATH_LAYOUT.MATRIX_4X4_ELEMENT_COUNT);
        this.#worldMatrixInverse          = new Float32Array(MathConstants.MATH_LAYOUT.MATRIX_4X4_ELEMENT_COUNT);
        this.#worldInverseTransposeMatrix = new Float32Array(MathConstants.MATH_LAYOUT.MATRIX_4X4_ELEMENT_COUNT);
        this.#cameraPosition              = new Float32Array(MathConstants.MATH_LAYOUT.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.
     *
     * Only `Mesh` instances are rendered. Other `Object3D` nodes are skipped.
     *
     * @param {Object3D} visitedObject - Visited scene node.
     * @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 < RendererConstants.RENDERER_OPACITY.OPAQUE_THRESHOLD;

        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 >= RendererConstants.RENDERER_MATERIAL_APPLY_PARAM_COUNTS.WORLD_MATRIX;
        const wantsNormalMatrix   = applyParameterCount >= RendererConstants.RENDERER_MATERIAL_APPLY_PARAM_COUNTS.WORLD_INVERSE_TRANSPOSE;
        const wantsCameraPosition = applyParameterCount >= RendererConstants.RENDERER_MATERIAL_APPLY_PARAM_COUNTS.CAMERA_POSITION;

        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),
            RendererConstants.RENDERER_DRAW.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 = RendererConstants.RENDERER_TRAVERSAL.STACK_EMPTY_LENGTH;
        stack.push(scene);

        while (
            stack.length > RendererConstants.RENDERER_TRAVERSAL.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 = RendererConstants.RENDERER_TRAVERSAL.CHILD_LOOP_START_INDEX;
                index < children.length;
                index += RendererConstants.RENDERER_TRAVERSAL.CHILD_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(RendererExceptionMessages.RENDERER_EXCEPTION_MESSAGES.UNKNOWN_PRIMITIVE);
    }
}