UNPKG

@pmndrs/xr

Version:
551 lines (550 loc) 22.5 kB
import { Vector3 } from 'three'; import { createStore } from 'zustand/vanilla'; import { updateXRControllerState } from './controller/index.js'; import { updateXRHandState } from './hand/state.js'; import { buildXRSessionInit } from './init.js'; import { createSyncXRInputSourceStates } from './input.js'; export function resolveInputSourceImplementation(implementation, handedness, defaultValue) { if (typeof implementation === 'function') { return implementation; } if (typeof implementation === 'object') { if (handedness != null && hasKey(implementation, handedness)) { implementation = implementation[handedness]; } else if ('default' in implementation) { implementation = implementation.default; } } if (implementation === false) { return false; } if (implementation === true) { return defaultValue; } return implementation ?? defaultValue; } function hasKey(val, key) { return key in val; } const baseInitialState = { session: undefined, mediaBinding: undefined, originReferenceSpace: undefined, visibilityState: undefined, mode: null, frameRate: undefined, inputSourceStates: [], detectedMeshes: [], detectedPlanes: [], layerEntries: [], }; async function injectEmulator(store, emulateOptions, alert) { if (typeof navigator === 'undefined') { return false; } const [vr, ar] = await Promise.all([ navigator.xr?.isSessionSupported('immersive-vr').catch((e) => { console.error(e); return false; }), navigator.xr?.isSessionSupported('immersive-ar').catch((e) => { console.error(e); return false; }), ]); if (ar || vr) { return false; } const { emulate } = await import('./emulate.js'); if (alert) { window.alert(`emulator started`); } store.setState({ emulator: emulate(emulateOptions === true ? 'metaQuest3' : emulateOptions), }); return true; } //helpers for layer sorting const cameraWorldPosition = new Vector3(); const tempLayerWorldPosition = new Vector3(); export function createXRStore(options) { //dom overlay root element creation const domOverlayRoot = typeof HTMLElement === 'undefined' ? undefined : options?.domOverlay instanceof HTMLElement ? options.domOverlay : document.createElement('div'); //store const store = createStore(() => ({ ...baseInitialState, controller: options?.controller, hand: options?.hand, gaze: options?.gaze, screenInput: options?.screenInput, transientPointer: options?.transientPointer, domOverlayRoot, })); const unsubscribeSessionOffer = store.subscribe(({ session }, { session: oldSession }) => { if (oldSession != null && session == null && xrManager != null) { offerSession(xrManager, options, domOverlayRoot).catch(console.error); } }); //emulation const emulate = options?.emulate ?? 'metaQuest3'; let cleanupEmulate; if (typeof window !== 'undefined' && emulate != false) { const inject = (typeof emulate === 'object' ? emulate.inject : undefined) ?? { hostname: 'localhost' }; if (inject === true || (typeof inject != 'boolean' && window.location.hostname === inject.hostname)) { injectEmulator(store, emulate, false).then((emulate) => { if (!emulate || xrManager == null) { return; } offerSession(xrManager, options, domOverlayRoot); }); } const keydownListener = (e) => { if (e.altKey && e.metaKey && e.code === 'KeyE') { injectEmulator(store, emulate, true).then((emulate) => { if (!emulate || xrManager == null) { return; } offerSession(xrManager, options, domOverlayRoot); }); } }; window.addEventListener('keydown', keydownListener); cleanupEmulate = () => window.removeEventListener('keydown', keydownListener); } //dom overlay root setup let cleanupDomOverlayRoot; if (domOverlayRoot != null) { if (domOverlayRoot.parentNode == null) { const setupDisplay = (state) => { domOverlayRoot.style.display = state.session != null ? 'block' : 'none'; }; const unsubscribe = store.subscribe(setupDisplay); setupDisplay(store.getState()); document.body.appendChild(domOverlayRoot); cleanupDomOverlayRoot = () => { domOverlayRoot.remove(); unsubscribe(); }; } document.body.append(domOverlayRoot); } const syncXRInputSourceStates = createSyncXRInputSourceStates((state) => store.setState({ inputSourceStates: [...store.getState().inputSourceStates, state] }), options); const bindToSession = createBindToSession(store, syncXRInputSourceStates, options?.secondaryInputSources ?? false); const cleanupSessionGrantedListener = setupSessionGrantedListener(options?.enterGrantedSession, (mode) => enterXRSession(domOverlayRoot, mode, options, xrManager)); const frameRequests = []; let xrManager; const onSessionStart = () => { store.setState(bindToSession(xrManager.getSession())); }; return Object.assign(store, { addLayerEntry(layerEntry) { if (store.getState().session == null) { return; } store.setState({ layerEntries: [...store.getState().layerEntries, layerEntry] }); }, removeLayerEntry(layerEntry) { if (store.getState().session == null) { return; } store.setState({ layerEntries: store.getState().layerEntries.filter((entry) => entry != layerEntry) }); }, requestFrame() { return new Promise((resolve) => frameRequests.push(resolve)); }, setWebXRManager(newXrManager) { if (xrManager === newXrManager) { return; } xrManager?.removeEventListener('sessionstart', onSessionStart); xrManager = newXrManager; xrManager.addEventListener('sessionstart', onSessionStart); const { foveation, bounded } = options ?? {}; xrManager.setReferenceSpaceType(bounded ? 'bounded-floor' : 'local-floor'); if (foveation != null) { xrManager.setFoveation(foveation); } offerSession(xrManager, options, domOverlayRoot).catch(console.error); }, setFrameRate(value) { const { session } = store.getState(); if (session == null) { return; } setFrameRate(session, value); }, setHand(implementation, handedness) { if (handedness == null) { store.setState({ hand: implementation }); return; } const currentImplementation = store.getState().hand; const newControllerImplementation = {}; if (typeof currentImplementation === 'object') { Object.assign(newControllerImplementation, currentImplementation); } Object.assign(newControllerImplementation, { default: resolveInputSourceImplementation(currentImplementation, undefined, {}), [handedness]: implementation, }); store.setState({ hand: newControllerImplementation, }); }, setController(implementation, handedness) { if (handedness == null) { store.setState({ controller: implementation }); return; } const currentImplementation = store.getState().controller; const newControllerImplementation = {}; if (typeof currentImplementation === 'object') { Object.assign(newControllerImplementation, currentImplementation); } Object.assign(newControllerImplementation, { default: resolveInputSourceImplementation(currentImplementation, undefined, {}), [handedness]: implementation, }); store.setState({ controller: newControllerImplementation, }); }, setTransientPointer(implementation, handedness) { if (handedness == null) { store.setState({ transientPointer: implementation }); return; } const currentImplementation = store.getState().transientPointer; const newControllerImplementation = {}; if (typeof currentImplementation === 'object') { Object.assign(newControllerImplementation, currentImplementation); } Object.assign(newControllerImplementation, { default: resolveInputSourceImplementation(currentImplementation, undefined, {}), [handedness]: implementation, }); store.setState({ transientPointer: newControllerImplementation, }); }, setGaze(implementation) { store.setState({ gaze: implementation }); }, setScreenInput(implementation) { store.setState({ screenInput: implementation }); }, destroy() { xrManager?.removeEventListener('sessionstart', onSessionStart); cleanupEmulate?.(); cleanupDomOverlayRoot?.(); cleanupSessionGrantedListener?.(); unsubscribeSessionOffer(); //unbinding the session bindToSession(undefined); }, enterXR: (mode) => enterXRSession(domOverlayRoot, mode, options, xrManager), enterAR: () => enterXRSession(domOverlayRoot, 'immersive-ar', options, xrManager), enterVR: () => enterXRSession(domOverlayRoot, 'immersive-vr', options, xrManager), onBeforeFrame(scene, camera, frame) { let update; const referenceSpace = xrManager?.getReferenceSpace() ?? undefined; const state = store.getState(); //update origin const origin = camera.parent ?? scene; if (state.origin != origin) { update ??= {}; update.origin = origin; } //update reference space if (referenceSpace != state.originReferenceSpace) { update ??= {}; update.originReferenceSpace = referenceSpace; } //set xr space on current origin (and reset on previous) origin.xrSpace = referenceSpace; if (state.origin != origin && state.origin != null) { state.origin.xrSpace = undefined; } if (frame != null) { if (xrManager != null) { updateSession(store, frame, xrManager); } if (state.body != frame.body) { update ??= {}; update.body = frame.body; } } if (update != null) { store.setState(update); } if (frame != null) { const length = frameRequests.length; for (let i = 0; i < length; i++) { frameRequests[i](frame); } frameRequests.length = 0; } }, onBeforeRender() { const { session, layerEntries } = store.getState(); if (session == null || xrManager == null) { return; } const xrCamera = xrManager.getCamera(); //update camera aspect ratio xrCamera.aspect = xrCamera.projectionMatrix.elements[5] / xrCamera.projectionMatrix.elements[0]; const currentLayers = session?.renderState.layers; if (currentLayers == null) { return; } //layer sorting xrCamera.getWorldPosition(cameraWorldPosition); layerEntries.sort((entryA, entryB) => { const renderOrderDifference = entryA.renderOrder - entryB.renderOrder; //if renderOrder is the same, sort by distance to camera if (renderOrderDifference !== 0) { return renderOrderDifference; } entryA.object3D.getWorldPosition(tempLayerWorldPosition); const distA_sq = tempLayerWorldPosition.distanceToSquared(cameraWorldPosition); entryB.object3D.getWorldPosition(tempLayerWorldPosition); const distB_sq = tempLayerWorldPosition.distanceToSquared(cameraWorldPosition); return distB_sq - distA_sq; }); let changed = false; const layers = layerEntries.map(({ layer }, i) => { if (layer != currentLayers[i]) { changed = true; } return layer; }); if (!changed) { return; } layers.push(xrManager.getBaseLayer()); session.updateRenderState({ layers, }); }, }); } async function offerSession(manager, options, domOverlayRoot) { //offer session const offerSessionOptions = options?.offerSession ?? true; if (navigator.xr?.offerSession == null || offerSessionOptions === false) { return; } let mode; if (offerSessionOptions === true) { const arSupported = (await navigator.xr.isSessionSupported('immersive-ar')) ?? false; mode = arSupported ? 'immersive-ar' : 'immersive-vr'; } else { mode = offerSessionOptions; } const session = await navigator.xr.offerSession(mode, buildXRSessionInit(mode, domOverlayRoot, options)); setupXRSession(session, manager, options); } async function setFrameRate(session, frameRate) { if (frameRate === false) { return; } const { supportedFrameRates } = session; if (supportedFrameRates == null || supportedFrameRates.length === 0) { return; } if (typeof frameRate === 'function') { const value = frameRate(supportedFrameRates); if (value === false) { return; } return session.updateTargetFrameRate(value); } const multiplier = frameRate === 'high' ? 1 : frameRate === 'mid' ? 0.5 : 0; return session.updateTargetFrameRate(supportedFrameRates[Math.ceil((supportedFrameRates.length - 1) * multiplier)]); } async function enterXRSession(domOverlayRoot, mode, options, manager) { if (typeof navigator === 'undefined' || navigator.xr == null) { return Promise.reject(new Error(`WebXR not supported`)); } if (manager == null) { return Promise.reject(new Error(`not connected to three.js. You either might be missing the <XR> component or the canvas is not yet loaded?`)); } const session = await navigator.xr.requestSession(mode, buildXRSessionInit(mode, domOverlayRoot, options)); setupXRSession(session, manager, options); return session; } function setupXRSession(session, manager, options) { setFrameRate(session, options?.frameRate ?? 'high'); setupXRManager(manager, session, options); } function setupXRManager(xr, session, options) { if (xr == null) { return; } const maxFrameBufferScalingFactor = XRWebGLLayer.getNativeFramebufferScaleFactor(session); let frameBufferScaling = options?.frameBufferScaling; if (typeof frameBufferScaling === 'function') { frameBufferScaling = frameBufferScaling(maxFrameBufferScalingFactor); } if (typeof frameBufferScaling === 'string') { frameBufferScaling = frameBufferScaling === 'high' ? maxFrameBufferScalingFactor : frameBufferScaling === 'mid' ? 1 : 0.5; } if (frameBufferScaling != null) { xr?.setFramebufferScaleFactor(frameBufferScaling); } xr?.setSession(session); } const allSessionModes = ['immersive-ar', 'immersive-vr', 'inline']; function setupSessionGrantedListener(enterGrantedSession = allSessionModes, enterXR) { if (typeof navigator === 'undefined' || enterGrantedSession === false) { return; } if (enterGrantedSession === true) { enterGrantedSession = allSessionModes; } const sessionGrantedListener = async () => { for (const mode of enterGrantedSession) { if (!(await navigator.xr?.isSessionSupported(mode))) { continue; } enterXR(mode); } }; navigator.xr?.addEventListener('sessiongranted', sessionGrantedListener); return () => navigator.xr?.removeEventListener('sessiongranted', sessionGrantedListener); } function createBindToSession(store, syncXRInputSourceStates, secondayInputSources) { let cleanupSession; return (session) => { cleanupSession?.(); if (session == null) { return {}; } //for debouncing the input source and tracked source changes const inputSourceChangesList = []; let inputSourceChangesTimeout; const applySourcesChange = () => { inputSourceChangesTimeout = undefined; store.setState({ inputSourceStates: syncXRInputSourceStates(session, store.getState().inputSourceStates, inputSourceChangesList), }); inputSourceChangesList.length = 0; }; const onSourcesChange = (isPrimary, e) => { inputSourceChangesList.push({ isPrimary, added: e.added, removed: e.removed }); if (inputSourceChangesTimeout != null) { return; } if (secondayInputSources) { inputSourceChangesTimeout = setTimeout(applySourcesChange, 100); } else { applySourcesChange(); } }; const onInputSourcesChange = onSourcesChange.bind(null, true); session.addEventListener('inputsourceschange', onInputSourcesChange); let cleanupSecondaryInputSources; if (secondayInputSources) { const onTrackedSourcesChange = onSourcesChange.bind(null, false); session.addEventListener('trackedsourceschange', onTrackedSourcesChange); cleanupSecondaryInputSources = () => session.removeEventListener('trackedsourceschange', onTrackedSourcesChange); } //frameratechange and visibilitychange handlers const onChange = () => store.setState({ frameRate: session.frameRate, visibilityState: session.visibilityState }); session.addEventListener('frameratechange', onChange); session.addEventListener('visibilitychange', onChange); //end handler const onEnd = () => { cleanupSession?.(); cleanupSession = undefined; store.setState({ emulator: store.getState().emulator, ...baseInitialState, }); }; session.addEventListener('end', onEnd); const initialChanges = [ { isPrimary: true, added: session.inputSources }, ]; if (secondayInputSources) { initialChanges.push({ isPrimary: false, added: session.trackedSources }); } const inputSourceStates = syncXRInputSourceStates(session, [], initialChanges); cleanupSession = () => { //cleanup cleanupSecondaryInputSources?.(); clearTimeout(inputSourceChangesTimeout); syncXRInputSourceStates(session, store.getState().inputSourceStates, 'remove-all'); session.removeEventListener('end', onEnd); session.removeEventListener('frameratechange', onChange); session.removeEventListener('visibilitychange', onChange); session.removeEventListener('inputsourceschange', onInputSourcesChange); }; return { inputSourceStates, frameRate: session.frameRate, visibilityState: session.visibilityState, detectedMeshes: [], detectedPlanes: [], mode: session.environmentBlendMode === 'opaque' ? 'immersive-vr' : 'immersive-ar', session, mediaBinding: typeof XRMediaBinding == 'undefined' ? undefined : new XRMediaBinding(session), }; }; } function updateSession(store, frame, manager) { const referenceSpace = manager.getReferenceSpace(); const { detectedMeshes: prevMeshes, detectedPlanes: prevPlanes, session, inputSourceStates } = store.getState(); if (referenceSpace == null || session == null) { //not in a XR session return; } //update detected planes and meshes const detectedPlanes = updateDetectedEntities(prevPlanes, frame.detectedPlanes); const detectedMeshes = updateDetectedEntities(prevMeshes, frame.detectedMeshes); if (prevPlanes != detectedPlanes || prevMeshes != detectedMeshes) { store.setState({ detectedPlanes, detectedMeshes }); } //update input sources const inputSourceStatesLength = inputSourceStates.length; for (let i = 0; i < inputSourceStatesLength; i++) { const inputSourceState = inputSourceStates[i]; switch (inputSourceState.type) { case 'controller': updateXRControllerState(inputSourceState); break; case 'hand': updateXRHandState(inputSourceState, frame, manager); break; } } } const emptyArray = []; function updateDetectedEntities(prevDetectedEntities, detectedEntities) { if (detectedEntities == null) { return emptyArray; } if (prevDetectedEntities != null && equalContent(detectedEntities, prevDetectedEntities)) { return prevDetectedEntities; } return Array.from(detectedEntities); } function equalContent(set, arr) { if (set.size != arr.length) { return false; } for (const entry of arr) { if (!set.has(entry)) { return false; } } return true; }