Reference Source

src/renderBase.js

import OrbitControls from "./lib/OrbitControls";
import { SSAARenderPass, OBJExporter, GLTFExporter, PLYExporter } from "threejs-ext";
import EffectComposer, { ShaderPass, CopyShader } from "@johh/three-effectcomposer";
import * as THREE from "three";
import OnScreen from "onscreen";
import * as $ from "jquery";
import { trimCanvas, DEFAULT_ROOT } from "./functions";

/**
 * @property {boolean} showAxes                 Debugging - Show the scene's axes
 * @property {boolean} showOutlines             Debugging - Show bounding boxes
 * @property {boolean} showGrid                 Debugging - Show coordinate grid
 *
 * @property {object} controls                  Controls settings
 * @property {boolean} [controls.enabled=true]  Toggle controls
 * @property {boolean} [controls.zoom=true]     Toggle zoom
 * @property {boolean} [controls.rotate=true]   Toggle rotation
 * @property {boolean} [controls.pan=true]      Toggle panning
 *
 * @property {object} camera                    Camera settings
 * @property {string} [camera.type=perspective] Camera type
 * @property {number} camera.x                  Camera X-position
 * @property {number} camera.y                  Camera Y-Position
 * @property {number} camera.z                  Camera Z-Position
 * @property {number[]} camera.target           [x,y,z] array where the camera should look
 */
export const defaultOptions = {
    showAxes: false,
    showGrid: false,
    autoResize: false,
    controls: {
        enabled: true,
        zoom: true,
        rotate: true,
        pan: true,
        keys: true
    },
    camera: {
        type: "perspective",
        x: 20,
        y: 35,
        z: 20,
        target: [0, 0, 0]
    },
    canvas: {
        width: undefined,
        height: undefined
    },
    pauseHidden: true,
    forceContext: false,
    sendStats: true
};

/**
 * Base class for all Renders
 */
export default class Render {

    /**
     * @param {object} options The options for this renderer, see {@link defaultOptions}
     * @param {object} defOptions Additional default options, provided by the individual renders
     * @param {HTMLElement} [element=document.body] DOM Element to attach the renderer to - defaults to document.body
     * @constructor
     */
    constructor(options, defOptions, element) {
        /**
         * DOM Element to attach the renderer to
         * @type {HTMLElement}
         */
        this.element = element || document.body;
        /**
         * Combined options
         * @type {{} & defaultOptions & defOptions & options}
         */
        this.options = Object.assign({}, defaultOptions, defOptions, options);

        this.renderType = "_Base_";
    }

    /**
     * @param {boolean} [trim=false] whether to trim transparent pixels
     * @param {string} [mime=image/png] mime type of the image
     * @returns {string} The content of the renderer's canvas as a Base64 encoded image
     */
    toImage(trim, mime) {
        if (!mime) mime = "image/png";
        if (this._renderer) {
            if (!trim) {
                return this._renderer.domElement.toDataURL(mime);
            } else {
                // Clone the canvas onto a 2d context, so we can trim it properly
                let newCanvas = document.createElement('canvas');
                let context = newCanvas.getContext('2d');

                newCanvas.width = this._renderer.domElement.width;
                newCanvas.height = this._renderer.domElement.height;

                context.drawImage(this._renderer.domElement, 0, 0);

                let trimmed = trimCanvas(newCanvas);
                return trimmed.toDataURL(mime);
            }
        }
    };

    /**
     * Export the current scene content in the .obj format (only geometries, no textures)
     * @returns {string} the .obj file content
     */
    toObj() {
        if (this._scene) {
            let exporter = new OBJExporter();
            return exporter.parse(this._scene);
        }
    }

    /**
     * Export the current scene content in the .gltf format (geometries + textures)
     * @returns {Promise<any>} a promise which resolves with the .gltf file content
     */
    toGLTF(exportOptions) {
        return new Promise((resolve, reject) => {
            if (this._scene) {
                let exporter = new GLTFExporter();
                exporter.parse(this._scene, (gltf) => {
                    resolve(gltf);
                }, exportOptions)
            } else {
                reject();
            }
        })
    }

    toPLY(exportOptions) {
        if (this._scene) {
            let exporter = new PLYExporter();
            return exporter.parse(this._scene, exportOptions);
        }
    }

