@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/LICENSE +202 -0
- package/NOTICE +9 -0
- package/README.md +106 -0
- package/dist/hands.d.ts +64 -0
- package/dist/hands.js +134 -0
- package/dist/index.d.ts +36 -0
- package/dist/index.js +29 -0
- package/dist/input.d.ts +67 -0
- package/dist/input.js +92 -0
- package/dist/layers.d.ts +44 -0
- package/dist/layers.js +107 -0
- package/dist/session.d.ts +56 -0
- package/dist/session.js +131 -0
- package/dist/support.d.ts +51 -0
- package/dist/support.js +123 -0
- package/dist/testing/synthetic.d.ts +66 -0
- package/dist/testing/synthetic.js +238 -0
- package/dist/types.d.ts +103 -0
- package/dist/types.js +15 -0
- package/dist/views.d.ts +43 -0
- package/dist/views.js +74 -0
- package/package.json +57 -0
- package/src/hands.ts +161 -0
- package/src/index.ts +71 -0
- package/src/input.ts +134 -0
- package/src/layers.ts +151 -0
- package/src/session.ts +167 -0
- package/src/support.ts +148 -0
- package/src/testing/synthetic.ts +296 -0
- package/src/types.ts +118 -0
- package/src/views.ts +95 -0
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
|
+
}
|