Skip to content

Commit

Permalink
feat: add teleportation support (pmndrs#263)
Browse files Browse the repository at this point in the history
  • Loading branch information
richardanaya committed Jul 2, 2023
1 parent df017c8 commit a5e748b
Show file tree
Hide file tree
Showing 7 changed files with 214 additions and 5 deletions.
37 changes: 36 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -289,6 +289,41 @@ button.addEventListener('click', handleClick)
document.appendChild(button)
```

## Teleportation

To facilitate instant or accessible movement, react-xr provides teleportation helpers.

### TeleportationPlane

A teleportation plane with a marker that will teleport on interaction.

```jsx
import { TeleportationPlane } from '@react-three/xr'
;<TeleportationPlane
/** Whether to allow teleportation from left controller. Default is `false` */
leftHand={false}
/** Whether to allow teleportation from right controller. Default is `false` */
rightHand={false}
/** The maximum distance from the camera to the teleportation point. Default is `10` */
maxDistance={10}
/** The radial size of the teleportation marker. Default is `0.25` */
size={0.25}
/>
```

### useTeleportation

Returns a `TeleportCallback` to teleport the player to a position.

```jsx
import { useTeleportation } from '@react-three/xr'

const teleport = useTeleportation()

teleport([x, y, z])
teleport(new THREE.Vector3(x, y, z))
```

## Built with react-xr

* <a href="https://github.com/richardanaya/avatar-poser"><img src="https://raw.githubusercontent.com/richardanaya/avatar-poser/main/public/avatar-poser.png" alt="Avatar Poser github link" width="100"/></a>
- <a href="https://github.com/richardanaya/avatar-poser"><img src="https://raw.githubusercontent.com/richardanaya/avatar-poser/main/public/avatar-poser.png" alt="Avatar Poser github link" width="100"/></a>
30 changes: 30 additions & 0 deletions examples/src/demos/Teleport.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { Canvas } from '@react-three/fiber'
import { Hands, XR, VRButton, TeleportationPlane, Controllers } from '@react-three/xr'

export default function () {
return (
<>
<VRButton onError={(e) => console.error(e)} />
<Canvas>
<color attach="background" args={['black']} />
<XR>
<Controllers />
<TeleportationPlane leftHand />
<mesh position={[1, 0, 0]}>
<boxGeometry args={[0.1, 0.1, 0.1]} />
<meshBasicMaterial color="red" />
</mesh>
<mesh position={[0, 1, 0]}>
<boxGeometry args={[0.1, 0.1, 0.1]} />
<meshBasicMaterial color="green" />
</mesh>
<mesh position={[0, 0, 1]}>
<boxGeometry args={[0.1, 0.1, 0.1]} />
<meshBasicMaterial color="blue" />
</mesh>
<ambientLight intensity={0.5} />
</XR>
</Canvas>
</>
)
}
3 changes: 2 additions & 1 deletion examples/src/demos/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,6 @@ const HitTest = { Component: lazy(() => import('./HitTest')) }
const Player = { Component: lazy(() => import('./Player')) }
const Text = { Component: lazy(() => import('./Text')) }
const Hands = { Component: lazy(() => import('./Hands')) }
const Teleport = { Component: lazy(() => import('./Teleport')) }

export { Interactive, HitTest, Player, Text, Hands }
export { Interactive, HitTest, Player, Text, Hands, Teleport }
10 changes: 8 additions & 2 deletions examples/vite.config.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
import { defineConfig } from 'vite'
import path from 'node:path'
import react from '@vitejs/plugin-react'
import { vanillaExtractPlugin } from '@vanilla-extract/vite-plugin';
import { vanillaExtractPlugin } from '@vanilla-extract/vite-plugin'

// https://vitejs.dev/config/
export default defineConfig({
plugins: [react(), vanillaExtractPlugin()]
plugins: [react(), vanillaExtractPlugin()],
resolve: {
alias: {
'@react-three/xr': path.resolve(__dirname, '../src')
}
}
})
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@
"vitest": "^0.29.1"
},
"dependencies": {
"@types/webxr": "*",
"three-stdlib": "^2.21.1",
"zustand": "^3.7.1"
},
Expand Down
135 changes: 135 additions & 0 deletions src/Teleportation.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import * as THREE from 'three'
import * as React from 'react'
import { useFrame, useThree } from '@react-three/fiber'
import { Interactive, type XRInteractionEvent } from './Interactions'

const _q = /* @__PURE__ */ new THREE.Quaternion()

/**
* Teleport callback, accepting a world-space target position to teleport to.
*/
export type TeleportCallback = (target: THREE.Vector3 | THREE.Vector3Tuple) => void

/**
* Returns a {@link TeleportCallback} to teleport the player to a position.
*/
export function useTeleportation(): TeleportCallback {
const frame = React.useRef<XRFrame>()
const baseReferenceSpace = React.useRef<XRReferenceSpace | null>(null)
const teleportReferenceSpace = React.useRef<XRReferenceSpace | null>(null)

useFrame((state, _, xrFrame) => {
frame.current = xrFrame

const referenceSpace = state.gl.xr.getReferenceSpace()
baseReferenceSpace.current ??= referenceSpace

const teleportOffset = teleportReferenceSpace.current
if (teleportOffset && referenceSpace !== teleportOffset) {
state.gl.xr.setReferenceSpace(teleportOffset)
}
})

return React.useCallback((target) => {
const base = baseReferenceSpace.current
if (base) {
const [x, y, z] = Array.from(target as THREE.Vector3Tuple)
const offsetFromBase = { x: -x, y: -y, z: -z }

const pose = frame.current?.getViewerPose(base)
if (pose) {
offsetFromBase.x += pose.transform.position.x
offsetFromBase.z += pose.transform.position.z
}

const teleportOffset = new XRRigidTransform(offsetFromBase, _q)
teleportReferenceSpace.current = base.getOffsetReferenceSpace(teleportOffset)
}
}, [])
}

export interface TeleportationPlaneProps extends Partial<JSX.IntrinsicElements['group']> {
/** Whether to allow teleportation from left controller. Default is `false` */
leftHand?: boolean
/** Whether to allow teleportation from right controller. Default is `false` */
rightHand?: boolean
/** The maximum distance from the camera to the teleportation point. Default is `10` */
maxDistance?: number
/** The radial size of the teleportation marker. Default is `0.25` */
size?: number
}

/**
* Creates a teleportation plane with a marker that will teleport on interaction.
*/
export const TeleportationPlane = React.forwardRef<THREE.Group, TeleportationPlaneProps>(function TeleportationPlane(
{ leftHand = false, rightHand = false, maxDistance = 10, size = 0.25, ...props },
ref
) {
const teleport = useTeleportation()
const marker = React.useRef<THREE.Mesh>(null!)
const intersection = React.useRef<THREE.Vector3>()
const camera = useThree((state) => state.camera)

const isInteractive = React.useCallback(
(e: XRInteractionEvent): boolean => {
const { handedness } = e.target.inputSource
return !!((handedness !== 'left' || leftHand) && (handedness !== 'right' || rightHand))
},
[leftHand, rightHand]
)

return (
<group ref={ref} {...props}>
<mesh ref={marker} visible={false} rotation-x={-Math.PI / 2}>
<circleGeometry args={[size, 32]} />
<meshBasicMaterial color="white" />
</mesh>
<Interactive
onMove={(e) => {
if (!isInteractive(e) || !e.intersection) return

const distanceFromCamera = e.intersection.point.distanceTo(camera.position)
marker.current.visible = distanceFromCamera <= maxDistance
marker.current.scale.setScalar(1)

intersection.current = e.intersection.point
marker.current.position.copy(intersection.current)
}}
onHover={(e) => {
if (!isInteractive(e) || !e.intersection) return

const distanceFromCamera = e.intersection.point.distanceTo(camera.position)
marker.current.visible = distanceFromCamera <= maxDistance
marker.current.scale.setScalar(1)
}}
onBlur={(e) => {
if (!isInteractive(e)) return
marker.current.visible = false
}}
onSelectStart={(e) => {
if (!isInteractive(e) || !e.intersection) return

const distanceFromCamera = e.intersection.point.distanceTo(camera.position)
marker.current.visible = distanceFromCamera <= maxDistance
marker.current.scale.setScalar(1.1)
}}
onSelectEnd={(e) => {
if (!isInteractive(e) || !intersection.current) return

marker.current.visible = true
marker.current.scale.setScalar(1)

const distanceFromCamera = intersection.current.distanceTo(camera.position)
if (distanceFromCamera <= maxDistance) {
teleport(intersection.current)
}
}}
>
<mesh rotation-x={-Math.PI / 2} visible={false} scale={1000}>
<planeGeometry />
</mesh>
</Interactive>
</group>
)
})
3 changes: 2 additions & 1 deletion src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,5 @@ export * from './XR'
export * from './XRController'
export * from './XREvents'
export * from './XRControllerModelFactory'
export { type XRState } from './context'
export * from './Teleportation'
export { type XRState } from './context'

0 comments on commit a5e748b

Please sign in to comment.