@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, andpointerup
fires when it ends.pointerover
andpointerout
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
andpointerleave
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.Vector3
s instead ofTHREE.Vector2
s. 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 aXRControllerEvent
orXRHandEvent
rather than aDomEvent
. In the case of hover events such aspointerMove
, 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>