threlte logo
@threlte/xr

Getting Started

The package @threlte/xr provides tools and abstractions to more easily create VR and AR experiences.

<script lang="ts">
  import { Canvas } from '@threlte/core'
  import { World } from '@threlte/rapier'
  import { VRButton } from '@threlte/xr'
  import Scene from './Scene.svelte'
</script>

<div>
  <Canvas>
    <World gravity={[0, 0, 0]}>
      <Scene />
    </World>
  </Canvas>
  <VRButton />
</div>

<style>
  div {
    height: 100%;
  }
</style>
<script lang="ts">
  import { Vector3 } from 'three'
  import type { RigidBody as RapierRigidBody } from '@dimforge/rapier3d-compat'
  import { T, useTask } from '@threlte/core'
  import { InstancedMesh, Instance, RoundedBoxGeometry, Outlines } from '@threlte/extras'
  import { Collider, RigidBody } from '@threlte/rapier'

  interface Props {
    playing: boolean
    oncomplete: () => void
  }

  let { playing, oncomplete }: Props = $props()

  const colors = [
    '#ff5252',
    '#ff4081',
    '#d500f9',
    '#3d5afe',
    '#40c4ff',
    '#18ffff',
    '#f9a825',
    '#ffd740',
    '#bf360c'
  ] as const

  const positions = [
    [-1, -1],
    [-1, 0],
    [-1, 1],
    [0, -1],
    [0, 0],
    [0, 1],
    [1, -1],
    [1, 0],
    [1, 1]
  ] as const

  type Block = {
    position: Vector3
    color: string
  }

  let cubes: Block[] = []
  const numCubes = 100
  const margin = 0.4
  const spacing = 8

  for (let i = 0; i < numCubes; i += 1) {
    const [x, y] = positions[Math.trunc(Math.random() * positions.length)]!
    cubes.push({
      position: new Vector3(x - margin, y - margin, -i * spacing),
      color: colors[i % colors.length]!
    })
  }

  const boxRadius = 0.15
  const boxSize = 0.6
  const offsetY = 1.8
  const offsetZ = 50
  const speed = 9
  const passedZ = 3

  const bodies = $state<RapierRigidBody[]>([])

  $effect(() => {
    if (!playing) return
    for (let i = 0; i < numCubes; i += 1) {
      const body = bodies[i]
      if (!body) continue
      const spawn = cubes[i]!.position
      body.setTranslation({ x: spawn.x, y: spawn.y + offsetY, z: spawn.z - offsetZ }, true)
      body.setLinvel({ x: 0, y: 0, z: speed }, true)
    }
  })

  useTask(() => {
    if (!playing) return
    let passed = 0
    for (const body of bodies) {
      if (body && body.translation().z > passedZ) passed += 1
    }
    if (passed === numCubes) {
      for (const body of bodies) body?.setLinvel({ x: 0, y: 0, z: 0 }, true)
      oncomplete()
    }
  })
</script>

