@driftengine/xr 3.61.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/hands.ts ADDED
@@ -0,0 +1,161 @@
1
+ /**
2
+ * Twenty-five joints a hand, read into flat arrays.
3
+ *
4
+ * **The joint names are the specification's and their order is this file's**, fixed once as
5
+ * `HAND_JOINTS` so an index means the same thing in every frame, on every device and in a recording
6
+ * somebody kept. Reading them into a `Float32Array` rather than handing back objects is the same
7
+ * decision every other per-frame reader in this engine makes: a hand is twenty-five poses, twice,
8
+ * at ninety hertz, and an object per joint is four and a half thousand allocations a second on the
9
+ * device this arrangement exists for.
10
+ *
11
+ * **A joint that is not tracked is not zeroed**, and that is the interesting decision here. Hand
12
+ * tracking loses joints constantly, one at a time, as fingers occlude each other from the camera's
13
+ * view. Writing an identity matrix into an untracked joint would collapse that finger to the wrist
14
+ * for one frame and snap it back on the next, which reads as a violent twitch. So the last known
15
+ * pose stays, `tracked` says it is stale, and a consumer decides whether to hold, fade or hide.
16
+ * That is a policy a game owns, and this package refuses to make it for them.
17
+ */
18
+
19
+ import type { XrFrame, XrHand, XrInputSource, XrJointPose } from './types.ts';
20
+
21
+ /**
22
+ * Every joint WebXR defines, in the order this engine indexes them.
23
+ *
24
+ * Wrist first, then each finger from its base outward, which is the order the specification lists
25
+ * them in and the order a reader expects. **Never reordered**: an index is written into recordings
26
+ * and compared across runs, so moving one would change what a stored hand means.
27
+ */
28
+ export const HAND_JOINTS = [
29
+ 'wrist',
30
+ 'thumb-metacarpal',
31
+ 'thumb-phalanx-proximal',
32
+ 'thumb-phalanx-distal',
33
+ 'thumb-tip',
34
+ 'index-finger-metacarpal',
35
+ 'index-finger-phalanx-proximal',
36
+ 'index-finger-phalanx-intermediate',
37
+ 'index-finger-phalanx-distal',
38
+ 'index-finger-tip',
39
+ 'middle-finger-metacarpal',
40
+ 'middle-finger-phalanx-proximal',
41
+ 'middle-finger-phalanx-intermediate',
42
+ 'middle-finger-phalanx-distal',
43
+ 'middle-finger-tip',
44
+ 'ring-finger-metacarpal',
45
+ 'ring-finger-phalanx-proximal',
46
+ 'ring-finger-phalanx-intermediate',
47
+ 'ring-finger-phalanx-distal',
48
+ 'ring-finger-tip',
49
+ 'pinky-finger-metacarpal',
50
+ 'pinky-finger-phalanx-proximal',
51
+ 'pinky-finger-phalanx-intermediate',
52
+ 'pinky-finger-phalanx-distal',
53
+ 'pinky-finger-tip',
54
+ ] as const;
55
+
56
+ export type HandJoint = (typeof HAND_JOINTS)[number];
57
+
58
+ export const JOINT_COUNT = HAND_JOINTS.length;
59
+
60
+ /** The index of a named joint, so a consumer never counts. */
61
+ export const JOINT_INDEX: Readonly<Record<string, number>> = Object.freeze(
62
+ Object.fromEntries(HAND_JOINTS.map((name, index) => [name, index])),
63
+ );
64
+
65
+ /**
66
+ * One hand's joints, allocated once and written into every frame.
67
+ *
68
+ * Sixteen floats a joint because a joint carries a full transform: a fingertip has an orientation
69
+ * as well as a position, and a consumer drawing a mesh needs both.
70
+ */
71
+ export class HandSkeleton {
72
+ /** Row-major transforms, `JOINT_COUNT * 16`, in world space. */
73
+ readonly matrices = new Float32Array(JOINT_COUNT * 16);
74
+ /** Joint radii in metres, or 0 where the runtime offered none. */
75
+ readonly radii = new Float32Array(JOINT_COUNT);
76
+ /** Whether each joint had a pose in the last frame that was read. */
77
+ readonly tracked = new Uint8Array(JOINT_COUNT);
78
+ /** How many joints were tracked in that frame. Zero means the hand is not visible. */
79
+ trackedCount = 0;
80
+
81
+ /** Whether anything at all was seen, which is what a consumer branches on before drawing. */
82
+ get visible(): boolean {
83
+ return this.trackedCount > 0;
84
+ }
85
+
86
+ /**
87
+ * Forget everything, which is what a source going away means.
88
+ *
89
+ * Separate from a frame where every joint is merely untracked: a hand that has left has no last
90
+ * known pose worth holding, and a consumer that fades on loss needs the two told apart.
91
+ */
92
+ clear(): void {
93
+ this.matrices.fill(0);
94
+ this.radii.fill(0);
95
+ this.tracked.fill(0);
96
+ this.trackedCount = 0;
97
+ }
98
+ }
99
+
100
+ /**
101
+ * Read one hand into a skeleton.
102
+ *
103
+ * Returns whether anything was tracked. `false` with the skeleton left holding its last poses is
104
+ * the ordinary case for a hand that has moved out of the cameras' view, and it is not an error.
105
+ */
106
+ export function readHand(
107
+ source: XrInputSource,
108
+ frame: XrFrame,
109
+ referenceSpace: unknown,
110
+ into: HandSkeleton,
111
+ ): boolean {
112
+ const hand = source.hand as XrHand | undefined;
113
+ if (hand === undefined || frame.getJointPose === undefined) {
114
+ into.trackedCount = 0;
115
+ into.tracked.fill(0);
116
+ return false;
117
+ }
118
+
119
+ let tracked = 0;
120
+ for (let i = 0; i < JOINT_COUNT; i++) {
121
+ const space = hand.get(HAND_JOINTS[i] as string);
122
+ const pose =
123
+ space === undefined || space === null
124
+ ? null
125
+ : ((frame.getJointPose(space, referenceSpace) as XrJointPose | null | undefined) ?? null);
126
+
127
+ if (pose === null) {
128
+ /*
129
+ * Left as it was. See the header: zeroing an occluded joint collapses that finger onto the
130
+ * wrist for one frame and snaps it back on the next, and a hand tracker loses joints
131
+ * constantly. The flag says the pose is stale; what to do about it is the consumer's.
132
+ */
133
+ into.tracked[i] = 0;
134
+ continue;
135
+ }
136
+
137
+ into.matrices.set(pose.transform.matrix, i * 16);
138
+ into.radii[i] = pose.radius ?? 0;
139
+ into.tracked[i] = 1;
140
+ tracked++;
141
+ }
142
+
143
+ into.trackedCount = tracked;
144
+ return tracked > 0;
145
+ }
146
+
147
+ /** A joint's position, read out of the flat storage without allocating. */
148
+ export function jointPosition(
149
+ skeleton: HandSkeleton,
150
+ joint: HandJoint | number,
151
+ out: Float32Array,
152
+ ): boolean {
153
+ const index = typeof joint === 'number' ? joint : (JOINT_INDEX[joint] ?? -1);
154
+ if (index < 0 || index >= JOINT_COUNT) return false;
155
+ /* Column-major, as every matrix in this engine is, so the translation is elements 12 to 14. */
156
+ const at = index * 16;
157
+ out[0] = skeleton.matrices[at + 12] as number;
158
+ out[1] = skeleton.matrices[at + 13] as number;
159
+ out[2] = skeleton.matrices[at + 14] as number;
160
+ return skeleton.tracked[index] === 1;
161
+ }
package/src/index.ts ADDED
@@ -0,0 +1,71 @@
1
+ /*! DriftEngine | Copyright 2026 Drift Technologies | Apache-2.0 | https://github.com/drftrun/driftengine */
2
+ /**
3
+ * WebXR: sessions, stereo views, controller input and hand joints.
4
+ *
5
+ * **A package and not part of core**, so a game that never enters a session carries none of it.
6
+ * `size-gate.test.mjs` keeps that a measurement rather than an intention.
7
+ *
8
+ * **It supplies views to a camera and core never reaches back for a session.** That is the whole
9
+ * shape of the dependency: `Camera.adoptView` lives in core because the camera is core's, and the
10
+ * reason a camera cannot derive an XR eye's matrices is written beside it. Everything about
11
+ * sessions, layers, poses and hands is here.
12
+ *
13
+ * ## What is measured, and what is written down instead
14
+ *
15
+ * A real immersive session has never been run against this code. The machine it was written on has
16
+ * no headset: `immersive-vr` reports false, and `makeXRCompatible` throws, so no `XRFrame` can be
17
+ * produced there at all. What that machine does reach is asserted by `scripts/xr-check.mjs`, and
18
+ * everything past the first frame is exercised against a synthetic `XRSystem` in the unit tests.
19
+ *
20
+ * So **stereo on hardware, controller input, hand tracking, device performance and the compositor's
21
+ * reprojection are unmeasured**, and are tracked as such rather than claimed. Nothing
22
+ * here says otherwise.
23
+ */
24
+
25
+ export type {
26
+ XrFrame,
27
+ XrGamepad,
28
+ XrHand,
29
+ XrInputSource,
30
+ XrJointPose,
31
+ XrMode,
32
+ XrRigidTransform,
33
+ XrSession,
34
+ XrSystem,
35
+ XrView,
36
+ XrViewerPose,
37
+ XrViewport,
38
+ XrWebGlLayer,
39
+ } from './types.ts';
40
+
41
+ export type { XrCompatibleContext, XrSupport } from './support.ts';
42
+ export { bestMode, probeXrSupport } from './support.ts';
43
+
44
+ export type { LayerBackend, LayerSources, XrLayer } from './layers.ts';
45
+ export { chooseLayer } from './layers.ts';
46
+
47
+ export type { EnterXrOptions, EnterXrResult, XrRun } from './session.ts';
48
+ export { enterXr } from './session.ts';
49
+
50
+ export type { EyeView } from './views.ts';
51
+ export { aimCameraAtEye, eyeViews } from './views.ts';
52
+
53
+ export type { ControllerState, Handedness, XrButton } from './input.ts';
54
+ export {
55
+ XR_BUTTON,
56
+ buttonPressed,
57
+ buttonValue,
58
+ controllerFor,
59
+ readController,
60
+ readControllers,
61
+ } from './input.ts';
62
+
63
+ export type { HandJoint } from './hands.ts';
64
+ export {
65
+ HAND_JOINTS,
66
+ HandSkeleton,
67
+ JOINT_COUNT,
68
+ JOINT_INDEX,
69
+ jointPosition,
70
+ readHand,
71
+ } from './hands.ts';
package/src/input.ts ADDED
@@ -0,0 +1,134 @@
1
+ /**
2
+ * Controllers, read the way the rest of this engine reads input.
3
+ *
4
+ * **A controller is a gamepad with a pose, and the specification says so**: an `XRInputSource`
5
+ * carries an ordinary `Gamepad` whose button order is fixed by the WebXR gamepad profile. So the
6
+ * buttons are named here rather than indexed at every call site, and the names are the profile's
7
+ * rather than any device's marketing: `trigger`, `squeeze`, `touchpad`, `thumbstick`.
8
+ *
9
+ * **What is deliberately absent is a binding layer.** This engine has `ActionMap`, and a second one
10
+ * that only XR could reach would be a second answer to what a button means. A consumer maps a
11
+ * trigger to an action the same way they map a key, which is why this reads state and never
12
+ * dispatches an intent.
13
+ *
14
+ * **Nothing here is deterministic and none of it belongs in a fixed step.** A controller pose
15
+ * arrives with a frame, from a device, at a rate the runtime chooses. `drift/ui` already settled
16
+ * that a pointer position is `input.read` and outside `DETERMINISTIC_EFFECTS`, and a hand is the
17
+ * same kind of fact.
18
+ */
19
+
20
+ import type { XrFrame, XrInputSource, XrRigidTransform, XrSession } from './types.ts';
21
+
22
+ /** Which hand, as WebXR reports it. `none` is a source with no handedness, like a gaze pointer. */
23
+ export type Handedness = 'left' | 'right' | 'none';
24
+
25
+ /**
26
+ * The buttons the WebXR gamepad profile fixes, in its order.
27
+ *
28
+ * Indexes and not a guess: the profile pins 0 to the trigger and 1 to the squeeze for every device
29
+ * that reports `xr-standard`, which is what makes reading them by name safe. A device offering
30
+ * fewer buttons simply has none at the higher indexes, which reads as zero rather than as an error.
31
+ */
32
+ export const XR_BUTTON = {
33
+ trigger: 0,
34
+ squeeze: 1,
35
+ touchpad: 2,
36
+ thumbstick: 3,
37
+ } as const;
38
+
39
+ export type XrButton = keyof typeof XR_BUTTON;
40
+
41
+ /** One controller, as much of it as this engine reads. */
42
+ export interface ControllerState {
43
+ readonly handedness: Handedness;
44
+ /** How the source is aimed: `tracked-pointer`, `gaze` or `screen`. */
45
+ readonly targetRayMode: string;
46
+ /** Whether a pose was available this frame. False while a controller is out of view. */
47
+ readonly tracked: boolean;
48
+ /** The grip transform's matrix, valid only while `tracked`. */
49
+ readonly gripMatrix: Float32Array | null;
50
+ /** Where the source is aiming, which is not where it is held. */
51
+ readonly rayMatrix: Float32Array | null;
52
+ /** Analogue values in the profile's order, and zero for a button the device does not have. */
53
+ readonly buttons: readonly number[];
54
+ readonly pressed: readonly boolean[];
55
+ readonly axes: readonly number[];
56
+ }
57
+
58
+ const NO_BUTTONS: readonly number[] = [];
59
+ const NO_PRESSED: readonly boolean[] = [];
60
+ const NO_AXES: readonly number[] = [];
61
+
62
+ /**
63
+ * Read every input source a session currently reports.
64
+ *
65
+ * `out` is filled and returned, so a frame loop allocates nothing after the first call. Sources
66
+ * come and go as controllers wake and sleep, which is why the list is read from the session every
67
+ * frame rather than cached at `inputsourceschange`: the event exists for a consumer that wants to
68
+ * react, and this is the polling half.
69
+ */
70
+ export function readControllers(
71
+ session: XrSession,
72
+ frame: XrFrame,
73
+ referenceSpace: unknown,
74
+ out: ControllerState[] = [],
75
+ ): ControllerState[] {
76
+ out.length = 0;
77
+ for (const source of session.inputSources) {
78
+ out.push(readController(source, frame, referenceSpace));
79
+ }
80
+ return out;
81
+ }
82
+
83
+ function matrixOf(frame: XrFrame, space: unknown, referenceSpace: unknown): Float32Array | null {
84
+ if (space === undefined || space === null || frame.getPose === undefined) return null;
85
+ const pose = frame.getPose(space, referenceSpace);
86
+ const transform = pose?.transform as XrRigidTransform | undefined;
87
+ return transform?.matrix ?? null;
88
+ }
89
+
90
+ export function readController(
91
+ source: XrInputSource,
92
+ frame: XrFrame,
93
+ referenceSpace: unknown,
94
+ ): ControllerState {
95
+ const gripMatrix = matrixOf(frame, source.gripSpace, referenceSpace);
96
+ const rayMatrix = matrixOf(frame, source.targetRaySpace, referenceSpace);
97
+ const pad = source.gamepad;
98
+
99
+ return {
100
+ handedness: source.handedness,
101
+ targetRayMode: source.targetRayMode,
102
+ /*
103
+ * Tracked means a pose came back this frame, which is a different question from whether the
104
+ * source is listed. A controller set down on a table is still an input source and has no pose,
105
+ * and a consumer drawing a model at a stale matrix would leave it floating where it was
106
+ * dropped.
107
+ */
108
+ tracked: gripMatrix !== null || rayMatrix !== null,
109
+ gripMatrix,
110
+ rayMatrix,
111
+ buttons: pad ? pad.buttons.map((button) => button.value) : NO_BUTTONS,
112
+ pressed: pad ? pad.buttons.map((button) => button.pressed) : NO_PRESSED,
113
+ axes: pad ? [...pad.axes] : NO_AXES,
114
+ };
115
+ }
116
+
117
+ /** One named button's analogue value, or zero where the device has no such button. */
118
+ export function buttonValue(state: ControllerState, button: XrButton): number {
119
+ return state.buttons[XR_BUTTON[button]] ?? 0;
120
+ }
121
+
122
+ /** Whether a named button is pressed, false where the device has no such button. */
123
+ export function buttonPressed(state: ControllerState, button: XrButton): boolean {
124
+ return state.pressed[XR_BUTTON[button]] ?? false;
125
+ }
126
+
127
+ /** The first source for a hand, or null. Two sources may share a handedness and rarely do. */
128
+ export function controllerFor(
129
+ states: readonly ControllerState[],
130
+ handedness: Handedness,
131
+ ): ControllerState | null {
132
+ for (const state of states) if (state.handedness === handedness) return state;
133
+ return null;
134
+ }
package/src/layers.ts ADDED
@@ -0,0 +1,151 @@
1
+ /**
2
+ * Where a session draws, on either backend.
3
+ *
4
+ * **Both paths are built, and the WebGPU one is newer than the engine that uses it.** Measured
5
+ * 2026-09-05 in Chrome 151: `XRGPUBinding` is undefined under ordinary flags and present under
6
+ * `--enable-experimental-web-platform-features`, isolated from three other flags that do not do it.
7
+ * Chrome's own note calls WebGPU in WebXR available for developer testing on Windows and Android.
8
+ *
9
+ * So the choice is made from what the runtime **has**, never from a preference:
10
+ *
11
+ * - `XRGPUBinding` present and a device to hand: a projection layer, and the session composites it.
12
+ * - Otherwise `XRWebGLLayer`, which every WebXR implementation has had since the first one.
13
+ *
14
+ * **A backend the session cannot bind is not a failure to report, it is the other path.** A game
15
+ * running on WebGPU on a browser whose WebXR is WebGL-only should enter a session on WebGL2 rather
16
+ * than refuse, which is why `chooseLayer` answers with what it built and which backend it is for
17
+ * instead of throwing.
18
+ */
19
+
20
+ import type { XrSession, XrWebGlLayer } from './types.ts';
21
+
22
+ export type LayerBackend = 'webgl2' | 'webgpu';
23
+
24
+ export interface XrLayer {
25
+ readonly backend: LayerBackend;
26
+ /** Set on the session's render state. A WebGL layer here; a projection layer goes in `layers`. */
27
+ readonly baseLayer: XrWebGlLayer | null;
28
+ /** The WebGPU projection layer, when that is the path taken. */
29
+ readonly projectionLayer: unknown | null;
30
+ /** A whole sentence when the chosen path was not the one asked for. Empty when it was. */
31
+ readonly reason: string;
32
+ }
33
+
34
+ /** What a caller offers. Whichever of the two it has; both is allowed and neither is not. */
35
+ export interface LayerSources {
36
+ /** A WebGL2 context, already made XR compatible by the caller. */
37
+ readonly gl?: unknown;
38
+ /** A `GPUDevice`. */
39
+ readonly device?: unknown;
40
+ }
41
+
42
+ interface WebGlLayerCtor {
43
+ new (session: XrSession, gl: unknown): XrWebGlLayer;
44
+ }
45
+
46
+ interface GpuBindingCtor {
47
+ new (
48
+ session: XrSession,
49
+ device: unknown,
50
+ ): {
51
+ createProjectionLayer(options: { colorFormat: unknown }): unknown;
52
+ };
53
+ getPreferredColorFormat?(): unknown;
54
+ }
55
+
56
+ function webGlLayerCtor(): WebGlLayerCtor | null {
57
+ return (globalThis as { XRWebGLLayer?: WebGlLayerCtor }).XRWebGLLayer ?? null;
58
+ }
59
+
60
+ function gpuBindingCtor(): GpuBindingCtor | null {
61
+ return (globalThis as { XRGPUBinding?: GpuBindingCtor }).XRGPUBinding ?? null;
62
+ }
63
+
64
+ /**
65
+ * Build the layer this session and these sources allow.
66
+ *
67
+ * Never throws. It is called at the moment a session starts, which is a moment a consumer is
68
+ * showing a user something, and `AGENTS.md`'s rule about not throwing in a frame loop is the same
69
+ * rule one step earlier: a session that cannot draw should say so and leave the page alive.
70
+ */
71
+ export function chooseLayer(session: XrSession, sources: LayerSources): XrLayer {
72
+ const binding = gpuBindingCtor();
73
+
74
+ if (sources.device !== undefined && binding !== null) {
75
+ try {
76
+ const bound = new binding(session, sources.device);
77
+ const colorFormat = binding.getPreferredColorFormat?.() ?? 'bgra8unorm';
78
+ const projectionLayer = bound.createProjectionLayer({ colorFormat });
79
+ session.updateRenderState({ layers: [projectionLayer] });
80
+ return { backend: 'webgpu', baseLayer: null, projectionLayer, reason: '' };
81
+ } catch (error) {
82
+ /*
83
+ * Falling through to WebGL2 rather than failing. The binding constructs on a browser whose
84
+ * runtime cannot actually composite a WebGPU layer, so the honest test of this path is
85
+ * building the layer, and the honest answer to it failing is the path that has worked since
86
+ * WebXR shipped.
87
+ */
88
+ const name = (error as Error)?.name ?? 'Error';
89
+ const fallback = buildWebGlLayer(session, sources);
90
+ return fallback.baseLayer === null
91
+ ? {
92
+ ...fallback,
93
+ reason:
94
+ `the WebGPU layer could not be built (${name}) and no WebGL2 context was offered to ` +
95
+ 'fall back to.',
96
+ }
97
+ : {
98
+ ...fallback,
99
+ reason:
100
+ `the WebGPU layer could not be built (${name}), so this session draws through ` +
101
+ 'WebGL2. WebGPU in WebXR is experimental and is behind a flag in current browsers.',
102
+ };
103
+ }
104
+ }
105
+
106
+ const layer = buildWebGlLayer(session, sources);
107
+ if (layer.baseLayer !== null && sources.device !== undefined && binding === null) {
108
+ return {
109
+ ...layer,
110
+ reason:
111
+ 'this browser has no XRGPUBinding, so a WebGPU device cannot be bound to a session and ' +
112
+ 'this one draws through WebGL2.',
113
+ };
114
+ }
115
+ return layer;
116
+ }
117
+
118
+ function buildWebGlLayer(session: XrSession, sources: LayerSources): XrLayer {
119
+ const ctor = webGlLayerCtor();
120
+ if (ctor === null) {
121
+ return {
122
+ backend: 'webgl2',
123
+ baseLayer: null,
124
+ projectionLayer: null,
125
+ reason: 'this browser has no XRWebGLLayer, so a session has nothing to draw into.',
126
+ };
127
+ }
128
+ if (sources.gl === undefined) {
129
+ return {
130
+ backend: 'webgl2',
131
+ baseLayer: null,
132
+ projectionLayer: null,
133
+ reason: 'no WebGL2 context was offered, so a session has nothing to draw into.',
134
+ };
135
+ }
136
+ try {
137
+ const baseLayer = new ctor(session, sources.gl);
138
+ session.updateRenderState({ baseLayer });
139
+ return { backend: 'webgl2', baseLayer, projectionLayer: null, reason: '' };
140
+ } catch (error) {
141
+ const name = (error as Error)?.name ?? 'Error';
142
+ return {
143
+ backend: 'webgl2',
144
+ baseLayer: null,
145
+ projectionLayer: null,
146
+ reason:
147
+ `the WebGL2 layer could not be built (${name}), which is what happens when the context ` +
148
+ 'was never made XR compatible.',
149
+ };
150
+ }
151
+ }
package/src/session.ts ADDED
@@ -0,0 +1,167 @@
1
+ /**
2
+ * A session, from asking for one to giving it back.
3
+ *
4
+ * **`requestSession` must be called from a user gesture** and no wrapper can change that, so this
5
+ * takes the call rather than deciding when to make it: a consumer's button handler calls
6
+ * `enterXr`, and everything after that is this file's.
7
+ *
8
+ * What it owns is the order, which has four steps and fails differently at each:
9
+ *
10
+ * 1. the session itself, which a browser refuses without a device or a gesture,
11
+ * 2. a layer to draw into, which needs a context the runtime accepts,
12
+ * 3. a reference space, which decides what the poses in every frame are relative to,
13
+ * 4. the frame source, which is what makes the engine's loop run on the headset's clock.
14
+ *
15
+ * **Every failure answers with a sentence and none of them throws.** A consumer whose button did
16
+ * nothing is owed the difference between a browser without WebXR, a machine without a headset, a
17
+ * context that would not become XR compatible, and a reference space the runtime declined. Those
18
+ * are four different things to do next, and `reason` is where they are told apart.
19
+ */
20
+
21
+ import type { FrameSource } from '@driftengine/core';
22
+ import { chooseLayer } from './layers.ts';
23
+ import type { LayerBackend, LayerSources, XrLayer } from './layers.ts';
24
+ import type { XrFrame, XrMode, XrSession, XrSystem } from './types.ts';
25
+
26
+ /**
27
+ * Which space poses are reported in.
28
+ *
29
+ * `local-floor` first, because a scene authored in metres from a floor is what a room-scale
30
+ * experience is, and `local` puts the origin at the headset's starting height instead, which stands
31
+ * a player's feet wherever their head happened to be. `viewer` is the last resort and the only one
32
+ * an inline session is guaranteed.
33
+ */
34
+ const SPACE_ORDER: readonly string[] = ['local-floor', 'local', 'viewer'];
35
+
36
+ export interface EnterXrOptions {
37
+ readonly mode?: XrMode;
38
+ /** Passed to `requestSession`. `hand-tracking` is here rather than required. */
39
+ readonly optionalFeatures?: readonly string[];
40
+ readonly sources: LayerSources;
41
+ /** Override the system, which is how a test drives this without a headset. */
42
+ readonly system?: XrSystem | null;
43
+ }
44
+
45
+ export interface XrRun {
46
+ readonly session: XrSession;
47
+ readonly referenceSpace: unknown;
48
+ readonly referenceSpaceType: string;
49
+ readonly layer: XrLayer;
50
+ readonly backend: LayerBackend;
51
+ /** Hand this to `startLoop` and the engine runs on the session's clock. */
52
+ readonly frameSource: FrameSource;
53
+ /** Ends the session. Safe to call twice. */
54
+ end(): Promise<void>;
55
+ /** Runs when the session ends, however it ends, including the user taking the headset off. */
56
+ onEnd(listener: () => void): void;
57
+ }
58
+
59
+ export type EnterXrResult =
60
+ { readonly ok: true; readonly run: XrRun } | { readonly ok: false; readonly reason: string };
61
+
62
+ function systemOf(override?: XrSystem | null): XrSystem | null {
63
+ if (override !== undefined) return override;
64
+ return (globalThis.navigator as { xr?: XrSystem } | undefined)?.xr ?? null;
65
+ }
66
+
67
+ /**
68
+ * Ask for a session and set everything up behind it.
69
+ *
70
+ * Call it from a click. Everything it can go wrong at is a `reason` and never a throw.
71
+ */
72
+ export async function enterXr(options: EnterXrOptions): Promise<EnterXrResult> {
73
+ const system = systemOf(options.system);
74
+ if (system === null) {
75
+ return { ok: false, reason: 'this browser has no WebXR: navigator.xr is not defined.' };
76
+ }
77
+
78
+ const mode = options.mode ?? 'immersive-vr';
79
+ let session: XrSession;
80
+ try {
81
+ session = await system.requestSession(mode, { optionalFeatures: options.optionalFeatures });
82
+ } catch (error) {
83
+ const name = (error as Error)?.name ?? 'Error';
84
+ return {
85
+ ok: false,
86
+ reason:
87
+ `the browser refused an immersive session (${mode}, ${name}). That is what it answers when no device ` +
88
+ 'is attached, and also when the call did not come from a user gesture.',
89
+ };
90
+ }
91
+
92
+ const layer = chooseLayer(session, options.sources);
93
+ if (layer.baseLayer === null && layer.projectionLayer === null) {
94
+ await session.end().catch(() => {});
95
+ return { ok: false, reason: layer.reason };
96
+ }
97
+
98
+ const space = await firstSpace(session);
99
+ if (space === null) {
100
+ await session.end().catch(() => {});
101
+ return {
102
+ ok: false,
103
+ reason:
104
+ `the session offered none of ${SPACE_ORDER.join(', ')} as a reference space, so there is ` +
105
+ 'nothing to report poses relative to.',
106
+ };
107
+ }
108
+
109
+ let ended = false;
110
+ const listeners: (() => void)[] = [];
111
+ session.addEventListener('end', () => {
112
+ ended = true;
113
+ for (const listener of listeners) listener();
114
+ });
115
+
116
+ const frameSource: FrameSource = {
117
+ /*
118
+ * Narrowed here and nowhere else. `core`'s loop types this argument `unknown` because it has no
119
+ * business naming an `XRFrame`; this is the one place that knows what arrived, and it hands it
120
+ * straight on rather than reading it, because the reading belongs to whoever draws.
121
+ */
122
+ requestAnimationFrame: (callback: (timeMs: number, frame?: unknown) => void): number =>
123
+ session.requestAnimationFrame((timeMs: number, frame: XrFrame) => callback(timeMs, frame)),
124
+ cancelAnimationFrame: (handle: number): void => {
125
+ session.cancelAnimationFrame(handle);
126
+ },
127
+ };
128
+
129
+ return {
130
+ ok: true,
131
+ run: {
132
+ session,
133
+ referenceSpace: space.space,
134
+ referenceSpaceType: space.type,
135
+ layer,
136
+ backend: layer.backend,
137
+ frameSource,
138
+ async end(): Promise<void> {
139
+ if (ended) return;
140
+ ended = true;
141
+ await session.end().catch(() => {});
142
+ },
143
+ onEnd(listener: () => void): void {
144
+ if (ended) listener();
145
+ else listeners.push(listener);
146
+ },
147
+ },
148
+ };
149
+ }
150
+
151
+ /**
152
+ * The best reference space this session will give, in the order that matters to a scene.
153
+ *
154
+ * Asked one at a time rather than in parallel, because a runtime may charge for a space it then has
155
+ * to discard and because the order is the whole point: taking whichever resolves first would be a
156
+ * race deciding where a player's floor is.
157
+ */
158
+ async function firstSpace(session: XrSession): Promise<{ space: unknown; type: string } | null> {
159
+ for (const type of SPACE_ORDER) {
160
+ try {
161
+ return { space: await session.requestReferenceSpace(type), type };
162
+ } catch {
163
+ /* Declined. The next one is not a fallback so much as the next preference. */
164
+ }
165
+ }
166
+ return null;
167
+ }