@threlte/xr 1.5.4 → 1.5.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -117,7 +117,10 @@
117
117
  {/if}
118
118
 
119
119
  {#if targetRay}
120
- <T is={targetRay}>
120
+ <T
121
+ is={targetRay}
122
+ attach={scene}
123
+ >
121
124
  {@render targetRaySnippet?.()}
122
125
 
123
126
  {#if hasPointerControls || hasTeleportControls}
@@ -117,7 +117,10 @@
117
117
  </T>
118
118
 
119
119
  {#if targetRay !== undefined}
120
- <T is={xrHand.targetRay}>
120
+ <T
121
+ is={xrHand.targetRay}
122
+ attach={scene}
123
+ >
121
124
  {@render targetRay()}
122
125
  </T>
123
126
  {/if}
@@ -128,7 +128,7 @@ This should be placed within a Threlte `<Canvas />`.
128
128
  }
129
129
 
130
130
  const handleInputSourcesChange = (event: XRInputSourcesChangeEvent) => {
131
- isHandTracking.current = Object.values(event.session.inputSources).some((source) => source.hand)
131
+ isHandTracking.current = Array.from(event.session.inputSources).some((source) => source.hand)
132
132
  oninputsourceschange?.(event)
133
133
  }
134
134
 
@@ -1,5 +1,5 @@
1
1
  <script lang="ts">
2
- import { Color, DoubleSide, RawShaderMaterial, type ColorRepresentation } from 'three'
2
+ import { Color, DoubleSide, ShaderMaterial, type ColorRepresentation } from 'three'
3
3
  import { T } from '@threlte/core'
4
4
 
5
5
  interface Props {
@@ -11,10 +11,6 @@
11
11
  const { color = new Color('white'), size = 0.03, thickness = 0.035 }: Props = $props()
12
12
 
13
13
  const vertexShader = `
14
- uniform mat4 projectionMatrix;
15
- uniform mat4 modelViewMatrix;
16
- attribute vec2 uv;
17
- attribute vec3 position;
18
14
  varying vec2 vUv;
19
15
  void main() {
20
16
  vUv = uv;
@@ -23,14 +19,13 @@
23
19
  `
24
20
 
25
21
  const fragmentShader = `
26
- precision mediump float;
27
22
  uniform float thickness;
28
23
  uniform vec3 color;
29
24
  varying vec2 vUv;
30
25
  void main() {
31
- float radius = 0.1;
32
- float dist = length(vUv - vec2(0.5));
33
- float alpha = 1.0 - step(thickness, abs(distance(vUv, vec2(0.5)) - 0.25));
26
+ float d = abs(distance(vUv, vec2(0.5)) - 0.25);
27
+ float edge = fwidth(d);
28
+ float alpha = 1.0 - smoothstep(thickness - edge, thickness + edge, d);
34
29
  gl_FragColor = vec4(color, alpha);
35
30
  }
36
31
  `
@@ -40,7 +35,7 @@
40
35
  color: { value: color }
41
36
  }
42
37
 
43
- const shaderMaterial = new RawShaderMaterial({
38
+ const shaderMaterial = new ShaderMaterial({
44
39
  vertexShader,
45
40
  fragmentShader,
46
41
  uniforms,
@@ -11,6 +11,7 @@
11
11
 
12
12
  <script lang="ts">
13
13
  import { T, useTask, useThrelte } from '@threlte/core'
14
+ import { untrack } from 'svelte'
14
15
  import { pointerIntersection, pointerState } from '../../internal/state.svelte.js'
15
16
  import Cursor from './Cursor.svelte'
16
17
  import type { Snippet } from 'svelte'
@@ -28,6 +29,8 @@
28
29
 
29
30
  const ref = new Group()
30
31
 
32
+ const SURFACE_OFFSET = 0.002
33
+
31
34
  useTask(
32
35
  () => {
33
36
  if (intersection === undefined) {
@@ -43,17 +46,28 @@
43
46
 
44
47
  normalMatrix.getNormalMatrix(object.matrixWorld)
45
48
  worldNormal.copy(face.normal).applyMatrix3(normalMatrix).normalize()
46
- ref.lookAt(vec3.addVectors(point, worldNormal))
49
+
50
+ // Float the reticle just above the surface so it doesn't z-fight
51
+ // with the coplanar face underneath.
52
+ ref.position.addScaledVector(worldNormal, SURFACE_OFFSET)
53
+
54
+ ref.lookAt(vec3.addVectors(ref.position, worldNormal))
47
55
  },
48
56
  {
49
57
  running: () => hovering && intersection !== undefined
50
58
  }
51
59
  )
52
60
 
61
+ // Snap to the hit point on hover entry so the reticle doesn't visibly
62
+ // fly in from its previous location. `intersection` is read untracked
63
+ // so this only reruns on hover transitions, not every frame.
53
64
  $effect.pre(() => {
54
- if (hovering && intersection) {
55
- ref.position.copy(intersection.point)
56
- }
65
+ if (!hovering) return
66
+ untrack(() => {
67
+ if (intersection) {
68
+ ref.position.copy(intersection.point)
69
+ }
70
+ })
57
71
  })
58
72
  </script>
59
73
 
@@ -40,7 +40,10 @@
40
40
  if (face) {
41
41
  normalMatrix.getNormalMatrix(object.matrixWorld)
42
42
  worldNormal.copy(face.normal).applyMatrix3(normalMatrix).normalize()
43
- ref.lookAt(vec3.addVectors(point, worldNormal))
43
+ // lookAt from the lerped position (matches PointerCursor) — using the
44
+ // raw hit point here causes orientation to wobble while position is
45
+ // still easing toward the target.
46
+ ref.lookAt(vec3.addVectors(ref.position, worldNormal))
44
47
  }
45
48
  },
46
49
  {
@@ -1,5 +1,6 @@
1
1
  import { useTask, useThrelte } from '@threlte/core';
2
2
  import { hands } from './useHand.svelte.js';
3
+ import { isPresenting } from '../internal/state.svelte.js';
3
4
  import { toCurrentReadable } from './currentReadable.svelte.js';
4
5
  /**
5
6
  * Provides a reference to a requested hand joint, once available.
@@ -22,6 +23,6 @@ export const useHandJoint = (handedness, joint) => {
22
23
  jointSpace = undefined;
23
24
  invalidate();
24
25
  }
25
- });
26
+ }, { running: () => isPresenting.current });
26
27
  return toCurrentReadable(() => jointSpace);
27
28
  };
@@ -9,12 +9,12 @@ export const setupControllers = (factory) => {
9
9
  const hasHands = useHandTrackingState();
10
10
  const targetRaySpaces = [xr.getController(0), xr.getController(1)];
11
11
  const indexMap = new Map();
12
+ const modelFactory = factory ?? new XRControllerModelFactory();
12
13
  targetRaySpaces.forEach((targetRay, index) => {
13
- const model = (factory ?? new XRControllerModelFactory()).createControllerModel(targetRay);
14
14
  indexMap.set(targetRay, {
15
15
  targetRay,
16
16
  grip: xr.getControllerGrip(index),
17
- model
17
+ model: modelFactory.createControllerModel(targetRay)
18
18
  });
19
19
  });
20
20
  onMount(() => {
@@ -25,8 +25,13 @@ export const setupControllers = (factory) => {
25
25
  controllerEvents[data.handedness]?.[`on${event.type}`]?.(event);
26
26
  };
27
27
  function handleConnected(event) {
28
- const { model, targetRay, grip } = indexMap.get(this);
29
28
  const { data: inputSource } = event;
29
+ // The targetRaySpace 'connected' event fires for both controller and
30
+ // hand-tracking input sources. The controllers slot represents a physical
31
+ // controller — setupHands handles the hand-tracking side.
32
+ if (inputSource.hand)
33
+ return;
34
+ const { model, targetRay, grip } = indexMap.get(this);
30
35
  controllers[event.data.handedness] = {
31
36
  inputSource,
32
37
  targetRay,
@@ -9,12 +9,12 @@ export const setupHands = (factory) => {
9
9
  const hasHands = useHandTrackingState();
10
10
  const handSpaces = [xr.getHand(0), xr.getHand(1)];
11
11
  const map = new Map();
12
+ const modelFactory = factory ?? new XRHandModelFactory();
12
13
  handSpaces.forEach((handSpace, index) => {
13
- const model = (factory ?? new XRHandModelFactory()).createHandModel(handSpace, 'mesh');
14
14
  map.set(handSpace, {
15
15
  hand: handSpace,
16
16
  targetRay: xr.getController(index),
17
- model
17
+ model: modelFactory.createHandModel(handSpace, 'mesh')
18
18
  });
19
19
  });
20
20
  onMount(() => {
@@ -15,10 +15,10 @@ export const toggleXRSession = async (sessionMode, sessionInit, force) => {
15
15
  return currentSession;
16
16
  if (force === 'exit' && !hasSession)
17
17
  return;
18
- // Exit a session if entered
18
+ // Exit a session if entered. `session.current` is cleared by XR.svelte's
19
+ // `handleSessionEnd` when the 'end' event fires — don't duplicate that here.
19
20
  if (hasSession) {
20
21
  await currentSession.end();
21
- session.current = undefined;
22
22
  return;
23
23
  }
24
24
  if (xr.current === undefined) {
@@ -5,6 +5,10 @@ export const defaultComputeFunction = (context, handContext) => {
5
5
  const targetRay = controllers[handContext.hand]?.targetRay;
6
6
  if (targetRay === undefined)
7
7
  return;
8
+ // `<Controller>` attaches targetRay to the scene root so local === world;
9
+ // we can read `.position`/`.quaternion` directly without a matrixWorld
10
+ // roundtrip (which would force-recompose the matrix three.js writes from
11
+ // the XR pose and introduce a drift against the visible render).
8
12
  forward.set(0, 0, -1).applyQuaternion(targetRay.quaternion);
9
13
  context.raycaster.set(targetRay.position, forward);
10
14
  };
@@ -5,7 +5,6 @@ import { injectPointerControlsPlugin } from './plugin.svelte.js';
5
5
  import { setupPointerControls } from './setup.svelte.js';
6
6
  import { getControlsContext, getHandContext, setControlsContext, setHandContext, setInternalContext } from './context.js';
7
7
  import { pointerState } from '../../internal/state.svelte.js';
8
- let controlsCounter = 0;
9
8
  export const pointerControls = (handedness, options) => {
10
9
  if (getControlsContext() === undefined) {
11
10
  injectPointerControlsPlugin();
@@ -35,8 +34,7 @@ export const pointerControls = (handedness, options) => {
35
34
  }
36
35
  const handContext = getHandContext(handedness);
37
36
  observe.pre(() => [handContext.enabled], ([enabled]) => {
38
- controlsCounter += enabled ? 1 : -1;
39
- pointerState[handedness].enabled = controlsCounter > 0;
37
+ pointerState[handedness].enabled = enabled;
40
38
  });
41
39
  observe.pre(() => [handContext.pointerOverTarget], ([hovering]) => {
42
40
  pointerState[handedness].hovering = hovering;
@@ -5,11 +5,6 @@ export const injectPointerControlsPlugin = () => {
5
5
  injectPlugin('threlte-pointer-controls', (args) => {
6
6
  if (!isInstanceOf(args.ref, 'Object3D'))
7
7
  return;
8
- const hasEventHandlers = Object.entries(args.props).some(([key, value]) => {
9
- return value !== undefined && events.includes(key);
10
- });
11
- if (!hasEventHandlers)
12
- return;
13
8
  const { addInteractiveObject, removeInteractiveObject } = usePointerControls();
14
9
  $effect.pre(() => {
15
10
  const ref = args.ref;
@@ -5,8 +5,19 @@ import { controllers } from '../../hooks/useController.svelte.js';
5
5
  import { useHand } from '../../hooks/useHand.svelte.js';
6
6
  import { useFixed } from '../../internal/useFixed.js';
7
7
  import { isPresenting, pointerIntersection } from '../../internal/state.svelte.js';
8
+ // Hover identity must match the dedup key used in `getHits`, otherwise the ID
9
+ // changes mid-hover (e.g. the hit's face index changes as the ray sweeps a
10
+ // plain mesh) and the object flickers between pointerout/pointerenter every
11
+ // frame.
8
12
  const getIntersectionId = (intersection) => {
9
- return `${(intersection.eventObject || intersection.object).uuid}|${intersection.index}|${intersection.instanceId}`;
13
+ const target = intersection.eventObject ?? intersection.object;
14
+ if (intersection.instanceId !== undefined) {
15
+ return `${target.uuid}|${intersection.instanceId}`;
16
+ }
17
+ if (intersection.object.isPoints) {
18
+ return `${target.uuid}|${intersection.index}`;
19
+ }
20
+ return target.uuid;
10
21
  };
11
22
  const EPSILON = 0.0001;
12
23
  export const setupPointerControls = (context, handContext, fixedStep = 1 / 40) => {
@@ -29,24 +40,18 @@ export const setupPointerControls = (context, handContext, fixedStep = 1 / 40) =
29
40
  handleEvent('onpointerup', event);
30
41
  };
31
42
  const handleClick = (event) => {
32
- // If a click yields no results, pass it back to the user as a miss
33
- // Missed events have to come first in order to establish user-land side-effect clean up
34
- if (hits.length === 0) {
35
- pointerMissed(context.interactiveObjects, event);
36
- }
37
43
  handleEvent('onclick', event);
38
44
  };
39
45
  function cancelPointer(intersections) {
40
46
  if (handContext.hovered.size === 0)
41
47
  return;
48
+ const currentIds = new Set();
49
+ for (const hit of intersections) {
50
+ currentIds.add(getIntersectionId(hit));
51
+ }
42
52
  const toRemove = [];
43
53
  for (const [id, hoveredObj] of handContext.hovered) {
44
- // When no objects were hit or the hovered object wasn't found underneath the cursor
45
- // we call pointerout and delete the object from the hovered elements map
46
- if (intersections.length === 0 ||
47
- !intersections.some((hit) => hit.object === hoveredObj.object &&
48
- hit.index === hoveredObj.index &&
49
- hit.instanceId === hoveredObj.instanceId)) {
54
+ if (!currentIds.has(id)) {
50
55
  toRemove.push([id, hoveredObj]);
51
56
  }
52
57
  }
@@ -114,7 +119,13 @@ export const setupPointerControls = (context, handContext, fixedStep = 1 / 40) =
114
119
  const handleEvent = (name, event) => {
115
120
  const isPointerMove = name === 'onpointermove';
116
121
  const isClickEvent = name === 'onclick' || name === 'oncontextmenu';
117
- // Take care of unhover
122
+ // Fire pointermissed for objects that were not under the pointer at pointerdown.
123
+ // Must come before the dispatch loop so user-land cleanup runs first.
124
+ if (isClickEvent) {
125
+ pointerMissed(context.interactiveObjects.filter((object) => !handContext.initialHits.includes(object)), event);
126
+ }
127
+ // Update hover state before dispatch so that pointerout/pointerleave fire
128
+ // before pointerover/pointerenter on newly hit objects.
118
129
  if (isPointerMove)
119
130
  cancelPointer(hits);
120
131
  let stopped = false;
@@ -127,6 +138,7 @@ export const setupPointerControls = (context, handContext, fixedStep = 1 / 40) =
127
138
  stopped,
128
139
  ...hit,
129
140
  intersections: hits,
141
+ handedness,
130
142
  stopPropagation() {
131
143
  stopped = true;
132
144
  intersectionEvent.stopped = true;
@@ -166,15 +178,11 @@ export const setupPointerControls = (context, handContext, fixedStep = 1 / 40) =
166
178
  // Call pointer move
167
179
  events.onpointermove?.(intersectionEvent);
168
180
  }
169
- else if ((!isClickEvent || handContext.initialHits.includes(hit.eventObject)) &&
170
- events[name] !== undefined) {
171
- // Missed events have to come first
172
- pointerMissed(context.interactiveObjects.filter((object) => !handContext.initialHits.includes(object)), event);
173
- // Call the event
174
- events[name]?.(intersectionEvent);
175
- }
176
- else if (isClickEvent && handContext.initialHits.includes(hit.eventObject)) {
177
- pointerMissed(context.interactiveObjects.filter((object) => !handContext.initialHits.includes(object)), event);
181
+ else if (events[name] !== undefined) {
182
+ // All other events
183
+ if (!isClickEvent || handContext.initialHits.includes(hit.eventObject)) {
184
+ events[name]?.(intersectionEvent);
185
+ }
178
186
  }
179
187
  if (stopped)
180
188
  break dispatchEvents;
@@ -194,42 +202,28 @@ export const setupPointerControls = (context, handContext, fixedStep = 1 / 40) =
194
202
  autoStart: false
195
203
  });
196
204
  observe.pre(() => [controller, handContext.enabled], ([controller, $enabled]) => {
197
- if (controller === undefined)
205
+ if (controller === undefined || !$enabled)
198
206
  return;
199
- const removeHandlers = () => {
207
+ controller.targetRay.addEventListener('selectstart', handlePointerDown);
208
+ controller.targetRay.addEventListener('selectend', handlePointerUp);
209
+ controller.targetRay.addEventListener('select', handleClick);
210
+ return () => {
200
211
  controller.targetRay.removeEventListener('selectstart', handlePointerDown);
201
212
  controller.targetRay.removeEventListener('selectend', handlePointerUp);
202
213
  controller.targetRay.removeEventListener('select', handleClick);
203
214
  };
204
- if ($enabled) {
205
- controller.targetRay.addEventListener('selectstart', handlePointerDown);
206
- controller.targetRay.addEventListener('selectend', handlePointerUp);
207
- controller.targetRay.addEventListener('select', handleClick);
208
- return removeHandlers;
209
- }
210
- else {
211
- removeHandlers();
212
- return;
213
- }
214
215
  });
215
216
  observe.pre(() => [hand, handContext.enabled], ([input, enabled]) => {
216
- if (input === undefined)
217
+ if (input === undefined || !enabled)
217
218
  return;
218
- const removeHandlers = () => {
219
+ input.hand.addEventListener('pinchstart', handlePointerDown);
220
+ input.hand.addEventListener('pinchend', handlePointerUp);
221
+ input.hand.addEventListener('pinchend', handleClick);
222
+ return () => {
219
223
  input.hand.removeEventListener('pinchstart', handlePointerDown);
220
224
  input.hand.removeEventListener('pinchend', handlePointerUp);
221
225
  input.hand.removeEventListener('pinchend', handleClick);
222
226
  };
223
- if (enabled) {
224
- input.hand.addEventListener('pinchstart', handlePointerDown);
225
- input.hand.addEventListener('pinchend', handlePointerUp);
226
- input.hand.addEventListener('pinchend', handleClick);
227
- return removeHandlers;
228
- }
229
- else {
230
- removeHandlers();
231
- return;
232
- }
233
227
  });
234
228
  observe.pre(() => [isPresenting.current, handContext.enabled], ([isPresenting, $enabled]) => {
235
229
  if (isPresenting && $enabled) {
@@ -13,6 +13,8 @@ export interface IntersectionEvent extends Intersection {
13
13
  eventObject: Object3D;
14
14
  /** An array of intersections */
15
15
  intersections: Intersection[];
16
+ /** Which hand dispatched this event. Each controller/hand fires enter/leave/etc. independently. */
17
+ handedness: 'left' | 'right';
16
18
  /** Normalized event coordinates */
17
19
  pointer: Vector3;
18
20
  /** Delta between first click and this event */
@@ -7,5 +7,6 @@ export const events = [
7
7
  'onpointerout',
8
8
  'onpointerenter',
9
9
  'onpointerleave',
10
- 'onpointermove'
10
+ 'onpointermove',
11
+ 'onpointermissed'
11
12
  ];
@@ -5,6 +5,10 @@ export const defaultComputeFunction = (context, handContext) => {
5
5
  const targetRay = controllers[handContext.hand]?.targetRay;
6
6
  if (targetRay === undefined)
7
7
  return;
8
+ // `<Controller>` attaches targetRay to the scene root so local === world;
9
+ // we can read `.position`/`.quaternion` directly without a matrixWorld
10
+ // roundtrip (which would force-recompose the matrix three.js writes from
11
+ // the XR pose and introduce a drift against the visible render).
8
12
  forward.set(0, 0, -1).applyQuaternion(targetRay.quaternion);
9
13
  context.raycaster.set(targetRay.position, forward);
10
14
  };
@@ -4,7 +4,6 @@ import { injectTeleportControlsPlugin } from './plugin.svelte.js';
4
4
  import { setHandContext } from './context.js';
5
5
  import { setupTeleportControls } from './setup.svelte.js';
6
6
  import { teleportState } from '../../internal/state.svelte.js';
7
- let controlsCounter = 0;
8
7
  export const teleportControls = (handedness, options) => {
9
8
  if (useTeleportControls() === undefined) {
10
9
  injectTeleportControlsPlugin();
@@ -13,7 +12,6 @@ export const teleportControls = (handedness, options) => {
13
12
  const context = useTeleportControls();
14
13
  if (getHandContext(handedness) === undefined) {
15
14
  const enabled = options?.enabled ?? true;
16
- controlsCounter += enabled ? 1 : -1;
17
15
  const ctx = {
18
16
  hand: handedness,
19
17
  active: currentWritable(false),
@@ -25,8 +23,7 @@ export const teleportControls = (handedness, options) => {
25
23
  }
26
24
  const handContext = getHandContext(handedness);
27
25
  observe.pre(() => [handContext.enabled], ([enabled]) => {
28
- controlsCounter += enabled ? 1 : -1;
29
- teleportState[handedness].enabled = controlsCounter > 0;
26
+ teleportState[handedness].enabled = enabled;
30
27
  });
31
28
  observe.pre(() => [handContext.active], ([hovering]) => {
32
29
  teleportState[handedness].hovering = hovering;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@threlte/xr",
3
- "version": "1.5.4",
3
+ "version": "1.5.5",
4
4
  "author": "Micheal Parks <michealparks1989@gmail.com> (https://parks.lol)",
5
5
  "license": "MIT",
6
6
  "description": "Tools to more easily create VR and AR experiences with Threlte",
@@ -28,7 +28,8 @@
28
28
  "typescript-eslint": "^8.32.0",
29
29
  "vite": "^7.1.4",
30
30
  "vite-plugin-mkcert": "^1.17.5",
31
- "@threlte/core": "8.5.9"
31
+ "@threlte/core": "8.5.9",
32
+ "@threlte/extras": "9.14.9"
32
33
  },
33
34
  "peerDependencies": {
34
35
  "svelte": ">=5",