diff --git a/examples/files.json b/examples/files.json index 474be30f7c4ef2..a886b7144cc241 100644 --- a/examples/files.json +++ b/examples/files.json @@ -448,7 +448,8 @@ "webgpu_video_panorama", "webgpu_volume_cloud", "webgpu_volume_perlin", - "webgpu_water" + "webgpu_water", + "webgpu_xr_cubes" ], "webaudio": [ "webaudio_orientation", diff --git a/examples/jsm/webxr/XRButtonGPU.js b/examples/jsm/webxr/XRButtonGPU.js new file mode 100644 index 00000000000000..2abb10925a5bcb --- /dev/null +++ b/examples/jsm/webxr/XRButtonGPU.js @@ -0,0 +1,224 @@ +// temporary version of XRButton until WebGPURenderer fully support layers + +class XRButton { + + static createButton( renderer, sessionInit = {} ) { + + const button = document.createElement( 'button' ); + + function showStartXR( mode ) { + + let currentSession = null; + + async function onSessionStarted( session ) { + + session.addEventListener( 'end', onSessionEnded ); + + await renderer.xr.setSession( session ); + + button.textContent = 'STOP XR'; + + currentSession = session; + + } + + function onSessionEnded( /*event*/ ) { + + currentSession.removeEventListener( 'end', onSessionEnded ); + + button.textContent = 'START XR'; + + currentSession = null; + + } + + // + + button.style.display = ''; + + button.style.cursor = 'pointer'; + button.style.left = 'calc(50% - 50px)'; + button.style.width = '100px'; + + button.textContent = 'START XR'; + + const sessionOptions = { + ...sessionInit, + optionalFeatures: [ + 'local-floor', + 'bounded-floor', + ...( sessionInit.optionalFeatures || [] ) + ], + }; + + button.onmouseenter = function () { + + button.style.opacity = '1.0'; + + }; + + button.onmouseleave = function () { + + button.style.opacity = '0.5'; + + }; + + button.onclick = function () { + + if ( currentSession === null ) { + + navigator.xr.requestSession( mode, sessionOptions ) + .then( onSessionStarted ); + + } else { + + currentSession.end(); + + if ( navigator.xr.offerSession !== undefined ) { + + navigator.xr.offerSession( mode, sessionOptions ) + .then( onSessionStarted ) + .catch( ( err ) => { + + console.warn( err ); + + } ); + + } + + } + + }; + + if ( navigator.xr.offerSession !== undefined ) { + + navigator.xr.offerSession( mode, sessionOptions ) + .then( onSessionStarted ) + .catch( ( err ) => { + + console.warn( err ); + + } ); + + } + + } + + function disableButton() { + + button.style.display = ''; + + button.style.cursor = 'auto'; + button.style.left = 'calc(50% - 75px)'; + button.style.width = '150px'; + + button.onmouseenter = null; + button.onmouseleave = null; + + button.onclick = null; + + } + + function showXRNotSupported() { + + disableButton(); + + button.textContent = 'XR NOT SUPPORTED'; + + } + + function showXRNotAllowed( exception ) { + + disableButton(); + + console.warn( 'Exception when trying to call xr.isSessionSupported', exception ); + + button.textContent = 'XR NOT ALLOWED'; + + } + + function stylizeElement( element ) { + + element.style.position = 'absolute'; + element.style.bottom = '20px'; + element.style.padding = '12px 6px'; + element.style.border = '1px solid #fff'; + element.style.borderRadius = '4px'; + element.style.background = 'rgba(0,0,0,0.1)'; + element.style.color = '#fff'; + element.style.font = 'normal 13px sans-serif'; + element.style.textAlign = 'center'; + element.style.opacity = '0.5'; + element.style.outline = 'none'; + element.style.zIndex = '999'; + + } + + if ( 'xr' in navigator ) { + + button.id = 'XRButton'; + button.style.display = 'none'; + + stylizeElement( button ); + + navigator.xr.isSessionSupported( 'immersive-ar' ) + .then( function ( supported ) { + + if ( supported ) { + + showStartXR( 'immersive-ar' ); + + } else { + + navigator.xr.isSessionSupported( 'immersive-vr' ) + .then( function ( supported ) { + + if ( supported ) { + + showStartXR( 'immersive-vr' ); + + } else { + + showXRNotSupported(); + + } + + } ).catch( showXRNotAllowed ); + + } + + } ).catch( showXRNotAllowed ); + + return button; + + } else { + + const message = document.createElement( 'a' ); + + if ( window.isSecureContext === false ) { + + message.href = document.location.href.replace( /^http:/, 'https:' ); + message.innerHTML = 'WEBXR NEEDS HTTPS'; // TODO Improve message + + } else { + + message.href = 'https://immersiveweb.dev/'; + message.innerHTML = 'WEBXR NOT AVAILABLE'; + + } + + message.style.left = 'calc(50% - 90px)'; + message.style.width = '180px'; + message.style.textDecoration = 'none'; + + stylizeElement( message ); + + return message; + + } + + } + +} + +export { XRButton }; diff --git a/examples/screenshots/webgpu_xr_cubes.jpg b/examples/screenshots/webgpu_xr_cubes.jpg new file mode 100644 index 00000000000000..fae83571b60f89 Binary files /dev/null and b/examples/screenshots/webgpu_xr_cubes.jpg differ diff --git a/examples/webgpu_xr_cubes.html b/examples/webgpu_xr_cubes.html new file mode 100644 index 00000000000000..dc58e2e66b9470 --- /dev/null +++ b/examples/webgpu_xr_cubes.html @@ -0,0 +1,273 @@ + + + + three.js xr - cubes + + + + + + +
+ three.js xr - interactive cubes +
+ + + + + + diff --git a/src/Three.Core.js b/src/Three.Core.js index 782018000d8111..922180b084069a 100644 --- a/src/Three.Core.js +++ b/src/Three.Core.js @@ -4,6 +4,7 @@ export { WebGLArrayRenderTarget } from './renderers/WebGLArrayRenderTarget.js'; export { WebGL3DRenderTarget } from './renderers/WebGL3DRenderTarget.js'; export { WebGLCubeRenderTarget } from './renderers/WebGLCubeRenderTarget.js'; export { WebGLRenderTarget } from './renderers/WebGLRenderTarget.js'; +export { WebXRController } from './renderers/webxr/WebXRController.js'; export { FogExp2 } from './scenes/FogExp2.js'; export { Fog } from './scenes/Fog.js'; export { Scene } from './scenes/Scene.js'; diff --git a/src/renderers/common/Animation.js b/src/renderers/common/Animation.js index 195bc2bdcd4012..5d863070cbaa4c 100644 --- a/src/renderers/common/Animation.js +++ b/src/renderers/common/Animation.js @@ -89,6 +89,17 @@ class Animation { } + /** + * Returns the user-level animation loop. + * + * @return {Function} The animation loop. + */ + getAnimationLoop() { + + return this._animationLoop; + + } + /** * Defines the user-level animation loop. * @@ -100,6 +111,17 @@ class Animation { } + /** + * Returns the animation context. + * + * @return {Window|XRSession} The animation context. + */ + getContext() { + + return this._context; + + } + /** * Defines the context in which `requestAnimationFrame()` is executed. * diff --git a/src/renderers/common/Renderer.js b/src/renderers/common/Renderer.js index 7a28cfc9b167f9..e91ea9835901af 100644 --- a/src/renderers/common/Renderer.js +++ b/src/renderers/common/Renderer.js @@ -16,6 +16,7 @@ import QuadMesh from './QuadMesh.js'; import RenderBundles from './RenderBundles.js'; import NodeLibrary from './nodes/NodeLibrary.js'; import Lighting from './Lighting.js'; +import XRManager from './XRManager.js'; import NodeMaterial from '../../materials/nodes/NodeMaterial.js'; @@ -643,13 +644,11 @@ class Renderer { */ /** - * The renderer's XR configuration. + * The renderer's XR manager. * - * @type {module:Renderer~XRConfig} + * @type {XRManager} */ - this.xr = { - enabled: false - }; + this.xr = new XRManager( this ); /** * Debug configuration. @@ -1225,8 +1224,9 @@ class Renderer { // const coordinateSystem = this.coordinateSystem; + const xr = this.xr; - if ( camera.coordinateSystem !== coordinateSystem ) { + if ( camera.coordinateSystem !== coordinateSystem && xr.isPresenting === false ) { camera.coordinateSystem = coordinateSystem; camera.updateProjectionMatrix(); @@ -1250,6 +1250,13 @@ class Renderer { if ( camera.parent === null && camera.matrixWorldAutoUpdate === true ) camera.updateMatrixWorld(); + if ( xr.enabled === true && xr.isPresenting === true ) { + + if ( xr.cameraAutoUpdate === true ) xr.updateCamera( camera ); + camera = xr.getCamera(); // use XR camera for rendering + + } + // let viewport = this._viewport; @@ -1343,7 +1350,11 @@ class Renderer { // - this._background.update( sceneRef, renderList, renderContext ); + if ( xr.enabled === false || xr.isPresenting === false ) { + + this._background.update( sceneRef, renderList, renderContext ); + + } // @@ -2026,6 +2037,29 @@ class Renderer { } + /** + * Ensures the renderer is XR compatible. + * + * @async + * @return {Promise} A Promise that resolve when the renderer is XR compatible. + */ + async makeXRCompatible() { + + await this.backend.makeXRCompatible(); + + } + + /** + * Sets the XR rendering destination. + * + * @param {WebGLFramebuffer} xrTarget - The XR target. + */ + setXRTarget( xrTarget ) { + + this.backend.setXRTarget( xrTarget ); + + } + /** * Sets the given render target. Calling this method means the renderer does not * target the default framebuffer (meaning the canvas) anymore but a custom framebuffer. diff --git a/src/renderers/common/XRManager.js b/src/renderers/common/XRManager.js new file mode 100644 index 00000000000000..a46d9e214c5bfc --- /dev/null +++ b/src/renderers/common/XRManager.js @@ -0,0 +1,1075 @@ +import { ArrayCamera } from '../../cameras/ArrayCamera.js'; +import { EventDispatcher } from '../../core/EventDispatcher.js'; +import { RenderTarget } from '../../core/RenderTarget.js'; +import { PerspectiveCamera } from '../../cameras/PerspectiveCamera.js'; +import { RAD2DEG } from '../../math/MathUtils.js'; +import { Vector2 } from '../../math/Vector2.js'; +import { Vector3 } from '../../math/Vector3.js'; +import { Vector4 } from '../../math/Vector4.js'; +import { WebXRController } from '../webxr/WebXRController.js'; +import { RGBAFormat, UnsignedByteType } from '../../constants.js'; + +const _cameraLPos = /*@__PURE__*/ new Vector3(); +const _cameraRPos = /*@__PURE__*/ new Vector3(); + +/** + * The XR manager is built on top of the WebXR Device API to + * manage XR sessions with `WebGPURenderer`. + * + * XR is currently only supported with a WebGL 2 backend. + */ +class XRManager extends EventDispatcher { + + /** + * Constructs a new XR manager. + * + * @param {Renderer} renderer - The renderer. + */ + constructor( renderer ) { + + super(); + + /** + * This flag globally enables XR rendering. + * + * @type {Boolean} + * @default false + */ + this.enabled = false; + + /** + * Whether the XR device is currently presenting or not. + * + * @type {Boolean} + * @default false + * @readonly + */ + this.isPresenting = false; + + /** + * Whether the XR camera should automatically be updated or not. + * + * @type {Boolean} + * @default true + */ + this.cameraAutoUpdate = true; + + /** + * The renderer. + * + * @private + * @type {Renderer} + */ + this._renderer = renderer; + + // camera + + /** + * Represents the camera for the left eye. + * + * @private + * @type {PerspectiveCamera} + */ + this._cameraL = new PerspectiveCamera(); + this._cameraL.viewport = new Vector4(); + + /** + * Represents the camera for the right eye. + * + * @private + * @type {PerspectiveCamera} + */ + this._cameraR = new PerspectiveCamera(); + this._cameraR.viewport = new Vector4(); + + /** + * A list of cameras used for rendering the XR views. + * + * @private + * @type {Array} + */ + this._cameras = [ this._cameraL, this._cameraR ]; + + /** + * The main XR camera. + * + * @private + * @type {ArrayCamera} + */ + this._cameraXR = new ArrayCamera(); + + /** + * The current near value of the XR camera. + * + * @private + * @type {Number?} + * @default null + */ + this._currentDepthNear = null; + + /** + * The current far value of the XR camera. + * + * @private + * @type {Number?} + * @default null + */ + this._currentDepthFar = null; + + /** + * A list of WebXR controllers requested by the application. + * + * @private + * @type {Array} + */ + this._controllers = []; + + /** + * A list of XR input source. Each input source belongs to + * an instance of WebXRController. + * + * @private + * @type {Array} + */ + this._controllerInputSources = []; + + /** + * The current render target of the renderer. + * + * @private + * @type {RenderTarget?} + * @default null + */ + this._currentRenderTarget = null; + + /** + * The XR render target that represents the rendering destination + * during an active XR session. + * + * @private + * @type {RenderTarget?} + * @default null + */ + this._xrRenderTarget = null; + + /** + * The current animation context. + * + * @private + * @type {Window?} + * @default null + */ + this._currentAnimationContext = null; + + /** + * The current animation loop. + * + * @private + * @type {Function?} + * @default null + */ + this._currentAnimationLoop = null; + + /** + * The current pixel ratio. + * + * @private + * @type {Number?} + * @default null + */ + this._currentPixelRatio = null; + + /** + * The current size of the renderer's canvas + * in logical pixel unit. + * + * @private + * @type {Vector2} + */ + this._currentSize = new Vector2(); + + /** + * The default event listener for handling events inside a XR session. + * + * @private + * @type {Function} + */ + this._onSessionEvent = onSessionEvent.bind( this ); + + /** + * The event listener for handling the end of a XR session. + * + * @private + * @type {Function} + */ + this._onSessionEnd = onSessionEnd.bind( this ); + + /** + * The event listener for handling the `inputsourceschange` event. + * + * @private + * @type {Function} + */ + this._onInputSourcesChange = onInputSourcesChange.bind( this ); + + /** + * The animation loop which is used as a replacement for the default + * animation loop of the applicatio. It is only used when a XR session + * is active. + * + * @private + * @type {Function} + */ + this._onAnimationFrame = onAnimationFrame.bind( this ); + + /** + * The current XR reference space. + * + * @private + * @type {XRReferenceSpace?} + * @default null + */ + this._referenceSpace = null; + + /** + * The current XR reference space type. + * + * @private + * @type {String} + * @default 'local-floor' + */ + this._referenceSpaceType = 'local-floor'; + + /** + * A custom reference space defined by the application. + * + * @private + * @type {XRReferenceSpace?} + * @default null + */ + this._customReferenceSpace = null; + + /** + * The framebuffer scale factor. + * + * @private + * @type {Number} + * @default 1 + */ + this._framebufferScaleFactor = 1; + + /** + * The foveation factor. + * + * @private + * @type {Number} + * @default 1 + */ + this._foveation = 1.0; + + /** + * A reference to the current XR session. + * + * @private + * @type {XRSession?} + * @default null + */ + this._session = null; + + /** + * A reference to the current XR base layer. + * + * @private + * @type {XRWebGLLayer?} + * @default null + */ + this._glBaseLayer = null; + + /** + * A reference to the current XR frame. + * + * @private + * @type {XRFrame?} + * @default null + */ + this._xrFrame = null; + + } + + /** + * Returns an instance of `THREE.Group` that represents the transformation + * of a XR controller in target ray space. The requested controller is defined + * by the given index. + * + * @param {Number} index - The index of the XR controller. + * @return {Group} A group that represents the controller's transformation. + */ + getController( index ) { + + const controller = this._getController( index ); + + return controller.getTargetRaySpace(); + + } + + /** + * Returns an instance of `THREE.Group` that represents the transformation + * of a XR controller in grip space. The requested controller is defined + * by the given index. + * + * @param {Number} index - The index of the XR controller. + * @return {Group} A group that represents the controller's transformation. + */ + getControllerGrip( index ) { + + const controller = this._getController( index ); + + return controller.getGripSpace(); + + } + + /** + * Returns an instance of `THREE.Group` that represents the transformation + * of a XR controller in hand space. The requested controller is defined + * by the given index. + * + * @param {Number} index - The index of the XR controller. + * @return {Group} A group that represents the controller's transformation. + */ + getHand( index ) { + + const controller = this._getController( index ); + + return controller.getHandSpace(); + + } + + /** + * Returns the foveation value. + * + * @return {Number|undefined} The foveation value. Returns `undefined` if no base layer is defined. + */ + getFoveation() { + + if ( this._glBaseLayer === null ) { + + return undefined; + + } + + return this._foveation; + + } + + /** + * Sets the foveation value. + * + * @param {Number} foveation - A number in the range `[0,1]` where `0` means no foveation (full resolution) + * and `1` means maximum foveation (the edges render at lower resolution). + */ + setFoveation( foveation ) { + + this._foveation = foveation; + + if ( this._glBaseLayer !== null && this._glBaseLayer.fixedFoveation !== undefined ) { + + this._glBaseLayer.fixedFoveation = foveation; + + } + + } + + /** + * Returns the frammebuffer scale factor. + * + * @return {Number} The frammebuffer scale factor. + */ + getFramebufferScaleFactor() { + + return this._framebufferScaleFactor; + + } + + /** + * Sets the frammebuffer scale factor. + * + * This method can not be used during a XR session. + * + * @param {Number} factor - The frammebuffer scale factor. + */ + setFramebufferScaleFactor( factor ) { + + this._framebufferScaleFactor = factor; + + if ( this.isPresenting === true ) { + + console.warn( 'THREE.XRManager: Cannot change framebuffer scale while presenting.' ); + + } + + } + + /** + * Returns the reference space type. + * + * @return {String} The reference space type. + */ + getReferenceSpaceType() { + + return this._referenceSpaceType; + + } + + /** + * Sets the reference space type. + * + * This method can not be used during a XR session. + * + * @param {String} type - The reference space type. + */ + setReferenceSpaceType( type ) { + + this._referenceSpaceType = type; + + if ( this.isPresenting === true ) { + + console.warn( 'THREE.XRManager: Cannot change reference space type while presenting.' ); + + } + + } + + /** + * Returns the XR reference space. + * + * @return {XRReferenceSpace} The XR reference space. + */ + getReferenceSpace() { + + return this._customReferenceSpace || this._referenceSpace; + + } + + /** + * Sets a custom XR reference space. + * + * @param {XRReferenceSpace} space - The XR reference space. + */ + setReferenceSpace( space ) { + + this._customReferenceSpace = space; + + } + + /** + * Returns the XR camera. + * + * @return {ArrayCamera} The XR camera. + */ + getCamera() { + + return this._cameraXR; + + } + + /** + * Returns the environment blend mode from the current XR session. + * + * @return {'opaque'|'additive'|'alpha-blend'} The environment blend mode. + */ + getEnvironmentBlendMode() { + + if ( this._session !== null ) { + + return this._session.environmentBlendMode; + + } + + } + + /** + * Returns the current XR frame. + * + * @return {XRFrame?} The XR frame. Returns `null` when used outside a XR session. + */ + getFrame() { + + return this._xrFrame; + + } + + /** + * Returns the current XR session. + * + * @return {XRSession?} The XR session. Returns `null` when used outside a XR session. + */ + getSession() { + + return this._session; + + } + + /** + * After a XR session has been requested usually with one of the `*Button` modules, it + * is injected into the renderer with this method. This method triggers the start of + * the actual XR rendering. + * + * @async + * @param {XRSession} session - The XR session to set. + * @return {Promise} A Promise that resolves when the session has been set. + */ + async setSession( session ) { + + const renderer = this._renderer; + const gl = renderer.getContext(); + + this._session = session; + + if ( session !== null ) { + + if ( renderer.backend.isWebGPUBackend === true ) throw new Error( 'THREE.XRManager: XR is currently not supported with a WebGPU backend. Use WebGL by passing "{ forceWebGL: true }" to the constructor of the renderer.' ); + + this._currentRenderTarget = renderer.getRenderTarget(); + + session.addEventListener( 'select', this._onSessionEvent ); + session.addEventListener( 'selectstart', this._onSessionEvent ); + session.addEventListener( 'selectend', this._onSessionEvent ); + session.addEventListener( 'squeeze', this._onSessionEvent ); + session.addEventListener( 'squeezestart', this._onSessionEvent ); + session.addEventListener( 'squeezeend', this._onSessionEvent ); + session.addEventListener( 'end', this._onSessionEnd ); + session.addEventListener( 'inputsourceschange', this._onInputSourcesChange ); + + await renderer.makeXRCompatible(); + + this._currentPixelRatio = renderer.getPixelRatio(); + renderer.getSize( this._currentSize ); + + this._currentAnimationContext = renderer._animation.getContext(); + this._currentAnimationLoop = renderer._animation.getAnimationLoop(); + renderer._animation.stop(); + + const attributes = gl.getContextAttributes(); + + const layerInit = { + antialias: attributes.antialias, + alpha: true, + depth: attributes.depth, + stencil: attributes.stencil, + framebufferScaleFactor: this.getFramebufferScaleFactor() + }; + + const glBaseLayer = new XRWebGLLayer( session, gl, layerInit ); + this._glBaseLayer = glBaseLayer; + + session.updateRenderState( { baseLayer: glBaseLayer } ); + + renderer.setPixelRatio( 1 ); + renderer.setSize( glBaseLayer.framebufferWidth, glBaseLayer.framebufferHeight, false ); + + this._xrRenderTarget = new RenderTarget( + glBaseLayer.framebufferWidth, + glBaseLayer.framebufferHeight, + { + format: RGBAFormat, + type: UnsignedByteType, + colorSpace: renderer.outputColorSpace, + stencilBuffer: attributes.stencil + } + ); + + this._xrRenderTarget.isXRRenderTarget = true; // TODO Remove this when possible, see #23278 + + this.setFoveation( this.getFoveation() ); + + this._referenceSpace = await session.requestReferenceSpace( this.getReferenceSpaceType() ); + + renderer._animation.setAnimationLoop( this._onAnimationFrame ); + renderer._animation.setContext( session ); + renderer._animation.start(); + + this.isPresenting = true; + + this.dispatchEvent( { type: 'sessionstart' } ); + + } + + } + + /** + * This method is called by the renderer per frame and updates the XR camera + * and it sub cameras based on the given camera. The given camera is the "normal" + * camera created on application level and used for non-XR rendering. + * + * @param {PerspectiveCamera} camera - The camera. + */ + updateCamera( camera ) { + + const session = this._session; + + if ( session === null ) return; + + const depthNear = camera.near; + const depthFar = camera.far; + + const cameraXR = this._cameraXR; + const cameraL = this._cameraL; + const cameraR = this._cameraR; + + cameraXR.near = cameraR.near = cameraL.near = depthNear; + cameraXR.far = cameraR.far = cameraL.far = depthFar; + + if ( this._currentDepthNear !== cameraXR.near || this._currentDepthFar !== cameraXR.far ) { + + // Note that the new renderState won't apply until the next frame. See #18320 + + session.updateRenderState( { + depthNear: cameraXR.near, + depthFar: cameraXR.far + } ); + + this._currentDepthNear = cameraXR.near; + this._currentDepthFar = cameraXR.far; + + } + + cameraL.layers.mask = camera.layers.mask | 0b010; + cameraR.layers.mask = camera.layers.mask | 0b100; + cameraXR.layers.mask = cameraL.layers.mask | cameraR.layers.mask; + + const parent = camera.parent; + const cameras = cameraXR.cameras; + + updateCamera( cameraXR, parent ); + + for ( let i = 0; i < cameras.length; i ++ ) { + + updateCamera( cameras[ i ], parent ); + + } + + // update projection matrix for proper view frustum culling + + if ( cameras.length === 2 ) { + + setProjectionFromUnion( cameraXR, cameraL, cameraR ); + + } else { + + // assume single camera setup (AR) + + cameraXR.projectionMatrix.copy( cameraL.projectionMatrix ); + + } + + // update user camera and its children + + updateUserCamera( camera, cameraXR, parent ); + + + } + + /** + * Returns a WebXR controller for the given controller index. + * + * @private + * @param {Number} index - The controller index. + * @return {WebXRController} The XR controller. + */ + _getController( index ) { + + let controller = this._controllers[ index ]; + + if ( controller === undefined ) { + + controller = new WebXRController(); + this._controllers[ index ] = controller; + + } + + return controller; + + } + +} + +/** + * Assumes 2 cameras that are parallel and share an X-axis, and that + * the cameras' projection and world matrices have already been set. + * And that near and far planes are identical for both cameras. + * Visualization of this technique: https://computergraphics.stackexchange.com/a/4765 + * + * @param {ArrayCamera} camera - The camera to update. + * @param {PerspectiveCamera} cameraL - The left camera. + * @param {PerspectiveCamera} cameraR - The right camera. + */ +function setProjectionFromUnion( camera, cameraL, cameraR ) { + + _cameraLPos.setFromMatrixPosition( cameraL.matrixWorld ); + _cameraRPos.setFromMatrixPosition( cameraR.matrixWorld ); + + const ipd = _cameraLPos.distanceTo( _cameraRPos ); + + const projL = cameraL.projectionMatrix.elements; + const projR = cameraR.projectionMatrix.elements; + + // VR systems will have identical far and near planes, and + // most likely identical top and bottom frustum extents. + // Use the left camera for these values. + const near = projL[ 14 ] / ( projL[ 10 ] - 1 ); + const far = projL[ 14 ] / ( projL[ 10 ] + 1 ); + const topFov = ( projL[ 9 ] + 1 ) / projL[ 5 ]; + const bottomFov = ( projL[ 9 ] - 1 ) / projL[ 5 ]; + + const leftFov = ( projL[ 8 ] - 1 ) / projL[ 0 ]; + const rightFov = ( projR[ 8 ] + 1 ) / projR[ 0 ]; + const left = near * leftFov; + const right = near * rightFov; + + // Calculate the new camera's position offset from the + // left camera. xOffset should be roughly half `ipd`. + const zOffset = ipd / ( - leftFov + rightFov ); + const xOffset = zOffset * - leftFov; + + // TODO: Better way to apply this offset? + cameraL.matrixWorld.decompose( camera.position, camera.quaternion, camera.scale ); + camera.translateX( xOffset ); + camera.translateZ( zOffset ); + camera.matrixWorld.compose( camera.position, camera.quaternion, camera.scale ); + camera.matrixWorldInverse.copy( camera.matrixWorld ).invert(); + + // Check if the projection uses an infinite far plane. + if ( projL[ 10 ] === - 1.0 ) { + + // Use the projection matrix from the left eye. + // The camera offset is sufficient to include the view volumes + // of both eyes (assuming symmetric projections). + camera.projectionMatrix.copy( cameraL.projectionMatrix ); + camera.projectionMatrixInverse.copy( cameraL.projectionMatrixInverse ); + + } else { + + // Find the union of the frustum values of the cameras and scale + // the values so that the near plane's position does not change in world space, + // although must now be relative to the new union camera. + const near2 = near + zOffset; + const far2 = far + zOffset; + const left2 = left - xOffset; + const right2 = right + ( ipd - xOffset ); + const top2 = topFov * far / far2 * near2; + const bottom2 = bottomFov * far / far2 * near2; + + camera.projectionMatrix.makePerspective( left2, right2, top2, bottom2, near2, far2 ); + camera.projectionMatrixInverse.copy( camera.projectionMatrix ).invert(); + + } + +} + +/** + * Updates the world matrices for the given camera based on the parent 3D object. + * + * @inner + * @param {Camera} camera - The camera to update. + * @param {Object3D} parent - The parent 3D object. + */ +function updateCamera( camera, parent ) { + + if ( parent === null ) { + + camera.matrixWorld.copy( camera.matrix ); + + } else { + + camera.matrixWorld.multiplyMatrices( parent.matrixWorld, camera.matrix ); + + } + + camera.matrixWorldInverse.copy( camera.matrixWorld ).invert(); + +} + +/** + * Updates the given camera with the transfomration of the XR camera and parent object. + * + * @inner + * @param {Camera} camera - The camera to update. + * @param {ArrayCamera} cameraXR - The XR camera. + * @param {Object3D} parent - The parent 3D object. + */ +function updateUserCamera( camera, cameraXR, parent ) { + + if ( parent === null ) { + + camera.matrix.copy( cameraXR.matrixWorld ); + + } else { + + camera.matrix.copy( parent.matrixWorld ); + camera.matrix.invert(); + camera.matrix.multiply( cameraXR.matrixWorld ); + + } + + camera.matrix.decompose( camera.position, camera.quaternion, camera.scale ); + camera.updateMatrixWorld( true ); + + camera.projectionMatrix.copy( cameraXR.projectionMatrix ); + camera.projectionMatrixInverse.copy( cameraXR.projectionMatrixInverse ); + + if ( camera.isPerspectiveCamera ) { + + camera.fov = RAD2DEG * 2 * Math.atan( 1 / camera.projectionMatrix.elements[ 5 ] ); + camera.zoom = 1; + + } + +} + +function onSessionEvent( event ) { + + const controllerIndex = this._controllerInputSources.indexOf( event.inputSource ); + + if ( controllerIndex === - 1 ) { + + return; + + } + + const controller = this._controllers[ controllerIndex ]; + + if ( controller !== undefined ) { + + const referenceSpace = this.getReferenceSpace(); + + controller.update( event.inputSource, event.frame, referenceSpace ); + controller.dispatchEvent( { type: event.type, data: event.inputSource } ); + + } + +} + +function onSessionEnd() { + + const session = this._session; + const renderer = this._renderer; + + session.removeEventListener( 'select', this._onSessionEvent ); + session.removeEventListener( 'selectstart', this._onSessionEvent ); + session.removeEventListener( 'selectend', this._onSessionEvent ); + session.removeEventListener( 'squeeze', this._onSessionEvent ); + session.removeEventListener( 'squeezestart', this._onSessionEvent ); + session.removeEventListener( 'squeezeend', this._onSessionEvent ); + session.removeEventListener( 'end', this._onSessionEnd ); + session.removeEventListener( 'inputsourceschange', this._onInputSourcesChange ); + + for ( let i = 0; i < this._controllers.length; i ++ ) { + + const inputSource = this._controllerInputSources[ i ]; + + if ( inputSource === null ) continue; + + this._controllerInputSources[ i ] = null; + + this._controllers[ i ].disconnect( inputSource ); + + } + + this._currentDepthNear = null; + this._currentDepthFar = null; + + // restore framebuffer/rendering state + + renderer.setRenderTarget( this._currentRenderTarget ); + + this._session = null; + this._xrRenderTarget = null; + + // + + this.isPresenting = false; + + renderer._animation.stop(); + + renderer._animation.setAnimationLoop( this._currentAnimationLoop ); + renderer._animation.setContext( this._currentAnimationContext ); + renderer._animation.start(); + + renderer.setPixelRatio( this._currentPixelRatio ); + renderer.setSize( this._currentSize.width, this._currentSize.height, false ); + + renderer.setXRTarget( null ); + + this.dispatchEvent( { type: 'sessionend' } ); + +} + +function onInputSourcesChange( event ) { + + const controllers = this._controllers; + const controllerInputSources = this._controllerInputSources; + + // Notify disconnected + + for ( let i = 0; i < event.removed.length; i ++ ) { + + const inputSource = event.removed[ i ]; + const index = controllerInputSources.indexOf( inputSource ); + + if ( index >= 0 ) { + + controllerInputSources[ index ] = null; + controllers[ index ].disconnect( inputSource ); + + } + + } + + // Notify connected + + for ( let i = 0; i < event.added.length; i ++ ) { + + const inputSource = event.added[ i ]; + + let controllerIndex = controllerInputSources.indexOf( inputSource ); + + if ( controllerIndex === - 1 ) { + + // Assign input source a controller that currently has no input source + + for ( let i = 0; i < controllers.length; i ++ ) { + + if ( i >= controllerInputSources.length ) { + + controllerInputSources.push( inputSource ); + controllerIndex = i; + break; + + } else if ( controllerInputSources[ i ] === null ) { + + controllerInputSources[ i ] = inputSource; + controllerIndex = i; + break; + + } + + } + + // If all controllers do currently receive input we ignore new ones + + if ( controllerIndex === - 1 ) break; + + } + + const controller = controllers[ controllerIndex ]; + + if ( controller ) { + + controller.connect( inputSource ); + + } + + } + +} + +function onAnimationFrame( time, frame ) { + + if ( frame === undefined ) return; + + const cameraXR = this._cameraXR; + const renderer = this._renderer; + + const glBaseLayer = this._glBaseLayer; + + const referenceSpace = this.getReferenceSpace(); + const pose = frame.getViewerPose( referenceSpace ); + + this._xrFrame = frame; + + if ( pose !== null ) { + + const views = pose.views; + + renderer.setXRTarget( glBaseLayer.framebuffer ); + renderer.setRenderTarget( this._xrRenderTarget ); + + let cameraXRNeedsUpdate = false; + + // check if it's necessary to rebuild cameraXR's camera list + + if ( views.length !== cameraXR.cameras.length ) { + + cameraXR.cameras.length = 0; + cameraXRNeedsUpdate = true; + + } + + for ( let i = 0; i < views.length; i ++ ) { + + const view = views[ i ]; + + const viewport = glBaseLayer.getViewport( view ); + + let camera = this._cameras[ i ]; + + if ( camera === undefined ) { + + camera = new PerspectiveCamera(); + camera.layers.enable( i ); + camera.viewport = new Vector4(); + this._cameras[ i ] = camera; + + } + + camera.matrix.fromArray( view.transform.matrix ); + camera.matrix.decompose( camera.position, camera.quaternion, camera.scale ); + camera.projectionMatrix.fromArray( view.projectionMatrix ); + camera.projectionMatrixInverse.copy( camera.projectionMatrix ).invert(); + camera.viewport.set( viewport.x, viewport.y, viewport.width, viewport.height ); + + if ( i === 0 ) { + + cameraXR.matrix.copy( camera.matrix ); + cameraXR.matrix.decompose( cameraXR.position, cameraXR.quaternion, cameraXR.scale ); + + } + + if ( cameraXRNeedsUpdate === true ) { + + cameraXR.cameras.push( camera ); + + } + + } + + } + + // + + for ( let i = 0; i < this._controllers.length; i ++ ) { + + const inputSource = this._controllerInputSources[ i ]; + const controller = this._controllers[ i ]; + + if ( inputSource !== null && controller !== undefined ) { + + controller.update( inputSource, frame, referenceSpace ); + + } + + } + + if ( this._currentAnimationLoop ) this._currentAnimationLoop( time, frame ); + + if ( frame.detectedPlanes ) { + + this.dispatchEvent( { type: 'planesdetected', data: frame } ); + + } + + this._xrFrame = null; + +} + +export default XRManager; diff --git a/src/renderers/webgl-fallback/WebGLBackend.js b/src/renderers/webgl-fallback/WebGLBackend.js index 4f5b7c5df49eda..6d5898a159dc01 100644 --- a/src/renderers/webgl-fallback/WebGLBackend.js +++ b/src/renderers/webgl-fallback/WebGLBackend.js @@ -185,6 +185,16 @@ class WebGLBackend extends Backend { */ this._knownBindings = new WeakSet(); + /** + * The target framebuffer when rendering with + * the WebXR device API. + * + * @private + * @type {WebGLFramebuffer} + * @default null + */ + this._xrFamebuffer = null; + } /** @@ -284,6 +294,34 @@ class WebGLBackend extends Backend { } + /** + * Ensures the backend is XR compatible. + * + * @async + * @return {Promise} A Promise that resolve when the renderer is XR compatible. + */ + async makeXRCompatible() { + + const attributes = this.gl.getContextAttributes(); + + if ( attributes.xrCompatible !== true ) { + + await this.gl.makeXRCompatible(); + + } + + } + /** + * Sets the XR rendering destination. + * + * @param {WebGLFramebuffer} xrFamebuffer - The XR framebuffer. + */ + setXRTarget( xrFamebuffer ) { + + this._xrFamebuffer = xrFamebuffer; + + } + /** * Inits a time stamp query for the given render context. * @@ -1082,18 +1120,18 @@ class WebGLBackend extends Backend { data[ 0 ] = i; gl.bindBuffer( gl.UNIFORM_BUFFER, bufferGPU ); - gl.bufferData( gl.UNIFORM_BUFFER, data, gl.DYNAMIC_DRAW ); + gl.bufferData( gl.UNIFORM_BUFFER, data, gl.STATIC_DRAW ); indexesGPU.push( bufferGPU ); } cameraData.indexesGPU = indexesGPU; // TODO: Create a global library for this - cameraData.cameraIndex = renderObject.getBindingGroup( 'cameraIndex' ).bindings[ 0 ]; } - const cameraIndexData = this.get( cameraData.cameraIndex ); + const cameraIndex = renderObject.getBindingGroup( 'cameraIndex' ).bindings[ 0 ]; + const cameraIndexData = this.get( cameraIndex ); const pixelRatio = this.renderer.getPixelRatio(); for ( let i = 0, len = cameras.length; i < len; i ++ ) { @@ -1869,6 +1907,7 @@ class WebGLBackend extends Backend { const isCube = renderTarget.isWebGLCubeRenderTarget === true; const isRenderTarget3D = renderTarget.isRenderTarget3D === true; const isRenderTargetArray = renderTarget.isRenderTargetArray === true; + const isXRRenderTarget = renderTarget.isXRRenderTarget === true; let msaaFb = renderTargetContextData.msaaFrameBuffer; let depthRenderbuffer = renderTargetContextData.depthRenderbuffer; @@ -1883,6 +1922,10 @@ class WebGLBackend extends Backend { fb = renderTargetContextData.cubeFramebuffers[ cacheKey ]; + } else if ( isXRRenderTarget ) { + + fb = this._xrFamebuffer; + } else { renderTargetContextData.framebuffers || ( renderTargetContextData.framebuffers = {} );