@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
|
@@ -0,0 +1,238 @@
|
|
|
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 { mat4 } from 'gl-matrix';
|
|
25
|
+
import { HAND_JOINTS } from '../hands.js';
|
|
26
|
+
function transform(matrix) {
|
|
27
|
+
return {
|
|
28
|
+
matrix: matrix,
|
|
29
|
+
inverse: { matrix: mat4.invert(mat4.create(), matrix) },
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
/** Two eyes, 64 mm apart, with off-axis frusta that differ from each other. */
|
|
33
|
+
function stereoViews(headHeight) {
|
|
34
|
+
const half = 0.032;
|
|
35
|
+
const make = (eye, offset, left, right) => {
|
|
36
|
+
const world = mat4.create();
|
|
37
|
+
mat4.translate(world, world, [offset, headHeight, 0]);
|
|
38
|
+
return {
|
|
39
|
+
eye,
|
|
40
|
+
projectionMatrix: mat4.frustum(mat4.create(), left, right, -0.08, 0.08, 0.1, 1000),
|
|
41
|
+
/* The eye's pose in the reference space, which is what `XRView.transform` is. An earlier
|
|
42
|
+
draft inverted this twice by accident, which typechecked as `mat4 | null` and said so. */
|
|
43
|
+
transform: transform(world),
|
|
44
|
+
};
|
|
45
|
+
};
|
|
46
|
+
/* Each eye's frustum is off-centre towards its own side, which is what a headset's optics do. */
|
|
47
|
+
return [make('left', -half, -0.11, 0.09), make('right', half, -0.09, 0.11)];
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Build a runtime.
|
|
51
|
+
*
|
|
52
|
+
* Nothing is on a timer: `advance` is the only thing that produces a frame, so a test asserting
|
|
53
|
+
* about frame N is asserting about a frame it caused rather than one it waited for.
|
|
54
|
+
*/
|
|
55
|
+
export function syntheticXr(options = {}) {
|
|
56
|
+
const modes = options.modes ?? ['immersive-vr', 'inline'];
|
|
57
|
+
const spaces = options.spaces ?? ['local-floor', 'local', 'viewer'];
|
|
58
|
+
const headHeight = options.headHeight ?? 1.6;
|
|
59
|
+
const wanted = options.controllers ?? [
|
|
60
|
+
{ handedness: 'left', hand: true },
|
|
61
|
+
{ handedness: 'right' },
|
|
62
|
+
];
|
|
63
|
+
const sessions = [];
|
|
64
|
+
const system = {
|
|
65
|
+
sessions,
|
|
66
|
+
async isSessionSupported(mode) {
|
|
67
|
+
return modes.includes(mode);
|
|
68
|
+
},
|
|
69
|
+
async requestSession(mode) {
|
|
70
|
+
if (!modes.includes(mode)) {
|
|
71
|
+
const error = new Error(`no ${mode} device`);
|
|
72
|
+
error.name = 'NotSupportedError';
|
|
73
|
+
throw error;
|
|
74
|
+
}
|
|
75
|
+
const session = buildSession(mode);
|
|
76
|
+
sessions.push(session);
|
|
77
|
+
return session;
|
|
78
|
+
},
|
|
79
|
+
advance(timeMs = 16) {
|
|
80
|
+
for (const session of sessions)
|
|
81
|
+
session.fire(timeMs);
|
|
82
|
+
},
|
|
83
|
+
endLatest() {
|
|
84
|
+
const session = sessions.at(-1);
|
|
85
|
+
void session?.end();
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
function buildSession(mode) {
|
|
89
|
+
let pending = null;
|
|
90
|
+
let handle = 0;
|
|
91
|
+
let ended = 0;
|
|
92
|
+
let clock = 0;
|
|
93
|
+
const listeners = new Map();
|
|
94
|
+
const renderState = {};
|
|
95
|
+
const buttons = new Map();
|
|
96
|
+
const untracked = new Set();
|
|
97
|
+
for (const controller of wanted)
|
|
98
|
+
buttons.set(controller.handedness, [0, 0, 0, 0]);
|
|
99
|
+
const inputSources = wanted.map((controller) => {
|
|
100
|
+
const gripSpace = { kind: 'grip', handedness: controller.handedness };
|
|
101
|
+
const targetRaySpace = { kind: 'ray', handedness: controller.handedness };
|
|
102
|
+
const gamepad = {
|
|
103
|
+
get buttons() {
|
|
104
|
+
const values = buttons.get(controller.handedness) ?? [];
|
|
105
|
+
return values.map((value) => ({ pressed: value > 0.5, touched: value > 0, value }));
|
|
106
|
+
},
|
|
107
|
+
axes: [0, 0, 0.25, -0.5],
|
|
108
|
+
};
|
|
109
|
+
const hand = controller.hand
|
|
110
|
+
? {
|
|
111
|
+
size: HAND_JOINTS.length,
|
|
112
|
+
get: (joint) => ({ kind: 'joint', joint, handedness: controller.handedness }),
|
|
113
|
+
keys: () => HAND_JOINTS[Symbol.iterator](),
|
|
114
|
+
}
|
|
115
|
+
: undefined;
|
|
116
|
+
return {
|
|
117
|
+
handedness: controller.handedness,
|
|
118
|
+
targetRayMode: 'tracked-pointer',
|
|
119
|
+
targetRaySpace,
|
|
120
|
+
gripSpace,
|
|
121
|
+
gamepad,
|
|
122
|
+
hand,
|
|
123
|
+
};
|
|
124
|
+
});
|
|
125
|
+
const viewport = (index) => ({
|
|
126
|
+
x: index * 512,
|
|
127
|
+
y: 0,
|
|
128
|
+
width: 512,
|
|
129
|
+
height: 512,
|
|
130
|
+
});
|
|
131
|
+
const frameFor = (session) => ({
|
|
132
|
+
session,
|
|
133
|
+
getViewerPose() {
|
|
134
|
+
const views = options.views === 'mono'
|
|
135
|
+
? [stereoViews(headHeight)[0]]
|
|
136
|
+
: stereoViews(headHeight);
|
|
137
|
+
const head = mat4.create();
|
|
138
|
+
mat4.translate(head, head, [0, headHeight, 0]);
|
|
139
|
+
return { views, transform: transform(head) };
|
|
140
|
+
},
|
|
141
|
+
getPose(space) {
|
|
142
|
+
const described = space;
|
|
143
|
+
if (described?.handedness === undefined)
|
|
144
|
+
return null;
|
|
145
|
+
const world = mat4.create();
|
|
146
|
+
mat4.translate(world, world, [described.handedness === 'left' ? -0.25 : 0.25, 1.1, -0.35]);
|
|
147
|
+
return { transform: transform(world) };
|
|
148
|
+
},
|
|
149
|
+
getJointPose(joint) {
|
|
150
|
+
const described = joint;
|
|
151
|
+
if (described?.joint === undefined)
|
|
152
|
+
return null;
|
|
153
|
+
if (untracked.has(`${described.handedness}:${described.joint}`))
|
|
154
|
+
return null;
|
|
155
|
+
const index = HAND_JOINTS.indexOf(described.joint);
|
|
156
|
+
const world = mat4.create();
|
|
157
|
+
mat4.translate(world, world, [
|
|
158
|
+
described.handedness === 'left' ? -0.25 : 0.25,
|
|
159
|
+
1.1 + index * 0.005,
|
|
160
|
+
-0.35,
|
|
161
|
+
]);
|
|
162
|
+
return { transform: transform(world), radius: 0.008 };
|
|
163
|
+
},
|
|
164
|
+
});
|
|
165
|
+
const session = {
|
|
166
|
+
renderState,
|
|
167
|
+
get endedCount() {
|
|
168
|
+
return ended;
|
|
169
|
+
},
|
|
170
|
+
get inputSources() {
|
|
171
|
+
return inputSources;
|
|
172
|
+
},
|
|
173
|
+
async requestReferenceSpace(type) {
|
|
174
|
+
if (!spaces.includes(type)) {
|
|
175
|
+
const error = new Error(`no ${type}`);
|
|
176
|
+
error.name = 'NotSupportedError';
|
|
177
|
+
throw error;
|
|
178
|
+
}
|
|
179
|
+
return { kind: 'space', type };
|
|
180
|
+
},
|
|
181
|
+
updateRenderState(state) {
|
|
182
|
+
if (state.baseLayer !== undefined)
|
|
183
|
+
renderState.baseLayer = state.baseLayer;
|
|
184
|
+
if (state.layers !== undefined)
|
|
185
|
+
renderState.layers = state.layers;
|
|
186
|
+
},
|
|
187
|
+
requestAnimationFrame(callback) {
|
|
188
|
+
pending = callback;
|
|
189
|
+
return ++handle;
|
|
190
|
+
},
|
|
191
|
+
cancelAnimationFrame() {
|
|
192
|
+
pending = null;
|
|
193
|
+
},
|
|
194
|
+
async end() {
|
|
195
|
+
ended++;
|
|
196
|
+
for (const listener of listeners.get('end') ?? [])
|
|
197
|
+
listener();
|
|
198
|
+
},
|
|
199
|
+
addEventListener(type, listener) {
|
|
200
|
+
const list = listeners.get(type) ?? [];
|
|
201
|
+
list.push(listener);
|
|
202
|
+
listeners.set(type, list);
|
|
203
|
+
},
|
|
204
|
+
setButton(handedness, index, value) {
|
|
205
|
+
const values = buttons.get(handedness);
|
|
206
|
+
if (values !== undefined)
|
|
207
|
+
values[index] = value;
|
|
208
|
+
},
|
|
209
|
+
setJointTracked(handedness, joint, tracked) {
|
|
210
|
+
const key = `${handedness}:${joint}`;
|
|
211
|
+
if (tracked)
|
|
212
|
+
untracked.delete(key);
|
|
213
|
+
else
|
|
214
|
+
untracked.add(key);
|
|
215
|
+
},
|
|
216
|
+
};
|
|
217
|
+
session.fire = (timeMs) => {
|
|
218
|
+
const callback = pending;
|
|
219
|
+
pending = null;
|
|
220
|
+
clock += timeMs;
|
|
221
|
+
callback?.(clock, frameFor(session));
|
|
222
|
+
};
|
|
223
|
+
void mode;
|
|
224
|
+
return session;
|
|
225
|
+
}
|
|
226
|
+
return system;
|
|
227
|
+
}
|
|
228
|
+
/** A layer a synthetic session accepts, so the render-state path can be exercised too. */
|
|
229
|
+
export function syntheticGlLayer() {
|
|
230
|
+
return {
|
|
231
|
+
framebuffer: { kind: 'framebuffer' },
|
|
232
|
+
framebufferWidth: 1024,
|
|
233
|
+
framebufferHeight: 512,
|
|
234
|
+
getViewport(view) {
|
|
235
|
+
return { x: view.eye === 'right' ? 512 : 0, y: 0, width: 512, height: 512 };
|
|
236
|
+
},
|
|
237
|
+
};
|
|
238
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
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
|
+
/** A pose's transform. Only the matrix is read; the decomposed parts are the runtime's convenience. */
|
|
16
|
+
export interface XrRigidTransform {
|
|
17
|
+
readonly matrix: Float32Array;
|
|
18
|
+
readonly inverse?: {
|
|
19
|
+
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
|
+
export interface XrViewerPose {
|
|
29
|
+
readonly views: readonly XrView[];
|
|
30
|
+
readonly transform: XrRigidTransform;
|
|
31
|
+
}
|
|
32
|
+
/** Where in the layer's texture one eye draws. */
|
|
33
|
+
export interface XrViewport {
|
|
34
|
+
readonly x: number;
|
|
35
|
+
readonly y: number;
|
|
36
|
+
readonly width: number;
|
|
37
|
+
readonly height: number;
|
|
38
|
+
}
|
|
39
|
+
export interface XrWebGlLayer {
|
|
40
|
+
readonly framebuffer: unknown;
|
|
41
|
+
readonly framebufferWidth: number;
|
|
42
|
+
readonly framebufferHeight: number;
|
|
43
|
+
getViewport(view: XrView): XrViewport | undefined;
|
|
44
|
+
}
|
|
45
|
+
/** A joint of a hand, keyed by the twenty-five names the specification fixes. */
|
|
46
|
+
export interface XrJointPose {
|
|
47
|
+
readonly transform: XrRigidTransform;
|
|
48
|
+
readonly radius: number | null;
|
|
49
|
+
}
|
|
50
|
+
export interface XrHand {
|
|
51
|
+
readonly size: number;
|
|
52
|
+
get(joint: string): unknown;
|
|
53
|
+
keys(): IterableIterator<string>;
|
|
54
|
+
}
|
|
55
|
+
export interface XrGamepadButton {
|
|
56
|
+
readonly pressed: boolean;
|
|
57
|
+
readonly touched: boolean;
|
|
58
|
+
readonly value: number;
|
|
59
|
+
}
|
|
60
|
+
export interface XrGamepad {
|
|
61
|
+
readonly buttons: readonly XrGamepadButton[];
|
|
62
|
+
readonly axes: readonly number[];
|
|
63
|
+
}
|
|
64
|
+
export interface XrInputSource {
|
|
65
|
+
readonly handedness: 'left' | 'right' | 'none';
|
|
66
|
+
readonly targetRayMode: string;
|
|
67
|
+
readonly targetRaySpace: unknown;
|
|
68
|
+
readonly gripSpace?: unknown;
|
|
69
|
+
readonly gamepad?: XrGamepad;
|
|
70
|
+
readonly hand?: XrHand;
|
|
71
|
+
}
|
|
72
|
+
export interface XrFrame {
|
|
73
|
+
readonly session: XrSession;
|
|
74
|
+
getViewerPose(space: unknown): XrViewerPose | null | undefined;
|
|
75
|
+
getPose?(space: unknown, base: unknown): {
|
|
76
|
+
readonly transform: XrRigidTransform;
|
|
77
|
+
} | null | undefined;
|
|
78
|
+
getJointPose?(joint: unknown, base: unknown): XrJointPose | null | undefined;
|
|
79
|
+
}
|
|
80
|
+
export interface XrRenderState {
|
|
81
|
+
baseLayer?: XrWebGlLayer;
|
|
82
|
+
layers?: readonly unknown[];
|
|
83
|
+
depthNear?: number;
|
|
84
|
+
depthFar?: number;
|
|
85
|
+
}
|
|
86
|
+
export interface XrSession {
|
|
87
|
+
readonly inputSources: Iterable<XrInputSource>;
|
|
88
|
+
requestReferenceSpace(type: string): Promise<unknown>;
|
|
89
|
+
updateRenderState(state: XrRenderState): void;
|
|
90
|
+
requestAnimationFrame(callback: (timeMs: number, frame: XrFrame) => void): number;
|
|
91
|
+
cancelAnimationFrame(handle: number): void;
|
|
92
|
+
end(): Promise<void>;
|
|
93
|
+
addEventListener(type: string, listener: () => void): void;
|
|
94
|
+
removeEventListener?(type: string, listener: () => void): void;
|
|
95
|
+
}
|
|
96
|
+
export interface XrSystem {
|
|
97
|
+
isSessionSupported(mode: string): Promise<boolean>;
|
|
98
|
+
requestSession(mode: string, options?: {
|
|
99
|
+
optionalFeatures?: readonly string[];
|
|
100
|
+
}): Promise<XrSession>;
|
|
101
|
+
}
|
|
102
|
+
/** The session modes this package will ask for. */
|
|
103
|
+
export type XrMode = 'immersive-vr' | 'immersive-ar' | 'inline';
|
package/dist/types.js
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
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
|
+
export {};
|
package/dist/views.d.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
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
|
+
import { mat4 } from 'gl-matrix';
|
|
16
|
+
import type { Camera } from '@driftengine/core';
|
|
17
|
+
import type { XrViewerPose, XrViewport, XrWebGlLayer } from './types.ts';
|
|
18
|
+
/** One eye's matrices and where it draws. */
|
|
19
|
+
export interface EyeView {
|
|
20
|
+
readonly eye: 'left' | 'right' | 'none';
|
|
21
|
+
/** The inverse of the eye's transform, which is what a camera calls its view. */
|
|
22
|
+
readonly view: mat4;
|
|
23
|
+
readonly projection: mat4;
|
|
24
|
+
/** Where in the layer's texture this eye belongs, or null when the layer offered none. */
|
|
25
|
+
readonly viewport: XrViewport | null;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Turn a pose's views into eye views, into reused storage.
|
|
29
|
+
*
|
|
30
|
+
* `out` is filled and returned so a caller in a frame loop allocates nothing. Its length is set to
|
|
31
|
+
* the number of views the pose carried, which is one for an inline session and two for a stereo
|
|
32
|
+
* one, and which this package never assumes.
|
|
33
|
+
*/
|
|
34
|
+
export declare function eyeViews(pose: XrViewerPose, layer: XrWebGlLayer | null, out?: EyeView[]): EyeView[];
|
|
35
|
+
/**
|
|
36
|
+
* Point a camera at one eye.
|
|
37
|
+
*
|
|
38
|
+
* A one-line function on purpose: the interesting half is `Camera.adoptView`, which lives in core
|
|
39
|
+
* because the camera is core's and the reason it cannot derive these matrices is written there.
|
|
40
|
+
* What this adds is the direction of the dependency. `@driftengine/xr` supplies views to a camera;
|
|
41
|
+
* nothing in core reaches back for a session.
|
|
42
|
+
*/
|
|
43
|
+
export declare function aimCameraAtEye(camera: Camera, eye: EyeView): void;
|
package/dist/views.js
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
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
|
+
import { mat4 } from 'gl-matrix';
|
|
16
|
+
/**
|
|
17
|
+
* Reused across frames and eyes, because this runs twice a frame forever.
|
|
18
|
+
*
|
|
19
|
+
* Two of each, so a caller holding the left eye's matrices while it draws the right one still has
|
|
20
|
+
* them. A single scratch would be the kind of reuse that works until somebody keeps a reference.
|
|
21
|
+
*/
|
|
22
|
+
const scratch = [
|
|
23
|
+
{ view: mat4.create(), projection: mat4.create() },
|
|
24
|
+
{ view: mat4.create(), projection: mat4.create() },
|
|
25
|
+
];
|
|
26
|
+
/** Grown only if a runtime ever offers more views than eyes. Nothing does today. */
|
|
27
|
+
function slotFor(index) {
|
|
28
|
+
while (scratch.length <= index)
|
|
29
|
+
scratch.push({ view: mat4.create(), projection: mat4.create() });
|
|
30
|
+
return scratch[index];
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Turn a pose's views into eye views, into reused storage.
|
|
34
|
+
*
|
|
35
|
+
* `out` is filled and returned so a caller in a frame loop allocates nothing. Its length is set to
|
|
36
|
+
* the number of views the pose carried, which is one for an inline session and two for a stereo
|
|
37
|
+
* one, and which this package never assumes.
|
|
38
|
+
*/
|
|
39
|
+
export function eyeViews(pose, layer, out = []) {
|
|
40
|
+
out.length = 0;
|
|
41
|
+
for (let i = 0; i < pose.views.length; i++) {
|
|
42
|
+
const view = pose.views[i];
|
|
43
|
+
const slot = slotFor(i);
|
|
44
|
+
/*
|
|
45
|
+
* The runtime's own inverse where there is one. A transform's `inverse` is a spec field and is
|
|
46
|
+
* normally present; inverting to recompute it would be a matrix inversion per eye per frame for
|
|
47
|
+
* a number already in memory.
|
|
48
|
+
*/
|
|
49
|
+
const inverse = view.transform.inverse?.matrix;
|
|
50
|
+
if (inverse !== undefined)
|
|
51
|
+
mat4.copy(slot.view, inverse);
|
|
52
|
+
else
|
|
53
|
+
mat4.invert(slot.view, view.transform.matrix);
|
|
54
|
+
mat4.copy(slot.projection, view.projectionMatrix);
|
|
55
|
+
out.push({
|
|
56
|
+
eye: view.eye,
|
|
57
|
+
view: slot.view,
|
|
58
|
+
projection: slot.projection,
|
|
59
|
+
viewport: layer?.getViewport(view) ?? null,
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
return out;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Point a camera at one eye.
|
|
66
|
+
*
|
|
67
|
+
* A one-line function on purpose: the interesting half is `Camera.adoptView`, which lives in core
|
|
68
|
+
* because the camera is core's and the reason it cannot derive these matrices is written there.
|
|
69
|
+
* What this adds is the direction of the dependency. `@driftengine/xr` supplies views to a camera;
|
|
70
|
+
* nothing in core reaches back for a session.
|
|
71
|
+
*/
|
|
72
|
+
export function aimCameraAtEye(camera, eye) {
|
|
73
|
+
camera.adoptView(eye.view, eye.projection);
|
|
74
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@driftengine/xr",
|
|
3
|
+
"version": "3.61.0",
|
|
4
|
+
"description": "WebXR sessions, stereo views, controller input and hand joints, over both backends",
|
|
5
|
+
"license": "Apache-2.0",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "./dist/index.js",
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"drift-source": "./src/index.ts",
|
|
12
|
+
"types": "./dist/index.d.ts",
|
|
13
|
+
"default": "./dist/index.js"
|
|
14
|
+
},
|
|
15
|
+
"./package.json": "./package.json",
|
|
16
|
+
"./*": "./*"
|
|
17
|
+
},
|
|
18
|
+
"files": [
|
|
19
|
+
"dist",
|
|
20
|
+
"src",
|
|
21
|
+
"!src/**/*.test.ts",
|
|
22
|
+
"!src/**/*.test.mjs",
|
|
23
|
+
"!src/**/__snapshots__",
|
|
24
|
+
"README.md",
|
|
25
|
+
"LICENSE",
|
|
26
|
+
"NOTICE"
|
|
27
|
+
],
|
|
28
|
+
"sideEffects": false,
|
|
29
|
+
"peerDependencies": {
|
|
30
|
+
"@driftengine/core": "3.61.0"
|
|
31
|
+
},
|
|
32
|
+
"author": "Drift Technologies",
|
|
33
|
+
"repository": {
|
|
34
|
+
"type": "git",
|
|
35
|
+
"url": "git+https://github.com/drftrun/driftengine.git",
|
|
36
|
+
"directory": "packages/xr"
|
|
37
|
+
},
|
|
38
|
+
"homepage": "https://github.com/drftrun/driftengine#readme",
|
|
39
|
+
"bugs": "https://github.com/drftrun/driftengine/issues",
|
|
40
|
+
"keywords": [
|
|
41
|
+
"driftengine",
|
|
42
|
+
"3d",
|
|
43
|
+
"webgl",
|
|
44
|
+
"webgpu",
|
|
45
|
+
"typescript",
|
|
46
|
+
"webxr",
|
|
47
|
+
"virtual-reality",
|
|
48
|
+
"augmented-reality",
|
|
49
|
+
"hand-tracking"
|
|
50
|
+
],
|
|
51
|
+
"engines": {
|
|
52
|
+
"node": ">=22.12.0"
|
|
53
|
+
},
|
|
54
|
+
"publishConfig": {
|
|
55
|
+
"access": "public"
|
|
56
|
+
}
|
|
57
|
+
}
|