<InstancedMesh limit={numCubes}>
  <RoundedBoxGeometry
    radius={boxRadius}
    args={[boxSize, boxSize, boxSize]}
  />
  <T.MeshStandardMaterial
    roughness={0}
    metalness={0.2}
  />

  <Outlines />

  {#each cubes as { position, color }, index (index)}
    <T.Group
      position.x={position.x}
      position.y={position.y + offsetY}
      position.z={position.z - offsetZ}
    >
      <RigidBody bind:rigidBody={bodies[index]}>
        <Collider
          shape="cuboid"
          mass={0.5}
          args={[boxSize / 2, boxSize / 2, boxSize / 2]}
        />
        <Instance {color} />
      </RigidBody>
    </T.Group>
  {/each}
</InstancedMesh>
<script lang="ts">
  import { T } from '@threlte/core'
  import { Text as TitleText } from '@threlte/extras'
  import { Container, Content, Text, type VanillaContent } from 'threlte-uikit'
  import { Button } from 'threlte-uikit/kit'

  interface Props {
    onstart: () => void
  }

  let { onstart }: Props = $props()

  let titleRef = $state.raw<VanillaContent>()
</script>

<T.Group position={[0, 1.5, -0.8]}>
  <Container
    anchorX="center"
    anchorY="center"
    pixelSize={0.002}
    flexDirection="column"
    alignItems="center"
    justifyContent="center"
    gap={16}
    padding={32}
    backgroundColor="#0e1625"
    borderColor="hotpink"
    borderWidth={3}
    borderRadius={16}
  >
    <Content
      bind:ref={titleRef}
      width={320}
      height={80}
    >
      <TitleText
        anchorX="center"
        anchorY="middle"
        text="bonksaber!"
        font="/fonts/adrip1.ttf"
        color="red"
        fontSize={1}
        onsync={() => titleRef?.notifyAncestorsChanged()}
      />
    </Content>
    <Text
      text="objective: bonk the cubes as they fly by"
      fontSize={16}
      color="white"
    />
    <Button
      onclick={onstart}
      size="lg"
    >
      <Text
        text="Start"
        fontSize={22}
        color="#555"
      />
    </Button>
  </Container>
</T.Group>
<script lang="ts">
  import { Vector2 } from 'three'
  import { T } from '@threlte/core'
  import { Edges } from '@threlte/extras'

  const positions = Array(35)
    .keys()
    .map((index) => {
      const size = Math.random() * 20 + 4
      return {
        size,
        position: new Vector2(Math.cos(index), Math.sin(index))
          .subScalar(0.5)
          .normalize()
          .multiplyScalar(100)
      }
    })
</script>

<T.Mesh
  rotation.x={-Math.PI / 2}
  position.y={-0.1}
>
  <T.CircleGeometry args={[100]} />
  <T.MeshBasicMaterial color="rgb(14, 22, 37)" />
</T.Mesh>

{#each positions as { position, size }}
  <T.Group
    position={[position.x, size / 2 - 1, position.y]}
    oncreate={(ref) => ref.lookAt(0, size / 2, 0)}
  >
    <T.Mesh rotation.z={Math.PI / 2}>
      <T.CircleGeometry args={[size, 3]} />
      <Edges />
      <T.MeshBasicMaterial color="rgb(14, 22, 37)" />
    </T.Mesh>
  </T.Group>
{/each}
<script lang="ts">
  import { Vector3, Quaternion, Group } from 'three'
  import { T, useTask } from '@threlte/core'
  import { FakeGlowMaterial, Outlines } from '@threlte/extras'
  import { Collider, RigidBody } from '@threlte/rapier'
  import type { RigidBody as RapierRigidBody } from '@dimforge/rapier3d-compat'
  import { Controller, Hand, useController, useXR } from '@threlte/xr'

  const { isHandTracking } = useXR()

  const leftController = useController('left')
  const rightController = useController('right')

  const pulse = (hand: 'left' | 'right') => {
    const controller = hand === 'left' ? leftController.current : rightController.current
    controller?.inputSource.gamepad?.hapticActuators[0]?.pulse(0.8, 80)
  }

  let rigidBodyLeft = $state.raw<RapierRigidBody>()
  let rigidBodyRight = $state.raw<RapierRigidBody>()

  const leftSaber = new Group()
  const rightSaber = new Group()
  const leftHandSaber = new Group()
  const rightHandSaber = new Group()

  const left = $derived(isHandTracking.current ? leftHandSaber : leftSaber)
  const right = $derived(isHandTracking.current ? rightHandSaber : rightSaber)

  const vec3 = new Vector3()
  const quaternion = new Quaternion()

  useTask(() => {
    rigidBodyLeft?.setTranslation(left.getWorldPosition(vec3), true)
    rigidBodyLeft?.setRotation(left.getWorldQuaternion(quaternion), true)
    rigidBodyRight?.setTranslation(right.getWorldPosition(vec3), true)
    rigidBodyRight?.setRotation(right.getWorldQuaternion(quaternion), true)
  })

  const saberRadius = 0.02
  const saberLength = 1.4
</script>

{#snippet saber()}
  <T.Mesh>
    <T.CylinderGeometry args={[saberRadius, saberRadius, saberLength]} />
    <T.MeshBasicMaterial color="red" />
  </T.Mesh>

  <T.Mesh position={[0, saberLength / 2 + 0.05, 0]}>
    <T.CylinderGeometry args={[saberRadius, saberRadius, 0.1]} />
    <T.MeshStandardMaterial
      color="gray"
      roughness={0}
      metalness={0.5}
    />
  </T.Mesh>

  <T.Mesh>
    <T.CylinderGeometry args={[saberRadius, saberRadius, saberLength]} />
    <FakeGlowMaterial glowColor="red" />

    <Outlines
      color="hotpink"
      thickness={0.005}
    />
  </T.Mesh>
{/snippet}

<Controller left>
  <T
    is={leftSaber}
    rotation.x={Math.PI / 2}
    position.z={-saberLength / 2}
  >
    {@render saber()}
  </T>
</Controller>

<Controller right>
  <T
    is={rightSaber}
    rotation.x={Math.PI / 2}
    position.z={-saberLength / 2}
  >
    {@render saber()}
  </T>
</Controller>

<Hand left>
  {#snippet wrist()}
    <T
      is={leftHandSaber}
      rotation.x={Math.PI / 2}
      position.z={-saberLength / 2}
    >
      {@render saber()}
    </T>
  {/snippet}
</Hand>

<Hand right>
  {#snippet wrist()}
    <T
      is={rightHandSaber}
      rotation.x={Math.PI / 2}
      position.z={-saberLength / 2}
    >
      {@render saber()}
    </T>
  {/snippet}
</Hand>

<RigidBody
  type="kinematicPosition"
  bind:rigidBody={rigidBodyLeft}
  oncollisionenter={() => pulse('left')}
>
  <Collider
    shape="capsule"
    args={[saberLength / 2, saberRadius]}
  />
</RigidBody>

<RigidBody
  type="kinematicPosition"
  bind:rigidBody={rigidBodyRight}
  oncollisionenter={() => pulse('right')}
>
  <Collider
    shape="capsule"
    args={[saberLength / 2, saberRadius]}
  />
</RigidBody>
<script lang="ts">
  import { Color } from 'three'
  import { T, useThrelte } from '@threlte/core'
  import { Text, Grid, Outlines, VirtualEnvironment, Stars } from '@threlte/extras'
  import {
    XR,
    useXR,
    teleportControls,
    pointerControls,
    touchControls,
    Controller,
    Hand
  } from '@threlte/xr'
  import Sabers from './Sabers.svelte'
  import Blocks from './Blocks.svelte'
  import Mountains from './Mountains.svelte'
  import Menu from './Menu.svelte'
  import { Spring } from 'svelte/motion'

  const { scene } = useThrelte()
  const { isPresenting } = useXR()

  scene.environmentIntensity = 2
  scene.background = new Color('#0e1625')

  teleportControls('left')
  teleportControls('right')
  pointerControls('left')
  pointerControls('right')
  touchControls('left')
  touchControls('right')

  let playing = $state(false)

  const spring = Spring.of(() => ($isPresenting ? 0 : 1), { stiffness: 0.1, damping: 0.5 })
</script>

<XR>
  {#if playing}
    <Sabers />
  {:else}
    <Controller left />
    <Controller right />
    <Hand left />
    <Hand right />
  {/if}
  <Blocks
    {playing}
    oncomplete={() => (playing = false)}
  />

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

{#if $isPresenting && !playing}
  <Menu onstart={() => (playing = true)} />
{/if}

<Text
  anchorX="center"
  anchorY="center"
  position={[0, 1.9, 0]}
  text="bonksaber!"
  font="/fonts/adrip1.ttf"
  color="red"
  fillOpacity={spring.current}
  strokeOpacity={spring.current}
/>

<T.AmbientLight />
<T.DirectionalLight />

<Mountains />

<Stars />

<Grid
  infiniteGrid
  cellColor="purple"
  type="lines"
  axis="x"
/>

<!-- floor -->
<T.Mesh teleportSurface>
  <T.CylinderGeometry args={[2, 2, 0.1, 128]} />
  <T.MeshStandardMaterial
    color="white"
    roughness={0}
    metalness={0.1}
  />

  <Outlines color="hotpink" />
</T.Mesh>

<!-- sun -->
<T.Mesh
  position={[-30, 40, -100]}
  oncreate={(ref) => ref.lookAt(0, 0, 0)}
>
  <T.MeshBasicMaterial color="#FF4F4F" />
  <T.CircleGeometry args={[5 / 2]} />
</T.Mesh>

<VirtualEnvironment frames={20}>
  <T.Mesh
    position={[-8, 8, -10]}
    oncreate={(ref) => ref.lookAt(0, 0, 0)}
  >
    <T.MeshBasicMaterial color="#FF4F4F" />
    <T.CircleGeometry args={[5 / 2]} />
  </T.Mesh>

  <T.Mesh
    position={[6, 8, -10]}
    oncreate={(ref) => ref.lookAt(0, 0, 0)}
  >
    <T.PlaneGeometry args={[10, 10]} />
    <T.MeshBasicMaterial color="#FFD0CB" />
  </T.Mesh>

  <T.Mesh
    position={[4, 10, 5]}
    oncreate={(ref) => ref.lookAt(0, 0, 0)}
  >
    <T.PlaneGeometry args={[10, 10]} />
    <T.MeshBasicMaterial color="#2223FF" />
  </T.Mesh>
</VirtualEnvironment>

Installation

Terminal
npm install @threlte/xr

Usage

Setup

The following adds a button to start your session and controllers inside an XR manager to prepare your scene for WebXR rendering and interaction.

Scene.svelte
<script>
  import { Canvas } from '@threlte/core'
  import { VRButton } from '@threlte/xr'
  import Scene from './scene.svelte'
</script>

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

Then, in scene.svelte:

<script>
  import { XR, Controller, Hand } from '@threlte/xr'
</script>

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

This will set up your project to be able to enter a VR session with controllers and hand inputs added.

If you want hands, controllers, or any other objects to be added to your THREE.Scene only when the XR session starts, make them children of the <XR> component:

<script>
  import { XR, Controller, Hand } from '@threlte/xr'
</script>

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

The <XR>, <Controller>, and <Hand> components can provide a powerful foundation when composed with other Threlte components.

HTML

HTML cannot be rendered inside an XR environment, this is just a limitation of the WebXR API. An alternative approach for creating an HTML-like UI within your XR session is to use the threlte-uikit package.