diff --git a/examples/hit-test-anchor/app.tsx b/examples/hit-test-anchor/app.tsx
index 05c8b315..5c4df960 100644
--- a/examples/hit-test-anchor/app.tsx
+++ b/examples/hit-test-anchor/app.tsx
@@ -38,7 +38,7 @@ const store = createXRStore({
return (
<>
-
+
>
diff --git a/examples/room-with-shadows/src/App.jsx b/examples/room-with-shadows/src/App.jsx
index 50aae7f8..75085032 100644
--- a/examples/room-with-shadows/src/App.jsx
+++ b/examples/room-with-shadows/src/App.jsx
@@ -25,7 +25,21 @@ function Light() {
)
}
-const store = createXRStore()
+const store = createXRStore({
+ emulate: {
+ headset: {
+ position: [0, 1, 0],
+ },
+ controller: {
+ left: {
+ position: [-0.2, 1, -0.3],
+ },
+ right: {
+ position: [0.2, 1, -0.3],
+ },
+ },
+ },
+})
export default function App() {
return (
@@ -66,7 +80,6 @@ export default function App() {
-
>
diff --git a/examples/secondary-input-sources/app.tsx b/examples/secondary-input-sources/app.tsx
index f85d4dc2..89b68e3b 100644
--- a/examples/secondary-input-sources/app.tsx
+++ b/examples/secondary-input-sources/app.tsx
@@ -11,11 +11,9 @@ const store = createXRStore({
controller: () => {
// eslint-disable-next-line react-hooks/rules-of-hooks
const hasHands = useXR((xr) => xr.inputSourceStates.find((state) => state.type === 'hand') != null)
- // eslint-disable-next-line react-hooks/rules-of-hooks
- const controllerState = useXRInputSourceStateContext()
return (
<>
-
+
(
[model, state.layout, state.gamepad],
)
useFrame(update)
- return
+ return (
+
+
+
+ )
})
const LoadXRControllerLayoutSymbol = Symbol('loadXRControllerLayout')
diff --git a/packages/react/xr/src/default.tsx b/packages/react/xr/src/default.tsx
index 36912394..41cb0e36 100644
--- a/packages/react/xr/src/default.tsx
+++ b/packages/react/xr/src/default.tsx
@@ -29,7 +29,7 @@ import {
useRayPointer,
useTouchPointer,
} from './pointer.js'
-import { XRSpace as XRSpaceImpl } from './space.js'
+import { XRSpace as XRSpaceImpl, XRSpaceType } from './space.js'
import { xrInputSourceStateContext } from './contexts.js'
import { TeleportPointerRayModel } from './teleport.js'
import { createPortal, useFrame, useThree } from '@react-three/fiber'
@@ -51,7 +51,7 @@ export {
function DefaultXRInputSourceGrabPointer(
event: 'select' | 'squeeze',
- getSpace: (source: XRInputSource) => XRSpace,
+ spaceType: XRSpaceType,
options: DefaultXRInputSourceGrabPointerOptions,
) {
const state = useContext(xrInputSourceStateContext)
@@ -63,7 +63,7 @@ function DefaultXRInputSourceGrabPointer(
usePointerXRInputSourceEvents(pointer, state.inputSource, event, state.events)
const cursorModelOptions = options.cursorModel
return (
-
+
{cursorModelOptions !== false && (
)}
@@ -82,11 +82,7 @@ function DefaultXRInputSourceGrabPointer(
* - `cursorModel` properties for configuring how the cursor should look
* - `radius` the size of the intersection sphere
*/
-export const DefaultXRHandGrabPointer = DefaultXRInputSourceGrabPointer.bind(
- null,
- 'select',
- (inputSource) => inputSource.hand!.get('index-finger-tip')!,
-)
+export const DefaultXRHandGrabPointer = DefaultXRInputSourceGrabPointer.bind(null, 'select', 'index-finger-tip')
/**
* grab pointer for the XRController
@@ -99,11 +95,7 @@ export const DefaultXRHandGrabPointer = DefaultXRInputSourceGrabPointer.bind(
* - `cursorModel` properties for configuring how the cursor should look
* - `radius` the size of the intersection sphere
*/
-export const DefaultXRControllerGrabPointer = DefaultXRInputSourceGrabPointer.bind(
- null,
- 'squeeze',
- (inputSource) => inputSource.gripSpace!,
-)
+export const DefaultXRControllerGrabPointer = DefaultXRInputSourceGrabPointer.bind(null, 'squeeze', 'grip-space')
/**
* ray pointer for the XRInputSource
@@ -128,7 +120,7 @@ export function DefaultXRInputSourceRayPointer(options: DefaultXRInputSourceRayP
const rayModelOptions = options.rayModel
const cursorModelOptions = options.cursorModel
return (
-
+
{rayModelOptions !== false && (
)}
@@ -345,7 +337,7 @@ export function DefaultXRInputSourceTeleportPointer(options: DefaultXRInputSourc
})
return (
<>
-
+
{createPortal(
{rayModelOptions !== false && (
diff --git a/packages/react/xr/src/elements.tsx b/packages/react/xr/src/elements.tsx
index 671285ca..7b6afc97 100644
--- a/packages/react/xr/src/elements.tsx
+++ b/packages/react/xr/src/elements.tsx
@@ -68,13 +68,13 @@ function XRControllers() {
return null
}
return (
-
-
+
+
{typeof ResolvedImpl === 'function' ? : }
-
-
+
+
)
})}
>
@@ -95,13 +95,13 @@ function XRHands() {
return null
}
return (
-
-
+
+
{typeof ResolvedImpl === 'function' ? : }
-
-
+
+
)
})}
>
@@ -125,7 +125,7 @@ function XRTransientPointers() {
return null
}
return (
-
+
{typeof ResolvedImpl === 'function' ? (
@@ -152,7 +152,7 @@ function XRGazes() {
<>
{gazeStates.map((state) => {
return (
-
+
{typeof Implementation === 'function' ? (
@@ -179,7 +179,7 @@ function XRScreenInputs() {
<>
{screenInputStates.map((state) => {
return (
-
+
{typeof Implementation === 'function' ? (
diff --git a/packages/xr/package.json b/packages/xr/package.json
index 359a0761..b42a1437 100644
--- a/packages/xr/package.json
+++ b/packages/xr/package.json
@@ -30,7 +30,7 @@
"three": "*"
},
"dependencies": {
- "@iwer/devui": "^0.1.0",
+ "@iwer/devui": "^0.2.0",
"@pmndrs/pointer-events": "workspace:^",
"iwer": "^1.0.3",
"meshline": "^3.3.1",
diff --git a/packages/xr/src/emulate.ts b/packages/xr/src/emulate.ts
index cb499898..42510119 100644
--- a/packages/xr/src/emulate.ts
+++ b/packages/xr/src/emulate.ts
@@ -1,13 +1,110 @@
import { XRDevice, metaQuest3, metaQuest2, metaQuestPro, oculusQuest1 } from 'iwer'
import { DevUI } from '@iwer/devui'
+import type { XRDeviceOptions } from 'iwer/lib/device/XRDevice'
+import { Euler, Quaternion, Vector3, Vector3Tuple, Vector4Tuple } from 'three'
const configurations = { metaQuest3, metaQuest2, metaQuestPro, oculusQuest1 }
export type EmulatorType = keyof typeof configurations
-export function emulate(type: EmulatorType) {
- const xrdevice = new XRDevice(configurations[type])
- xrdevice.ipd = 0
+export type EmulatorTransformationOptions = {
+ position?: Vector3 | Vector3Tuple
+ rotation?: Euler | Vector3Tuple
+ quaternion?: Quaternion | Vector4Tuple
+}
+
+export type EmulatorOptions =
+ | EmulatorType
+ | ({
+ type?: EmulatorType
+ primaryInputMode?: XRDevice['primaryInputMode']
+ headset?: EmulatorTransformationOptions
+ controller?: Partial>
+ hand?: Partial>
+ } & Partial>)
+
+const handednessList: Array = ['left', 'none', 'right']
+
+export function emulate(options: EmulatorOptions) {
+ const type = typeof options === 'string' ? options : (options.type ?? 'metaQuest3')
+ const xrdevice = new XRDevice(configurations[type], typeof options === 'string' ? undefined : options)
+ if (typeof options != 'string') {
+ applyEmulatorTransformOptions(xrdevice, options.headset)
+ applyEmulatorInputSourcesOptions(xrdevice.hands, options.hand)
+ applyEmulatorInputSourcesOptions(xrdevice.controllers, options.controller)
+ xrdevice.primaryInputMode = options.primaryInputMode ?? 'controller'
+ }
+ xrdevice.ipd = typeof options === 'string' ? 0 : (options.ipd ?? 0)
xrdevice.installRuntime()
new DevUI(xrdevice)
+ return xrdevice
+}
+
+const eulerHelper = new Euler()
+const quaternionHelper = new Quaternion()
+
+function applyEmulatorInputSourcesOptions(
+ xrInputSources: XRDevice['controllers'] | XRDevice['hands'],
+ options: Partial> | undefined,
+) {
+ if (options == null) {
+ return
+ }
+ for (const handedness of handednessList) {
+ applyEmulatorTransformOptions(xrInputSources[handedness], options[handedness])
+ }
+}
+
+function applyEmulatorTransformOptions(
+ target: XRDevice['controllers']['left'] | XRDevice['hands']['left'] | XRDevice,
+ options: EmulatorTransformationOptions | undefined,
+) {
+ if (target == null || options == null) {
+ return
+ }
+ setVector(target.position, options.position)
+ setVector(eulerHelper, options.rotation)
+ setQuaternion(target.quaternion, quaternionHelper.setFromEuler(eulerHelper))
+ setQuaternion(target.quaternion, options.quaternion)
+}
+
+function setVector(
+ target: { x: number; y: number; z: number } | Euler,
+ value: Euler | Vector3 | Vector3Tuple | undefined,
+) {
+ if (value == null) {
+ return
+ }
+ if (value instanceof Euler && target instanceof Euler) {
+ target.copy(value)
+ }
+ if (Array.isArray(value)) {
+ target.x = value[0]
+ target.y = value[1]
+ target.z = value[2]
+ return
+ }
+ target.x = value.x
+ target.y = value.y
+ target.z = value.z
+}
+
+function setQuaternion(
+ target: { x: number; y: number; z: number; w: number },
+ value: Quaternion | Vector4Tuple | undefined,
+) {
+ if (value == null) {
+ return
+ }
+ if (Array.isArray(value)) {
+ target.x = value[0]
+ target.y = value[1]
+ target.z = value[2]
+ target.w = value[3]
+ return
+ }
+ target.x = value.x
+ target.y = value.y
+ target.z = value.z
+ target.w = value.w
}
diff --git a/packages/xr/src/store.ts b/packages/xr/src/store.ts
index 4228b6a8..3a70e3e0 100644
--- a/packages/xr/src/store.ts
+++ b/packages/xr/src/store.ts
@@ -5,8 +5,9 @@ import { XRControllerLayoutLoaderOptions, updateXRControllerState } from './cont
import { XRHandLoaderOptions } from './hand/index.js'
import { XRInputSourceState, XRInputSourceStateMap, createSyncXRInputSourceStates } from './input.js'
import { XRSessionInitOptions, buildXRSessionInit } from './init.js'
-import type { EmulatorType } from './emulate.js'
+import type { EmulatorOptions } from './emulate.js'
import { XRLayerEntry } from './layer.js'
+import { XRDevice } from 'iwer'
declare global {
export interface XRSessionEventMap {
@@ -68,6 +69,10 @@ export type XRState = Readonly<
* active additional webxr layers
*/
layerEntries: ReadonlyArray
+ /**
+ * access to the emulator values to change the emulated input device imperatively
+ */
+ emulator?: XRDevice
} & WithRecord
>
@@ -166,7 +171,7 @@ export type XRStoreOptions = {
* emulates a device if WebXR not supported and on localhost
* @default "metaQuest3"
*/
- emulate?: EmulatorType | boolean
+ emulate?: EmulatorOptions | boolean
/**
* sets the WebXR foveation between 0 and 1
* undefined refers to the default foveation provided by the device/browser
@@ -283,35 +288,30 @@ const baseInitialState: Omit<
layerEntries: [],
}
-function startEmulate(emulate: EmulatorType | true, alert: boolean) {
+function startEmulate(store: StoreApi>, emulate: EmulatorOptions | true, alert: boolean) {
if (typeof navigator === 'undefined') {
return
}
Promise.all([navigator.xr?.isSessionSupported('immersive-vr'), navigator.xr?.isSessionSupported('immersive-ar')])
.then(([vr, ar]) => (!ar && !vr ? import('./emulate.js') : undefined))
.then((pkg) => {
+ if (pkg == null) {
+ return
+ }
if (alert) {
window.alert(`emulator started`)
}
- pkg?.emulate(emulate === true ? 'metaQuest3' : emulate)
+ const emulator = pkg.emulate(emulate === true ? 'metaQuest3' : emulate)
+ if (emulator == null) {
+ return
+ }
+ store.setState({
+ emulator,
+ })
})
}
export function createXRStore(options?: XRStoreOptions): XRStore {
- const emulate = options?.emulate ?? 'metaQuest3'
- let cleanupEmulate: (() => void) | undefined
- if (typeof window !== 'undefined' && emulate != false) {
- if (window.location.hostname === 'localhost') {
- startEmulate(emulate, false)
- }
- const keydownListener = (e: KeyboardEvent) => {
- if (e.altKey && e.metaKey && e.code === 'KeyE') {
- startEmulate(emulate, true)
- }
- }
- window.addEventListener('keydown', keydownListener)
- cleanupEmulate = () => window.removeEventListener('keydown', keydownListener)
- }
const domOverlayRoot =
typeof HTMLElement === 'undefined'
? undefined
@@ -328,6 +328,22 @@ export function createXRStore(options?: XRSt
domOverlayRoot,
}))
+ //setup emulate
+ const emulate = options?.emulate ?? 'metaQuest3'
+ let cleanupEmulate: (() => void) | undefined
+ if (typeof window !== 'undefined' && emulate != false) {
+ if (window.location.hostname === 'localhost') {
+ startEmulate(store, emulate, false)
+ }
+ const keydownListener = (e: KeyboardEvent) => {
+ if (e.altKey && e.metaKey && e.code === 'KeyE') {
+ startEmulate(store, emulate, true)
+ }
+ }
+ window.addEventListener('keydown', keydownListener)
+ cleanupEmulate = () => window.removeEventListener('keydown', keydownListener)
+ }
+
let cleanupDomOverlayRoot: (() => void) | undefined
if (domOverlayRoot != null) {
if (domOverlayRoot.parentNode == null) {
@@ -677,7 +693,10 @@ function createBindToSession(
const onEnd = () => {
cleanupSession?.()
cleanupSession = undefined
- store.setState(baseInitialState)
+ store.setState({
+ emulator: store.getState().emulator,
+ ...baseInitialState,
+ })
}
session.addEventListener('end', onEnd)
diff --git a/packages/xr/src/vanilla/elements.ts b/packages/xr/src/vanilla/elements.ts
index 07b53b79..f8f48ca0 100644
--- a/packages/xr/src/vanilla/elements.ts
+++ b/packages/xr/src/vanilla/elements.ts
@@ -135,7 +135,7 @@ function setupSyncInputSourceElements(
if (implementation === false) {
return
}
- const spaceObject = new XRSpace(getSpace(key, state.inputSource))
+ const spaceObject = new XRSpace(state.inputSource.targetRaySpace)
target.add(spaceObject)
const customCleanup =
typeof implementation === 'object'
@@ -195,19 +195,6 @@ function cleanup(map: Map void) | undefined>) {
map.clear()
}
-function getSpace(type: keyof XRInputSourceStateMap, inputSource: XRInputSource): XRSpaceType {
- switch (type) {
- case 'controller':
- return inputSource.gripSpace!
- case 'hand':
- return inputSource.hand!.get('wrist')!
- case 'gaze':
- case 'screenInput':
- case 'transientPointer':
- return inputSource.targetRaySpace
- }
-}
-
export let xrUpdatesListContext: XRUpdatesList | undefined
function runInXRUpdatesListContext(updatesList: XRUpdatesList, fn: () => (() => void) | undefined | void) {
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 0e9f3d1c..f7be344b 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -367,8 +367,8 @@ importers:
packages/xr:
dependencies:
'@iwer/devui':
- specifier: ^0.1.0
- version: 0.1.1(iwer@1.0.4)
+ specifier: ^0.2.0
+ version: 0.2.0(iwer@1.0.4)
'@pmndrs/pointer-events':
specifier: workspace:^
version: link:../pointer-events
@@ -974,8 +974,8 @@ packages:
wrap-ansi-cjs: /wrap-ansi@7.0.0
dev: false
- /@iwer/devui@0.1.1(iwer@1.0.4):
- resolution: {integrity: sha512-kZnoWc7KeKsdhRM7n7KORRlNCtk5Y2NmCsDUnwRvFyY1Cv0y0bnf3QMN6+rnHtRH88PtNxqKpN+syN8byrCAgw==}
+ /@iwer/devui@0.2.0(iwer@1.0.4):
+ resolution: {integrity: sha512-/D9QlbC+JGco0NuG2WiyVWo7SJatrNZ8kotS2rV1r/z4A+bcDbDfiU+PhOwz7oktwIzdr83cFK/V7IDUe0iSSg==}
peerDependencies:
iwer: ^1.0.3
dependencies:
@@ -986,7 +986,7 @@ packages:
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
styled-components: 6.1.13(react-dom@18.3.1)(react@18.3.1)
- three: 0.166.1
+ three: 0.169.0
dev: false
/@jridgewell/gen-mapping@0.3.5:
@@ -4597,29 +4597,29 @@ packages:
postcss: 8.4.31
dev: false
- /postcss-import@15.1.0(postcss@8.4.31):
+ /postcss-import@15.1.0(postcss@8.4.43):
resolution: {integrity: sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==}
engines: {node: '>=14.0.0'}
peerDependencies:
postcss: ^8.0.0
dependencies:
- postcss: 8.4.31
+ postcss: 8.4.43
postcss-value-parser: 4.2.0
read-cache: 1.0.0
resolve: 1.22.8
dev: false
- /postcss-js@4.0.1(postcss@8.4.31):
+ /postcss-js@4.0.1(postcss@8.4.43):
resolution: {integrity: sha512-dDLF8pEO191hJMtlHFPRa8xsizHaM82MLfNkUHdUtVEV3tgTp5oj+8qbEqYM57SLfc74KSbw//4SeJma2LRVIw==}
engines: {node: ^12 || ^14 || >= 16}
peerDependencies:
postcss: ^8.4.21
dependencies:
camelcase-css: 2.0.1
- postcss: 8.4.31
+ postcss: 8.4.43
dev: false
- /postcss-load-config@4.0.2(postcss@8.4.31):
+ /postcss-load-config@4.0.2(postcss@8.4.43):
resolution: {integrity: sha512-bSVhyJGL00wMVoPUzAVAnbEoWyqRxkjv64tUl427SKnPrENtq6hJwUojroMz2VB+Q1edmi4IfrAPpami5VVgMQ==}
engines: {node: '>= 14'}
peerDependencies:
@@ -4632,17 +4632,17 @@ packages:
optional: true
dependencies:
lilconfig: 3.1.2
- postcss: 8.4.31
+ postcss: 8.4.43
yaml: 2.5.0
dev: false
- /postcss-nested@6.2.0(postcss@8.4.31):
+ /postcss-nested@6.2.0(postcss@8.4.43):
resolution: {integrity: sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==}
engines: {node: '>=12.0'}
peerDependencies:
postcss: ^8.2.14
dependencies:
- postcss: 8.4.31
+ postcss: 8.4.43
postcss-selector-parser: 6.1.2
dev: false
@@ -4683,7 +4683,6 @@ packages:
nanoid: 3.3.7
picocolors: 1.0.1
source-map-js: 1.2.0
- dev: true
/potpack@1.0.2:
resolution: {integrity: sha512-choctRBIV9EMT9WGAZHn3V7t0Z2pMQyl0EZE6pFc/6ml3ssw7Dlf/oAOvFwjm1HVsqfQN8GfeFyJ+d8tRzqueQ==}
@@ -5273,11 +5272,11 @@ packages:
normalize-path: 3.0.0
object-hash: 3.0.0
picocolors: 1.0.1
- postcss: 8.4.31
- postcss-import: 15.1.0(postcss@8.4.31)
- postcss-js: 4.0.1(postcss@8.4.31)
- postcss-load-config: 4.0.2(postcss@8.4.31)
- postcss-nested: 6.2.0(postcss@8.4.31)
+ postcss: 8.4.43
+ postcss-import: 15.1.0(postcss@8.4.43)
+ postcss-js: 4.0.1(postcss@8.4.43)
+ postcss-load-config: 4.0.2(postcss@8.4.43)
+ postcss-nested: 6.2.0(postcss@8.4.43)
postcss-selector-parser: 6.1.2
postcss-value-parser: 4.2.0
resolve: 1.22.8
@@ -5348,13 +5347,13 @@ packages:
resolution: {integrity: sha512-iC/hUBbl1vzFny7f5GtqzVXYjMJKaTPxiCxXfrvVdBi1Sf+jhd1CAkitiFwC7mIBFCo3MrDLJG97yisoaWig0w==}
dev: true
- /three@0.166.1:
- resolution: {integrity: sha512-LtuafkKHHzm61AQA1be2MAYIw1IjmhOUxhBa0prrLpEMWbV7ijvxCRHjSgHPGp2493wLBzwKV46tA9nivLEgKg==}
- dev: false
-
/three@0.167.1:
resolution: {integrity: sha512-gYTLJA/UQip6J/tJvl91YYqlZF47+D/kxiWrbTon35ZHlXEN0VOo+Qke2walF1/x92v55H6enomymg4Dak52kw==}
+ /three@0.169.0:
+ resolution: {integrity: sha512-Ed906MA3dR4TS5riErd4QBsRGPcx+HBDX2O5yYE5GqJeFQTPU+M56Va/f/Oph9X7uZo3W3o4l2ZhBZ6f6qUv0w==}
+ dev: false
+
/tiny-inflate@1.0.3:
resolution: {integrity: sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==}
dev: false