@pmndrs/xr
Version:
VR/AR for threejs
551 lines (550 loc) • 22.5 kB
JavaScript
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;
}