UNPKG

playcanvas

Version:

Open-source WebGL/WebGPU 3D engine for the web

265 lines (225 loc) 7.63 kB
import { Color, Quat, Script, Vec3, LAYERID_SKYBOX } from 'playcanvas'; /** @import { Entity } from 'playcanvas' */ /** * Manages WebXR session lifecycle for VR and AR experiences. This script handles starting and * ending XR sessions, manages camera rig transforms during XR, and provides automatic cleanup * when sessions end. * * Features: * - Supports both immersive-vr and immersive-ar session types * - Configurable app events for starting/ending sessions * - Automatic camera transform management for VR/AR transitions * - AR mode automatically makes camera background transparent and hides skybox * - ESC key support to exit XR sessions * - Proper cleanup on session end and script destruction * * This script should be attached to a parent entity of the camera entity used for the XR * session. The entity hierarchy should be: CameraParent (with XrSession) > CameraEntity. * Use it in conjunction with `XrControllers`, `XrNavigation`, and `XrMenu` scripts. * * @example * // Add to camera parent entity * cameraParent.addComponent('script'); * cameraParent.script.create(XrSession, { * properties: { * startVrEvent: 'vr:start', * startArEvent: 'ar:start', * endEvent: 'xr:end' * } * }); * * // Start VR from anywhere in your app * app.fire('vr:start'); * * // Or start AR * app.fire('ar:start'); */ class XrSession extends Script { static scriptName = 'xrSession'; /** * Event name to start the WebXR AR session. * * @type {string} * @attribute */ startArEvent = 'ar:start'; /** * Event name to start the WebXR VR session. * * @type {string} * @attribute */ startVrEvent = 'vr:start'; /** * Event name to end the WebXR VR session. * * @type {string} * @attribute */ endEvent = 'xr:end'; /** * Reference to the camera entity (child of this entity). * * @type {Entity|null} * @private */ cameraEntity = null; /** * Reference to the camera root entity (this entity). * * @type {Entity|null} * @private */ cameraRootEntity = null; /** * Cached clear color for restoration after AR session. * * @type {Color} * @private */ clearColor = new Color(); /** * Cached root entity position for restoration after XR session. * * @type {Vec3} * @private */ positionRoot = new Vec3(); /** * Cached root entity rotation for restoration after XR session. * * @type {Quat} * @private */ rotationRoot = new Quat(); /** * Cached camera entity position for restoration after XR session. * * @type {Vec3} * @private */ positionCamera = new Vec3(); /** * Cached camera entity rotation for restoration after XR session. * * @type {Quat} * @private */ rotationCamera = new Quat(); /** * Bound keydown event handler for ESC key detection. * * @type {((event: KeyboardEvent) => void)|null} * @private */ onKeyDownHandler = null; /** * Cached sky layer enabled state for restoration after AR session. * * @type {boolean} * @private */ _skyEnabled = true; initialize() { this.cameraEntity = this.entity.findComponent('camera')?.entity || null; this.cameraRootEntity = this.entity || null; // Listen to global XR lifecycle to mirror example.mjs behavior this.app.xr?.on('start', this.onXrStart, this); this.app.xr?.on('end', this.onXrEnd, this); // Listen for external events to control session this.app.on(this.startArEvent, this.onStartArEvent, this); this.app.on(this.startVrEvent, this.onStartVrEvent, this); this.app.on(this.endEvent, this.onEndEvent, this); // ESC to exit this.onKeyDownHandler = (event) => { if (event.key === 'Escape' && this.app.xr?.active) { this.endSession(); } }; window.addEventListener('keydown', this.onKeyDownHandler); this.on('destroy', () => { this.onDestroy(); }); } onDestroy() { this.app.xr?.off('start', this.onXrStart, this); this.app.xr?.off('end', this.onXrEnd, this); this.app.off(this.startVrEvent, this.onStartVrEvent, this); this.app.off(this.startArEvent, this.onStartArEvent, this); this.app.off(this.endEvent, this.onEndEvent, this); if (this.onKeyDownHandler) { window.removeEventListener('keydown', this.onKeyDownHandler); this.onKeyDownHandler = null; } } onStartArEvent(space = 'local-floor') { this.startSession('immersive-ar', space); } onStartVrEvent(space = 'local-floor') { this.startSession('immersive-vr', space); } onEndEvent() { this.endSession(); } startSession(type = 'immersive-vr', space = 'local-floor') { if (!this.cameraEntity.camera) { console.error('XrSession: No cameraEntity.camera found on the entity.'); return; } // Start XR on the camera component this.cameraEntity.camera.startXr(type, space, { callback: (err) => { if (err) console.error(`WebXR ${type} failed to start: ${err.message}`); } }); } endSession() { if (!this.cameraEntity.camera) return; this.cameraEntity.camera.endXr(); } onXrStart() { if (!this.cameraEntity || !this.cameraRootEntity) return; // Cache original camera rig transforms this.positionRoot.copy(this.cameraRootEntity.getPosition()); this.rotationRoot.copy(this.cameraRootEntity.getRotation()); this.positionCamera.copy(this.cameraEntity.getPosition()); this.rotationCamera.copy(this.cameraEntity.getRotation()); // Place root at camera position, but reset orientation to horizontal this.cameraRootEntity.setPosition(this.positionCamera.x, 0, this.positionCamera.z); // Only preserve Y-axis rotation (yaw), reset pitch and roll for VR const eulerAngles = this.rotationCamera.getEulerAngles(); this.cameraRootEntity.setEulerAngles(0, eulerAngles.y, 0); if (this.app.xr.type === 'immersive-ar') { // Make camera background transparent and hide the sky this.clearColor.copy(this.cameraEntity.camera.clearColor); this.cameraEntity.camera.clearColor = new Color(0, 0, 0, 0); this.disableSky(); } } onXrEnd() { if (!this.cameraEntity || !this.cameraRootEntity) return; // Restore original transforms this.cameraRootEntity.setPosition(this.positionRoot); this.cameraRootEntity.setRotation(this.rotationRoot); this.cameraEntity.setPosition(this.positionCamera); this.cameraEntity.setRotation(this.rotationCamera); if (this.app.xr.type === 'immersive-ar') { this.cameraEntity.camera.clearColor = this.clearColor; this.restoreSky(); } } disableSky() { const layer = this.app.scene.layers.getLayerById(LAYERID_SKYBOX); if (layer) { this._skyEnabled = layer.enabled; layer.enabled = false; } } restoreSky() { const layer = this.app.scene.layers.getLayerById(LAYERID_SKYBOX); if (layer) { layer.enabled = this._skyEnabled; } } } export { XrSession };