@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/support.ts
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
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
|
+
|
|
23
|
+
import type { XrMode, XrSystem } from './types.ts';
|
|
24
|
+
|
|
25
|
+
export interface XrSupport {
|
|
26
|
+
/** Whether `navigator.xr` exists at all. Everything else is false without it. */
|
|
27
|
+
readonly present: boolean;
|
|
28
|
+
readonly immersiveVr: boolean;
|
|
29
|
+
readonly immersiveAr: boolean;
|
|
30
|
+
readonly inline: boolean;
|
|
31
|
+
/**
|
|
32
|
+
* Whether a rendering context could be made XR compatible.
|
|
33
|
+
*
|
|
34
|
+
* The step that actually fails without a device, and the one no `isSessionSupported` reports on.
|
|
35
|
+
*/
|
|
36
|
+
readonly compatible: boolean;
|
|
37
|
+
/** Empty when a session could be entered. A whole sentence otherwise. */
|
|
38
|
+
readonly reason: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const NOTHING: XrSupport = {
|
|
42
|
+
present: false,
|
|
43
|
+
immersiveVr: false,
|
|
44
|
+
immersiveAr: false,
|
|
45
|
+
inline: false,
|
|
46
|
+
compatible: false,
|
|
47
|
+
reason: 'this browser has no WebXR: navigator.xr is not defined.',
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
/** A context that can be asked to become XR compatible. Both backends' contexts satisfy it. */
|
|
51
|
+
export interface XrCompatibleContext {
|
|
52
|
+
makeXRCompatible?(): Promise<void>;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function systemOf(): XrSystem | null {
|
|
56
|
+
const nav = globalThis.navigator as { xr?: XrSystem } | undefined;
|
|
57
|
+
return nav?.xr ?? null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function supports(system: XrSystem, mode: XrMode): Promise<boolean> {
|
|
61
|
+
try {
|
|
62
|
+
return await system.isSessionSupported(mode);
|
|
63
|
+
} catch {
|
|
64
|
+
/*
|
|
65
|
+
* A rejection is an answer and not an error. `isSessionSupported` rejects rather than resolving
|
|
66
|
+
* false where a permissions policy forbids the mode, which is a `false` a consumer can do
|
|
67
|
+
* nothing about and must not be a thrown exception on a page that merely asked.
|
|
68
|
+
*/
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Ask, and try the step that lies.
|
|
75
|
+
*
|
|
76
|
+
* `context` is optional because a consumer may want the modes before they have built a renderer.
|
|
77
|
+
* Without one, `compatible` is false and `reason` says the question was not asked, which is a
|
|
78
|
+
* different thing from asked and refused.
|
|
79
|
+
*/
|
|
80
|
+
export async function probeXrSupport(
|
|
81
|
+
context?: XrCompatibleContext | null,
|
|
82
|
+
/*
|
|
83
|
+
* The system, overridable for the same reason `enterXr` takes one: a test cannot assign
|
|
84
|
+
* `globalThis.navigator`, which is getter-only, and stubbing a global to ask a question about a
|
|
85
|
+
* pure function would be surgery on the environment to avoid a parameter.
|
|
86
|
+
*/
|
|
87
|
+
override?: XrSystem | null,
|
|
88
|
+
): Promise<XrSupport> {
|
|
89
|
+
const system = override === undefined ? systemOf() : override;
|
|
90
|
+
if (system === null) return NOTHING;
|
|
91
|
+
|
|
92
|
+
const [immersiveVr, immersiveAr, inline] = await Promise.all([
|
|
93
|
+
supports(system, 'immersive-vr'),
|
|
94
|
+
supports(system, 'immersive-ar'),
|
|
95
|
+
supports(system, 'inline'),
|
|
96
|
+
]);
|
|
97
|
+
|
|
98
|
+
let compatible = false;
|
|
99
|
+
let compatibleReason = '';
|
|
100
|
+
if (context?.makeXRCompatible === undefined) {
|
|
101
|
+
compatibleReason =
|
|
102
|
+
'no rendering context was offered, so whether one can be made XR compatible is unasked.';
|
|
103
|
+
} else {
|
|
104
|
+
try {
|
|
105
|
+
await context.makeXRCompatible();
|
|
106
|
+
compatible = true;
|
|
107
|
+
} catch (error) {
|
|
108
|
+
const name = (error as Error)?.name ?? 'Error';
|
|
109
|
+
compatibleReason =
|
|
110
|
+
`the rendering context refused to become XR compatible (${name}), which is what happens ` +
|
|
111
|
+
'when the browser has WebXR but this machine has no XR device attached.';
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/*
|
|
116
|
+
* **The deepest failure wins, and the first draft had this backwards.** It reported the mode
|
|
117
|
+
* message before the compatibility one, so a machine whose context had actually been offered and
|
|
118
|
+
* refused was told "only an inline session is available here" — true, and not the thing that had
|
|
119
|
+
* just gone wrong. `xr-check.mjs` caught it: `compatible` was false while `reason` said nothing
|
|
120
|
+
* about it.
|
|
121
|
+
*
|
|
122
|
+
* A refusal that was *tried* outranks a report about what a mode list says, because it is the
|
|
123
|
+
* concrete step that failed and the one a consumer can do something about.
|
|
124
|
+
*/
|
|
125
|
+
const anyMode = immersiveVr || immersiveAr || inline;
|
|
126
|
+
let reason = '';
|
|
127
|
+
if (context?.makeXRCompatible !== undefined && !compatible) {
|
|
128
|
+
reason = compatibleReason;
|
|
129
|
+
} else if (!anyMode) {
|
|
130
|
+
reason =
|
|
131
|
+
'this browser has WebXR but reports no session mode as supported, which is what a machine ' +
|
|
132
|
+
'with no headset answers.';
|
|
133
|
+
} else if (!immersiveVr && !immersiveAr) {
|
|
134
|
+
reason =
|
|
135
|
+
'only an inline session is available here. An inline session has one view and no headset ' +
|
|
136
|
+
'pose, so it draws a window into the scene and does not present to a device.';
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return { present: true, immersiveVr, immersiveAr, inline, compatible, reason };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** The best mode this support answer allows, or null when none is worth asking for. */
|
|
143
|
+
export function bestMode(support: XrSupport): XrMode | null {
|
|
144
|
+
if (support.immersiveVr) return 'immersive-vr';
|
|
145
|
+
if (support.immersiveAr) return 'immersive-ar';
|
|
146
|
+
if (support.inline) return 'inline';
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
@@ -0,0 +1,296 @@
|
|
|
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
|
+
|
|
25
|
+
import { mat4 } from 'gl-matrix';
|
|
26
|
+
import type {
|
|
27
|
+
XrFrame,
|
|
28
|
+
XrGamepad,
|
|
29
|
+
XrHand,
|
|
30
|
+
XrInputSource,
|
|
31
|
+
XrJointPose,
|
|
32
|
+
XrRigidTransform,
|
|
33
|
+
XrSession,
|
|
34
|
+
XrSystem,
|
|
35
|
+
XrView,
|
|
36
|
+
XrViewerPose,
|
|
37
|
+
XrViewport,
|
|
38
|
+
XrWebGlLayer,
|
|
39
|
+
} from '../types.ts';
|
|
40
|
+
import { HAND_JOINTS } from '../hands.ts';
|
|
41
|
+
|
|
42
|
+
function transform(matrix: mat4): XrRigidTransform {
|
|
43
|
+
return {
|
|
44
|
+
matrix: matrix as unknown as Float32Array,
|
|
45
|
+
inverse: { matrix: mat4.invert(mat4.create(), matrix) as unknown as Float32Array },
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Two eyes, 64 mm apart, with off-axis frusta that differ from each other. */
|
|
50
|
+
function stereoViews(headHeight: number): XrView[] {
|
|
51
|
+
const half = 0.032;
|
|
52
|
+
const make = (eye: 'left' | 'right', offset: number, left: number, right: number): XrView => {
|
|
53
|
+
const world = mat4.create();
|
|
54
|
+
mat4.translate(world, world, [offset, headHeight, 0]);
|
|
55
|
+
return {
|
|
56
|
+
eye,
|
|
57
|
+
projectionMatrix: mat4.frustum(
|
|
58
|
+
mat4.create(),
|
|
59
|
+
left,
|
|
60
|
+
right,
|
|
61
|
+
-0.08,
|
|
62
|
+
0.08,
|
|
63
|
+
0.1,
|
|
64
|
+
1000,
|
|
65
|
+
) as unknown as Float32Array,
|
|
66
|
+
/* The eye's pose in the reference space, which is what `XRView.transform` is. An earlier
|
|
67
|
+
draft inverted this twice by accident, which typechecked as `mat4 | null` and said so. */
|
|
68
|
+
transform: transform(world),
|
|
69
|
+
};
|
|
70
|
+
};
|
|
71
|
+
/* Each eye's frustum is off-centre towards its own side, which is what a headset's optics do. */
|
|
72
|
+
return [make('left', -half, -0.11, 0.09), make('right', half, -0.09, 0.11)];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface SyntheticOptions {
|
|
76
|
+
/** Modes the system reports and will grant. Everything else is refused. */
|
|
77
|
+
readonly modes?: readonly string[];
|
|
78
|
+
/** Reference spaces the session will grant, in the order it is asked. */
|
|
79
|
+
readonly spaces?: readonly string[];
|
|
80
|
+
readonly headHeight?: number;
|
|
81
|
+
/** One entry per controller. `hand` gives it twenty-five joints as well. */
|
|
82
|
+
readonly controllers?: readonly { handedness: 'left' | 'right'; hand?: boolean }[];
|
|
83
|
+
/** Views a frame carries. Two by default, which is what a headset gives. */
|
|
84
|
+
readonly views?: 'stereo' | 'mono';
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export interface SyntheticSystem extends XrSystem {
|
|
88
|
+
/** Deliver one frame to whoever asked. Nothing happens on its own. */
|
|
89
|
+
advance(timeMs?: number): void;
|
|
90
|
+
/** Sessions this system has granted, in order. */
|
|
91
|
+
readonly sessions: SyntheticSession[];
|
|
92
|
+
/** End the newest session the way a runtime does when a headset is removed. */
|
|
93
|
+
endLatest(): void;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export interface SyntheticSession extends XrSession {
|
|
97
|
+
readonly renderState: { baseLayer?: XrWebGlLayer; layers?: readonly unknown[] };
|
|
98
|
+
readonly endedCount: number;
|
|
99
|
+
/** Set a button's analogue value on a controller, which also sets `pressed` past a half. */
|
|
100
|
+
setButton(handedness: 'left' | 'right', index: number, value: number): void;
|
|
101
|
+
/** Stop reporting a joint, the way an occluded finger stops being tracked. */
|
|
102
|
+
setJointTracked(handedness: 'left' | 'right', joint: string, tracked: boolean): void;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Build a runtime.
|
|
107
|
+
*
|
|
108
|
+
* Nothing is on a timer: `advance` is the only thing that produces a frame, so a test asserting
|
|
109
|
+
* about frame N is asserting about a frame it caused rather than one it waited for.
|
|
110
|
+
*/
|
|
111
|
+
export function syntheticXr(options: SyntheticOptions = {}): SyntheticSystem {
|
|
112
|
+
const modes = options.modes ?? ['immersive-vr', 'inline'];
|
|
113
|
+
const spaces = options.spaces ?? ['local-floor', 'local', 'viewer'];
|
|
114
|
+
const headHeight = options.headHeight ?? 1.6;
|
|
115
|
+
const wanted = options.controllers ?? [
|
|
116
|
+
{ handedness: 'left', hand: true },
|
|
117
|
+
{ handedness: 'right' },
|
|
118
|
+
];
|
|
119
|
+
const sessions: SyntheticSession[] = [];
|
|
120
|
+
|
|
121
|
+
const system: SyntheticSystem = {
|
|
122
|
+
sessions,
|
|
123
|
+
async isSessionSupported(mode: string): Promise<boolean> {
|
|
124
|
+
return modes.includes(mode);
|
|
125
|
+
},
|
|
126
|
+
async requestSession(mode: string): Promise<XrSession> {
|
|
127
|
+
if (!modes.includes(mode)) {
|
|
128
|
+
const error = new Error(`no ${mode} device`);
|
|
129
|
+
error.name = 'NotSupportedError';
|
|
130
|
+
throw error;
|
|
131
|
+
}
|
|
132
|
+
const session = buildSession(mode);
|
|
133
|
+
sessions.push(session);
|
|
134
|
+
return session;
|
|
135
|
+
},
|
|
136
|
+
advance(timeMs = 16): void {
|
|
137
|
+
for (const session of sessions)
|
|
138
|
+
(session as unknown as { fire(t: number): void }).fire(timeMs);
|
|
139
|
+
},
|
|
140
|
+
endLatest(): void {
|
|
141
|
+
const session = sessions.at(-1);
|
|
142
|
+
void session?.end();
|
|
143
|
+
},
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
function buildSession(mode: string): SyntheticSession {
|
|
147
|
+
let pending: ((timeMs: number, frame: XrFrame) => void) | null = null;
|
|
148
|
+
let handle = 0;
|
|
149
|
+
let ended = 0;
|
|
150
|
+
let clock = 0;
|
|
151
|
+
const listeners = new Map<string, (() => void)[]>();
|
|
152
|
+
const renderState: { baseLayer?: XrWebGlLayer; layers?: readonly unknown[] } = {};
|
|
153
|
+
|
|
154
|
+
const buttons = new Map<string, number[]>();
|
|
155
|
+
const untracked = new Set<string>();
|
|
156
|
+
for (const controller of wanted) buttons.set(controller.handedness, [0, 0, 0, 0]);
|
|
157
|
+
|
|
158
|
+
const inputSources: XrInputSource[] = wanted.map((controller) => {
|
|
159
|
+
const gripSpace = { kind: 'grip', handedness: controller.handedness };
|
|
160
|
+
const targetRaySpace = { kind: 'ray', handedness: controller.handedness };
|
|
161
|
+
const gamepad: XrGamepad = {
|
|
162
|
+
get buttons() {
|
|
163
|
+
const values = buttons.get(controller.handedness) ?? [];
|
|
164
|
+
return values.map((value) => ({ pressed: value > 0.5, touched: value > 0, value }));
|
|
165
|
+
},
|
|
166
|
+
axes: [0, 0, 0.25, -0.5],
|
|
167
|
+
};
|
|
168
|
+
const hand: XrHand | undefined = controller.hand
|
|
169
|
+
? {
|
|
170
|
+
size: HAND_JOINTS.length,
|
|
171
|
+
get: (joint: string) => ({ kind: 'joint', joint, handedness: controller.handedness }),
|
|
172
|
+
keys: () => HAND_JOINTS[Symbol.iterator]() as unknown as IterableIterator<string>,
|
|
173
|
+
}
|
|
174
|
+
: undefined;
|
|
175
|
+
return {
|
|
176
|
+
handedness: controller.handedness,
|
|
177
|
+
targetRayMode: 'tracked-pointer',
|
|
178
|
+
targetRaySpace,
|
|
179
|
+
gripSpace,
|
|
180
|
+
gamepad,
|
|
181
|
+
hand,
|
|
182
|
+
};
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
const viewport = (index: number): XrViewport => ({
|
|
186
|
+
x: index * 512,
|
|
187
|
+
y: 0,
|
|
188
|
+
width: 512,
|
|
189
|
+
height: 512,
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
const frameFor = (session: SyntheticSession): XrFrame => ({
|
|
193
|
+
session,
|
|
194
|
+
getViewerPose(): XrViewerPose | null {
|
|
195
|
+
const views =
|
|
196
|
+
options.views === 'mono'
|
|
197
|
+
? [stereoViews(headHeight)[0] as XrView]
|
|
198
|
+
: stereoViews(headHeight);
|
|
199
|
+
const head = mat4.create();
|
|
200
|
+
mat4.translate(head, head, [0, headHeight, 0]);
|
|
201
|
+
return { views, transform: transform(head) };
|
|
202
|
+
},
|
|
203
|
+
getPose(space: unknown) {
|
|
204
|
+
const described = space as { kind?: string; handedness?: string } | null;
|
|
205
|
+
if (described?.handedness === undefined) return null;
|
|
206
|
+
const world = mat4.create();
|
|
207
|
+
mat4.translate(world, world, [described.handedness === 'left' ? -0.25 : 0.25, 1.1, -0.35]);
|
|
208
|
+
return { transform: transform(world) };
|
|
209
|
+
},
|
|
210
|
+
getJointPose(joint: unknown): XrJointPose | null {
|
|
211
|
+
const described = joint as { joint?: string; handedness?: string } | null;
|
|
212
|
+
if (described?.joint === undefined) return null;
|
|
213
|
+
if (untracked.has(`${described.handedness}:${described.joint}`)) return null;
|
|
214
|
+
const index = HAND_JOINTS.indexOf(described.joint as (typeof HAND_JOINTS)[number]);
|
|
215
|
+
const world = mat4.create();
|
|
216
|
+
mat4.translate(world, world, [
|
|
217
|
+
described.handedness === 'left' ? -0.25 : 0.25,
|
|
218
|
+
1.1 + index * 0.005,
|
|
219
|
+
-0.35,
|
|
220
|
+
]);
|
|
221
|
+
return { transform: transform(world), radius: 0.008 };
|
|
222
|
+
},
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
const session: SyntheticSession = {
|
|
226
|
+
renderState,
|
|
227
|
+
get endedCount() {
|
|
228
|
+
return ended;
|
|
229
|
+
},
|
|
230
|
+
get inputSources() {
|
|
231
|
+
return inputSources;
|
|
232
|
+
},
|
|
233
|
+
async requestReferenceSpace(type: string): Promise<unknown> {
|
|
234
|
+
if (!spaces.includes(type)) {
|
|
235
|
+
const error = new Error(`no ${type}`);
|
|
236
|
+
error.name = 'NotSupportedError';
|
|
237
|
+
throw error;
|
|
238
|
+
}
|
|
239
|
+
return { kind: 'space', type };
|
|
240
|
+
},
|
|
241
|
+
updateRenderState(state) {
|
|
242
|
+
if (state.baseLayer !== undefined) renderState.baseLayer = state.baseLayer;
|
|
243
|
+
if (state.layers !== undefined) renderState.layers = state.layers;
|
|
244
|
+
},
|
|
245
|
+
requestAnimationFrame(callback) {
|
|
246
|
+
pending = callback;
|
|
247
|
+
return ++handle;
|
|
248
|
+
},
|
|
249
|
+
cancelAnimationFrame() {
|
|
250
|
+
pending = null;
|
|
251
|
+
},
|
|
252
|
+
async end(): Promise<void> {
|
|
253
|
+
ended++;
|
|
254
|
+
for (const listener of listeners.get('end') ?? []) listener();
|
|
255
|
+
},
|
|
256
|
+
addEventListener(type, listener) {
|
|
257
|
+
const list = listeners.get(type) ?? [];
|
|
258
|
+
list.push(listener);
|
|
259
|
+
listeners.set(type, list);
|
|
260
|
+
},
|
|
261
|
+
setButton(handedness, index, value) {
|
|
262
|
+
const values = buttons.get(handedness);
|
|
263
|
+
if (values !== undefined) values[index] = value;
|
|
264
|
+
},
|
|
265
|
+
setJointTracked(handedness, joint, tracked) {
|
|
266
|
+
const key = `${handedness}:${joint}`;
|
|
267
|
+
if (tracked) untracked.delete(key);
|
|
268
|
+
else untracked.add(key);
|
|
269
|
+
},
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
(session as unknown as { fire(t: number): void }).fire = (timeMs: number): void => {
|
|
273
|
+
const callback = pending;
|
|
274
|
+
pending = null;
|
|
275
|
+
clock += timeMs;
|
|
276
|
+
callback?.(clock, frameFor(session));
|
|
277
|
+
};
|
|
278
|
+
|
|
279
|
+
void mode;
|
|
280
|
+
return session;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
return system;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** A layer a synthetic session accepts, so the render-state path can be exercised too. */
|
|
287
|
+
export function syntheticGlLayer(): XrWebGlLayer {
|
|
288
|
+
return {
|
|
289
|
+
framebuffer: { kind: 'framebuffer' },
|
|
290
|
+
framebufferWidth: 1024,
|
|
291
|
+
framebufferHeight: 512,
|
|
292
|
+
getViewport(view: XrView): XrViewport {
|
|
293
|
+
return { x: view.eye === 'right' ? 512 : 0, y: 0, width: 512, height: 512 };
|
|
294
|
+
},
|
|
295
|
+
};
|
|
296
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The parts of WebXR this package touches, declared here.
|
|
3
|
+
*
|
|
4
|
+
* **Declared and not imported, because `tsconfig.json` sets `"types": []`.** The engine's config
|
|
5
|
+
* says why: `@types/node` is installed for the tools, and TypeScript pulls in every `@types`
|
|
6
|
+
* package it can find unless a config says otherwise, so a stray `Buffer` would compile here and
|
|
7
|
+
* fail in somebody's game. `@types/webxr` would be the same trade in the other direction, and its
|
|
8
|
+
* own note already sanctions the alternative: *`src/` and `demo/` reach the DOM through the `lib`
|
|
9
|
+
* above and declare anything else they need themselves.*
|
|
10
|
+
*
|
|
11
|
+
* So these are the shapes this package reads, written out. They are deliberately **narrower than
|
|
12
|
+
* the specification**: what is here is what the code below touches, and a field nobody reads is a
|
|
13
|
+
* field nobody has to keep in step with a moving standard.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** A pose's transform. Only the matrix is read; the decomposed parts are the runtime's convenience. */
|
|
17
|
+
export interface XrRigidTransform {
|
|
18
|
+
readonly matrix: Float32Array;
|
|
19
|
+
readonly inverse?: { readonly matrix: Float32Array };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** One eye, or the single view an inline session offers. */
|
|
23
|
+
export interface XrView {
|
|
24
|
+
readonly eye: 'left' | 'right' | 'none';
|
|
25
|
+
readonly projectionMatrix: Float32Array;
|
|
26
|
+
readonly transform: XrRigidTransform;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface XrViewerPose {
|
|
30
|
+
readonly views: readonly XrView[];
|
|
31
|
+
readonly transform: XrRigidTransform;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Where in the layer's texture one eye draws. */
|
|
35
|
+
export interface XrViewport {
|
|
36
|
+
readonly x: number;
|
|
37
|
+
readonly y: number;
|
|
38
|
+
readonly width: number;
|
|
39
|
+
readonly height: number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface XrWebGlLayer {
|
|
43
|
+
readonly framebuffer: unknown;
|
|
44
|
+
readonly framebufferWidth: number;
|
|
45
|
+
readonly framebufferHeight: number;
|
|
46
|
+
getViewport(view: XrView): XrViewport | undefined;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** A joint of a hand, keyed by the twenty-five names the specification fixes. */
|
|
50
|
+
export interface XrJointPose {
|
|
51
|
+
readonly transform: XrRigidTransform;
|
|
52
|
+
readonly radius: number | null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface XrHand {
|
|
56
|
+
readonly size: number;
|
|
57
|
+
get(joint: string): unknown;
|
|
58
|
+
keys(): IterableIterator<string>;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface XrGamepadButton {
|
|
62
|
+
readonly pressed: boolean;
|
|
63
|
+
readonly touched: boolean;
|
|
64
|
+
readonly value: number;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface XrGamepad {
|
|
68
|
+
readonly buttons: readonly XrGamepadButton[];
|
|
69
|
+
readonly axes: readonly number[];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface XrInputSource {
|
|
73
|
+
readonly handedness: 'left' | 'right' | 'none';
|
|
74
|
+
readonly targetRayMode: string;
|
|
75
|
+
readonly targetRaySpace: unknown;
|
|
76
|
+
readonly gripSpace?: unknown;
|
|
77
|
+
readonly gamepad?: XrGamepad;
|
|
78
|
+
readonly hand?: XrHand;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface XrFrame {
|
|
82
|
+
readonly session: XrSession;
|
|
83
|
+
getViewerPose(space: unknown): XrViewerPose | null | undefined;
|
|
84
|
+
getPose?(
|
|
85
|
+
space: unknown,
|
|
86
|
+
base: unknown,
|
|
87
|
+
): { readonly transform: XrRigidTransform } | null | undefined;
|
|
88
|
+
getJointPose?(joint: unknown, base: unknown): XrJointPose | null | undefined;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export interface XrRenderState {
|
|
92
|
+
baseLayer?: XrWebGlLayer;
|
|
93
|
+
layers?: readonly unknown[];
|
|
94
|
+
depthNear?: number;
|
|
95
|
+
depthFar?: number;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export interface XrSession {
|
|
99
|
+
readonly inputSources: Iterable<XrInputSource>;
|
|
100
|
+
requestReferenceSpace(type: string): Promise<unknown>;
|
|
101
|
+
updateRenderState(state: XrRenderState): void;
|
|
102
|
+
requestAnimationFrame(callback: (timeMs: number, frame: XrFrame) => void): number;
|
|
103
|
+
cancelAnimationFrame(handle: number): void;
|
|
104
|
+
end(): Promise<void>;
|
|
105
|
+
addEventListener(type: string, listener: () => void): void;
|
|
106
|
+
removeEventListener?(type: string, listener: () => void): void;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export interface XrSystem {
|
|
110
|
+
isSessionSupported(mode: string): Promise<boolean>;
|
|
111
|
+
requestSession(
|
|
112
|
+
mode: string,
|
|
113
|
+
options?: { optionalFeatures?: readonly string[] },
|
|
114
|
+
): Promise<XrSession>;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** The session modes this package will ask for. */
|
|
118
|
+
export type XrMode = 'immersive-vr' | 'immersive-ar' | 'inline';
|
package/src/views.ts
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An `XRView` turned into something the engine's camera can adopt.
|
|
3
|
+
*
|
|
4
|
+
* **The whole of the stereo path is here, and it is smaller than it sounds.** A frame carries one
|
|
5
|
+
* view per eye; each has a projection the runtime computed for that eye's optics and a transform
|
|
6
|
+
* giving where that eye is in the reference space. The engine draws the scene once per view, into
|
|
7
|
+
* that view's slice of one layer texture.
|
|
8
|
+
*
|
|
9
|
+
* **A view matrix is the inverse of the eye's transform**, and that is the only arithmetic in this
|
|
10
|
+
* file. WebXR hands over the transform's own `inverse` when it has one, because it usually does and
|
|
11
|
+
* inverting a matrix per eye per frame to recompute a number already sitting there would be work
|
|
12
|
+
* for nothing. When it does not, this inverts. Both paths are exercised, because a runtime is free
|
|
13
|
+
* to omit it and a path taken only on somebody else's hardware is a path nobody has run.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { mat4 } from 'gl-matrix';
|
|
17
|
+
import type { Camera } from '@driftengine/core';
|
|
18
|
+
import type { XrView, XrViewerPose, XrViewport, XrWebGlLayer } from './types.ts';
|
|
19
|
+
|
|
20
|
+
/** One eye's matrices and where it draws. */
|
|
21
|
+
export interface EyeView {
|
|
22
|
+
readonly eye: 'left' | 'right' | 'none';
|
|
23
|
+
/** The inverse of the eye's transform, which is what a camera calls its view. */
|
|
24
|
+
readonly view: mat4;
|
|
25
|
+
readonly projection: mat4;
|
|
26
|
+
/** Where in the layer's texture this eye belongs, or null when the layer offered none. */
|
|
27
|
+
readonly viewport: XrViewport | null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Reused across frames and eyes, because this runs twice a frame forever.
|
|
32
|
+
*
|
|
33
|
+
* Two of each, so a caller holding the left eye's matrices while it draws the right one still has
|
|
34
|
+
* them. A single scratch would be the kind of reuse that works until somebody keeps a reference.
|
|
35
|
+
*/
|
|
36
|
+
const scratch = [
|
|
37
|
+
{ view: mat4.create(), projection: mat4.create() },
|
|
38
|
+
{ view: mat4.create(), projection: mat4.create() },
|
|
39
|
+
];
|
|
40
|
+
|
|
41
|
+
/** Grown only if a runtime ever offers more views than eyes. Nothing does today. */
|
|
42
|
+
function slotFor(index: number): { view: mat4; projection: mat4 } {
|
|
43
|
+
while (scratch.length <= index) scratch.push({ view: mat4.create(), projection: mat4.create() });
|
|
44
|
+
return scratch[index] as { view: mat4; projection: mat4 };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Turn a pose's views into eye views, into reused storage.
|
|
49
|
+
*
|
|
50
|
+
* `out` is filled and returned so a caller in a frame loop allocates nothing. Its length is set to
|
|
51
|
+
* the number of views the pose carried, which is one for an inline session and two for a stereo
|
|
52
|
+
* one, and which this package never assumes.
|
|
53
|
+
*/
|
|
54
|
+
export function eyeViews(
|
|
55
|
+
pose: XrViewerPose,
|
|
56
|
+
layer: XrWebGlLayer | null,
|
|
57
|
+
out: EyeView[] = [],
|
|
58
|
+
): EyeView[] {
|
|
59
|
+
out.length = 0;
|
|
60
|
+
for (let i = 0; i < pose.views.length; i++) {
|
|
61
|
+
const view = pose.views[i] as XrView;
|
|
62
|
+
const slot = slotFor(i);
|
|
63
|
+
|
|
64
|
+
/*
|
|
65
|
+
* The runtime's own inverse where there is one. A transform's `inverse` is a spec field and is
|
|
66
|
+
* normally present; inverting to recompute it would be a matrix inversion per eye per frame for
|
|
67
|
+
* a number already in memory.
|
|
68
|
+
*/
|
|
69
|
+
const inverse = view.transform.inverse?.matrix;
|
|
70
|
+
if (inverse !== undefined) mat4.copy(slot.view, inverse as unknown as mat4);
|
|
71
|
+
else mat4.invert(slot.view, view.transform.matrix as unknown as mat4);
|
|
72
|
+
|
|
73
|
+
mat4.copy(slot.projection, view.projectionMatrix as unknown as mat4);
|
|
74
|
+
|
|
75
|
+
out.push({
|
|
76
|
+
eye: view.eye,
|
|
77
|
+
view: slot.view,
|
|
78
|
+
projection: slot.projection,
|
|
79
|
+
viewport: layer?.getViewport(view) ?? null,
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
return out;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Point a camera at one eye.
|
|
87
|
+
*
|
|
88
|
+
* A one-line function on purpose: the interesting half is `Camera.adoptView`, which lives in core
|
|
89
|
+
* because the camera is core's and the reason it cannot derive these matrices is written there.
|
|
90
|
+
* What this adds is the direction of the dependency. `@driftengine/xr` supplies views to a camera;
|
|
91
|
+
* nothing in core reaches back for a session.
|
|
92
|
+
*/
|
|
93
|
+
export function aimCameraAtEye(camera: Camera, eye: EyeView): void {
|
|
94
|
+
camera.adoptView(eye.view, eye.projection);
|
|
95
|
+
}
|