@threlte/xr
Version:
Tools to more easily create VR and AR experiences with Threlte
257 lines (256 loc) • 10.6 kB
JavaScript
import { Vector3 } from 'three';
import { fromStore } from 'svelte/store';
import { getInternalContext } from './context.js';
import { addSubscriber } from '../../internal/inputSources.svelte.js';
import { useFixed } from '../../internal/useFixed.js';
import { isPresenting } from '../../internal/state.svelte.js';
// Hover identity must match the dedup key used in `getHits`, otherwise the ID
// changes mid-hover (e.g. the hit's face index changes as the ray sweeps a
// plain mesh) and the object flickers between pointerout/pointerenter every
// frame.
const getIntersectionId = (intersection) => {
const target = intersection.eventObject ?? intersection.object;
if (intersection.instanceId !== undefined) {
return `${target.uuid}|${intersection.instanceId}`;
}
if (intersection.object.isPoints) {
return `${target.uuid}|${intersection.index}`;
}
return target.uuid;
};
const EPSILON = 0.0001;
// Starts high enough to stay clear of browser-assigned DOM pointerIds in the
// same session. Incremented per setupPointerControls call so each hand — and
// each reconnect — gets a distinct id.
let nextPointerId = 1001;
export const setupPointerControls = (context, handContext, fixedStep = 1 / 40) => {
const handedness = handContext.hand;
const pointerId = nextPointerId++;
const enabled = fromStore(handContext.enabled);
const { dispatchers } = getInternalContext();
let hits = [];
const lastRayOrigin = new Vector3();
const lastRayDirection = new Vector3();
const handlePointerDown = (event) => {
// Save initial coordinates on pointer-down
const [hit] = hits;
if (!hit)
return;
handContext.initialClick = [hit.point.x, hit.point.y, hit.point.z];
handContext.initialHits = hits.map((hit) => hit.eventObject);
handleEvent('onpointerdown', event);
};
const handlePointerUp = (event) => {
handleEvent('onpointerup', event);
};
const handleClick = (event) => {
handleEvent('onclick', event);
};
function cancelPointer(intersections) {
if (handContext.hovered.size === 0)
return;
const currentIds = new Set();
for (const hit of intersections) {
currentIds.add(getIntersectionId(hit));
}
const toRemove = [];
for (const [id, hoveredObj] of handContext.hovered) {
if (!currentIds.has(id)) {
toRemove.push([id, hoveredObj]);
}
}
for (const [id, hoveredObj] of toRemove) {
const { eventObject } = hoveredObj;
handContext.hovered.delete(id);
const events = dispatchers.get(eventObject);
if (events !== undefined) {
// Clear out intersects, they are outdated by now
const data = { ...hoveredObj, intersections };
events.onpointerout?.(data);
events.onpointerleave?.(data);
}
}
if (handContext.hovered.size === 0) {
handContext.pointerOverTarget.set(false);
}
handContext.syncSharedState();
}
const getHits = () => {
const intersections = [];
const rawHits = handContext.raycaster.intersectObjects(context.interactiveObjects, true);
const seen = new Set();
// Deduplicate hits by object. When recursive=true, intersectObjects searches
// each registered object's full subtree, so a child that is itself registered
// appears once per registered ancestor — causing duplicate events. The key is
// context-sensitive so that legitimate multi-hit objects are preserved:
// InstancedMesh — each instance is a distinct target, key by instanceId
// Points — each point is a distinct target, key by point index
// Mesh / other — uuid only; multiple face hits are the same surface
const hits = rawHits.filter((hit) => {
const key = hit.instanceId !== undefined
? `${hit.object.uuid}|${hit.instanceId}`
: hit.object.isPoints
? `${hit.object.uuid}|${hit.index}`
: hit.object.uuid;
if (seen.has(key))
return false;
seen.add(key);
return true;
});
const filtered = handContext.filter === undefined ? hits : handContext.filter(hits, context, handContext);
handContext.currentIntersection = filtered[0];
handContext.syncSharedState();
// Bubble up the events, find the event source (eventObject)
for (const hit of filtered) {
let eventObject = hit.object;
// Bubble event up
while (eventObject) {
if (dispatchers.has(eventObject)) {
intersections.push({ ...hit, eventObject });
}
eventObject = eventObject.parent;
}
}
return intersections;
};
function pointerMissed(objects, event) {
for (const object of objects) {
dispatchers.get(object)?.onpointermissed?.(event);
}
}
function processHits() {
handContext.compute(context, handContext);
return getHits();
}
const handleEvent = (name, event) => {
const isPointerMove = name === 'onpointermove';
const isClickEvent = name === 'onclick' || name === 'oncontextmenu';
// Fire pointermissed for objects that were not under the pointer at pointerdown.
// Must come before the dispatch loop so user-land cleanup runs first.
if (isClickEvent) {
pointerMissed(context.interactiveObjects.filter((object) => !handContext.initialHits.includes(object)), event);
}
// Update hover state before dispatch so that pointerout/pointerleave fire
// before pointerover/pointerenter on newly hit objects.
if (isPointerMove)
cancelPointer(hits);
let stopped = false;
// loop through all hits and dispatch events
dispatchEvents: for (const hit of hits) {
const events = dispatchers.get(hit.eventObject);
if (events === undefined)
continue;
const intersectionEvent = {
stopped,
...hit,
intersections: hits,
handedness,
pointerId,
stopPropagation() {
stopped = true;
intersectionEvent.stopped = true;
if (handContext.hovered.size > 0 &&
Array.from(handContext.hovered.values()).some((i) => i.eventObject === hit.eventObject)) {
// Objects cannot flush out higher up objects that have already caught the event
const higher = hits.slice(0, hits.indexOf(hit));
cancelPointer([...higher, hit]);
}
},
delta: 0,
nativeEvent: event,
pointer: handContext.pointer.current,
ray: handContext.raycaster.ray
};
if (isPointerMove) {
// Move event ...
handContext.pointer.update((value) => value.copy(intersectionEvent.point));
if (events.onpointerover ||
events.onpointerenter ||
events.onpointerout ||
events.onpointerleave) {
const id = getIntersectionId(intersectionEvent);
const hoveredItem = handContext.hovered.get(id);
if (hoveredItem === undefined) {
// If the object wasn't previously hovered, book it and call its handler
handContext.hovered.set(id, intersectionEvent);
events.onpointerover?.(intersectionEvent);
events.onpointerenter?.(intersectionEvent);
handContext.pointerOverTarget.set(true);
handContext.syncSharedState();
}
else if (hoveredItem.stopped) {
// If the object was previously hovered and stopped, we shouldn't allow other items to proceed
intersectionEvent.stopPropagation();
}
}
// Call pointer move
events.onpointermove?.(intersectionEvent);
}
else if (events[name] !== undefined) {
// All other events
if (!isClickEvent || handContext.initialHits.includes(hit.eventObject)) {
events[name]?.(intersectionEvent);
}
}
if (stopped)
break dispatchEvents;
}
};
const { start, stop } = useFixed(() => {
hits = processHits();
const ray = handContext.raycaster.ray;
if (ray.origin.distanceToSquared(lastRayOrigin) > EPSILON * EPSILON ||
1 - ray.direction.dot(lastRayDirection) > EPSILON) {
handleEvent('onpointermove');
}
lastRayOrigin.copy(ray.origin);
lastRayDirection.copy(ray.direction);
}, {
fixedStep,
autoStart: false
});
$effect.pre(() => {
if (isPresenting.current && enabled.current) {
start();
}
else {
stop();
hits = [];
handContext.currentIntersection = undefined;
cancelPointer([]);
handContext.syncSharedState();
}
});
$effect.pre(() => {
if (handContext.sourceType !== 'controller')
return;
if (!enabled.current)
return;
return addSubscriber({
type: 'controller',
handedness,
callbacks: {
onselectstart: handlePointerDown,
onselectend: handlePointerUp,
onselect: handleClick
}
});
});
$effect.pre(() => {
if (handContext.sourceType !== 'hand')
return;
if (!enabled.current)
return;
return addSubscriber({
type: 'hand',
handedness,
callbacks: {
onpinchstart: handlePointerDown,
onpinchend: ((event) => {
handlePointerUp(event);
handleClick(event);
})
}
});
});
};