@threlte/xr
<Hand>
<Hands /> instantiates XRHand inputs for devices that allow hand tracking.
<Hand left />
<Hand right />
It will by default load a hand model.
Default hand 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. If you want to override the default factory behavior, you can provide your own XRHandModelFactory or XRControllerModelFactory to the <XR> component:
<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 { Mesh, PerspectiveCamera } from 'three'
import { T, useThrelte } from '@threlte/core'
import { XR, Controller, type XRControllerEvent } from '@threlte/xr'
import { setupHands } from './setupHands'
const { renderer } = useThrelte()
const boxes = $state<Mesh[]>([])
const handleControllerEvent = (event: XRControllerEvent) => {
console.log('Controller', event)
}
const hands = ['left', 'right'] as const
const { leftHand, rightHand, handFactory } = setupHands(renderer)
</script>
<XR {handFactory}>
<T is={leftHand} />
<T is={rightHand} />
{#each hands as hand (hand)}
<Controller
{hand}
onconnected={handleControllerEvent}
ondisconnected={handleControllerEvent}
onselect={handleControllerEvent}
onsqueeze={handleControllerEvent}
onselectstart={handleControllerEvent}
onselectend={handleControllerEvent}
onsqueezestart={handleControllerEvent}
onsqueezeend={handleControllerEvent}
/>
{/each}
{#snippet fallback()}
<T.PerspectiveCamera
makeDefault
position={[0, 1.8, 1]}
oncreate={(ref: PerspectiveCamera) => ref.lookAt(0, 1.8, 0)}
/>
{/snippet}
</XR>
<T.Mesh rotation={[-Math.PI / 2, 0, 0]}>
<T.CircleGeometry args={[1]} />
<T.MeshBasicMaterial />
</T.Mesh>
<T.AmbientLight />
<T.DirectionalLight />
{#each boxes as box (box.uuid)}
<T is={box}>
<T.BoxGeometry args={[0.05, 0.05, 0.05]} />
<T.MeshStandardMaterial color={Math.random() * 0xffffff} />
</T>
{/each}
import { MeshBasicMaterial, Object3D, SkinnedMesh, WebGLRenderer, type XRHandSpace } from 'three'
import { GLTFLoader, XRHandModelFactory } from 'three/examples/jsm/Addons.js'
let hand1: XRHandSpace, hand2: XRHandSpace
export function setupHands(renderer: WebGLRenderer) {
const gltf = new GLTFLoader()
gltf.setPath('/models/xr/')
const updateHandMesh = (object: Object3D) => {
const mesh = object.getObjectByProperty('type', 'SkinnedMesh') as SkinnedMesh
if (mesh) {
mesh.material = new MeshBasicMaterial({ color: '#1b2866' })
mesh.frustumCulled = false
mesh.castShadow = false
mesh.receiveShadow = false
}
}
const handModelFactory = new XRHandModelFactory(gltf, updateHandMesh)
hand1 = renderer.xr.getHand(0)
hand1.add(handModelFactory.createHandModel(hand1, 'mesh'))
hand2 = renderer.xr.getHand(1)
hand2.add(handModelFactory.createHandModel(hand2, 'mesh'))
return { leftHand: hand1, rightHand: hand2, handFactory: handModelFactory }
}
<Hand> can accept a snippet to replace the default model.
<Hand left>
<T.Mesh>
<T.IcosahedronGeometry args={[0.2]} />
<T.MeshStandardMaterial color="turquoise" />
</T.Mesh>
</Hand>
A snippet, wrist, will place any children within the wrist space of the hand:
<Hand left>
{#snippet wrist()}
<T.Mesh>
<T.IcosahedronGeometry args={[0.2]} />
<T.MeshStandardMaterial color="hotpink" />
</T.Mesh>
{/snippet}
</Hand>
To trigger reactive changes based on whether hand input is or is not present, the useXR hook provides a currentWritable store:
const { isHandTracking } = useXR()
Hand tracking can serve as a powerful input device, as any joint position, and not just the wrist, can be read from in real time:
<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 { Mesh } from 'three'
import { T } from '@threlte/core'
import { XR, Hand, Controller, type XRHandEvent, type XRControllerEvent } from '@threlte/xr'
const boxes = $state<Mesh[]>([])
const handleEvent = (event: XRHandEvent) => {
console.log('Hand', event)
if (event.type === 'pinchend') {
createBox(event)
}
}
const handleControllerEvent = (event: XRControllerEvent) => {
console.log('Controller', event)
}
const createBox = (event: XRHandEvent) => {
const controller = event.target
const indexTip = controller?.joints['index-finger-tip']
if (!indexTip) return
const box = new Mesh()
box.position.copy(indexTip.position)
box.quaternion.copy(indexTip.quaternion)
boxes.push(box)
}
const hands = ['left', 'right'] as const
</script>
<XR>
{#each hands as hand (hand)}
<Hand
{hand}
onconnected={handleEvent}
ondisconnected={handleEvent}
onpinchstart={handleEvent}
onpinchend={handleEvent}
/>
<Controller
{hand}
onconnected={handleControllerEvent}
ondisconnected={handleControllerEvent}
onselect={handleControllerEvent}
onsqueeze={handleControllerEvent}
onselectstart={handleControllerEvent}
onselectend={handleControllerEvent}
onsqueezestart={handleControllerEvent}
onsqueezeend={handleControllerEvent}
/>
{/each}
{#snippet fallback()}
<T.PerspectiveCamera
makeDefault
position={[0, 1.8, 1]}
oncreate={(ref) => ref.lookAt(0, 1.8, 0)}
/>
{/snippet}
</XR>
<T.Mesh rotation={[-Math.PI / 2, 0, 0]}>
<T.CircleGeometry args={[1]} />
<T.MeshBasicMaterial />
</T.Mesh>
<T.AmbientLight />
<T.DirectionalLight />
{#each boxes as box (box.uuid)}
<T is={box}>
<T.BoxGeometry args={[0.05, 0.05, 0.05]} />
<T.MeshStandardMaterial color={Math.random() * 0xffffff} />
</T>
{/each}