Răsfoiți Sursa

WebGPURenderer: Add `XRManager`. (#30346)

* WebGPURenderer: Add `XRManager`.

* Renderer: Fix projection matrix computation in XR.

* WebGLBackend: Use `gl.STATIC_DRAW` for camera index buffers.

* Renderer: Clean up.

* WebGLBackend: Don't cache `cameraIndex`.

* XRManager: Add documentation.

* E2E: Update screenshot.

* Renderer: Fix `xr` usage.
Michael Herzog 1 an în urmă
părinte
comite
e6a79f84a5

+ 2 - 1
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",

+ 224 - 0
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 };

BIN
examples/screenshots/webgpu_xr_cubes.jpg


+ 273 - 0
examples/webgpu_xr_cubes.html

@@ -0,0 +1,273 @@
+<!DOCTYPE html>
+<html lang="en">
+	<head>
+		<title>three.js xr - cubes</title>
+		<meta charset="utf-8">
+		<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
+		<link type="text/css" rel="stylesheet" href="main.css">
+	</head>
+	<body>
+
+		<div id="info">
+			<a href="https://threejs.org" target="_blank" rel="noopener">three.js</a> xr - interactive cubes
+		</div>
+
+		<script type="importmap">
+			{
+				"imports": {
+					"three": "../build/three.webgpu.js",
+					"three/webgpu": "../build/three.webgpu.js",
+					"three/tsl": "../build/three.tsl.js",
+					"three/addons/": "./jsm/"
+				}
+			}
+		</script>
+
+		<script type="module">
+
+			import * as THREE from 'three';
+
+			import { BoxLineGeometry } from 'three/addons/geometries/BoxLineGeometry.js';
+			import { XRButton } from 'three/addons/webxr/XRButtonGPU.js';
+			import { XRControllerModelFactory } from 'three/addons/webxr/XRControllerModelFactory.js';
+
+			const clock = new THREE.Clock();
+
+			let container;
+			let camera, scene, raycaster, renderer;
+
+			let room;
+
+			let controller, controllerGrip;
+			let INTERSECTED;
+
+			init();
+
+			function init() {
+
+				container = document.createElement( 'div' );
+				document.body.appendChild( container );
+
+				scene = new THREE.Scene();
+				scene.background = new THREE.Color( 0x505050 );
+
+				camera = new THREE.PerspectiveCamera( 50, window.innerWidth / window.innerHeight, 0.1, 10 );
+				camera.position.set( 0, 1.6, 3 );
+				scene.add( camera );
+
+				room = new THREE.LineSegments(
+					new BoxLineGeometry( 6, 6, 6, 10, 10, 10 ).translate( 0, 3, 0 ),
+					new THREE.LineBasicMaterial( { color: 0xbcbcbc } )
+				);
+				scene.add( room );
+
+				scene.add( new THREE.HemisphereLight( 0xa5a5a5, 0x898989, 3 ) );
+
+				const light = new THREE.DirectionalLight( 0xffffff, 3 );
+				light.position.set( 1, 1, 1 ).normalize();
+				scene.add( light );
+
+				const geometry = new THREE.BoxGeometry( 0.15, 0.15, 0.15 );
+
+				for ( let i = 0; i < 200; i ++ ) {
+
+					const object = new THREE.Mesh( geometry, new THREE.MeshLambertMaterial( { color: Math.random() * 0xffffff } ) );
+
+					object.position.x = Math.random() * 4 - 2;
+					object.position.y = Math.random() * 4;
+					object.position.z = Math.random() * 4 - 2;
+
+					object.rotation.x = Math.random() * 2 * Math.PI;
+					object.rotation.y = Math.random() * 2 * Math.PI;
+					object.rotation.z = Math.random() * 2 * Math.PI;
+
+					object.scale.x = Math.random() + 0.5;
+					object.scale.y = Math.random() + 0.5;
+					object.scale.z = Math.random() + 0.5;
+
+					object.userData.velocity = new THREE.Vector3();
+					object.userData.velocity.x = Math.random() * 0.01 - 0.005;
+					object.userData.velocity.y = Math.random() * 0.01 - 0.005;
+					object.userData.velocity.z = Math.random() * 0.01 - 0.005;
+
+					room.add( object );
+
+				}
+
+				raycaster = new THREE.Raycaster();
+
+				renderer = new THREE.WebGPURenderer( { forceWebGL: true } );
+				renderer.setPixelRatio( window.devicePixelRatio );
+				renderer.setSize( window.innerWidth, window.innerHeight );
+				renderer.setAnimationLoop( animate );
+				renderer.xr.enabled = true;
+				container.appendChild( renderer.domElement );
+
+				//
+
+				function onSelectStart() {
+
+					this.userData.isSelecting = true;
+
+				}
+
+				function onSelectEnd() {
+
+					this.userData.isSelecting = false;
+
+				}
+
+				controller = renderer.xr.getController( 0 );
+				controller.addEventListener( 'selectstart', onSelectStart );
+				controller.addEventListener( 'selectend', onSelectEnd );
+				controller.addEventListener( 'connected', function ( event ) {
+
+					this.add( buildController( event.data ) );
+
+				} );
+				controller.addEventListener( 'disconnected', function () {
+
+					this.remove( this.children[ 0 ] );
+
+				} );
+				scene.add( controller );
+
+				const controllerModelFactory = new XRControllerModelFactory();
+
+				controllerGrip = renderer.xr.getControllerGrip( 0 );
+				controllerGrip.add( controllerModelFactory.createControllerModel( controllerGrip ) );
+				scene.add( controllerGrip );
+
+				window.addEventListener( 'resize', onWindowResize );
+
+				//
+
+				document.body.appendChild( XRButton.createButton( renderer ) );
+
+			}
+
+			function buildController( data ) {
+
+				let geometry, material;
+
+				switch ( data.targetRayMode ) {
+
+					case 'tracked-pointer':
+
+						geometry = new THREE.BufferGeometry();
+						geometry.setAttribute( 'position', new THREE.Float32BufferAttribute( [ 0, 0, 0, 0, 0, - 1 ], 3 ) );
+						geometry.setAttribute( 'color', new THREE.Float32BufferAttribute( [ 0.5, 0.5, 0.5, 0, 0, 0 ], 3 ) );
+
+						material = new THREE.LineBasicMaterial( { vertexColors: true, blending: THREE.AdditiveBlending } );
+
+						return new THREE.Line( geometry, material );
+
+					case 'gaze':
+
+						geometry = new THREE.RingGeometry( 0.02, 0.04, 32 ).translate( 0, 0, - 1 );
+						material = new THREE.MeshBasicMaterial( { opacity: 0.5, transparent: true } );
+						return new THREE.Mesh( geometry, material );
+
+				}
+
+			}
+
+			function onWindowResize() {
+
+				camera.aspect = window.innerWidth / window.innerHeight;
+				camera.updateProjectionMatrix();
+
+				renderer.setSize( window.innerWidth, window.innerHeight );
+
+			}
+
+			//
+
+			function animate() {
+
+				const delta = clock.getDelta() * 60;
+
+				if ( controller.userData.isSelecting === true ) {
+
+					const cube = room.children[ 0 ];
+					room.remove( cube );
+
+					cube.position.copy( controller.position );
+					cube.userData.velocity.x = ( Math.random() - 0.5 ) * 0.02 * delta;
+					cube.userData.velocity.y = ( Math.random() - 0.5 ) * 0.02 * delta;
+					cube.userData.velocity.z = ( Math.random() * 0.01 - 0.05 ) * delta;
+					cube.userData.velocity.applyQuaternion( controller.quaternion );
+					room.add( cube );
+
+				}
+
+				// find intersections
+
+				raycaster.setFromXRController( controller );
+
+				const intersects = raycaster.intersectObjects( room.children, false );
+
+				if ( intersects.length > 0 ) {
+
+					if ( INTERSECTED != intersects[ 0 ].object ) {
+
+						if ( INTERSECTED ) INTERSECTED.material.emissive.setHex( INTERSECTED.currentHex );
+
+						INTERSECTED = intersects[ 0 ].object;
+						INTERSECTED.currentHex = INTERSECTED.material.emissive.getHex();
+						INTERSECTED.material.emissive.setHex( 0xff0000 );
+
+					}
+
+				} else {
+
+					if ( INTERSECTED ) INTERSECTED.material.emissive.setHex( INTERSECTED.currentHex );
+
+					INTERSECTED = undefined;
+
+				}
+
+				// Keep cubes inside room
+
+				for ( let i = 0; i < room.children.length; i ++ ) {
+
+					const cube = room.children[ i ];
+
+					cube.userData.velocity.multiplyScalar( 1 - ( 0.001 * delta ) );
+
+					cube.position.add( cube.userData.velocity );
+
+					if ( cube.position.x < - 3 || cube.position.x > 3 ) {
+
+						cube.position.x = THREE.MathUtils.clamp( cube.position.x, - 3, 3 );
+						cube.userData.velocity.x = - cube.userData.velocity.x;
+
+					}
+
+					if ( cube.position.y < 0 || cube.position.y > 6 ) {
+
+						cube.position.y = THREE.MathUtils.clamp( cube.position.y, 0, 6 );
+						cube.userData.velocity.y = - cube.userData.velocity.y;
+
+					}
+
+					if ( cube.position.z < - 3 || cube.position.z > 3 ) {
+
+						cube.position.z = THREE.MathUtils.clamp( cube.position.z, - 3, 3 );
+						cube.userData.velocity.z = - cube.userData.velocity.z;
+
+					}
+
+					cube.rotation.x += cube.userData.velocity.x * 2 * delta;
+					cube.rotation.y += cube.userData.velocity.y * 2 * delta;
+					cube.rotation.z += cube.userData.velocity.z * 2 * delta;
+
+				}
+
+				renderer.render( scene, camera );
+
+			}
+
+		</script>
+	</body>
+</html>

+ 1 - 0
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';

+ 22 - 0
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.
 	 *

+ 41 - 7
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.

+ 1075 - 0
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<Camera>}
+		 */
+		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<WebXRController>}
+		 */
+		this._controllers = [];
+
+		/**
+		 * A list of XR input source. Each input source belongs to
+		 * an instance of WebXRController.
+		 *
+		 * @private
+		 * @type {Array<XRInputSource?>}
+		 */
+		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;

+ 46 - 3
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 = {} );

粤ICP备19079148号