src/components/Abstract3DWorld.js

goog.module('gep.components.Abstract3DWorld');

const {Component} = goog.require('clulib.cm');
const Completer = goog.require('clulib.async.Completer');

const ResizeProvider = goog.require('nbsrc.provider.ResizeProvider');
const {WorldProvider,WorldEventType} = goog.require('gep.provider.WorldProvider');

const {listen,removeAll,EventType} = goog.require('goog.events');
const {setStyle} = goog.require('goog.style');
const classlist = goog.require('goog.dom.classlist');

/**
 * Abstracte class to create and monitor the THREE renderer.
 * Component is used from {@Link https://www.npmjs.com/package/clulib}
 * @extends {Component}
 */
class Abstract3DWorld extends Component
{
    constructor()
    {
        super();

        /**
         * Reference to ResizeProvider for monitoring device size changes.
         * @type {ResizeProvider}
         * @protected
         */
        this.resizeProvider_ = ResizeProvider.getInstance();

        /**
         * Reference to the WorldProvider for monitoring actions, loading processes and status changes of the created THREE.Scene.
         * @type {WorldProvider}
         * @protected
         */
        this.worldProvider_ = WorldProvider.getInstance();

        /**
         * Instance of the window.requestAnimationFrame call
         * @type {?number}
         * @protected
         */
        this.requestFrameId_ = null;

        /**
         * Main WebGLRenderer
         * @type {THREE.WebGLRenderer}
         * @protected
         */
        this.renderer_ = null;

        /**
         * @type {THREE.Clock}
         * @protected
         */
        this.clock_ = null;

        /**
         * @type {THREE.Raycaster}
         * @protected
         */
        this.raycaster_ = null;

        /**
         * Stats object {@Link https://www.npmjs.com/package/stats-js}
         * @type {Stats}
         * @protected
         */
        this.stats_ = null;

        /**
         * Defines if the rendering is active
         * @type {boolean}
         * @protected
         */
        this.enabled_ = false;

        /**
         * Completer is a helper which returns a promise. Its solved if the creation of the 3D scene is completed.
         * @type {Completer}
         * @protected
         */
        this.creationCompleter_ = new Completer();

        /**
         * Determines whether the application should monitor the framerate to decide if it is necessary
         * to create a renderer with different settings with lower quality but better performance.
         * @type {boolean}
         * @protected
         */
        this.checkFrames_ = false;

        /**
         * Current frame count since rendering was started
         * @type {number}
         * @protected
         */
        this.frames_ = 0;

        /**
         * Current framerate
         * @type {number}
         * @protected
         */
        this.currentFps_ = 0;

        /**
         * Used as a value to check the framerate only in a certain time interval.
         * @type {number}
         * @protected
         */
        this.prevTime_ = 0;

        /**
         * Frame count of lower frames than the defined {@Link Abstract3DWorld#fpsThreshhold_}
         * @type {number}
         * @protected
         */
        this.lowFramerateCount_ = 0;

        /**
         * Limit of {@Link Abstract3DWorld#lowFramerateCount_} for the current {@Link Abstract3DWorld#lowSettingStep_} of lower setting check
         * @type {number|Array<number>}
         * @protected
         */
        this.lowSettingThreshhold_ = [60, 60];

        /**
         * Current step of the lower setting reduction
         * @type {number}
         * @protected
         */
        this.lowSettingStep_ = 0;

        /**
         * General limit or list of limits for the current {@Link Abstract3DWorld#lowSettingStep_} for the check if the current framerate is detected as a lower framerate.
         * @type {number|Array<number>}
         * @protected
         */
        this.fpsThreshhold_ = 20;

        /**
         * Defines if antialias should be activated for the WebGLRenderer
         * @type {boolean}
         * @protected
         */
        this.antialiasDisabled_ = false;

        /**
         * Defines if the rendering context should be rendered for retina in double resolution
         * @type {number}
         * @protected
         */
        this.maxPixelRatio_ = this.worldProvider_.isMobileDevice ? 1 : 2;

        /**
         * Defines whether the 3D scene should be rendered immediately after the component is initialized.
         * @type {boolean}
         * @protected
         */
        this.shouldBuildImmediately_ = true;

        /**
         * Current camera object which is used in the render process.
         * @type {THREE.Camera}
         * @protected
         */
        this.camera_ = null;
    }

    /**
     * Component is ready and had loaded all dependencies (inherit method waitFor and sub components).
     * @inheritDoc
     */
    onInit()
    {
        super.onInit();

        let supportsWebGL = this.checkWebGlSupport_();

        if(supportsWebGL)
        {
            if(window['STATS_MODE'] == 'on')
            {
                this.stats_ = new Stats();
                document.body.appendChild(this.stats_.dom);
            }

            if(this.shouldBuildImmediately_)
            {
                this.initScene_().then(() => {
                    this.creationCompleter_.resolve();
                });
            }
        }
        else
        {
            this.displayNoSupportMessage_();
        }
    }