    /**
     * Initializes the scene
     * @param renderCb
     * @param doNotAnimate
     * @protected
     */
    initScene(renderCb, doNotAnimate) {
        let renderObj = this;

        console.log(" ");
        console.log('%c       ', 'font-size: 100px; background: url(https://minerender.org/img/minerender.svg) no-repeat;');
        console.log("MineRender/" + (renderObj.renderType || renderObj.constructor.name) + "/" + VERSION);
        console.log((PRODUCTION ? "PRODUCTION" : "DEVELOPMENT") + " build");
        console.log("Built @ " + BUILD_DATE);
        console.log(" ");

        if (renderObj.options.sendStats) {
            // Send stats

            let iframe = false;
            try {
                iframe = window.self !== window.top;
            } catch (e) {
                return true;
            }
            let hostname;
            try{
                hostname = new URL(iframe ? document.referrer : window.location).hostname;
            }catch (e) {
                console.warn("Failed to get hostname");
            }

            $.post({
                url: "https://minerender.org/stats.php",
                data: {
                    action: "init",
                    type: renderObj.renderType,
                    host: hostname,
                    source: (iframe ? "iframe" : "javascript")
                }
            });
        }

        // Scene INIT
        let scene = new THREE.Scene();
        renderObj._scene = scene;
        let camera;
        if (renderObj.options.camera.type === "orthographic") {
            camera = new THREE.OrthographicCamera((renderObj.options.canvas.width || window.innerWidth) / -2, (renderObj.options.canvas.width || window.innerWidth) / 2, (renderObj.options.canvas.height || window.innerHeight) / 2, (renderObj.options.canvas.height || window.innerHeight) / -2, 1, 1000);
        } else {
            camera = new THREE.PerspectiveCamera(75, (renderObj.options.canvas.width || window.innerWidth) / (renderObj.options.canvas.height || window.innerHeight), 5, 1000);
        }
        renderObj._camera = camera;

        if (renderObj.options.camera.zoom) {
            camera.zoom = renderObj.options.camera.zoom;
        }

        let renderer = new THREE.WebGLRenderer({alpha: true, antialias: true, preserveDrawingBuffer: true});
        renderObj._renderer = renderer;
        renderer.setSize((renderObj.options.canvas.width || window.innerWidth), (renderObj.options.canvas.height || window.innerHeight));
        renderer.setClearColor(0x000000, 0);
        renderer.setPixelRatio(window.devicePixelRatio);
        renderer.shadowMap.enabled = true;
        renderer.shadowMap.type = THREE.PCFSoftShadowMap;
        renderObj.element.appendChild(renderObj._canvas = renderer.domElement);

        let composer = new EffectComposer(renderer);
        composer.setSize((renderObj.options.canvas.width || window.innerWidth), (renderObj.options.canvas.height || window.innerHeight));
        renderObj._composer = composer;
        let ssaaRenderPass = new SSAARenderPass(scene, camera);
        ssaaRenderPass.unbiased = true;
        composer.addPass(ssaaRenderPass);
        // let renderPass = new RenderPass(scene, camera);
        // renderPass.enabled = false;
        // composer.addPass(renderPass);
        let copyPass = new ShaderPass(CopyShader);
        copyPass.renderToScreen = true;
        composer.addPass(copyPass);

        if (renderObj.options.autoResize) {
            window.addEventListener("resize", function () {
                let width = (renderObj.element && renderObj.element !== document.body) ? renderObj.element.offsetWidth : window.innerWidth;
                let height = (renderObj.element && renderObj.element !== document.body) ? renderObj.element.offsetHeight : window.innerHeight;

                renderObj._resize(width, height);
            });
        }
        renderObj._resize = function (width, height) {
            if (renderObj.options.camera.type === "orthographic") {
                camera.left = width / -2;
                camera.right = width / 2;
                camera.top = height / 2;
                camera.bottom = height / -2;
            } else {
                camera.aspect = width / height;
            }
            camera.updateProjectionMatrix();

            renderer.setSize(width, height);
            composer.setSize(width, height);
        };

        // Helpers
        if (renderObj.options.showAxes) {
            scene.add(new THREE.AxesHelper(50));
        }
        if (renderObj.options.showGrid) {
            scene.add(new THREE.GridHelper(100, 100));
        }

        let light = new THREE.AmbientLight(0xFFFFFF); // soft white light
        scene.add(light);

        // Init controls
        let controls = new OrbitControls(camera, renderer.domElement);
        renderObj._controls = controls;
        controls.enableZoom = renderObj.options.controls.zoom;
        controls.enableRotate = renderObj.options.controls.rotate;
        controls.enablePan = renderObj.options.controls.pan;
        controls.enableKeys = renderObj.options.controls.keys;
        controls.target.set(renderObj.options.camera.target[0], renderObj.options.camera.target[1], renderObj.options.camera.target[2]);

        // Set camera location & target
        camera.position.x = renderObj.options.camera.x;
        camera.position.y = renderObj.options.camera.y;
        camera.position.z = renderObj.options.camera.z;
        camera.lookAt(new THREE.Vector3(renderObj.options.camera.target[0], renderObj.options.camera.target[1], renderObj.options.camera.target[2]));

        // Do the render!
        let animate = function () {
            renderObj._animId = requestAnimationFrame(animate);

            if (renderObj.onScreen) {
                if (typeof renderCb === "function") renderCb();

                composer.render();
            }
        };
        renderObj._animate = animate;

        if (!doNotAnimate) {
            animate();
        }

        renderObj.onScreen = true;// default to true, in case the checking is disabled
        let id = "minerender-canvas-" + renderObj._scene.uuid + "-" + Date.now();
        renderObj._canvas.id = id;
        if (renderObj.options.pauseHidden) {
            renderObj.onScreen = false;// set to false if the check is enabled
            let os = new OnScreen();

            os.on("enter", "#" + id, (element, event) => {
                renderObj.onScreen = true;
                if (renderObj.options.forceContext) {
                    renderObj._renderer.forceContextRestore();
                }
            })
            os.on("leave", "#" + id, (element, event) => {
                renderObj.onScreen = false;
                if (renderObj.options.forceContext) {
                    renderObj._renderer.forceContextLoss();
                }
            });
        }
    };

