threlte logo
@threlte/xr

pointerControls

The pointerControls plugin adds pointer events to an immersive XR session. This means that pointing at any mesh with your hand or a controller will trigger DOM-like pointer events.

To get started, import and call the plugin in a component within your app.

<script>
  import { pointerControls } from '@threlte/xr'
  pointerControls('left' | 'right')
</script>

Any mesh within this component and all child components will now receive events if the controller or hand with the specified handedness points at it.

<T.Mesh
  onclick={() => {
    console.log('clicked')
  }}
>
  <T.BoxGeometry />
  <T.MeshStandardMaterial color="red" />
</T.Mesh>

If you wish to add pointer controls for both hands / controllers, simply call the plugin for both hands.

<script>
  import { pointerControls } from '@threlte/xr'
  pointerControls('left')
  pointerControls('right')
</script>

Pointer controls can be enabled or disabled when initialized or during runtime.

<script>
  import { pointerControls } from '@threlte/xr'
  // "enabled" is a currentWritable
  const { enabled } = pointerControls('left', { enabled: false })

  // At some later time...
  enabled.set(true)
</script>

Available Events

The following events are available:

<T.Mesh
  onclick={(e) => console.log('click')}
  onpointerup={(e) => console.log('up')}
  onpointerdown={(e) => console.log('down')}
  onpointerover={(e) => console.log('over')}
  onpointerout={(e) => console.log('out')}
  onpointerenter={(e) => console.log('enter')}
  onpointerleave={(e) => console.log('leave')}
  onpointermove={(e) => console.log('move')}
/>

While a controller or hand is pointed at this mesh…

  • click fires when a user selects the primary action input. This usually means pulling a primary trigger with a controller or pinching with a hand.
  • pointerdown fires when a primary action begins, and pointerup fires when it ends.
  • pointerover and pointerout fire when the ray of the pointing device is moved onto an object, or onto one of its children. It bubbles, meaning it can trigger on the object that the pointer is over or any of its ancestor objects.
  • pointerenter and pointerleave fire when the ray of the pointing device enters / leaves the boundaries of an object, and does not bubble. It only triggers on the exact element the pointer has entered / left.

To replace the default ray and cursor that are created by the plugin, the following snippets can be added to a <Controller> or a <Hand>:

<script>
  import { Hand, Controller } from '@threlte/xr'
  import CustomRay from './CustomRay.svelte'
  import CustomCursor from './CustomCursor.svelte'
</script>

