@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/dist/input.js ADDED
@@ -0,0 +1,92 @@
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
+ * The buttons the WebXR gamepad profile fixes, in its order.
21
+ *
22
+ * Indexes and not a guess: the profile pins 0 to the trigger and 1 to the squeeze for every device
23
+ * that reports `xr-standard`, which is what makes reading them by name safe. A device offering
24
+ * fewer buttons simply has none at the higher indexes, which reads as zero rather than as an error.
25
+ */
26
+ export const XR_BUTTON = {
27
+ trigger: 0,
28
+ squeeze: 1,
29
+ touchpad: 2,
30
+ thumbstick: 3,
31
+ };
32
+ const NO_BUTTONS = [];
33
+ const NO_PRESSED = [];
34
+ const NO_AXES = [];
35
+ /**
36
+ * Read every input source a session currently reports.
37
+ *
38
+ * `out` is filled and returned, so a frame loop allocates nothing after the first call. Sources
39
+ * come and go as controllers wake and sleep, which is why the list is read from the session every
40
+ * frame rather than cached at `inputsourceschange`: the event exists for a consumer that wants to
41
+ * react, and this is the polling half.
42
+ */
43
+ export function readControllers(session, frame, referenceSpace, out = []) {
44
+ out.length = 0;
45
+ for (const source of session.inputSources) {
46
+ out.push(readController(source, frame, referenceSpace));
47
+ }
48
+ return out;
49
+ }
50
+ function matrixOf(frame, space, referenceSpace) {
51
+ if (space === undefined || space === null || frame.getPose === undefined)
52
+ return null;
53
+ const pose = frame.getPose(space, referenceSpace);
54
+ const transform = pose?.transform;
55
+ return transform?.matrix ?? null;
56
+ }
57
+ export function readController(source, frame, referenceSpace) {
58
+ const gripMatrix = matrixOf(frame, source.gripSpace, referenceSpace);
59
+ const rayMatrix = matrixOf(frame, source.targetRaySpace, referenceSpace);
60
+ const pad = source.gamepad;
61
+ return {
62
+ handedness: source.handedness,
63
+ targetRayMode: source.targetRayMode,
64
+ /*
65
+ * Tracked means a pose came back this frame, which is a different question from whether the
66
+ * source is listed. A controller set down on a table is still an input source and has no pose,
67
+ * and a consumer drawing a model at a stale matrix would leave it floating where it was
68
+ * dropped.
69
+ */
70
+ tracked: gripMatrix !== null || rayMatrix !== null,
71
+ gripMatrix,
72
+ rayMatrix,
73
+ buttons: pad ? pad.buttons.map((button) => button.value) : NO_BUTTONS,
74
+ pressed: pad ? pad.buttons.map((button) => button.pressed) : NO_PRESSED,
75
+ axes: pad ? [...pad.axes] : NO_AXES,
76
+ };
77
+ }
78
+ /** One named button's analogue value, or zero where the device has no such button. */
79
+ export function buttonValue(state, button) {
80
+ return state.buttons[XR_BUTTON[button]] ?? 0;
81
+ }
82
+ /** Whether a named button is pressed, false where the device has no such button. */
83
+ export function buttonPressed(state, button) {
84
+ return state.pressed[XR_BUTTON[button]] ?? false;
85
+ }
86
+ /** The first source for a hand, or null. Two sources may share a handedness and rarely do. */
87
+ export function controllerFor(states, handedness) {
88
+ for (const state of states)
89
+ if (state.handedness === handedness)
90
+ return state;
91
+ return null;
92
+ }
@@ -0,0 +1,44 @@
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
+ import type { XrSession, XrWebGlLayer } from './types.ts';
20
+ export type LayerBackend = 'webgl2' | 'webgpu';
21
+ export interface XrLayer {
22
+ readonly backend: LayerBackend;
23
+ /** Set on the session's render state. A WebGL layer here; a projection layer goes in `layers`. */
24
+ readonly baseLayer: XrWebGlLayer | null;
25
+ /** The WebGPU projection layer, when that is the path taken. */
26
+ readonly projectionLayer: unknown | null;
27
+ /** A whole sentence when the chosen path was not the one asked for. Empty when it was. */
28
+ readonly reason: string;
29
+ }
30
+ /** What a caller offers. Whichever of the two it has; both is allowed and neither is not. */
31
+ export interface LayerSources {
32
+ /** A WebGL2 context, already made XR compatible by the caller. */
33
+ readonly gl?: unknown;
34
+ /** A `GPUDevice`. */
35
+ readonly device?: unknown;
36
+ }
37
+ /**
38
+ * Build the layer this session and these sources allow.
39
+ *
40
+ * Never throws. It is called at the moment a session starts, which is a moment a consumer is
41
+ * showing a user something, and `AGENTS.md`'s rule about not throwing in a frame loop is the same
42
+ * rule one step earlier: a session that cannot draw should say so and leave the page alive.
43
+ */
44
+ export declare function chooseLayer(session: XrSession, sources: LayerSources): XrLayer;
package/dist/layers.js ADDED
@@ -0,0 +1,107 @@
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
+ function webGlLayerCtor() {
20
+ return globalThis.XRWebGLLayer ?? null;
21
+ }
22
+ function gpuBindingCtor() {
23
+ return globalThis.XRGPUBinding ?? null;
24
+ }
25
+ /**
26
+ * Build the layer this session and these sources allow.
27
+ *
28
+ * Never throws. It is called at the moment a session starts, which is a moment a consumer is
29
+ * showing a user something, and `AGENTS.md`'s rule about not throwing in a frame loop is the same
30
+ * rule one step earlier: a session that cannot draw should say so and leave the page alive.
31
+ */
32
+ export function chooseLayer(session, sources) {
33
+ const binding = gpuBindingCtor();
34
+ if (sources.device !== undefined && binding !== null) {
35
+ try {
36
+ const bound = new binding(session, sources.device);
37
+ const colorFormat = binding.getPreferredColorFormat?.() ?? 'bgra8unorm';
38
+ const projectionLayer = bound.createProjectionLayer({ colorFormat });
39
+ session.updateRenderState({ layers: [projectionLayer] });
40
+ return { backend: 'webgpu', baseLayer: null, projectionLayer, reason: '' };
41
+ }
42
+ catch (error) {
43
+ /*
44
+ * Falling through to WebGL2 rather than failing. The binding constructs on a browser whose
45
+ * runtime cannot actually composite a WebGPU layer, so the honest test of this path is
46
+ * building the layer, and the honest answer to it failing is the path that has worked since
47
+ * WebXR shipped.
48
+ */
49
+ const name = error?.name ?? 'Error';
50
+ const fallback = buildWebGlLayer(session, sources);
51
+ return fallback.baseLayer === null
52
+ ? {
53
+ ...fallback,
54
+ reason: `the WebGPU layer could not be built (${name}) and no WebGL2 context was offered to ` +
55
+ 'fall back to.',
56
+ }
57
+ : {
58
+ ...fallback,
59
+ reason: `the WebGPU layer could not be built (${name}), so this session draws through ` +
60
+ 'WebGL2. WebGPU in WebXR is experimental and is behind a flag in current browsers.',
61
+ };
62
+ }
63
+ }
64
+ const layer = buildWebGlLayer(session, sources);
65
+ if (layer.baseLayer !== null && sources.device !== undefined && binding === null) {
66
+ return {
67
+ ...layer,
68
+ reason: 'this browser has no XRGPUBinding, so a WebGPU device cannot be bound to a session and ' +
69
+ 'this one draws through WebGL2.',
70
+ };
71
+ }
72
+ return layer;
73
+ }
74
+ function buildWebGlLayer(session, sources) {
75
+ const ctor = webGlLayerCtor();
76
+ if (ctor === null) {
77
+ return {
78
+ backend: 'webgl2',
79
+ baseLayer: null,
80
+ projectionLayer: null,
81
+ reason: 'this browser has no XRWebGLLayer, so a session has nothing to draw into.',
82
+ };
83
+ }
84
+ if (sources.gl === undefined) {
85
+ return {
86
+ backend: 'webgl2',
87
+ baseLayer: null,
88
+ projectionLayer: null,
89
+ reason: 'no WebGL2 context was offered, so a session has nothing to draw into.',
90
+ };
91
+ }
92
+ try {
93
+ const baseLayer = new ctor(session, sources.gl);
94
+ session.updateRenderState({ baseLayer });
95
+ return { backend: 'webgl2', baseLayer, projectionLayer: null, reason: '' };
96
+ }
97
+ catch (error) {
98
+ const name = error?.name ?? 'Error';
99
+ return {
100
+ backend: 'webgl2',
101
+ baseLayer: null,
102
+ projectionLayer: null,
103
+ reason: `the WebGL2 layer could not be built (${name}), which is what happens when the context ` +
104
+ 'was never made XR compatible.',
105
+ };
106
+ }
107
+ }
@@ -0,0 +1,56 @@
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
+ import type { FrameSource } from '@driftengine/core';
21
+ import type { LayerBackend, LayerSources, XrLayer } from './layers.ts';
22
+ import type { XrMode, XrSession, XrSystem } from './types.ts';
23
+ export interface EnterXrOptions {
24
+ readonly mode?: XrMode;
25
+ /** Passed to `requestSession`. `hand-tracking` is here rather than required. */
26
+ readonly optionalFeatures?: readonly string[];
27
+ readonly sources: LayerSources;
28
+ /** Override the system, which is how a test drives this without a headset. */
29
+ readonly system?: XrSystem | null;
30
+ }
31
+ export interface XrRun {
32
+ readonly session: XrSession;
33
+ readonly referenceSpace: unknown;
34
+ readonly referenceSpaceType: string;
35
+ readonly layer: XrLayer;
36
+ readonly backend: LayerBackend;
37
+ /** Hand this to `startLoop` and the engine runs on the session's clock. */
38
+ readonly frameSource: FrameSource;
39
+ /** Ends the session. Safe to call twice. */
40
+ end(): Promise<void>;
41
+ /** Runs when the session ends, however it ends, including the user taking the headset off. */
42
+ onEnd(listener: () => void): void;
43
+ }
44
+ export type EnterXrResult = {
45
+ readonly ok: true;
46
+ readonly run: XrRun;
47
+ } | {
48
+ readonly ok: false;
49
+ readonly reason: string;
50
+ };
51
+ /**
52
+ * Ask for a session and set everything up behind it.
53
+ *
54
+ * Call it from a click. Everything it can go wrong at is a `reason` and never a throw.
55
+ */
56
+ export declare function enterXr(options: EnterXrOptions): Promise<EnterXrResult>;
@@ -0,0 +1,131 @@
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
+ import { chooseLayer } from './layers.js';
21
+ /**
22
+ * Which space poses are reported in.
23
+ *
24
+ * `local-floor` first, because a scene authored in metres from a floor is what a room-scale
25
+ * experience is, and `local` puts the origin at the headset's starting height instead, which stands
26
+ * a player's feet wherever their head happened to be. `viewer` is the last resort and the only one
27
+ * an inline session is guaranteed.
28
+ */
29
+ const SPACE_ORDER = ['local-floor', 'local', 'viewer'];
30
+ function systemOf(override) {
31
+ if (override !== undefined)
32
+ return override;
33
+ return globalThis.navigator?.xr ?? null;
34
+ }
35
+ /**
36
+ * Ask for a session and set everything up behind it.
37
+ *
38
+ * Call it from a click. Everything it can go wrong at is a `reason` and never a throw.
39
+ */
40
+ export async function enterXr(options) {
41
+ const system = systemOf(options.system);
42
+ if (system === null) {
43
+ return { ok: false, reason: 'this browser has no WebXR: navigator.xr is not defined.' };
44
+ }
45
+ const mode = options.mode ?? 'immersive-vr';
46
+ let session;
47
+ try {
48
+ session = await system.requestSession(mode, { optionalFeatures: options.optionalFeatures });
49
+ }
50
+ catch (error) {
51
+ const name = error?.name ?? 'Error';
52
+ return {
53
+ ok: false,
54
+ reason: `the browser refused an immersive session (${mode}, ${name}). That is what it answers when no device ` +
55
+ 'is attached, and also when the call did not come from a user gesture.',
56
+ };
57
+ }
58
+ const layer = chooseLayer(session, options.sources);
59
+ if (layer.baseLayer === null && layer.projectionLayer === null) {
60
+ await session.end().catch(() => { });
61
+ return { ok: false, reason: layer.reason };
62
+ }
63
+ const space = await firstSpace(session);
64
+ if (space === null) {
65
+ await session.end().catch(() => { });
66
+ return {
67
+ ok: false,
68
+ reason: `the session offered none of ${SPACE_ORDER.join(', ')} as a reference space, so there is ` +
69
+ 'nothing to report poses relative to.',
70
+ };
71
+ }
72
+ let ended = false;
73
+ const listeners = [];
74
+ session.addEventListener('end', () => {
75
+ ended = true;
76
+ for (const listener of listeners)
77
+ listener();
78
+ });
79
+ const frameSource = {
80
+ /*
81
+ * Narrowed here and nowhere else. `core`'s loop types this argument `unknown` because it has no
82
+ * business naming an `XRFrame`; this is the one place that knows what arrived, and it hands it
83
+ * straight on rather than reading it, because the reading belongs to whoever draws.
84
+ */
85
+ requestAnimationFrame: (callback) => session.requestAnimationFrame((timeMs, frame) => callback(timeMs, frame)),
86
+ cancelAnimationFrame: (handle) => {
87
+ session.cancelAnimationFrame(handle);
88
+ },
89
+ };
90
+ return {
91
+ ok: true,
92
+ run: {
93
+ session,
94
+ referenceSpace: space.space,
95
+ referenceSpaceType: space.type,
96
+ layer,
97
+ backend: layer.backend,
98
+ frameSource,
99
+ async end() {
100
+ if (ended)
101
+ return;
102
+ ended = true;
103
+ await session.end().catch(() => { });
104
+ },
105
+ onEnd(listener) {
106
+ if (ended)
107
+ listener();
108
+ else
109
+ listeners.push(listener);
110
+ },
111
+ },
112
+ };
113
+ }
114
+ /**
115
+ * The best reference space this session will give, in the order that matters to a scene.
116
+ *
117
+ * Asked one at a time rather than in parallel, because a runtime may charge for a space it then has
118
+ * to discard and because the order is the whole point: taking whichever resolves first would be a
119
+ * race deciding where a player's floor is.
120
+ */
121
+ async function firstSpace(session) {
122
+ for (const type of SPACE_ORDER) {
123
+ try {
124
+ return { space: await session.requestReferenceSpace(type), type };
125
+ }
126
+ catch {
127
+ /* Declined. The next one is not a fallback so much as the next preference. */
128
+ }
129
+ }
130
+ return null;
131
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * What this browser can actually do, asked by doing it.
3
+ *
4
+ * **`isSessionSupported` is a promise about a mode and not an answer about this machine**, and the
5
+ * difference is not academic. Measured on 2026-09-05, Chrome 151 on Linux with no headset:
6
+ * `isSessionSupported('inline')` answers **true**, `requestSession('inline')` **succeeds**,
7
+ * `requestReferenceSpace('viewer')` **succeeds**, and then `gl.makeXRCompatible()` throws
8
+ * `InvalidStateError` because there is no XR device. Without that call there is no base layer, and
9
+ * without a base layer a session delivers **no frame at all**. Every promise along the way was
10
+ * kept, and nothing could be drawn.
11
+ *
12
+ * So this asks the question `probeDevice` asks about a GPU, and for the reason `probeDevice`'s own
13
+ * header gives: *a device that reports support and then draws nothing is a device that lied.* It
14
+ * tries the step that fails.
15
+ *
16
+ * **`reason` is a whole sentence and never empty when something is missing.** A consumer whose
17
+ * button does nothing is owed the difference between a browser with no WebXR, a machine with no
18
+ * headset, and a context that refused to become XR compatible, because those are three different
19
+ * things to do next. The island pool's `parallelism.reason` and `createSplatSortWorker`'s single
20
+ * warning are the same discipline; a silent false is the one outcome none of them allows.
21
+ */
22
+ import type { XrMode, XrSystem } from './types.ts';
23
+ export interface XrSupport {
24
+ /** Whether `navigator.xr` exists at all. Everything else is false without it. */
25
+ readonly present: boolean;
26
+ readonly immersiveVr: boolean;
27
+ readonly immersiveAr: boolean;
28
+ readonly inline: boolean;
29
+ /**
30
+ * Whether a rendering context could be made XR compatible.
31
+ *
32
+ * The step that actually fails without a device, and the one no `isSessionSupported` reports on.
33
+ */
34
+ readonly compatible: boolean;
35
+ /** Empty when a session could be entered. A whole sentence otherwise. */
36
+ readonly reason: string;
37
+ }
38
+ /** A context that can be asked to become XR compatible. Both backends' contexts satisfy it. */
39
+ export interface XrCompatibleContext {
40
+ makeXRCompatible?(): Promise<void>;
41
+ }
42
+ /**
43
+ * Ask, and try the step that lies.
44
+ *
45
+ * `context` is optional because a consumer may want the modes before they have built a renderer.
46
+ * Without one, `compatible` is false and `reason` says the question was not asked, which is a
47
+ * different thing from asked and refused.
48
+ */
49
+ export declare function probeXrSupport(context?: XrCompatibleContext | null, override?: XrSystem | null): Promise<XrSupport>;
50
+ /** The best mode this support answer allows, or null when none is worth asking for. */
51
+ export declare function bestMode(support: XrSupport): XrMode | null;
@@ -0,0 +1,123 @@
1
+ /**
2
+ * What this browser can actually do, asked by doing it.
3
+ *
4
+ * **`isSessionSupported` is a promise about a mode and not an answer about this machine**, and the
5
+ * difference is not academic. Measured on 2026-09-05, Chrome 151 on Linux with no headset:
6
+ * `isSessionSupported('inline')` answers **true**, `requestSession('inline')` **succeeds**,
7
+ * `requestReferenceSpace('viewer')` **succeeds**, and then `gl.makeXRCompatible()` throws
8
+ * `InvalidStateError` because there is no XR device. Without that call there is no base layer, and
9
+ * without a base layer a session delivers **no frame at all**. Every promise along the way was
10
+ * kept, and nothing could be drawn.
11
+ *
12
+ * So this asks the question `probeDevice` asks about a GPU, and for the reason `probeDevice`'s own
13
+ * header gives: *a device that reports support and then draws nothing is a device that lied.* It
14
+ * tries the step that fails.
15
+ *
16
+ * **`reason` is a whole sentence and never empty when something is missing.** A consumer whose
17
+ * button does nothing is owed the difference between a browser with no WebXR, a machine with no
18
+ * headset, and a context that refused to become XR compatible, because those are three different
19
+ * things to do next. The island pool's `parallelism.reason` and `createSplatSortWorker`'s single
20
+ * warning are the same discipline; a silent false is the one outcome none of them allows.
21
+ */
22
+ const NOTHING = {
23
+ present: false,
24
+ immersiveVr: false,
25
+ immersiveAr: false,
26
+ inline: false,
27
+ compatible: false,
28
+ reason: 'this browser has no WebXR: navigator.xr is not defined.',
29
+ };
30
+ function systemOf() {
31
+ const nav = globalThis.navigator;
32
+ return nav?.xr ?? null;
33
+ }
34
+ async function supports(system, mode) {
35
+ try {
36
+ return await system.isSessionSupported(mode);
37
+ }
38
+ catch {
39
+ /*
40
+ * A rejection is an answer and not an error. `isSessionSupported` rejects rather than resolving
41
+ * false where a permissions policy forbids the mode, which is a `false` a consumer can do
42
+ * nothing about and must not be a thrown exception on a page that merely asked.
43
+ */
44
+ return false;
45
+ }
46
+ }
47
+ /**
48
+ * Ask, and try the step that lies.
49
+ *
50
+ * `context` is optional because a consumer may want the modes before they have built a renderer.
51
+ * Without one, `compatible` is false and `reason` says the question was not asked, which is a
52
+ * different thing from asked and refused.
53
+ */
54
+ export async function probeXrSupport(context,
55
+ /*
56
+ * The system, overridable for the same reason `enterXr` takes one: a test cannot assign
57
+ * `globalThis.navigator`, which is getter-only, and stubbing a global to ask a question about a
58
+ * pure function would be surgery on the environment to avoid a parameter.
59
+ */
60
+ override) {
61
+ const system = override === undefined ? systemOf() : override;
62
+ if (system === null)
63
+ return NOTHING;
64
+ const [immersiveVr, immersiveAr, inline] = await Promise.all([
65
+ supports(system, 'immersive-vr'),
66
+ supports(system, 'immersive-ar'),
67
+ supports(system, 'inline'),
68
+ ]);
69
+ let compatible = false;
70
+ let compatibleReason = '';
71
+ if (context?.makeXRCompatible === undefined) {
72
+ compatibleReason =
73
+ 'no rendering context was offered, so whether one can be made XR compatible is unasked.';
74
+ }
75
+ else {
76
+ try {
77
+ await context.makeXRCompatible();
78
+ compatible = true;
79
+ }
80
+ catch (error) {
81
+ const name = error?.name ?? 'Error';
82
+ compatibleReason =
83
+ `the rendering context refused to become XR compatible (${name}), which is what happens ` +
84
+ 'when the browser has WebXR but this machine has no XR device attached.';
85
+ }
86
+ }
87
+ /*
88
+ * **The deepest failure wins, and the first draft had this backwards.** It reported the mode
89
+ * message before the compatibility one, so a machine whose context had actually been offered and
90
+ * refused was told "only an inline session is available here" — true, and not the thing that had
91
+ * just gone wrong. `xr-check.mjs` caught it: `compatible` was false while `reason` said nothing
92
+ * about it.
93
+ *
94
+ * A refusal that was *tried* outranks a report about what a mode list says, because it is the
95
+ * concrete step that failed and the one a consumer can do something about.
96
+ */
97
+ const anyMode = immersiveVr || immersiveAr || inline;
98
+ let reason = '';
99
+ if (context?.makeXRCompatible !== undefined && !compatible) {
100
+ reason = compatibleReason;
101
+ }
102
+ else if (!anyMode) {
103
+ reason =
104
+ 'this browser has WebXR but reports no session mode as supported, which is what a machine ' +
105
+ 'with no headset answers.';
106
+ }
107
+ else if (!immersiveVr && !immersiveAr) {
108
+ reason =
109
+ 'only an inline session is available here. An inline session has one view and no headset ' +
110
+ 'pose, so it draws a window into the scene and does not present to a device.';
111
+ }
112
+ return { present: true, immersiveVr, immersiveAr, inline, compatible, reason };
113
+ }
114
+ /** The best mode this support answer allows, or null when none is worth asking for. */
115
+ export function bestMode(support) {
116
+ if (support.immersiveVr)
117
+ return 'immersive-vr';
118
+ if (support.immersiveAr)
119
+ return 'immersive-ar';
120
+ if (support.inline)
121
+ return 'inline';
122
+ return null;
123
+ }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * A WebXR runtime that answers, so everything past the first frame can be exercised.
3
+ *
4
+ * **This stands in for a browser API and not for an engine capability**, which is the distinction
5
+ * R1 drew when it withdrew the mock capability providers: a mock with no first implementation to
6
+ * check it against is only ever agreeing with itself. The first implementation of WebXR is a
7
+ * browser's, and `DeterministicProvider` already lives on the same footing, standing in for a thing
8
+ * that is genuinely elsewhere.
9
+ *
10
+ * **It exists because no frame can be produced on the machine this was written on.** Measured
11
+ * 2026-09-05, Chrome 151, no headset: an inline session starts and `makeXRCompatible` throws, so
12
+ * there is no base layer and therefore no `XRFrame` at all. Every path from the first frame onward
13
+ * would otherwise be code nobody had run.
14
+ *
15
+ * **What it cannot do is prove a real runtime accepts what the engine hands back.** It produces the
16
+ * shapes the specification describes; whether a compositor is happy with them is a question only
17
+ * hardware answers, and that is tracked as unmeasured rather than this file
18
+ * pretending otherwise.
19
+ *
20
+ * The numbers are deliberately asymmetric and deliberately not round. An eye projection is off-axis
21
+ * and the two eyes differ; a synthetic runtime handing back two identical symmetric matrices would
22
+ * let a stereo path that drew the same picture twice pass as correct.
23
+ */
24
+ import type { XrSession, XrSystem, XrWebGlLayer } from '../types.ts';
25
+ export interface SyntheticOptions {
26
+ /** Modes the system reports and will grant. Everything else is refused. */
27
+ readonly modes?: readonly string[];
28
+ /** Reference spaces the session will grant, in the order it is asked. */
29
+ readonly spaces?: readonly string[];
30
+ readonly headHeight?: number;
31
+ /** One entry per controller. `hand` gives it twenty-five joints as well. */
32
+ readonly controllers?: readonly {
33
+ handedness: 'left' | 'right';
34
+ hand?: boolean;
35
+ }[];
36
+ /** Views a frame carries. Two by default, which is what a headset gives. */
37
+ readonly views?: 'stereo' | 'mono';
38
+ }
39
+ export interface SyntheticSystem extends XrSystem {
40
+ /** Deliver one frame to whoever asked. Nothing happens on its own. */
41
+ advance(timeMs?: number): void;
42
+ /** Sessions this system has granted, in order. */
43
+ readonly sessions: SyntheticSession[];
44
+ /** End the newest session the way a runtime does when a headset is removed. */
45
+ endLatest(): void;
46
+ }
47
+ export interface SyntheticSession extends XrSession {
48
+ readonly renderState: {
49
+ baseLayer?: XrWebGlLayer;
50
+ layers?: readonly unknown[];
51
+ };
52
+ readonly endedCount: number;
53
+ /** Set a button's analogue value on a controller, which also sets `pressed` past a half. */
54
+ setButton(handedness: 'left' | 'right', index: number, value: number): void;
55
+ /** Stop reporting a joint, the way an occluded finger stops being tracked. */
56
+ setJointTracked(handedness: 'left' | 'right', joint: string, tracked: boolean): void;
57
+ }
58
+ /**
59
+ * Build a runtime.
60
+ *
61
+ * Nothing is on a timer: `advance` is the only thing that produces a frame, so a test asserting
62
+ * about frame N is asserting about a frame it caused rather than one it waited for.
63
+ */
64
+ export declare function syntheticXr(options?: SyntheticOptions): SyntheticSystem;
65
+ /** A layer a synthetic session accepts, so the render-state path can be exercised too. */
66
+ export declare function syntheticGlLayer(): XrWebGlLayer;