    /**
     * Adds an object to the scene & sets userData.renderType to this renderer's type
     * @param toAdd object to add
     */
    addToScene(toAdd) {
        let renderObj = this;
        if (renderObj._scene && toAdd) {
            toAdd.userData.renderType = renderObj.renderType;
            renderObj._scene.add(toAdd);
        }
    }

    /**
     * Clears the scene
     * @param onlySelfType whether to remove only objects whose type is equal to this renderer's type (useful for combined render)
     * @param filterFn Filter function to check which children of the scene to remove
     */
    clearScene(onlySelfType, filterFn) {
        if (onlySelfType || filterFn) {
            for (let i = this._scene.children.length - 1; i >= 0; i--) {
                let child = this._scene.children[i];
                if (filterFn) {
                    let shouldKeep = filterFn(child);
                    if (shouldKeep) {
                        continue;
                    }
                }
                if (onlySelfType) {
                    if (child.userData.renderType !== this.renderType) {
                        continue;
                    }
                }
                deepDisposeMesh(child, true);
                this._scene.remove(child);
            }
        } else {
            while (this._scene.children.length > 0) {
                this._scene.remove(this._scene.children[0]);
            }
        }
    };

    dispose() {
        cancelAnimationFrame(this._animId);

        this.clearScene();

        this._canvas.remove();
        let el = this.element;
        while (el.firstChild) {
            el.removeChild(el.firstChild);
        }
    };

}

// https://stackoverflow.com/questions/27217388/use-multiple-materials-for-merged-geometries-in-three-js/44485364#44485364
export function deepDisposeMesh(obj, removeChildren) {
    if (!obj) return;
    if (obj.geometry && obj.geometry.dispose) obj.geometry.dispose();
    if (obj.material && obj.material.dispose) obj.material.dispose();
    if (obj.texture && obj.texture.dispose) obj.texture.dispose();
    if (obj.children) {
        let children = obj.children;
        for (let i = 0; i < children.length; i++) {
            deepDisposeMesh(children[i], removeChildren);
        }

        if (removeChildren) {
            for (let i = obj.children.length - 1; i >= 0; i--) {
                obj.remove(children[i]);
            }
        }
    }
}

export function mergeMeshes__(meshes, toBufferGeometry) {
    let finalGeometry,
        materials = [],
        mergedGeometry = new THREE.Geometry(),
        mergedMesh;

    meshes.forEach(function (mesh, index) {
        mesh.updateMatrix();
        mesh.geometry.faces.forEach(function (face) {
            face.materialIndex = 0;
        });
        mergedGeometry.merge(mesh.geometry, mesh.matrix, index);
        materials.push(mesh.material);
    });

    mergedGeometry.groupsNeedUpdate = true;

    if (toBufferGeometry) {
        finalGeometry = new THREE.BufferGeometry().fromGeometry(mergedGeometry);
    } else {
        finalGeometry = mergedGeometry;
    }

    mergedMesh = new THREE.Mesh(finalGeometry, materials);
    mergedMesh.geometry.computeFaceNormals();
    mergedMesh.geometry.computeVertexNormals();

    return mergedMesh;

}

export function mergeCubeMeshes(cubes, toBuffer) {
    cubes = cubes.filter(c => !!c);

    let mergedCubes = new THREE.Geometry();
    let mergedMaterials = [];
    for (let i = 0; i < cubes.length; i++) {
        let offset = i * Math.max(cubes[i].material.length, 1);
        mergedCubes.merge(cubes[i].geometry, cubes[i].matrix, offset);
        for (let j = 0; j < cubes[i].material.length; j++) {
            mergedMaterials.push(cubes[i].material[j]);
        }
        // for (let j = 0; j < cubes[i].geometry.faces.length; j++) {
        //     cubes[i].geometry.faces[j].materialIndex=offset-1+j;
        // }

        deepDisposeMesh(cubes[i], true);
    }
    mergedCubes.mergeVertices();
    return {
        geometry: toBuffer ? new THREE.BufferGeometry().fromGeometry(mergedCubes) : mergedCubes,
        materials: mergedMaterials
    };
}