<Controller left>
  {#snippet pointerRay()}
    <CustomRay>
  {/snippet}

  {#snippet pointerCursor()}
    <CustomCursor>
  {/snippet}
</Controller>

This plugin can be used with the teleportControls plugin to allow both teleporting and interaction.

<script>
  import { pointerControls, teleportControls } from '@threlte/xr'
  teleportControls('left')
  pointerControls('right')
</script>

Since the default behavior of pointer and teleport controls have no overlap, they can be added to the same hand.

If these two plugins are added to the same hand, pointerControls will take over when pointing at a mesh with events, and teleportControls will take over otherwise.

pointerControls can also be used with interactivity to allow pointer events within and outside an immersive session.

<script>
  import { interactivity } from '@threlte/extras'
  import { pointerControls, teleportControls } from '@threlte/xr'
  interactivity()
  pointerControls('left')
</script>

The will be a few subtle differences when events are fired within an immersive session:

  • Pointers / cursors will be THREE.Vector3s instead of THREE.Vector2s. In XR, the cursor that intersects with the object that you interact with can be anywhere within a 3d space.
  • There will be no camera property on the event, since raycasting will originate from hands or controllers.
  • The nativeEvent property on event objects will be a XRControllerEvent or XRHandEvent rather than a DomEvent. In the case of hover events such as pointerMove, there will be no native event.
<script lang="ts">
  import { T, Canvas } from '@threlte/core'
  import { XR, VRButton } from '@threlte/xr'
  import Scene from './Scene.svelte'
</script>

<div>
  <Canvas>
    <Scene />

    <XR>
      {#snippet fallback()}
        <T.PerspectiveCamera
          makeDefault
          position={[0, 1.5, 4]}
          oncreate={(ref) => ref.lookAt(0, 1.5, 0)}
        />
      {/snippet}
    </XR>

    <T.AmbientLight />
    <T.DirectionalLight
      intensity={1.5}
      position={[1, 1, 1]}
    />
  </Canvas>
  <VRButton />
</div>

<style>
  div {
    height: 100%;
  }
</style>
<script lang="ts">
  import { BufferGeometry, Vector3, type Mesh } from 'three'
  import { onDestroy } from 'svelte'
  import { T, useTask } from '@threlte/core'
  import { Text } from '@threlte/extras'
  import { spring } from 'svelte/motion'
  import { interactivity } from '@threlte/extras'
  import { pointerControls, useXR, Controller, Hand } from '@threlte/xr'

  const { isPresenting } = useXR()
  const scale = spring(1)
  const eyeScale = spring(1, { stiffness: 0.5 })
  const points = [new Vector3(0, 0, 0), new Vector3(0, 0, -1000)]

  let debug = false
  let ref: Mesh
  let lookIntervalId: number | undefined
  let happy = false
  let lookAt = new Vector3()
  let point = new Vector3()
  let text = ''

  const handleEvent =
    (type: string) =>
    (event: any): any => {
      text = type
      switch (type) {
        case 'click': {
          scale.set(1.5)
          return
        }
        case 'pointermove': {
          point.copy(event.point)
          return
        }
        case 'pointerenter': {
          happy = true
          scale.set(1.1)
          return
        }
        case 'pointerleave': {
          happy = false
          scale.set(1)
          return
        }
        case 'pointermissed': {
          scale.set(0.5)
          return
        }
      }
    }

  const blink = () => {
    eyeScale.set(0.1).then(() => eyeScale.set(1))
  }

  const lookForCursor = () => {
    point.set(Math.random() - 0.5, 1.5 + Math.random() - 0.5, 1)
  }

  useTask(() => {
    lookAt.lerp(point, happy ? 0.5 : 0.2)
    ref.lookAt(lookAt.x, lookAt.y, 1)
  })

  interactivity()
  pointerControls('left')
  pointerControls('right')

  $: if (happy) {
    clearInterval(lookIntervalId)
  } else {
    lookIntervalId = window.setInterval(lookForCursor, 1000)
  }

  let blinkIntervalId = setInterval(blink, 3000)

  onDestroy(() => {
    clearInterval(blinkIntervalId)
    clearInterval(lookIntervalId)
  })
</script>

<svelte:window on:keyup={(e) => e.key === 'd' && (debug = !debug)} />

<Controller left>
  {#snippet targetRay()}
    <Text
      fontSize={0.05}
      {text}
      position.x={0.1}
    />
    <T.Line visible={debug}>
      <T is={new BufferGeometry().setFromPoints(points)} />
    </T.Line>
  {/snippet}
</Controller>

<Controller right>
  {#snippet targetRay()}
    <T.Line visible={debug}>
      <T is={new BufferGeometry().setFromPoints(points)} />
    </T.Line>
  {/snippet}
</Controller>

<Hand left />
<Hand right />

<T.Group
  position.y={1.5}
  position.z={-0.5}
  scale={$isPresenting ? 0.1 : 1}
>
  <T.Mesh
    bind:ref
    onclick={handleEvent('click')}
    onpointerdown={handleEvent('pointerdown')}
    onpointerup={handleEvent('pointerup')}
    onpointerover={handleEvent('pointerover')}
    onpointerout={handleEvent('pointerout')}
    onpointerenter={handleEvent('pointerenter')}
    onpointerleave={handleEvent('pointerleave')}
    onpointermove={handleEvent('pointermove')}
    onpointermissed={handleEvent('pointermissed')}
    scale={$scale}
  >
    <T.MeshStandardMaterial color="hotpink" />
    <T.BoxGeometry />

    <T.Mesh
      scale.y={$eyeScale}
      position={[-0.3, 0.25, 0.5]}
      raycast={() => false}
    >
      <T.MeshStandardMaterial color="#444" />
      <T.BoxGeometry args={[0.1, 0.325, 0.1]} />
    </T.Mesh>

    <T.Mesh
      scale.y={$eyeScale}
      position={[0.05, 0.25, 0.5]}
      raycast={() => false}
    >
      <T.MeshStandardMaterial color="#444" />
      <T.BoxGeometry args={[0.1, 0.325, 0.1]} />
    </T.Mesh>

    <T.Mesh
      visible={happy}
      position.y={-0.15}
      position.z={0.5}
      rotation.x={Math.PI / 2}
      raycast={() => false}
    >
      <T.MeshStandardMaterial color="#444" />
      <T.CylinderGeometry args={[0.3, 0.3, 0.1, 3]} />
    </T.Mesh>

    <T.Mesh
      visible={!happy}
      position.y={-0.15}
      position.z={0.5}
      rotation.x={Math.PI / 2}
      raycast={() => false}
    >
      <T.MeshStandardMaterial color="#444" />
      <T.CylinderGeometry args={[0.15, 0.15, 0.1]} />
    </T.Mesh>
  </T.Mesh>
</T.Group>