    /**
     * Checks if the browser supports WebGL
     * @return {boolean}
     * @private
     */
    checkWebGlSupport_()
    {
        try {
            let canvas = document.createElement('canvas');
            return !!(window['WebGLRenderingContext'] && (canvas.getContext('webgl') || canvas.getContext('experimental-webgl')));
        } catch (error) {
            return false;
        }
    }

    /**
     * Display a message if WebGL isn't supported.
     * @protected
     */
    displayNoSupportMessage_()
    {
        console.error('WebGL is not supported!');
    }

    /**
     * Can be called to receive a Promise that is triggered when the creation of the 3D scene is completed.
     * @return {Promise}
     */
    ready()
    {
        return this.creationCompleter_.getPromise();
    }

    /**
     * Initializes the renderer, creates the raycaster object and starts the creation of the 3D scene.
     * @return {Promise}
     * @protected
     */
    initScene_()
    {
        if(this.renderer_)
            return Promise.resolve();

        this.createRenderer_();

        if(!this.renderer_)
        {
            return Promise.reject('Renderer couldn\'t be created.')
        }
        else
        {
            this.raycaster_ = new THREE.Raycaster();

            return this.buildScene_().then(() => {
                listen(this.resizeProvider_, EventType.RESIZE, this.resize_, false, this);
            });
        }
    }

    /**
     * Creates the main WebGLRenderer
     * @protected
     */
    createRenderer_()
    {
        if(this.renderer_)
        {
            removeAll(this.renderer_.domElement);
            this.getElement().removeChild(this.renderer_.domElement);
        }

        try {
            this.renderer_ = new THREE.WebGLRenderer({'antialias': !this.antialiasDisabled_, 'failIfMajorPerformanceCaveat': true});
        }
        catch(error)
        {
            console.warn(error);
            try {
                this.renderer_ = new THREE.WebGLRenderer({'antialias': false, 'failIfMajorPerformanceCaveat': true});
                this.antialiasDisabled_ = true;
            }
            catch(error2) {console.warn(error2);}
        }

        if(this.renderer_)
        {
            let size = this.resizeProvider_.getViewportSize();
            this.maxPixelRatio_ = window.devicePixelRatio ? Math.min(window.devicePixelRatio, this.maxPixelRatio_) : 1;
            this.renderer_.setPixelRatio(this.maxPixelRatio_);
            this.renderer_.setSize(size.width, size.height);
            this.renderer_.shadowMap.enabled = false;
            this.renderer_.shadowMap.type = THREE.PCFSoftShadowMap;

            //this.renderer_.autoClear = false;
            classlist.add(this.renderer_.domElement, 'three-stage');
            this.getElement().appendChild(this.renderer_.domElement);

            listen(this.renderer_.domElement, 'webglcontextlost', () => {
                console.error("CONTEXT LOST");
            }, false, this);
            listen(this.renderer_.domElement, 'webglcontextrestored', () => {
                console.warn("CONTEXT RESTORED");
            }, false, this);

            listen(this.worldProvider_, WorldEventType.START_RENDERING, this.startRenderer_, false, this);
            listen(this.worldProvider_, WorldEventType.PAUSE_RENDERING, this.pauseRenderer_, false, this);
        }
    }

    /**
     * Loads and creates the 3D scene.
     * @return {Promise}
     * @protected
     */
    async buildScene_()
    {
        return Promise.resolve();
    }

    /**
     * Activates rendering
     * @protected
     */
    startRenderer_()
    {
        this.enabled_ = true;
    }

    /**
     * Deactivates rendering
     * @protected
     */
    pauseRenderer_()
    {
        this.enabled_ = false;
    }

    /**
     * Enable the active scene for rendering and interaction.
     * @protected
     */
    activateScene_()
    {
        if(!this.enabled)
        {
            this.enabled = true;
            this.startRenderScene_();
            this.resize_();

            this.activateInteraction_();
        }
    }

    /**
     * Disable the active scene for rendering and interaction.
     * @protected
     */
    deactivateScene_()
    {
        if(this.enabled)
        {
            this.enabled = false;
            this.stopRenderScene_();

            this.deactivateInteraction_();
        }
    }

    /**
     * Activates interactivity within the 3D scene
     * @protected
     */
    activateInteraction_()
    {
    }

    /**
     * Deactivates interactivity within the 3D scene
     * @protected
     */
    deactivateInteraction_()
    {
    }

    /**
     * Main call for 3D scene updates in the render process.
     * @param {number} delta
     * @protected
     */
    updateScene_(delta)
    {
    }

