UNPKG

@threlte/xr

Version:

Tools to more easily create VR and AR experiences with Threlte

320 lines (319 loc) 12.3 kB
import { Group, Vector3 } from 'three'; import { XRControllerModelFactory } from 'three/examples/jsm/webxr/XRControllerModelFactory.js'; import { XRHandModelFactory } from 'three/examples/jsm/webxr/XRHandModelFactory.js'; import { useTask, useThrelte } from '@threlte/core'; import { createInputSourceEvent, dispatchInputSourceStateEvent, inputSources } from './inputSources.svelte.js'; const PINCH_DISTANCE = 0.02; const PINCH_THRESHOLD = 0.005; const makeId = (inputSource) => `${inputSource.handedness}-${inputSource.hand ? 'hand' : 'nohand'}-${inputSource.targetRayMode}-${inputSource.profiles.join(',')}`; const createSpaceWithVelocity = () => { const group = new Group(); group.matrixAutoUpdate = false; group.visible = false; group.hasLinearVelocity = false; group.linearVelocity = new Vector3(); group.hasAngularVelocity = false; group.angularVelocity = new Vector3(); return group; }; const createTargetRaySpace = () => createSpaceWithVelocity(); const createGripSpace = () => createSpaceWithVelocity(); const createHandSpace = () => { const hand = new Group(); hand.matrixAutoUpdate = false; hand.visible = false; hand.joints = {}; hand.inputState = { pinching: false }; return hand; }; const hideState = (state) => { state.targetRay.visible = false; if (state.type === 'controller') { state.grip.visible = false; } if (state.type === 'hand') { state.hand.visible = false; for (const joint of Object.values(state.hand.joints ?? {})) { joint.visible = false; } state.hand.inputState.pinching = false; } }; const ensureHandJoint = (hand, inputJoint) => { const existing = hand.joints[inputJoint.jointName]; if (existing !== undefined) { return existing; } const joint = new Group(); joint.matrixAutoUpdate = false; joint.visible = false; hand.joints[inputJoint.jointName] = joint; hand.add(joint); return joint; }; const ensureHandJoints = (state) => { const hand = state.hand; for (const inputJoint of state.inputSource.hand.values()) { ensureHandJoint(hand, inputJoint); } }; const updateSpacePose = (space, pose) => { if (pose === undefined || pose === null) { space.visible = false; space.hasLinearVelocity = false; space.hasAngularVelocity = false; return; } space.matrix.fromArray(pose.transform.matrix); space.matrix.decompose(space.position, space.quaternion, space.scale); space.matrixWorldNeedsUpdate = true; space.visible = true; if ('linearVelocity' in pose && pose.linearVelocity !== null && pose.linearVelocity !== undefined) { ; space.hasLinearVelocity = true; space.linearVelocity.copy(pose.linearVelocity); } else { ; space.hasLinearVelocity = false; } if ('angularVelocity' in pose && pose.angularVelocity !== null && pose.angularVelocity !== undefined) { ; space.hasAngularVelocity = true; space.angularVelocity.copy(pose.angularVelocity); } else { ; space.hasAngularVelocity = false; } }; const updatePinchState = (state) => { const hand = state.hand; const inputState = hand.inputState; const indexTip = hand.joints['index-finger-tip']; const thumbTip = hand.joints['thumb-tip']; if (indexTip === undefined || thumbTip === undefined || !indexTip.visible || !thumbTip.visible) { if (inputState.pinching) { inputState.pinching = false; const event = createInputSourceEvent(state, 'pinchend', { handedness: state.handedness, target: hand }); dispatchInputSourceStateEvent(state, 'pinchend', event); } return; } const distance = indexTip.position.distanceTo(thumbTip.position); if (inputState.pinching && distance > PINCH_DISTANCE + PINCH_THRESHOLD) { inputState.pinching = false; const event = createInputSourceEvent(state, 'pinchend', { handedness: state.handedness, target: hand }); dispatchInputSourceStateEvent(state, 'pinchend', event); } else if (!inputState.pinching && distance <= PINCH_DISTANCE - PINCH_THRESHOLD) { inputState.pinching = true; const event = createInputSourceEvent(state, 'pinchstart', { handedness: state.handedness, target: hand }); dispatchInputSourceStateEvent(state, 'pinchstart', event); } }; const updateXRControllerState = (state, frame, referenceSpace) => { let gripPose; if (state.inputSource.gripSpace !== undefined) { gripPose = frame.getPose(state.inputSource.gripSpace, referenceSpace); updateSpacePose(state.grip, gripPose); } let targetRayPose = frame.getPose(state.inputSource.targetRaySpace, referenceSpace); if (targetRayPose === null && gripPose !== undefined && gripPose !== null) { targetRayPose = gripPose; } updateSpacePose(state.targetRay, targetRayPose); if (state.inputSource.gripSpace === undefined || gripPose === undefined || gripPose === null) { updateSpacePose(state.grip, targetRayPose); } }; const updateXRHandState = (state, frame, referenceSpace) => { updateSpacePose(state.targetRay, frame.getPose(state.inputSource.targetRaySpace, referenceSpace)); const hand = state.hand; ensureHandJoints(state); let visible = false; for (const inputJoint of state.inputSource.hand.values()) { const pose = frame.getJointPose?.(inputJoint, referenceSpace); const joint = ensureHandJoint(hand, inputJoint); if (pose !== undefined && pose !== null) { joint.matrix.fromArray(pose.transform.matrix); joint.matrix.decompose(joint.position, joint.quaternion, joint.scale); joint.matrixWorldNeedsUpdate = true; joint.jointRadius = pose.radius; visible = true; } joint.visible = pose !== undefined && pose !== null; } hand.visible = visible; updatePinchState(state); }; const updateXRInputSourceState = (state, frame, referenceSpace) => { if (frame.session.visibilityState === 'visible-blurred' || frame.session.visibilityState === 'hidden') { hideState(state); return; } switch (state.type) { case 'controller': updateXRControllerState(state, frame, referenceSpace); break; case 'hand': updateXRHandState(state, frame, referenceSpace); break; default: updateSpacePose(state.targetRay, frame.getPose(state.inputSource.targetRaySpace, referenceSpace)); break; } }; let idCounter = 0; const createXRInputSourceState = (id, inputSource, isPrimary, controllerModelFactory, handModelFactory) => { const base = { id, inputSource, handedness: inputSource.handedness, isPrimary, targetRay: createTargetRaySpace() }; if (inputSource.hand != null) { if (inputSource.handedness === 'none') return undefined; const hand = createHandSpace(); const state = { ...base, type: 'hand', inputSource: inputSource, hand, model: handModelFactory.createHandModel(hand, 'mesh') }; ensureHandJoints(state); return state; } switch (inputSource.targetRayMode) { case 'gaze': return { ...base, type: 'gaze' }; case 'screen': return { ...base, type: 'screenInput' }; case 'transient-pointer': return { ...base, type: 'transientPointer' }; case 'tracked-pointer': default: { const grip = createGripSpace(); return { ...base, type: 'controller', grip, model: controllerModelFactory.createControllerModel(grip) }; } } }; const createSyncXRInputSourceStates = (controllerModelFactory, handModelFactory) => { const idMap = new Map(); return (_session, current, changes) => { if (changes === 'remove-all') { for (const state of current) { const event = createInputSourceEvent(state, 'disconnected'); dispatchInputSourceStateEvent(state, 'disconnected', event); hideState(state); } return []; } const target = [...current]; for (const { added, isPrimary, removed } of changes) { if (removed != null) { for (const inputSource of removed) { const index = target.findIndex((state) => state.isPrimary === isPrimary && state.inputSource === inputSource); if (index === -1) continue; const [state] = target.splice(index, 1); const event = createInputSourceEvent(state, 'disconnected'); dispatchInputSourceStateEvent(state, 'disconnected', event); hideState(state); } } if (added == null) continue; for (const inputSource of added) { if (target.some((state) => state.isPrimary === isPrimary && state.inputSource === inputSource)) { continue; } const key = makeId(inputSource); let id = idMap.get(key); if (id == null) { id = `${idCounter++}`; idMap.set(key, id); } const state = createXRInputSourceState(id, inputSource, isPrimary, controllerModelFactory, handModelFactory); if (state === undefined) { continue; } target.push(state); const event = createInputSourceEvent(state, 'connected'); dispatchInputSourceStateEvent(state, 'connected', event); } } return target; }; }; const createBindToSession = (syncXRInputSourceStates) => { let cleanupSession; return (session) => { cleanupSession?.(); cleanupSession = undefined; if (session == null) { inputSources.current = []; return; } const inputSourceChangesList = []; const applySourcesChange = () => { inputSources.current = syncXRInputSourceStates(session, inputSources.current, inputSourceChangesList); inputSourceChangesList.length = 0; }; const onInputSourcesChange = (event) => { inputSourceChangesList.push({ isPrimary: true, added: event.added, removed: event.removed }); applySourcesChange(); }; session.addEventListener('inputsourceschange', onInputSourcesChange); inputSources.current = syncXRInputSourceStates(session, [], [{ isPrimary: true, added: session.inputSources }]); cleanupSession = () => { inputSources.current = syncXRInputSourceStates(session, inputSources.current, 'remove-all'); session.removeEventListener('inputsourceschange', onInputSourcesChange); }; }; }; export const setupInputSources = (controllerFactory, handFactory) => { const { xr } = useThrelte().renderer; const controllerModelFactory = controllerFactory ?? new XRControllerModelFactory(); const handModelFactory = handFactory ?? new XRHandModelFactory(); const syncXRInputSourceStates = createSyncXRInputSourceStates(controllerModelFactory, handModelFactory); const bindToSession = createBindToSession(syncXRInputSourceStates); useTask(() => { const frame = xr.getFrame(); const referenceSpace = xr.getReferenceSpace(); if (frame === null || referenceSpace === null) return; for (const state of inputSources.current) { updateXRInputSourceState(state, frame, referenceSpace); } }, { running: () => inputSources.current.length > 0 }); return bindToSession; };