/* * Copyright 2016 Google Inc. All Rights Reserved. * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ var AdaptivePlayer = require('./adaptive-player'); var EventEmitter = require('eventemitter3'); var Eyes = require('./eyes'); var HotspotRenderer = require('./hotspot-renderer'); var ReticleRenderer = require('./reticle-renderer'); var SphereRenderer = require('./sphere-renderer'); var TWEEN = require('@tweenjs/tween.js'); var Util = require('../util'); var VideoProxy = require('./video-proxy'); var WebVRManager = require('webvr-boilerplate'); var AUTOPAN_DURATION = 3000; var AUTOPAN_ANGLE = 0.4; /** * The main WebGL rendering entry point. Manages the scene, camera, VR-related * rendering updates. Interacts with the WebVRManager. * * Coordinates the other renderers: SphereRenderer, HotspotRenderer, * ReticleRenderer. * * Also manages the AdaptivePlayer and VideoProxy. * * Emits the following events: * load: when the scene is loaded. * error: if there is an error loading the scene. * modechange(Boolean isVR): if the mode (eg. VR, fullscreen, etc) changes. */ function WorldRenderer(params) { this.init_(params.hideFullscreenButton); this.sphereRenderer = new SphereRenderer(this.scene); this.hotspotRenderer = new HotspotRenderer(this); this.hotspotRenderer.on('focus', this.onHotspotFocus_.bind(this)); this.hotspotRenderer.on('blur', this.onHotspotBlur_.bind(this)); this.reticleRenderer = new ReticleRenderer(this.camera); // Get the VR Display as soon as we initialize. navigator.getVRDisplays().then(function(displays) { if (displays.length > 0) { this.vrDisplay = displays[0]; } }.bind(this)); } WorldRenderer.prototype = new EventEmitter(); WorldRenderer.prototype.render = function(time) { this.controls.update(); TWEEN.update(time); this.effect.render(this.scene, this.camera); this.hotspotRenderer.update(this.camera); }; /** * @return {Promise} When the scene is fully loaded. */ WorldRenderer.prototype.setScene = function(scene) { var self = this; var promise = new Promise(function(resolve, reject) { self.sceneResolve = resolve; self.sceneReject = reject; }); if (!scene || !scene.isValid()) { this.didLoadFail_(scene.errorMessage); return; } var params = { isStereo: scene.isStereo, loop: scene.loop, volume: scene.volume, muted: scene.muted }; this.setDefaultYaw_(scene.defaultYaw || 0); // Disable VR mode if explicitly disabled, or if we're loading a video on iOS // 9 or earlier. if (scene.isVROff || (scene.video && Util.isIOS9OrLess())) { this.manager.setVRCompatibleOverride(false); } // Set various callback overrides in iOS. if (Util.isIOS()) { this.manager.setFullscreenCallback(function() { Util.sendParentMessage({type: 'enter-fullscreen'}); }); this.manager.setExitFullscreenCallback(function() { Util.sendParentMessage({type: 'exit-fullscreen'}); }); this.manager.setVRCallback(function() { Util.sendParentMessage({type: 'enter-vr'}); }); } // If we're dealing with an image, and not a video. if (scene.image && !scene.video) { if (scene.preview) { // First load the preview. this.sphereRenderer.setPhotosphere(scene.preview, params).then(function() { // As soon as something is loaded, emit the load event to hide the // loading progress bar. self.didLoad_(); // Then load the full resolution image. self.sphereRenderer.setPhotosphere(scene.image, params); }).catch(self.didLoadFail_.bind(self)); } else { // No preview -- go straight to rendering the full image. this.sphereRenderer.setPhotosphere(scene.image, params).then(function() { self.didLoad_(); }).catch(self.didLoadFail_.bind(self)); } } else if (scene.video) { if (Util.isIE11()) { // On IE 11, if an 'image' param is provided, load it instead of showing // an error. // // TODO(smus): Once video textures are supported, remove this fallback. if (scene.image) { this.sphereRenderer.setPhotosphere(scene.image, params).then(function() { self.didLoad_(); }).catch(self.didLoadFail_.bind(self)); } else { this.didLoadFail_('Video is not supported on IE11.'); } } else { this.player = new AdaptivePlayer(params); this.player.on('load', function(videoElement, videoType) { self.sphereRenderer.set360Video(videoElement, videoType, params).then(function() { self.didLoad_({videoElement: videoElement}); }).catch(self.didLoadFail_.bind(self)); }); this.player.on('error', function(error) { self.didLoadFail_('Video load error: ' + error); }); this.player.load(scene.video); this.videoProxy = new VideoProxy(this.player.video); } } this.sceneInfo = scene; if (Util.isDebug()) { console.log('Loaded scene', scene); } return promise; }; WorldRenderer.prototype.isVRMode = function() { return !!this.vrDisplay && this.vrDisplay.isPresenting; }; WorldRenderer.prototype.submitFrame = function() { if (this.isVRMode()) { this.vrDisplay.submitFrame(); } }; WorldRenderer.prototype.disposeEye_ = function(eye) { if (eye) { if (eye.material.map) { eye.material.map.dispose(); } eye.material.dispose(); eye.geometry.dispose(); } }; WorldRenderer.prototype.dispose = function() { var eyeLeft = this.scene.getObjectByName('eyeLeft'); this.disposeEye_(eyeLeft); var eyeRight = this.scene.getObjectByName('eyeRight'); this.disposeEye_(eyeRight); }; WorldRenderer.prototype.destroy = function() { if (this.player) { this.player.removeAllListeners(); this.player.destroy(); this.player = null; } var photo = this.scene.getObjectByName('photo'); var eyeLeft = this.scene.getObjectByName('eyeLeft'); var eyeRight = this.scene.getObjectByName('eyeRight'); if (eyeLeft) { this.disposeEye_(eyeLeft); photo.remove(eyeLeft); this.scene.remove(eyeLeft); } if (eyeRight) { this.disposeEye_(eyeRight); photo.remove(eyeRight); this.scene.remove(eyeRight); } }; WorldRenderer.prototype.didLoad_ = function(opt_event) { var event = opt_event || {}; this.emit('load', event); if (this.sceneResolve) { this.sceneResolve(); } }; WorldRenderer.prototype.didLoadFail_ = function(message) { this.emit('error', message); if (this.sceneReject) { this.sceneReject(message); } }; /** * Sets the default yaw. * @param {Number} angleRad The yaw in radians. */ WorldRenderer.prototype.setDefaultYaw_ = function(angleRad) { // Rotate the camera parent to take into account the scene's rotation. // By default, it should be at the center of the image. var display = this.controls.getVRDisplay(); // For desktop, we subtract the current display Y axis var theta = display.theta_ || 0; // For devices with orientation we make the current view center if (display.poseSensor_) { display.poseSensor_.resetPose(); } this.camera.parent.rotation.y = (Math.PI / 2.0) + angleRad - theta; }; /** * Do the initial camera tween to rotate the camera, giving an indication that * there is live content there (on desktop only). */ WorldRenderer.prototype.autopan = function(duration) { var targetY = this.camera.parent.rotation.y - AUTOPAN_ANGLE; var tween = new TWEEN.Tween(this.camera.parent.rotation) .to({y: targetY}, AUTOPAN_DURATION) .easing(TWEEN.Easing.Quadratic.Out) .start(); }; WorldRenderer.prototype.init_ = function(hideFullscreenButton) { var container = document.querySelector('body'); var aspect = window.innerWidth / window.innerHeight; var camera = new THREE.PerspectiveCamera(75, aspect, 0.1, 100); camera.layers.enable(1); var cameraDummy = new THREE.Object3D(); cameraDummy.add(camera); // Antialiasing disabled to improve performance. var renderer = new THREE.WebGLRenderer({antialias: false}); renderer.setClearColor(0x000000, 0); renderer.setSize(window.innerWidth, window.innerHeight); renderer.setPixelRatio(window.devicePixelRatio); container.appendChild(renderer.domElement); var controls = new THREE.VRControls(camera); var effect = new THREE.VREffect(renderer); // Disable eye separation. effect.scale = 0; effect.setSize(window.innerWidth, window.innerHeight); // Present submission of frames automatically. This is done manually in // submitFrame(). effect.autoSubmitFrame = false; this.camera = camera; this.renderer = renderer; this.effect = effect; this.controls = controls; this.manager = new WebVRManager(renderer, effect, {predistorted: false, hideButton: hideFullscreenButton}); this.scene = this.createScene_(); this.scene.add(this.camera.parent); // Watch the resize event. window.addEventListener('resize', this.onResize_.bind(this)); // Prevent context menu. window.addEventListener('contextmenu', this.onContextMenu_.bind(this)); window.addEventListener('vrdisplaypresentchange', this.onVRDisplayPresentChange_.bind(this)); }; WorldRenderer.prototype.onResize_ = function() { this.effect.setSize(window.innerWidth, window.innerHeight); this.camera.aspect = window.innerWidth / window.innerHeight; this.camera.updateProjectionMatrix(); }; WorldRenderer.prototype.onVRDisplayPresentChange_ = function(e) { if (Util.isDebug()) { console.log('onVRDisplayPresentChange_'); } var isVR = this.isVRMode(); // If the mode changed to VR and there is at least one hotspot, show reticle. var isReticleVisible = isVR && this.hotspotRenderer.getCount() > 0; this.reticleRenderer.setVisibility(isReticleVisible); // Resize the renderer for good measure. this.onResize_(); // Analytics. if (window.analytics) { analytics.logModeChanged(isVR); } // When exiting VR mode from iOS, make sure we emit back an exit-fullscreen event. if (!isVR && Util.isIOS()) { Util.sendParentMessage({type: 'exit-fullscreen'}); } // Emit a mode change event back to any listeners. this.emit('modechange', isVR); }; WorldRenderer.prototype.createScene_ = function(opt_params) { var scene = new THREE.Scene(); // Add a group for the photosphere. var photoGroup = new THREE.Object3D(); photoGroup.name = 'photo'; scene.add(photoGroup); return scene; }; WorldRenderer.prototype.onHotspotFocus_ = function(id) { // Set the default cursor to be a pointer. this.setCursor_('pointer'); }; WorldRenderer.prototype.onHotspotBlur_ = function(id) { // Reset the default cursor to be the default one. this.setCursor_(''); }; WorldRenderer.prototype.setCursor_ = function(cursor) { this.renderer.domElement.style.cursor = cursor; }; WorldRenderer.prototype.onContextMenu_ = function(e) { e.preventDefault(); e.stopPropagation(); return false; }; module.exports = WorldRenderer;