@threlte/xr
Version:
Tools to more easily create VR and AR experiences with Threlte
320 lines (319 loc) • 12.3 kB
JavaScript
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;
};