    /**
     * Stops the rendering of the active 3D scene
     * @protected
     */
    stopRenderScene_()
    {
        if(this.requestFrameId_ != null)
            window.cancelAnimationFrame(this.requestFrameId_);

        if(this.clock_)
            this.clock_.stop();

        this.requestFrameId_ = null;
    }

    /**
     * Starts the rendering of the active 3D scene
     * @protected
     */
    startRenderScene_()
    {
        this.stopRenderScene_();

        if(!this.clock_)
            this.clock_ = new THREE.Clock();
        this.clock_.start();

        this.drawScene_();
    }

    /**
     *
     * @protected
     */
    drawScene_()
    {
        this.renderScene_();
        this.requestFrameId_ = window.requestAnimationFrame(this.drawScene_.bind(this));
    }

    /**
     * Main render process (window.requestAnimationFrame call).
     * Updates THREE clock, framerate check, stats and 3D scene {@Link Abstract3DWorld#updateScene_}
     * @param {boolean=} forcedRendering If it is set to `true`, a rendering will still take place even if {@Link Abstract3DWorld#enabled_} is set to `false`.
     * @protected
     */
    renderScene_(forcedRendering = false)
    {
        if(this.renderer_)
        {
            let delta = this.clock_ ? this.clock_.getDelta() : 1;

            if(this.enabled_ || forcedRendering)
            {
                if (this.stats_)
                    this.stats_.update();

                this.renderer_.clear();

                if (this.checkFrames_)
                    this.checkFramerate_();

                this.updateScene_(delta);
            }
        }
    }

    /**
     * Checks the framerate according to the settings {@Link Abstract3DWorld#lowSettingThreshhold_} and {@Link Abstract3DWorld#fpsThreshhold_}
     * for the current {@Link Abstract3DWorld#lowSettingStep_}.
     * @protected
     */
    checkFramerate_()
    {
        this.frames_++;
        let time = Date.now();

        if (time >= this.prevTime_ + 200)
        {
            this.currentFps_ = (this.frames_ * 1000) / (time - this.prevTime_);

            let minFps = typeof this.fpsThreshhold_ == 'number' ? this.fpsThreshhold_ : this.fpsThreshhold_[this.lowSettingStep_];
            if (this.currentFps_ < minFps)
            {
                this.lowFramerateCount_++;
            }

            this.prevTime_ = time;
            this.frames_ = 0;
        }

        let maxLowFrames = typeof this.lowSettingThreshhold_ == 'number' ? this.lowSettingThreshhold_ : this.lowSettingThreshhold_[this.lowSettingStep_];
        if (this.lowFramerateCount_ >= maxLowFrames)
        {
            this.lowSettingStep_++;
            this.lowFramerateCount_ = 0;
            this.changeToLowSettings_(this.lowSettingStep_);
            this.checkFrames_ = !(typeof this.lowSettingThreshhold_ == 'number' || this.lowSettingStep_ >= this.lowSettingThreshhold_.length);
        }
    }

    /**
     * Bad performance has been detected and the WebGLRenderer is being rebuilt with new settings to improve performance.
     * @param {number} step Current (already increased) {@Link Abstract3DWorld#lowSettingStep_}
     * @protected
     */
    changeToLowSettings_(step)
    {
        console.warn("changeToLowSettings", step);
        switch(step) {
            case 1: this.antialiasDisabled_ = true;
                    if(this.maxPixelRatio_ == 1) this.lowSettingStep_ = 2;
                    this.createRenderer_();
                    break;
            case 2: this.maxPixelRatio_ = 1;
                    this.createRenderer_();
                    break;
        }
    }

    /**
     * Detects window size changes and therefore updates renderer sizes.
     * @protected
     */
    resize_()
    {
        if(!this.renderer_)
            return;

        let size = this.resizeProvider_.getViewportSize();

        setStyle(this.renderer_.domElement, {
            'width':  size.width + "px",
            'height': size.height + "px"
        });
        this.renderer_.domElement.width = size.width;
        this.renderer_.domElement.height = size.height;

        this.renderer_.setSize(size.width, size.height);

        this.updateCameraMatrix_();
    }

    /**
     * Updates the current camera aspect based on the window size.
     * @protected
     */
    updateCameraMatrix_()
    {
        if(this.camera_)
        {
            let size = this.resizeProvider_.getViewportSize();
            this.camera_.aspect = size.width / size.height;
            this.camera_.updateProjectionMatrix();
        }
    }

    /**
     * Setter
     * @param {boolean} value
     */
    set enabled(value)
    {
        this.enabled_ = value;
    }

    /**
     * Getter
     * @return {boolean}
     */
    get enabled()
    {
        return this.enabled_;
    }
}

exports = Abstract3DWorld;