UNPKG

aframe

Version:

A web framework for building virtual reality experiences.

915 lines (792 loc) 29 kB
/* global Promise, customElements, screen */ import * as THREE from 'three'; import { inject as initMetaTags } from './metaTags.js'; import { initWakelock } from './wakelock.js'; import * as loadingScreen from './loadingScreen.js'; import scenes from './scenes.js'; import { systems } from '../system.js'; import { components } from '../component.js'; import * as utils from '../../utils/index.js'; // Require after. import { AEntity } from '../a-entity.js'; import { ANode } from '../a-node.js'; import { initPostMessageAPI } from './postMessage.js'; import '../../utils/ios-orientationchange-blank-bug.js'; var warn = utils.debug('core:a-scene:warn'); var isIOS = utils.device.isIOS(); var isMobile = utils.device.isMobile(); var isWebXRAvailable = utils.device.isWebXRAvailable; /** * Scene element, holds all entities. * * @member {Array} behaviors - Component instances that have registered themselves to be updated on every tick. * @member {object} camera - three.js Camera object. * @member {object} canvas * @member {boolean} isScene - Differentiates as scene entity as opposed to other entities. * @member {boolean} isMobile - Whether browser is mobile (via UA detection). * @member {object} object3D - Root three.js Scene object. * @member {object} renderer * @member {boolean} renderStarted * @member {object} systems - Registered instantiated systems. * @member {number} time */ export class AScene extends AEntity { constructor () { var self; super(); self = this; self.clock = new THREE.Clock(); self.isIOS = isIOS; self.isMobile = isMobile; self.hasWebXR = isWebXRAvailable; self.isAR = false; self.isScene = true; self.object3D = new THREE.Scene(); self.resize = self.resize.bind(self); self.render = self.render.bind(self); self.systems = {}; self.systemNames = []; self.time = self.delta = 0; self.usedOfferSession = false; self.componentOrder = []; self.behaviors = {}; self.hasLoaded = false; self.isPlaying = false; self.originalHTML = self.innerHTML; } addFullScreenStyles () { document.documentElement.classList.add('a-fullscreen'); } removeFullScreenStyles () { document.documentElement.classList.remove('a-fullscreen'); } doConnectedCallback () { var self = this; var embedded = this.hasAttribute('embedded'); // Default components. this.setAttribute('inspector', ''); this.setAttribute('keyboard-shortcuts', ''); this.setAttribute('screenshot', ''); this.setAttribute('xr-mode-ui', ''); this.setAttribute('device-orientation-permission-ui', ''); super.doConnectedCallback(); // Renderer initialization setupCanvas(this); this.setupRenderer(); loadingScreen.setup(this, getCanvasSize); this.resize(); if (!embedded) { this.addFullScreenStyles(); } initPostMessageAPI(this); initMetaTags(this); initWakelock(this); // Bind functions. this.enterVRBound = function () { self.enterVR(); }; this.exitVRBound = function () { self.exitVR(); }; window.addEventListener('sessionend', this.resize); // Camera set up by camera system. this.addEventListener('cameraready', function () { self.attachedCallbackPostCamera(); }); this.initSystems(); // Compute component order this.componentOrder = determineComponentBehaviorOrder(components, this.componentOrder); this.addEventListener('componentregistered', function () { // Recompute order self.componentOrder = determineComponentBehaviorOrder(components, self.componentOrder); }); // WebXR Immersive navigation handler. if (this.hasWebXR && navigator.xr && navigator.xr.addEventListener) { navigator.xr.addEventListener('sessiongranted', function () { self.enterVR(); }); } } attachedCallbackPostCamera () { var resize; var self = this; window.addEventListener('load', resize); window.addEventListener('resize', function () { // Workaround for a Webkit bug (https://bugs.webkit.org/show_bug.cgi?id=170595) // where the window does not contain the correct viewport size // after an orientation change. The window size is correct if the operation // is postponed a few milliseconds. // self.resize can be called directly once the bug above is fixed. if (self.isIOS) { setTimeout(self.resize, 100); } else { self.resize(); } }); this.play(); // Add to scene index. scenes.push(this); } /** * Initialize all systems. */ initSystems () { var name; // Initialize camera system first. this.initSystem('camera'); for (name in systems) { if (name === 'camera') { continue; } this.initSystem(name); } } /** * Initialize a system. */ initSystem (name) { if (this.systems[name]) { return; } this.systems[name] = new systems[name](this); this.systemNames.push(name); } /** * Shut down scene on detach. */ disconnectedCallback () { // Remove from scene index. var sceneIndex = scenes.indexOf(this); super.disconnectedCallback(); scenes.splice(sceneIndex, 1); window.removeEventListener('sessionend', this.resize); this.removeFullScreenStyles(); this.renderer.dispose(); } /** * Add ticks and tocks. * * @param {object} behavior - A component. */ addBehavior (behavior) { var behaviorSet; var behaviors = this.behaviors[behavior.name]; var behaviorType; if (!behaviors) { behaviors = this.behaviors[behavior.name] = { tick: { inUse: false, array: [], markedForRemoval: [] }, tock: { inUse: false, array: [], markedForRemoval: [] } }; } // Check if behavior has tick and/or tock and add the behavior to the appropriate list. for (behaviorType in behaviors) { if (!behavior[behaviorType]) { continue; } behaviorSet = behaviors[behaviorType]; // In case the behaviorSet is in use, make sure this behavior isn't on the removal list. if (behaviorSet.inUse) { var index = behaviorSet.markedForRemoval.indexOf(behavior); if (index !== -1) { behaviorSet.markedForRemoval.splice(index, 1); } } // Add behavior to the set if (behaviorSet.array.indexOf(behavior) === -1) { behaviorSet.array.push(behavior); } } } /** * For tests. */ getPointerLockElement () { return document.pointerLockElement; } /** * For tests. */ checkHeadsetConnected () { return utils.device.checkHeadsetConnected(); } enterAR () { var errorMessage; if (!this.hasWebXR) { errorMessage = 'Failed to enter AR mode, WebXR not supported.'; throw new Error(errorMessage); } if (!utils.device.checkARSupport()) { errorMessage = 'Failed to enter AR, WebXR immersive-ar mode not supported in your browser or device.'; throw new Error(errorMessage); } return this.enterVR(true); } /** * Call `requestPresent` if WebVR or WebVR polyfill. * Call `requestFullscreen` on desktop. * Handle events, states, fullscreen styles. * * @param {?boolean} useAR - if true, try immersive-ar mode * @returns {Promise} */ enterVR (useAR, useOfferSession) { var self = this; var vrManager = self.renderer.xr; var xrInit; // Don't enter VR if already in VR. if (useOfferSession && (!navigator.xr || !navigator.xr.offerSession)) { return Promise.resolve('OfferSession is not supported.'); } if (self.usedOfferSession && useOfferSession) { return Promise.resolve('OfferSession was already called.'); } if (this.is('vr-mode')) { return Promise.resolve('Already in VR.'); } // Has VR. if (this.checkHeadsetConnected() || this.isMobile) { var rendererSystem = self.getAttribute('renderer'); vrManager.enabled = true; if (this.hasWebXR) { // XR API. if (this.xrSession) { this.xrSession.removeEventListener('end', this.exitVRBound); } var refspace = this.sceneEl.systems.webxr.sessionReferenceSpaceType; vrManager.setReferenceSpaceType(refspace); var xrMode = useAR ? 'immersive-ar' : 'immersive-vr'; xrInit = this.sceneEl.systems.webxr.sessionConfiguration; return new Promise(function (resolve, reject) { var requestSession = useOfferSession ? navigator.xr.offerSession.bind(navigator.xr) : navigator.xr.requestSession.bind(navigator.xr); self.usedOfferSession |= useOfferSession; requestSession(xrMode, xrInit).then( function requestSuccess (xrSession) { if (useOfferSession) { self.usedOfferSession = false; } vrManager.layersEnabled = xrInit.requiredFeatures.indexOf('layers') !== -1; vrManager.setSession(xrSession).then(function () { vrManager.setFoveation(rendererSystem.foveationLevel); self.xrSession = xrSession; self.systems.renderer.setWebXRFrameRate(xrSession); xrSession.addEventListener('end', self.exitVRBound); enterVRSuccess(resolve); }); }, function requestFail (error) { var useAR = xrMode === 'immersive-ar'; var mode = useAR ? 'AR' : 'VR'; reject(new Error('Failed to enter ' + mode + ' mode (`requestSession`)', { cause: error })); } ); }); } else { var mode = useAR ? 'AR' : 'VR'; throw new Error('Failed to enter ' + mode + ' no WebXR'); } } // No VR. enterVRSuccess(); return Promise.resolve(); // Callback that happens on enter VR success or enter fullscreen (any API). function enterVRSuccess (resolve) { if (useAR) { self.addState('ar-mode'); } else { self.addState('vr-mode'); } self.emit('enter-vr', {target: self}); // Lock to landscape orientation on mobile. if (!self.hasWebXR && self.isMobile && screen.orientation && screen.orientation.lock) { screen.orientation.lock('landscape'); } self.addFullScreenStyles(); // On mobile, the polyfill handles fullscreen. // TODO: 07/16 Chromium builds break when `requestFullscreen`ing on a canvas // that we are also `requestPresent`ing. Until then, don't fullscreen if headset // connected. if (!self.isMobile && !self.checkHeadsetConnected()) { requestFullscreen(self.canvas); } self.resize(); if (resolve) { resolve(); } } } /** * Call `exitPresent` if WebVR / WebXR or WebVR polyfill. * Handle events, states, fullscreen styles. * * @returns {Promise} */ exitVR () { var self = this; var vrManager = this.renderer.xr; // Don't exit VR if not in VR. if (!this.is('vr-mode') && !this.is('ar-mode')) { return Promise.resolve('Not in immersive mode.'); } // Handle exiting VR if not yet already and in a headset or polyfill. if (this.checkHeadsetConnected() || this.isMobile) { vrManager.enabled = false; if (this.hasWebXR) { this.xrSession.removeEventListener('end', this.exitVRBound); // Capture promise to avoid errors. this.xrSession.end().then(function () {}, function () {}); this.xrSession = undefined; } else { throw Error('Failed to exit VR - no WebXR'); } } else { exitFullscreen(); } // Handle exiting VR in all other cases (2D fullscreen, external exit VR event). exitVRSuccess(); return Promise.resolve(); function exitVRSuccess () { self.removeState('vr-mode'); self.removeState('ar-mode'); // Lock to landscape orientation on mobile. if (self.isMobile && screen.orientation && screen.orientation.unlock) { screen.orientation.unlock(); } // Exiting VR in embedded mode, no longer need fullscreen styles. if (self.hasAttribute('embedded')) { self.removeFullScreenStyles(); } self.resize(); if (self.isIOS) { utils.forceCanvasResizeSafariMobile(self.canvas); } self.renderer.setPixelRatio(window.devicePixelRatio); self.emit('exit-vr', {target: self}); } } /** * Wraps Entity.getAttribute to take into account for systems. * If system exists, then return system data rather than possible component data. */ getAttribute (attr) { var system = this.systems[attr]; if (system) { return system.data; } return AEntity.prototype.getAttribute.call(this, attr); } /** * Wraps Entity.getDOMAttribute to take into account for systems. * If system exists, then return system data rather than possible component data. */ getDOMAttribute (attr) { var system = this.systems[attr]; if (system) { return system.data; } return AEntity.prototype.getDOMAttribute.call(this, attr); } /** * Wrap Entity.setAttribute to take into account for systems. * If system exists, then skip component initialization checks and do a normal * setAttribute. */ setAttribute (attr, value, componentPropValue) { // Check if system exists (i.e. is registered). if (systems[attr]) { ANode.prototype.setAttribute.call(this, attr, value); // Update system instance, if initialized on the scene. var system = this.systems[attr]; if (system) { system.updateProperties(value); } return; } AEntity.prototype.setAttribute.call(this, attr, value, componentPropValue); } /** * @param {object} behavior - A component. */ removeBehavior (behavior) { var behaviorSet; var behaviorType; var behaviors = this.behaviors[behavior.name]; var index; // Check if behavior has tick and/or tock and remove the behavior from the appropriate // array. for (behaviorType in behaviors) { if (!behavior[behaviorType]) { continue; } behaviorSet = behaviors[behaviorType]; index = behaviorSet.array.indexOf(behavior); if (index !== -1) { // Check if the behavior can safely be removed. if (behaviorSet.inUse) { // Set is in use, so only mark for removal. if (behaviorSet.markedForRemoval.indexOf(behavior) === -1) { behaviorSet.markedForRemoval.push(behavior); } } else { // Swap and remove from the end behaviorSet.array[index] = behaviorSet.array[behaviorSet.array.length - 1]; behaviorSet.array.pop(); } } } } resize () { var camera = this.camera; var canvas = this.canvas; var embedded; var isVRPresenting; var size; var isPresenting = this.renderer.xr.isPresenting; isVRPresenting = this.renderer.xr.enabled && isPresenting; // Do not update renderer, if a camera or a canvas have not been injected. // In VR mode, three handles canvas resize based on the dimensions returned by // the getEyeParameters function of the WebVR API. These dimensions are independent of // the window size, therefore should not be overwritten with the window's width and // height, // except when in fullscreen mode. if (!camera || !canvas || (this.is('vr-mode') && (this.isMobile || isVRPresenting))) { return; } // Update camera. embedded = this.getAttribute('embedded') && !this.is('vr-mode'); size = getCanvasSize(canvas, embedded, this.maxCanvasSize, this.is('vr-mode')); camera.aspect = size.width / size.height; camera.updateProjectionMatrix(); // Notify renderer of size change. this.renderer.setSize(size.width, size.height, false); this.emit('rendererresize', null, false); } setupRenderer () { var self = this; var renderer; var rendererAttr; var rendererAttrString; var rendererConfig; rendererConfig = { alpha: true, antialias: !isMobile, canvas: this.canvas, logarithmicDepthBuffer: false, powerPreference: 'high-performance' }; this.maxCanvasSize = {height: -1, width: -1}; if (this.hasAttribute('renderer')) { rendererAttrString = this.getAttribute('renderer'); rendererAttr = utils.styleParser.parse(rendererAttrString); if (rendererAttr.precision) { rendererConfig.precision = rendererAttr.precision + 'p'; } if (rendererAttr.antialias && rendererAttr.antialias !== 'auto') { rendererConfig.antialias = rendererAttr.antialias === 'true'; } if (rendererAttr.logarithmicDepthBuffer && rendererAttr.logarithmicDepthBuffer !== 'auto') { rendererConfig.logarithmicDepthBuffer = rendererAttr.logarithmicDepthBuffer === 'true'; } if (rendererAttr.alpha) { rendererConfig.alpha = rendererAttr.alpha === 'true'; } if (rendererAttr.stencil) { rendererConfig.stencil = rendererAttr.stencil === 'true'; } if (rendererAttr.multiviewStereo) { rendererConfig.multiviewStereo = rendererAttr.multiviewStereo === 'true'; } this.maxCanvasSize = { width: rendererAttr.maxCanvasWidth ? parseInt(rendererAttr.maxCanvasWidth) : this.maxCanvasSize.width, height: rendererAttr.maxCanvasHeight ? parseInt(rendererAttr.maxCanvasHeight) : this.maxCanvasSize.height }; } // Trick Webpack so that it can't statically determine the exact export used. // Otherwise it will conclude that one of the two exports can't be found in THREE. // Only one needs to exist, and this should be determined at runtime. var rendererImpl = ['WebGLRenderer', 'WebGPURenderer'].find(function (x) { return THREE[x]; }); renderer = this.renderer = new THREE[rendererImpl](rendererConfig); if (!renderer.xr.setPoseTarget) { renderer.xr.setPoseTarget = function () {}; } renderer.setPixelRatio(window.devicePixelRatio); if (this.camera) { renderer.xr.setPoseTarget(this.camera.el.object3D); } this.addEventListener('camera-set-active', function () { renderer.xr.setPoseTarget(self.camera.el.object3D); }); } /** * Handler attached to elements to help scene know when to kick off. * Scene waits for all entities to load. */ play () { var self = this; var sceneEl = this; if (this.renderStarted) { AEntity.prototype.play.call(this); return; } this.addEventListener('loaded', function () { var renderer = this.renderer; AEntity.prototype.play.call(this); // .play() *before* render. if (sceneEl.renderStarted) { return; } sceneEl.resize(); // Kick off render loop. if (sceneEl.renderer) { if (window.performance) { window.performance.mark('render-started'); } loadingScreen.remove(); renderer.setAnimationLoop(this.render); sceneEl.renderStarted = true; sceneEl.emit('renderstart'); } }); // setTimeout to wait for all nodes to attach and run their callbacks. setTimeout(function () { AEntity.prototype.load.call(self); }); } /** * Wrap `updateComponent` to not initialize the component if the component has a system * (aframevr/aframe#2365). */ updateComponent (componentName) { if (componentName in systems) { return; } AEntity.prototype.updateComponent.apply(this, arguments); } /** * Behavior-updater meant to be called from scene render. * Abstracted to a different function to facilitate unit testing (`scene.tick()`) without * needing to render. */ tick (time, timeDelta) { var i; var systems = this.systems; // Components. this.callComponentBehaviors('tick', time, timeDelta); // Systems. for (i = 0; i < this.systemNames.length; i++) { if (!systems[this.systemNames[i]].tick) { continue; } systems[this.systemNames[i]].tick(time, timeDelta); } } /** * Behavior-updater meant to be called after scene render for post processing purposes. * Abstracted to a different function to facilitate unit testing (`scene.tock()`) without * needing to render. */ tock (time, timeDelta, camera) { var i; var systems = this.systems; // Components. this.callComponentBehaviors('tock', time, timeDelta); // Systems. for (i = 0; i < this.systemNames.length; i++) { if (!systems[this.systemNames[i]].tock) { continue; } systems[this.systemNames[i]].tock(time, timeDelta, camera); } } /** * The render loop. * * Updates animations. * Updates behaviors. * Renders with request animation frame. */ render (time, frame) { var renderer = this.renderer; this.frame = frame; this.delta = this.clock.getDelta() * 1000; this.time = this.clock.elapsedTime * 1000; if (this.isPlaying) { this.tick(this.time, this.delta); } var savedBackground = null; if (this.is('ar-mode')) { // In AR mode, don't render the default background. Hide it, then // restore it again after rendering. savedBackground = this.object3D.background; this.object3D.background = null; } renderer.render(this.object3D, this.camera); if (savedBackground) { this.object3D.background = savedBackground; } if (this.isPlaying) { var renderCamera = renderer.xr.isPresenting ? renderer.xr.getCamera() : this.camera; this.tock(this.time, this.delta, renderCamera); } } callComponentBehaviors (behavior, time, timeDelta) { var i; for (var c = 0; c < this.componentOrder.length; c++) { var behaviors = this.behaviors[this.componentOrder[c]]; if (!behaviors) { continue; } var behaviorSet = behaviors[behavior]; behaviorSet.inUse = true; for (i = 0; i < behaviorSet.array.length; i++) { if (!behaviorSet.array[i].isPlaying) { continue; } behaviorSet.array[i][behavior](time, timeDelta); } behaviorSet.inUse = false; // Clean up any behaviors marked for removal for (i = 0; i < behaviorSet.markedForRemoval.length; i++) { this.removeBehavior(behaviorSet.markedForRemoval[i]); } behaviorSet.markedForRemoval.length = 0; } } } /** * Derives an ordering from the components, taking any before and after * constraints into account. * * @param {object} components - The components to order * @param {Array} array - Optional array to use as output */ export function determineComponentBehaviorOrder (components, array) { var graph = {}; var i; var key; var result = array || []; result.length = 0; // Construct graph nodes for each element for (key in components) { var element = components[key]; if (element === undefined) { continue; } var before = element.before ? element.before.slice(0) : []; var after = element.after ? element.after.slice(0) : []; graph[key] = { before: before, after: after, visited: false, done: false }; } // Normalize to after constraints, warn about missing nodes for (key in graph) { for (i = 0; i < graph[key].before.length; i++) { var beforeName = graph[key].before[i]; if (!(beforeName in graph)) { warn('Invalid ordering constraint, no component named `' + beforeName + '` referenced by `' + key + '`'); continue; } graph[beforeName].after.push(key); } } // Perform topological depth-first search // https://en.wikipedia.org/wiki/Topological_sorting#Depth-first_search function visit (name) { if (!(name in graph) || graph[name].done) { return; } if (graph[name].visited) { warn('Cycle detected, ignoring one or more before/after constraints. ' + 'The resulting order might be incorrect'); return; } graph[name].visited = true; for (var i = 0; i < graph[name].after.length; i++) { var afterName = graph[name].after[i]; if (!(afterName in graph)) { warn('Invalid before/after constraint, no component named `' + afterName + '` referenced in `' + name + '`'); } visit(afterName); } graph[name].done = true; result.push(name); } for (key in graph) { if (graph[key].done) { continue; } visit(key); } return result; } /** * Return size constrained to maxSize - maintaining aspect ratio. * * @param {object} size - size parameters (width and height). * @param {object} maxSize - Max size parameters (width and height). * @returns {object} Width and height. */ function constrainSizeTo (size, maxSize) { var aspectRatio; var pixelRatio = window.devicePixelRatio; if (!maxSize || (maxSize.width === -1 && maxSize.height === -1)) { return size; } if (size.width * pixelRatio < maxSize.width && size.height * pixelRatio < maxSize.height) { return size; } aspectRatio = size.width / size.height; if ((size.width * pixelRatio) > maxSize.width && maxSize.width !== -1) { size.width = Math.round(maxSize.width / pixelRatio); size.height = Math.round(maxSize.width / aspectRatio / pixelRatio); } if ((size.height * pixelRatio) > maxSize.height && maxSize.height !== -1) { size.height = Math.round(maxSize.height / pixelRatio); size.width = Math.round(maxSize.height * aspectRatio / pixelRatio); } return size; } customElements.define('a-scene', AScene); /** * Return the canvas size where the scene will be rendered. * Will be always the window size except when the scene is embedded. * The parent size will be returned in that case. * the returned size will be constrained to the maxSize maintaining aspect ratio. * * @param {Element} canvasEl - the canvas element * @param {boolean} embedded - Is the scene embedded? * @param {object} maxSize - Max size parameters * @param {boolean} isVR - If in VR * @returns {number} Max size */ function getCanvasSize (canvasEl, embedded, maxSize, isVR) { if (!canvasEl.parentElement) { return {height: 0, width: 0}; } if (embedded) { var size; size = { height: canvasEl.parentElement.offsetHeight, width: canvasEl.parentElement.offsetWidth }; return constrainSizeTo(size, maxSize); } return getMaxSize(maxSize, isVR); } /** * Return the canvas size. Will be the window size unless that size is greater than the * maximum size (1920x1920 by default). The constrained size will be returned in that case, * maintaining aspect ratio * * @param {object} maxSize - Max size parameters (width and height). * @param {boolean} isVR - If in VR. * @returns {object} Width and height. */ function getMaxSize (maxSize, isVR) { var size; size = {height: document.body.offsetHeight, width: document.body.offsetWidth}; if (isVR) { return size; } else { return constrainSizeTo(size, maxSize); } } function requestFullscreen (canvas) { var requestFullscreen = canvas.requestFullscreen || canvas.webkitRequestFullscreen || canvas.mozRequestFullScreen || // The capitalized `S` is not a typo. canvas.msRequestFullscreen; // Hide navigation buttons on Android. requestFullscreen.apply(canvas, [{navigationUI: 'hide'}]); } function exitFullscreen () { var fullscreenEl = document.fullscreenElement || document.webkitFullscreenElement || document.mozFullScreenElement; if (!fullscreenEl) { return; } if (document.exitFullscreen) { document.exitFullscreen(); } else if (document.mozCancelFullScreen) { document.mozCancelFullScreen(); } else if (document.webkitExitFullscreen) { document.webkitExitFullscreen(); } } export function setupCanvas (sceneEl) { var canvasEl; canvasEl = document.createElement('canvas'); canvasEl.classList.add('a-canvas'); // Mark canvas as provided/injected by A-Frame. canvasEl.dataset.aframeCanvas = true; sceneEl.appendChild(canvasEl); document.addEventListener('fullscreenchange', onFullScreenChange); document.addEventListener('mozfullscreenchange', onFullScreenChange); document.addEventListener('webkitfullscreenchange', onFullScreenChange); document.addEventListener('MSFullscreenChange', onFullScreenChange); // Prevent overscroll on mobile. canvasEl.addEventListener('touchmove', function (event) { event.preventDefault(); }, {passive: false}); // Set canvas on scene. sceneEl.canvas = canvasEl; sceneEl.emit('render-target-loaded', {target: canvasEl}); // For unknown reasons a synchronous resize does not work on desktop when // entering/exiting fullscreen. setTimeout(sceneEl.resize.bind(sceneEl), 0); function onFullScreenChange () { var fullscreenEl = document.fullscreenElement || document.mozFullScreenElement || document.webkitFullscreenElement; // No fullscreen element === exit fullscreen if (!fullscreenEl) { sceneEl.exitVR(); } document.activeElement.blur(); document.body.focus(); } }