threlte logo
@threlte/xr

<Controller>

<Controller /> represents a THREE.XRTargetRaySpace, a THREE.XRGripSpace, and a controller model for a specified hand.

<Controller left />
<Controller right />

It will by default load a controller model that attempts to match the physical controller.

Default controller models are fetched from the immersive web group’s webxr input profile repo. If you are developing an offline app, you should download and provide any anticipated models.

<Controller> can accept three slots.

If a default slot is provided, the default controller model will not be rendered, and will be replaced with the slot content.

<Controller left>
  <T.Mesh>
    <T.IcosahedronGeometry args={[0.2]} />
    <T.MeshStandardMaterial color='turquoise' />
  </T.Mesh>
</Controller>

Two additional slots exist to place children in the controller’s grip space and the controller’s target ray space.

<Controller left>
  <T.Mesh slot='grip'>
    <T.IcosahedronGeometry args={[0.2]} />
    <T.MeshStandardMaterial color='hotpink' />
  </T.Mesh>

  <T.Mesh slot='target-ray'>
    <T.IcosahedronGeometry args={[0.2]} />
    <T.MeshStandardMaterial color='orange' />
  </T.Mesh>
</Controller>
<script lang="ts">
  import { Canvas } from '@threlte/core'
  import { VRButton } from '@threlte/xr'
  import Scene from './Scene.svelte'
</script>

<div>
  <Canvas>
    <Scene />
  </Canvas>
  <VRButton />
</div>

<style>
  div {
    height: 100%;
  }
</style>
<script lang="ts">
  import * as THREE from 'three'
  import { T, useTask } from '@threlte/core'
  import { Text } from '@threlte/extras'
  import { Controller, type XRControllerEvent, useController } from '@threlte/xr'

  type $$Props = { left: true } | { right: true }

  let state = 'disconnected'
  let cursor = 0
  let text = ''

  const count = 100
  const positions = new Float32Array(count * 3)
  const velocities = new Float32Array(count * 3)
  const vec3 = new THREE.Vector3()
  const matrix = new THREE.Matrix4()
  const color = new THREE.Color()

  const controller = useController($$restProps.left ? 'left' : 'right')

  const instancedMesh = new THREE.InstancedMesh(
    new THREE.IcosahedronGeometry(0.01),
    new THREE.MeshPhongMaterial(),
    count
  )

  const colorMap = {
    connected: 'green',
    disconnected: 'crimson',
    selectstart: 'darkorchid',
    selectend: 'darkmagenta',
    select: 'darkmagenta',
    squeezestart: 'lightcoral',
    squeezeend: 'indianred',
    squeeze: 'indianred',
    pinchstart: 'lightcyan',
    pinchend: 'lightblue'
  } as const

  const handleEvent = (event: XRControllerEvent) => {
    text = `${event.data.handedness} ${event.type}`
    state = event.type
  }

  const fireParticle = () => {
    instancedMesh.setColorAt(cursor, color.setStyle(colorMap[state]))
    instancedMesh.instanceColor!.needsUpdate = true

    let i = cursor * 3

    const ray = controller.current?.targetRay

    ray?.getWorldDirection(vec3).negate().multiplyScalar(0.015)
    vec3.x += (Math.random() - 0.5) * 0.012
    vec3.y += (Math.random() - 0.5) * 0.012
    vec3.z += (Math.random() - 0.5) * 0.012

    const { x = 0, y = 0, z = 0 } = ray?.position ?? {}

    positions[i] = x
    positions[i + 1] = y
    positions[i + 2] = z

    velocities[i] = vec3.x
    velocities[i + 1] = vec3.y
    velocities[i + 2] = vec3.z

    cursor += 1
    cursor %= count
  }

  const updateParticles = () => {
    for (let i = 0, j = 0; i < count; i += 1, j += 3) {
      positions[j] += velocities[j]!
      positions[j + 1] += velocities[j + 1]!
      positions[j + 2] += velocities[j + 2]!
      matrix.setPosition(positions[j]!, positions[j + 1], positions[j + 2])
      instancedMesh.setMatrixAt(i, matrix)
    }

    instancedMesh.instanceMatrix.needsUpdate = true
  }

  useTask(() => {
    updateParticles()

    switch (state) {
      case 'squeezestart':
      case 'selectstart':
        return fireParticle()
    }
  })
</script>

<Controller
  left={$$props.left}
  right={$$props.right}
  on:connected={handleEvent}
  on:disconnected={handleEvent}
  on:selectstart={handleEvent}
  on:selectend={handleEvent}
  on:select={handleEvent}
  on:squeezestart={handleEvent}
  on:squeezeend={handleEvent}
  on:squeeze={handleEvent}
>
  <Text
    slot="target-ray"
    fontSize={0.05}
    {text}
    position.x={0.1}
  />
</Controller>

<T
  is={instancedMesh}
  frustumCulled={false}
/>
<script lang="ts">
  import * as THREE from 'three'
  import { T, useThrelte } from '@threlte/core'
  import { XR } from '@threlte/xr'
  import Controller from './Controller.svelte'

  const { scene } = useThrelte()

  scene.fog = new THREE.Fog('black', 1.5, 2)
  scene.background = new THREE.Color('black')
</script>

<XR>
  <Controller left />
  <Controller right />
</XR>

<T.AmbientLight intensity={1.5} />
<T.DirectionalLight intensity={1.5} />

Component Signature

Events

name
payload
description

connected
XRControllerEvent<'connected'>
Fired when the controller connects.

disconnected
XRControllerEvent<'disconnected'>
Fired when the controller disconnects.

select
XRControllerEvent<'select'>
Fired when a the user has completed a primary action.

selectstart
XRControllerEvent<'selectstart'>
Fired when a the user begins a primary action.

selectend
XRControllerEvent<'selectend'>
Fired when a the user ends a primary action or when the controller that is in the process of handling an ongoing primary action is disconnected without successfully completing the action.

squeeze
XRControllerEvent<'squeeze'>
Fired when the controller has completed a primary squeeze action.

squeezestart
XRControllerEvent<'squeezestart'>
Fired when the user begins a primary squeeze action.

squeezeend
XRControllerEvent<'squeezeend'>
Fired when a the user ends a primary squeeze action or when the controller that is in the process of handling an ongoing primary squeeeze action is disconnected without successfully completing the action.