Skip to content

Commit a5e748b

Browse files
authored
feat: add teleportation support (#263)
1 parent df017c8 commit a5e748b

File tree

7 files changed

+214
-5
lines changed

7 files changed

+214
-5
lines changed

README.md

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -289,6 +289,41 @@ button.addEventListener('click', handleClick)
289289
document.appendChild(button)
290290
```
291291

292+
## Teleportation
293+
294+
To facilitate instant or accessible movement, react-xr provides teleportation helpers.
295+
296+
### TeleportationPlane
297+
298+
A teleportation plane with a marker that will teleport on interaction.
299+
300+
```jsx
301+
import { TeleportationPlane } from '@react-three/xr'
302+
;<TeleportationPlane
303+
/** Whether to allow teleportation from left controller. Default is `false` */
304+
leftHand={false}
305+
/** Whether to allow teleportation from right controller. Default is `false` */
306+
rightHand={false}
307+
/** The maximum distance from the camera to the teleportation point. Default is `10` */
308+
maxDistance={10}
309+
/** The radial size of the teleportation marker. Default is `0.25` */
310+
size={0.25}
311+
/>
312+
```
313+
314+
### useTeleportation
315+
316+
Returns a `TeleportCallback` to teleport the player to a position.
317+
318+
```jsx
319+
import { useTeleportation } from '@react-three/xr'
320+
321+
const teleport = useTeleportation()
322+
323+
teleport([x, y, z])
324+
teleport(new THREE.Vector3(x, y, z))
325+
```
326+
292327
## Built with react-xr
293328

294-
* <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>
329+
- <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>

examples/src/demos/Teleport.tsx

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { Canvas } from '@react-three/fiber'
2+
import { Hands, XR, VRButton, TeleportationPlane, Controllers } from '@react-three/xr'
3+
4+
export default function () {
5+
return (
6+
<>
7+
<VRButton onError={(e) => console.error(e)} />
8+
<Canvas>
9+
<color attach="background" args={['black']} />
10+
<XR>
11+
<Controllers />
12+
<TeleportationPlane leftHand />
13+
<mesh position={[1, 0, 0]}>
14+
<boxGeometry args={[0.1, 0.1, 0.1]} />
15+
<meshBasicMaterial color="red" />
16+
</mesh>
17+
<mesh position={[0, 1, 0]}>
18+
<boxGeometry args={[0.1, 0.1, 0.1]} />
19+
<meshBasicMaterial color="green" />
20+
</mesh>
21+
<mesh position={[0, 0, 1]}>
22+
<boxGeometry args={[0.1, 0.1, 0.1]} />
23+
<meshBasicMaterial color="blue" />
24+
</mesh>
25+
<ambientLight intensity={0.5} />
26+
</XR>
27+
</Canvas>
28+
</>
29+
)
30+
}

examples/src/demos/index.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,5 +5,6 @@ const HitTest = { Component: lazy(() => import('./HitTest')) }
55
const Player = { Component: lazy(() => import('./Player')) }
66
const Text = { Component: lazy(() => import('./Text')) }
77
const Hands = { Component: lazy(() => import('./Hands')) }
8+
const Teleport = { Component: lazy(() => import('./Teleport')) }
89

9-
export { Interactive, HitTest, Player, Text, Hands }
10+
export { Interactive, HitTest, Player, Text, Hands, Teleport }

examples/vite.config.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,14 @@
11
import { defineConfig } from 'vite'
2+
import path from 'node:path'
23
import react from '@vitejs/plugin-react'
3-
import { vanillaExtractPlugin } from '@vanilla-extract/vite-plugin';
4+
import { vanillaExtractPlugin } from '@vanilla-extract/vite-plugin'
45

56
// https://vitejs.dev/config/
67
export default defineConfig({
7-
plugins: [react(), vanillaExtractPlugin()]
8+
plugins: [react(), vanillaExtractPlugin()],
9+
resolve: {
10+
alias: {
11+
'@react-three/xr': path.resolve(__dirname, '../src')
12+
}
13+
}
814
})

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@
6262
"vitest": "^0.29.1"
6363
},
6464
"dependencies": {
65+
"@types/webxr": "*",
6566
"three-stdlib": "^2.21.1",
6667
"zustand": "^3.7.1"
6768
},

src/Teleportation.tsx

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
import * as THREE from 'three'
2+
import * as React from 'react'
3+
import { useFrame, useThree } from '@react-three/fiber'
4+
import { Interactive, type XRInteractionEvent } from './Interactions'
5+
6+
const _q = /* @__PURE__ */ new THREE.Quaternion()
7+
8+
/**
9+
* Teleport callback, accepting a world-space target position to teleport to.
10+
*/
11+
export type TeleportCallback = (target: THREE.Vector3 | THREE.Vector3Tuple) => void
12+
13+
/**
14+
* Returns a {@link TeleportCallback} to teleport the player to a position.
15+
*/
16+
export function useTeleportation(): TeleportCallback {
17+
const frame = React.useRef<XRFrame>()
18+
const baseReferenceSpace = React.useRef<XRReferenceSpace | null>(null)
19+
const teleportReferenceSpace = React.useRef<XRReferenceSpace | null>(null)
20+
21+
useFrame((state, _, xrFrame) => {
22+
frame.current = xrFrame
23+
24+
const referenceSpace = state.gl.xr.getReferenceSpace()
25+
baseReferenceSpace.current ??= referenceSpace
26+
27+
const teleportOffset = teleportReferenceSpace.current
28+
if (teleportOffset && referenceSpace !== teleportOffset) {
29+
state.gl.xr.setReferenceSpace(teleportOffset)
30+
}
31+
})
32+
33+
return React.useCallback((target) => {
34+
const base = baseReferenceSpace.current
35+
if (base) {
36+
const [x, y, z] = Array.from(target as THREE.Vector3Tuple)
37+
const offsetFromBase = { x: -x, y: -y, z: -z }
38+
39+
const pose = frame.current?.getViewerPose(base)
40+
if (pose) {
41+
offsetFromBase.x += pose.transform.position.x
42+
offsetFromBase.z += pose.transform.position.z
43+
}
44+
45+
const teleportOffset = new XRRigidTransform(offsetFromBase, _q)
46+
teleportReferenceSpace.current = base.getOffsetReferenceSpace(teleportOffset)
47+
}
48+
}, [])
49+
}
50+
51+
export interface TeleportationPlaneProps extends Partial<JSX.IntrinsicElements['group']> {
52+
/** Whether to allow teleportation from left controller. Default is `false` */
53+
leftHand?: boolean
54+
/** Whether to allow teleportation from right controller. Default is `false` */
55+
rightHand?: boolean
56+
/** The maximum distance from the camera to the teleportation point. Default is `10` */
57+
maxDistance?: number
58+
/** The radial size of the teleportation marker. Default is `0.25` */
59+
size?: number
60+
}
61+
62+
/**
63+
* Creates a teleportation plane with a marker that will teleport on interaction.
64+
*/
65+
export const TeleportationPlane = React.forwardRef<THREE.Group, TeleportationPlaneProps>(function TeleportationPlane(
66+
{ leftHand = false, rightHand = false, maxDistance = 10, size = 0.25, ...props },
67+
ref
68+
) {
69+
const teleport = useTeleportation()
70+
const marker = React.useRef<THREE.Mesh>(null!)
71+
const intersection = React.useRef<THREE.Vector3>()
72+
const camera = useThree((state) => state.camera)
73+
74+
const isInteractive = React.useCallback(
75+
(e: XRInteractionEvent): boolean => {
76+
const { handedness } = e.target.inputSource
77+
return !!((handedness !== 'left' || leftHand) && (handedness !== 'right' || rightHand))
78+
},
79+
[leftHand, rightHand]
80+
)
81+
82+
return (
83+
<group ref={ref} {...props}>
84+
<mesh ref={marker} visible={false} rotation-x={-Math.PI / 2}>
85+
<circleGeometry args={[size, 32]} />
86+
<meshBasicMaterial color="white" />
87+
</mesh>
88+
<Interactive
89+
onMove={(e) => {
90+
if (!isInteractive(e) || !e.intersection) return
91+
92+
const distanceFromCamera = e.intersection.point.distanceTo(camera.position)
93+
marker.current.visible = distanceFromCamera <= maxDistance
94+
marker.current.scale.setScalar(1)
95+
96+
intersection.current = e.intersection.point
97+
marker.current.position.copy(intersection.current)
98+
}}
99+
onHover={(e) => {
100+
if (!isInteractive(e) || !e.intersection) return
101+
102+
const distanceFromCamera = e.intersection.point.distanceTo(camera.position)
103+
marker.current.visible = distanceFromCamera <= maxDistance
104+
marker.current.scale.setScalar(1)
105+
}}
106+
onBlur={(e) => {
107+
if (!isInteractive(e)) return
108+
marker.current.visible = false
109+
}}
110+
onSelectStart={(e) => {
111+
if (!isInteractive(e) || !e.intersection) return
112+
113+
const distanceFromCamera = e.intersection.point.distanceTo(camera.position)
114+
marker.current.visible = distanceFromCamera <= maxDistance
115+
marker.current.scale.setScalar(1.1)
116+
}}
117+
onSelectEnd={(e) => {
118+
if (!isInteractive(e) || !intersection.current) return
119+
120+
marker.current.visible = true
121+
marker.current.scale.setScalar(1)
122+
123+
const distanceFromCamera = intersection.current.distanceTo(camera.position)
124+
if (distanceFromCamera <= maxDistance) {
125+
teleport(intersection.current)
126+
}
127+
}}
128+
>
129+
<mesh rotation-x={-Math.PI / 2} visible={false} scale={1000}>
130+
<planeGeometry />
131+
</mesh>
132+
</Interactive>
133+
</group>
134+
)
135+
})

src/index.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,4 +5,5 @@ export * from './XR'
55
export * from './XRController'
66
export * from './XREvents'
77
export * from './XRControllerModelFactory'
8-
export { type XRState } from './context'
8+
export * from './Teleportation'
9+
export { type XRState } from './context'

0 commit comments

Comments
 (0)