@displayxr/inline3d 1.3.0 → 1.5.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/CHANGELOG.md +139 -0
- package/index.d.ts +295 -0
- package/js/inline3d-mode-switch.js +246 -0
- package/js/inline3d-model.js +1 -1
- package/js/inline3d-undock.js +251 -0
- package/js/inline3d.js +1151 -25
- package/package.json +7 -1
package/js/inline3d.js
CHANGED
|
@@ -24,6 +24,25 @@
|
|
|
24
24
|
// { supported:false } and your page shows its normal 2D content — inline-3D is progressive
|
|
25
25
|
// enhancement, never a hard dependency.
|
|
26
26
|
|
|
27
|
+
// Undock lives in its own module (it is a page-level action, not a per-frame concern, and it
|
|
28
|
+
// works standalone on a browser with no inline-3D at all). Imported here for ONE reason: to hand
|
|
29
|
+
// it a resolver so its API-first path can find the live XRDisplayLayer behind an element. The
|
|
30
|
+
// dependency runs one way — inline3d-undock.js imports nothing — so there is no cycle, and the
|
|
31
|
+
// three entry points are re-exported below so a page has a single import site.
|
|
32
|
+
import { undock, undockAvailable, undockUrl, tileScreenRect, setUndockLayerResolver } from './inline3d-undock.js';
|
|
33
|
+
export { undock, undockAvailable, undockUrl, tileScreenRect };
|
|
34
|
+
|
|
35
|
+
// The eased 2D<->3D transition. A pure state machine (no DOM, no WebXR) ported from the native
|
|
36
|
+
// `dxr::ModeSwitch`, so the browser eases the disparity around a mode switch the same way — and in
|
|
37
|
+
// the same ORDER — as the native apps and the demos. See _requestRenderingModeEased.
|
|
38
|
+
import {
|
|
39
|
+
ModeSwitch,
|
|
40
|
+
MODE_SWITCH_DEFAULT_DURATION_MS,
|
|
41
|
+
MODE_SWITCH_DEFAULT_EASING,
|
|
42
|
+
MODE_SWITCH_EASINGS,
|
|
43
|
+
normaliseModeSwitchEasing,
|
|
44
|
+
} from './inline3d-mode-switch.js';
|
|
45
|
+
|
|
27
46
|
// The document's single live manager. The browser's per-frame element-rect report is a
|
|
28
47
|
// WHOLE-WIDGET setter — each live session pushes the complete list of rects to weave — so two
|
|
29
48
|
// managers in one document overwrite each other frame by frame and neither one's tiles hold
|
|
@@ -31,6 +50,26 @@
|
|
|
31
50
|
// manager and opens the next) are legitimate and the common case, so nothing is refused.
|
|
32
51
|
let liveManager = null;
|
|
33
52
|
|
|
53
|
+
// How the undock helper's API-first path finds the layer behind an element. Registered here
|
|
54
|
+
// because only this module knows the canvas -> window -> layer map; the helper stays importable
|
|
55
|
+
// on its own (with no resolver, every call takes the protocol fallback).
|
|
56
|
+
setUndockLayerResolver((el) => {
|
|
57
|
+
const m = liveManager;
|
|
58
|
+
if (!m || !m._running || !el) return null;
|
|
59
|
+
// The canvas itself, then a woven canvas INSIDE the element (a card wrapping its tile), then
|
|
60
|
+
// the element sitting inside a window's own container (a button in the tile's box). Anything
|
|
61
|
+
// further away is not this window's rect and takes the fallback.
|
|
62
|
+
for (const win of m._windows.values()) if (win.canvas === el && win.layer) return win.layer;
|
|
63
|
+
for (const win of m._windows.values()) {
|
|
64
|
+
if (win.layer && typeof el.contains === 'function' && el.contains(win.canvas)) return win.layer;
|
|
65
|
+
}
|
|
66
|
+
for (const win of m._windows.values()) {
|
|
67
|
+
const box = win.canvas.parentElement;
|
|
68
|
+
if (win.layer && box && typeof box.contains === 'function' && box.contains(el)) return win.layer;
|
|
69
|
+
}
|
|
70
|
+
return null;
|
|
71
|
+
});
|
|
72
|
+
|
|
34
73
|
const hasWebXR = () => typeof navigator !== 'undefined' && !!navigator.xr;
|
|
35
74
|
const hasLayer = () =>
|
|
36
75
|
typeof window !== 'undefined' && typeof window.XRDisplayLayer === 'function';
|
|
@@ -58,6 +97,59 @@ const hasViewRig = () => {
|
|
|
58
97
|
return false; // a prototype that refuses to be probed is not a capability
|
|
59
98
|
}
|
|
60
99
|
};
|
|
100
|
+
// Display modes — the display's own capabilities, and the one thing a page can ask it to
|
|
101
|
+
// change. Three methods, all on XRDisplayLayer, all promise-returning:
|
|
102
|
+
//
|
|
103
|
+
// getDisplayInfo() the panel: physical size, pixel size, the view scale it
|
|
104
|
+
// RECOMMENDS. Null on a machine with no glasses-free display.
|
|
105
|
+
// getRenderingModes() every mode the runtime can put the panel in — view count, tile
|
|
106
|
+
// grid, per-view pixels, whether it is a hardware-3D mode, which
|
|
107
|
+
// one is active, and whether the browser may request it.
|
|
108
|
+
// requestRenderingMode(i) switch the panel to mode i. The browser renders exactly TWO
|
|
109
|
+
// views, so a mode with viewCount > 2 is listed and refused; a
|
|
110
|
+
// ONE-view mode IS requestable and is how a page goes flat.
|
|
111
|
+
//
|
|
112
|
+
// THE HARDWARE DISPLAY STATE (2D/3D) IS NOT A SEPARATE CONTROL, and that is the whole shape of
|
|
113
|
+
// this API. It is a CONSEQUENCE of the active rendering mode: request a one-view mode and the
|
|
114
|
+
// browser puts the panel in its 2D state and reports that mode active (the runtime carries on
|
|
115
|
+
// weaving the same fixed two-view atlas); request the two-view mode and the panel goes back to
|
|
116
|
+
// 3D. The transition arrives as `hardwaredisplaystatechange`. There is deliberately NO
|
|
117
|
+
// page-facing request for the hardware state on its own: a page that could move the panel flat
|
|
118
|
+
// while still submitting stereo would be showing the woven atlas flat, which is a blurry double
|
|
119
|
+
// image rather than 2D. Tying the two together makes that state unreachable.
|
|
120
|
+
//
|
|
121
|
+
// Probed the same way as setViewRig, and for the same reason: these are METHODS, so reading
|
|
122
|
+
// them off the prototype is a plain data-property read that calls nothing (an IDL *attribute*
|
|
123
|
+
// getter would throw `Illegal invocation` on the very browser that has it). All three are
|
|
124
|
+
// required — a browser with a partial set is a browser mid-implementation, and treating it as
|
|
125
|
+
// supported would hand a page a `requestRenderingMode is not a function` at the worst moment.
|
|
126
|
+
const DISPLAY_MODE_METHODS = ['getDisplayInfo', 'getRenderingModes', 'requestRenderingMode'];
|
|
127
|
+
// The two display events, fired on the XRSession rather than the layer — so a page hears about a
|
|
128
|
+
// mode or hardware-state change even while its tile's layer is closed (lazy mode), and one
|
|
129
|
+
// subscription covers every window in the document.
|
|
130
|
+
const DISPLAY_EVENTS = ['renderingmodechange', 'hardwaredisplaystatechange'];
|
|
131
|
+
const hasDisplayModes = () => {
|
|
132
|
+
if (!hasLayer()) return false;
|
|
133
|
+
try {
|
|
134
|
+
const proto = window.XRDisplayLayer.prototype;
|
|
135
|
+
return DISPLAY_MODE_METHODS.every((m) => typeof proto[m] === 'function');
|
|
136
|
+
} catch {
|
|
137
|
+
return false; // a prototype that refuses to be probed is not a capability
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
// Undock (XRDisplayLayer.undock / getUndockCapabilities) — lifting this window's asset out of
|
|
141
|
+
// the page into a floating native viewer over the desktop. Same METHOD probe, same reason. The
|
|
142
|
+
// helper in ./inline3d-undock.js falls back to the `displayxr-view:` OS protocol where this is
|
|
143
|
+
// absent, so a page never has to branch on it; what it IS good for is deciding whether to show
|
|
144
|
+
// an "undock" affordance at all (see `wall.undock`).
|
|
145
|
+
const hasUndock = () => {
|
|
146
|
+
if (!hasLayer()) return false;
|
|
147
|
+
try {
|
|
148
|
+
return typeof window.XRDisplayLayer.prototype.undock === 'function';
|
|
149
|
+
} catch {
|
|
150
|
+
return false;
|
|
151
|
+
}
|
|
152
|
+
};
|
|
61
153
|
// ── draw-order occlusion (browser Phase 2, browser patches 0063/0064) ─────────────────────
|
|
62
154
|
//
|
|
63
155
|
// The browser composites ANY 2D content over a woven tile per-pixel BY DRAW ORDER — headers,
|
|
@@ -202,6 +294,157 @@ export function inline3dViewRigSupported() {
|
|
|
202
294
|
return inline3DAvailable() && hasViewRig();
|
|
203
295
|
}
|
|
204
296
|
|
|
297
|
+
/**
|
|
298
|
+
* True when this browser exposes the DISPLAY-MODE API — `getDisplayInfo()`,
|
|
299
|
+
* `getRenderingModes()` and `requestRenderingMode()` on the tile handle, i.e. the page can read
|
|
300
|
+
* what the panel is and ask it to change. Sync + cheap; implies {@link inline3DAvailable}.
|
|
301
|
+
*
|
|
302
|
+
* Reads a capability (the presence of all three methods on `XRDisplayLayer.prototype`), never a
|
|
303
|
+
* version or UA string, and demands ALL THREE: a browser shipping half the set is one mid-
|
|
304
|
+
* implementation, and calling it supported would surface as a `not a function` inside a click
|
|
305
|
+
* handler rather than as a feature that is simply absent.
|
|
306
|
+
*
|
|
307
|
+
* Everything the API drives is optional enhancement — the window weaves identically without it —
|
|
308
|
+
* so a page needs this only to decide whether to SHOW display controls. The handle methods
|
|
309
|
+
* themselves reject with a clear Error rather than throwing at import or create time.
|
|
310
|
+
*/
|
|
311
|
+
export function inline3dDisplayModesSupported() {
|
|
312
|
+
return inline3DAvailable() && hasDisplayModes();
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* True when this browser can UNDOCK a window's asset into a floating native viewer through
|
|
317
|
+
* `XRDisplayLayer.undock()` — i.e. without the `displayxr-view:` protocol prompt the fallback
|
|
318
|
+
* path needs. Sync + cheap; implies {@link inline3DAvailable}.
|
|
319
|
+
*
|
|
320
|
+
* A page does not have to branch on this to undock (the helper falls back on its own); it is the
|
|
321
|
+
* probe for whether `wall.undock` carries capabilities, and for a UI that wants to say WHICH
|
|
322
|
+
* asset kinds this build can float.
|
|
323
|
+
*/
|
|
324
|
+
export function inline3dUndockSupported() {
|
|
325
|
+
return inline3DAvailable() && hasUndock();
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// The rig `virtualDisplayHeight` is shorthand for: a display rig, identity pose, all factors 1.
|
|
329
|
+
// Written out here because the automatic 1-view collapse has to be able to say "the default rig,
|
|
330
|
+
// but flat", and there is no way to express that as a scalar — the whole descriptor has to be sent.
|
|
331
|
+
function defaultDisplayRig(win) {
|
|
332
|
+
return {
|
|
333
|
+
type: 'display',
|
|
334
|
+
position: { x: 0, y: 0, z: 0 },
|
|
335
|
+
orientation: { x: 0, y: 0, z: 0, w: 1 },
|
|
336
|
+
virtualDisplayHeight: win.virtualDisplayHeight > 0 ? win.virtualDisplayHeight : 0.24,
|
|
337
|
+
ipdFactor: 1,
|
|
338
|
+
parallaxFactor: 1,
|
|
339
|
+
perspectiveFactor: 1,
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// The page's rig with the stereo dialled DOWN by `factor`: eye separation and head-tracking
|
|
344
|
+
// response scaled together, so `factor` 0 renders both eyes from the SAME place (the woven atlas
|
|
345
|
+
// carries one image twice) and `factor` 1 is exactly what the page asked for. Everything between
|
|
346
|
+
// is the eased 2D<->3D transition (see ModeSwitch) — which is why this is a scale and not a
|
|
347
|
+
// boolean: a flat panel and a full-disparity one are the two ENDS of one continuum.
|
|
348
|
+
//
|
|
349
|
+
// A COPY, never a mutation. A page driving a rig per frame reuses one descriptor object
|
|
350
|
+
// (cameraRigFromCamera's `out`), so scaling the factors in place would write the flattening into
|
|
351
|
+
// the page's own state and it would never come back — the restore would restore 0.
|
|
352
|
+
//
|
|
353
|
+
// An unset factor is the runtime's default of 1, so it scales like an explicit 1 rather than
|
|
354
|
+
// staying absent: a rig that says nothing about disparity still goes flat.
|
|
355
|
+
function scaledRig(rig, factor) {
|
|
356
|
+
const ipd = Number.isFinite(rig.ipdFactor) ? rig.ipdFactor : 1;
|
|
357
|
+
const parallax = Number.isFinite(rig.parallaxFactor) ? rig.parallaxFactor : 1;
|
|
358
|
+
return { ...rig, ipdFactor: ipd * factor, parallaxFactor: parallax * factor };
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// How often the fallback tick advances a transition when session frames are NOT arriving (a
|
|
362
|
+
// background tab, every tile scrolled away). Roughly one 60 Hz frame — the ramp is time-based, so
|
|
363
|
+
// this is a floor on smoothness, never on duration.
|
|
364
|
+
const MODE_SWITCH_TICK_MS = 16;
|
|
365
|
+
|
|
366
|
+
// Wall clock for the transition ramp, in ms. Read through the global on every call (never
|
|
367
|
+
// captured) so a test can install its own clock, and so a page that runs before `performance`
|
|
368
|
+
// exists still gets a monotonic-enough source. Frame COUNTS are deliberately not used: the ramp
|
|
369
|
+
// has to take the same time at 30 fps and at 144 fps.
|
|
370
|
+
function nowMs() {
|
|
371
|
+
return typeof performance !== 'undefined' && performance && typeof performance.now === 'function'
|
|
372
|
+
? performance.now()
|
|
373
|
+
: Date.now();
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// The easing option, validated here rather than in the state machine: the sequencer falls back
|
|
377
|
+
// silently (it has no opinion about a caller's config), but a typo in `createInline3D` is worth
|
|
378
|
+
// exactly one warning — a page that asked for 'ease-in-out' and got smoothstep should know.
|
|
379
|
+
let notedModeSwitchEasing = false;
|
|
380
|
+
function resolveModeSwitchEasing(easing) {
|
|
381
|
+
if (easing === undefined || easing === null) return MODE_SWITCH_DEFAULT_EASING;
|
|
382
|
+
const known = normaliseModeSwitchEasing(easing);
|
|
383
|
+
if (known) return known;
|
|
384
|
+
if (!notedModeSwitchEasing) {
|
|
385
|
+
notedModeSwitchEasing = true;
|
|
386
|
+
console.warn(
|
|
387
|
+
`[inline3d] createInline3D({ modeSwitch: { easing: ${JSON.stringify(easing)} } }) is not a ` +
|
|
388
|
+
`curve this SDK knows (${MODE_SWITCH_EASINGS.join(' / ')}); using ` +
|
|
389
|
+
`'${MODE_SWITCH_DEFAULT_EASING}'.`
|
|
390
|
+
);
|
|
391
|
+
}
|
|
392
|
+
return MODE_SWITCH_DEFAULT_EASING;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// Why the SDK collapses the rig behind the page's back, said once, where the code is.
|
|
396
|
+
let notedAutoCollapse = false;
|
|
397
|
+
function noteAutoCollapse() {
|
|
398
|
+
if (notedAutoCollapse) return;
|
|
399
|
+
notedAutoCollapse = true;
|
|
400
|
+
console.info(
|
|
401
|
+
'[inline3d] The active rendering mode is 1-view, so this SDK has zeroed every window rig ' +
|
|
402
|
+
'(ipdFactor/parallaxFactor -> 0) — both eyes now render from one place. The runtime keeps ' +
|
|
403
|
+
'weaving the same two-view atlas whatever the page submits, so leaving stereo in it would ' +
|
|
404
|
+
'put two slightly different images on a flat panel, i.e. a blurry double image instead of ' +
|
|
405
|
+
'2D. Your rendering is unchanged; the flattening is a copy applied on the way to the layer ' +
|
|
406
|
+
'and it is undone the moment a 2-view mode goes active again.'
|
|
407
|
+
);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// ── reading the two display events ──────────────────────────────────────────────────────
|
|
411
|
+
//
|
|
412
|
+
// The payload is read DEFENSIVELY, in the shape the 0128-era handling already established: an
|
|
413
|
+
// event's own `detail` when it carries one, and otherwise the event object itself. A browser
|
|
414
|
+
// mid-implementation carried nothing at all and the state had to be read back — which still
|
|
415
|
+
// works, because an unreadable payload leaves the value unknown and the mode list answers it.
|
|
416
|
+
// `!== undefined` rather than `in`: a CustomEvent always HAS a `detail` property, and a null one
|
|
417
|
+
// is not a payload.
|
|
418
|
+
function eventDetail(e) {
|
|
419
|
+
return e && e.detail !== undefined && e.detail !== null ? e.detail : undefined;
|
|
420
|
+
}
|
|
421
|
+
function eventPayloads(e) {
|
|
422
|
+
const d = eventDetail(e);
|
|
423
|
+
return d !== undefined ? [d, e] : [e];
|
|
424
|
+
}
|
|
425
|
+
/** The new active mode index an event carries — a bare number, `.modeIndex`, or `.mode`. -1 = not stated. */
|
|
426
|
+
function eventModeIndex(e) {
|
|
427
|
+
for (const src of eventPayloads(e)) {
|
|
428
|
+
if (typeof src === 'number' && Number.isFinite(src)) return src;
|
|
429
|
+
if (src && typeof src === 'object') {
|
|
430
|
+
const v = src.modeIndex !== undefined ? src.modeIndex : src.mode;
|
|
431
|
+
if (typeof v === 'number' && Number.isFinite(v)) return v;
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
return -1;
|
|
435
|
+
}
|
|
436
|
+
/** The hardware display state an event carries — a bare '2d'/'3d' or `.state`. null = not stated. */
|
|
437
|
+
function eventHardwareState(e) {
|
|
438
|
+
for (const src of eventPayloads(e)) {
|
|
439
|
+
if (src === '2d' || src === '3d') return src;
|
|
440
|
+
if (src && typeof src === 'object') {
|
|
441
|
+
const v = src.state !== undefined ? src.state : src.hardwareDisplayState;
|
|
442
|
+
if (v === '2d' || v === '3d') return v;
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
return null;
|
|
446
|
+
}
|
|
447
|
+
|
|
205
448
|
// One-shot notices about view rigs. Both are per-document, and both describe a situation that is
|
|
206
449
|
// identical on every frame — so warning per call would bury the page's own logs in a rAF loop.
|
|
207
450
|
let notedNoViewRig = false;
|
|
@@ -249,10 +492,30 @@ function noteRigWinsOverHeight() {
|
|
|
249
492
|
* exclusively via addGlobalOverlay()/data-inline3d-overlay. Ignored (nothing is
|
|
250
493
|
* scanned, no `will-change` is set on your DOM) on a browser with draw-order
|
|
251
494
|
* occlusion, where chrome occludes tiles by itself.
|
|
252
|
-
* @
|
|
495
|
+
* @param {object} [opts.modeSwitch] The EASED 2D<->3D transition, on by default.
|
|
496
|
+
* `{ durationMs=180, easing='smoothstep'|'linear'|'easeoutcubic', enabled=true }` — the
|
|
497
|
+
* same defaults the native DisplayXR apps configure. Instead of snapping the stereo rig
|
|
498
|
+
* the moment the panel's mode changes, a page-initiated switch ramps every window's
|
|
499
|
+
* `ipdFactor`/`parallaxFactor` between 0 and what the page asked for, in the order that
|
|
500
|
+
* looks right: going FLAT ramps the disparity out first and only then asks the panel to
|
|
501
|
+
* switch, and coming BACK asks first and eases the disparity in once the panel reports
|
|
502
|
+
* 3D. `enabled:false` restores the plain snap. It is aesthetic policy only — the runtime
|
|
503
|
+
* keeps the eye set coherent either way — and a mode change the page did NOT request
|
|
504
|
+
* (another tab, the shell, a panel opening flat) always snaps, because there is nothing
|
|
505
|
+
* to ramp from. Read the live state on `wall.modeSwitch`.
|
|
506
|
+
* @returns {Promise<Inline3D | {supported:false, error?:Error}>} the manager, which also carries
|
|
507
|
+
* the display API (`getDisplayInfo` / `getRenderingModes` / `requestRenderingMode` /
|
|
508
|
+
* `setStereoEnabled`, `on`/`off`) and `undock` — `{model, splat}` on a browser with
|
|
509
|
+
* `XRDisplayLayer.undock`, `null` on one without.
|
|
253
510
|
*/
|
|
254
511
|
export async function createInline3D(opts = {}) {
|
|
255
|
-
const {
|
|
512
|
+
const {
|
|
513
|
+
referenceSpace = 'viewer',
|
|
514
|
+
lazy = true,
|
|
515
|
+
rootMargin = '50% 0px',
|
|
516
|
+
autoChrome = true,
|
|
517
|
+
modeSwitch = null,
|
|
518
|
+
} = opts;
|
|
256
519
|
if (!inline3DAvailable()) return { supported: false };
|
|
257
520
|
let session;
|
|
258
521
|
try {
|
|
@@ -268,7 +531,7 @@ export async function createInline3D(opts = {}) {
|
|
|
268
531
|
} catch {
|
|
269
532
|
/* rAF still fires without a ref space; views are just null (fine for image/video). */
|
|
270
533
|
}
|
|
271
|
-
return new Inline3D(session, refSpace, { lazy, rootMargin, autoChrome });
|
|
534
|
+
return new Inline3D(session, refSpace, { lazy, rootMargin, autoChrome, modeSwitch });
|
|
272
535
|
}
|
|
273
536
|
|
|
274
537
|
/**
|
|
@@ -320,7 +583,7 @@ function chromeTextPlates(root) {
|
|
|
320
583
|
}
|
|
321
584
|
|
|
322
585
|
class Inline3D {
|
|
323
|
-
constructor(session, refSpace, { lazy, rootMargin, autoChrome = true }) {
|
|
586
|
+
constructor(session, refSpace, { lazy, rootMargin, autoChrome = true, modeSwitch = null }) {
|
|
324
587
|
this.supported = true;
|
|
325
588
|
this.session = session;
|
|
326
589
|
this.refSpace = refSpace;
|
|
@@ -346,6 +609,41 @@ class Inline3D {
|
|
|
346
609
|
// Set once the legacy occlusion machinery has been retired (draw-order browser whose
|
|
347
610
|
// capability flag could only be read from a live layer). See _standDownLegacyOcclusion.
|
|
348
611
|
this._stoodDown = false;
|
|
612
|
+
// ── display state ────────────────────────────────────────────────────────────────
|
|
613
|
+
// What the panel is doing, as last REPORTED (the first getRenderingModes read, then every
|
|
614
|
+
// renderingmodechange / hardwaredisplaystatechange). Never what was last requested: a
|
|
615
|
+
// refused request must leave every one of these untouched.
|
|
616
|
+
this._activeModeIndex = -1;
|
|
617
|
+
this._activeViewCount = 0; // 0 = not read yet
|
|
618
|
+
this._hardwareDisplayState = null; // '2d' | '3d' | null (never reported)
|
|
619
|
+
this._modes = null; // last getRenderingModes() result, for the viewCount lookup
|
|
620
|
+
// The rig-collapse latch, MANAGER-wide because the mode is the display's, not a window's.
|
|
621
|
+
// While true every rig that leaves for a layer is pushed flat (a copy — each window's own
|
|
622
|
+
// `viewRig` always holds what the page asked for, untouched).
|
|
623
|
+
this._stereoCollapsed = false;
|
|
624
|
+
this._displayListeners = new Map(); // event type -> Set(callback), for on()/off()
|
|
625
|
+
this._primedDisplayState = false;
|
|
626
|
+
// ── the eased 2D<->3D transition (opts.modeSwitch) ───────────────────────────────
|
|
627
|
+
// The collapse above is a LATCH; what actually reaches each layer is that latch turned into a
|
|
628
|
+
// SCALE — `_stereoFactor`, 0 (flat) to 1 (exactly the rig the page set). With the sequencer
|
|
629
|
+
// off, or for a mode change the page did not request, the scale is only ever 0 or 1 and
|
|
630
|
+
// nothing looks different. With it on, a page-initiated switch walks the scale across that
|
|
631
|
+
// range over `durationMs` and holds the mode request until the right end of the ramp.
|
|
632
|
+
const msOpts = modeSwitch && typeof modeSwitch === 'object' ? modeSwitch : {};
|
|
633
|
+
this._msEnabled = msOpts.enabled !== false;
|
|
634
|
+
this._modeSwitch = new ModeSwitch(
|
|
635
|
+
(Number.isFinite(msOpts.durationMs) ? Math.max(0, msOpts.durationMs) : MODE_SWITCH_DEFAULT_DURATION_MS) / 1000,
|
|
636
|
+
resolveModeSwitchEasing(msOpts.easing)
|
|
637
|
+
);
|
|
638
|
+
this._stereoFactor = 1; // what every window's ipd/parallax is multiplied by on the way out
|
|
639
|
+
this._msFire = null; // a ->2D request HELD until the ramp-down lands
|
|
640
|
+
this._msArmedUp = false; // a ->3D request went out; the up-ramp waits for the panel to say 3D
|
|
641
|
+
this._msLastMs = null; // wall clock of the previous advance (null = the ramp has not ticked)
|
|
642
|
+
this._msTick = null; // the frames-stopped fallback timer; see _armModeSwitchTick
|
|
643
|
+
// Undock capabilities, refreshed off the first live layer (see _refreshUndock). Null is the
|
|
644
|
+
// load-bearing value: it means this browser has no XRDisplayLayer.undock at all.
|
|
645
|
+
this.undock = hasUndock() ? { model: false, splat: false } : null;
|
|
646
|
+
this._undockRead = false;
|
|
349
647
|
this._running = true;
|
|
350
648
|
this._lazy = lazy;
|
|
351
649
|
this._observer =
|
|
@@ -370,6 +668,7 @@ class Inline3D {
|
|
|
370
668
|
}
|
|
371
669
|
liveManager = this;
|
|
372
670
|
session.addEventListener('end', () => this._teardown());
|
|
671
|
+
this._bindDisplayEvents();
|
|
373
672
|
this._scanChrome(); // page chrome usually exists before the session does
|
|
374
673
|
this._bindLifecycle();
|
|
375
674
|
this._armDprWatch();
|
|
@@ -616,29 +915,202 @@ class Inline3D {
|
|
|
616
915
|
* pointer it is not, and the fix is not to fight it — send an IDENTITY-posed camera rig
|
|
617
916
|
* and parent your eye cameras under the app camera, so three composes the world pose with
|
|
618
917
|
* zero lag (see `cameraRigFromCamera(..., {attach:true})` + `EyeCamera.setLocalFromView`).
|
|
918
|
+
*
|
|
919
|
+
* While `setStereoEnabled(false)` is in force the rig you pass here is STORED AS GIVEN and
|
|
920
|
+
* pushed FLAT (ipd/parallax 0) — the flattening is a latch on the way out, not a value
|
|
921
|
+
* written into your descriptor, so a page driving a rig every frame cannot undo the 2D
|
|
922
|
+
* state by simply carrying on, and `setStereoEnabled(true)` restores exactly what you last
|
|
923
|
+
* asked for. During the eased 2D<->3D transition the same applies with a FRACTION in place
|
|
924
|
+
* of the 0: what leaves for the layer is your rig scaled by `wall.modeSwitch.factor`.
|
|
619
925
|
*/
|
|
620
926
|
setViewRig: (rig) => {
|
|
621
927
|
win.viewRig = rig || null;
|
|
622
|
-
|
|
623
|
-
noteNoViewRig();
|
|
624
|
-
return false;
|
|
625
|
-
}
|
|
626
|
-
if (!win.layer) return false;
|
|
627
|
-
try {
|
|
628
|
-
win.layer.setViewRig(rig);
|
|
629
|
-
return true;
|
|
630
|
-
} catch {
|
|
631
|
-
// A closed layer or a descriptor the browser refused. Neither is worth throwing over
|
|
632
|
-
// in a per-frame call — the window keeps weaving on the rig it already has.
|
|
633
|
-
return false;
|
|
634
|
-
}
|
|
928
|
+
return this._pushViewRig(win);
|
|
635
929
|
},
|
|
930
|
+
/**
|
|
931
|
+
* The panel this window is weaving on: physical size in metres, pixel size, and the view
|
|
932
|
+
* scale the runtime RECOMMENDS. Resolves null on a machine with no glasses-free display.
|
|
933
|
+
*
|
|
934
|
+
* `recommendedViewScaleX/Y` are ADVISORY. The browser cannot resize a page's canvas, so
|
|
935
|
+
* nothing applies them for you: a page honours them by sizing its OWN backing store
|
|
936
|
+
* (canvas.width/height, `renderer.setSize`) to `viewPixels x scale`. Ignoring them costs
|
|
937
|
+
* sharpness or fill rate, never correctness.
|
|
938
|
+
*
|
|
939
|
+
* @returns {Promise<object|null>}
|
|
940
|
+
*/
|
|
941
|
+
getDisplayInfo: () => this._layerCall(win, 'getDisplayInfo', 'getDisplayInfo()'),
|
|
942
|
+
/**
|
|
943
|
+
* Every rendering mode the runtime can put this display in, as reported by the runtime:
|
|
944
|
+
* `{modeIndex, modeName, viewCount, viewScaleX, viewScaleY, tileColumns, tileRows,
|
|
945
|
+
* viewWidthPixels, viewHeightPixels, hardwareDisplay3D, isActive, isRequestable}`.
|
|
946
|
+
*
|
|
947
|
+
* The list is the DISPLAY's, not the browser's, so it includes modes this browser cannot
|
|
948
|
+
* drive: the browser is fixed at TWO views, so any mode with `viewCount !== 2` is reported
|
|
949
|
+
* with `isRequestable: false` and `requestRenderingMode` refuses it. Show those rows — they
|
|
950
|
+
* are what the panel can do — but mark them, don't offer them.
|
|
951
|
+
*
|
|
952
|
+
* @returns {Promise<ReadonlyArray<object>>}
|
|
953
|
+
*/
|
|
954
|
+
getRenderingModes: () => this._getRenderingModes(win),
|
|
955
|
+
/**
|
|
956
|
+
* Ask the runtime to switch the display to the mode with this `modeIndex`. A thin
|
|
957
|
+
* pass-through — it resolves and rejects exactly as the browser does.
|
|
958
|
+
*
|
|
959
|
+
* Rejects with a `TypeError` for a mode with `viewCount > 2` (the browser renders exactly
|
|
960
|
+
* two views and cannot fill a 4-view atlas — no view synthesis exists anywhere in this
|
|
961
|
+
* stack) or an unknown index, and with a `NotSupportedError` `DOMException` when the
|
|
962
|
+
* request was not forwardable. The browser raises the TypeError SYNCHRONOUSLY; this
|
|
963
|
+
* pass-through is async, so it reaches you as a rejection either way and one `.catch()`
|
|
964
|
+
* covers both. On a browser without the API at all it rejects with a plain Error saying so.
|
|
965
|
+
*
|
|
966
|
+
* A ONE-VIEW MODE IS REQUESTABLE, and requesting it is how a page goes flat: the browser
|
|
967
|
+
* puts the panel in its 2D hardware state and reports that mode active. The SDK then
|
|
968
|
+
* collapses every window's rig automatically (see {@link setStereoEnabled}) off the
|
|
969
|
+
* resulting `renderingmodechange` — so the request itself changes nothing about your
|
|
970
|
+
* rendering, and a REFUSED request changes nothing at all.
|
|
971
|
+
*
|
|
972
|
+
* On success the session fires `renderingmodechange` — see {@link on}. That event, not
|
|
973
|
+
* this promise, is when the new mode is in effect.
|
|
974
|
+
*
|
|
975
|
+
* EASED BY DEFAULT (`createInline3D({modeSwitch})`). A GOING-FLAT request (`viewCount === 1`)
|
|
976
|
+
* is HELD while the disparity ramps out, and forwarded only when it lands — so this promise
|
|
977
|
+
* resolves when the browser actually got the request, roughly `durationMs` later, and the
|
|
978
|
+
* panel flips on already-flat content. A request that a reversal drops in that window
|
|
979
|
+
* rejects with an `Error` named `superseded`; nothing was ever asked of the display. Coming
|
|
980
|
+
* BACK is unchanged in timing: the request goes out at once and the disparity eases in when
|
|
981
|
+
* the panel reports 3D.
|
|
982
|
+
*
|
|
983
|
+
* @param {number} modeIndex
|
|
984
|
+
* @returns {Promise<void>}
|
|
985
|
+
*/
|
|
986
|
+
requestRenderingMode: (modeIndex) => this._requestRenderingModeEased(modeIndex, win),
|
|
987
|
+
/**
|
|
988
|
+
* SUGAR over {@link requestRenderingMode}, and nothing more. `false` requests the first
|
|
989
|
+
* mode with `viewCount === 1 && isRequestable`; `true` requests the first with
|
|
990
|
+
* `viewCount === 2 && isRequestable`. It never touches the hardware display state
|
|
991
|
+
* directly — there is no such call in this API — and it never touches your rig.
|
|
992
|
+
*
|
|
993
|
+
* THE RIG COLLAPSE IS NOT PART OF THIS CALL. When a 1-view mode actually goes ACTIVE the
|
|
994
|
+
* SDK zeroes every window's `ipdFactor`/`parallaxFactor` on the way to the layer, and
|
|
995
|
+
* restores them when a 2-view mode goes active; that is driven by the
|
|
996
|
+
* `renderingmodechange` event (and by the first `getRenderingModes()` read), so it happens
|
|
997
|
+
* however the mode changed — this call, another tab, the shell — and a request that is
|
|
998
|
+
* REFUSED leaves everything exactly as it was, in both directions.
|
|
999
|
+
*
|
|
1000
|
+
* The flattening is a COPY pushed at the layer, never a write into your descriptor: a page
|
|
1001
|
+
* driving `setViewRig` every frame keeps having its rig stored intact and pushed flat, and
|
|
1002
|
+
* the restore is exactly the rig it last asked for. A page that never set a rig gets the
|
|
1003
|
+
* exact descriptor equivalent of its `virtualDisplayHeight`.
|
|
1004
|
+
*
|
|
1005
|
+
* Rejects when no such mode is listed (a plain Error naming what was looked for), and
|
|
1006
|
+
* otherwise exactly as `requestRenderingMode` does. Resolves to the boolean asked for —
|
|
1007
|
+
* the request was accepted; the mode is in force when the event says so.
|
|
1008
|
+
*
|
|
1009
|
+
* Eased by default, exactly as {@link requestRenderingMode} is: `false` ramps the disparity
|
|
1010
|
+
* out before the request goes anywhere, `true` requests first and eases the disparity back
|
|
1011
|
+
* in once the panel reports 3D, and pressing the pair in quick succession reverses cleanly
|
|
1012
|
+
* rather than firing a stale switch.
|
|
1013
|
+
*
|
|
1014
|
+
* @param {boolean} enabled
|
|
1015
|
+
* @returns {Promise<boolean>}
|
|
1016
|
+
*/
|
|
1017
|
+
setStereoEnabled: (enabled) => this._setStereoEnabled(enabled, win),
|
|
1018
|
+
/**
|
|
1019
|
+
* Subscribe to one display event, re-emitted on this handle:
|
|
1020
|
+
*
|
|
1021
|
+
* `renderingmodechange` `{type, modeIndex, viewCount, mode, detail}`
|
|
1022
|
+
* `hardwaredisplaystatechange` `{type, state:'2d'|'3d', detail}`
|
|
1023
|
+
*
|
|
1024
|
+
* They originate on the XRSession, not on the layer — so they arrive even for a window
|
|
1025
|
+
* whose layer is currently closed, and a page that only wants to KNOW does not have to
|
|
1026
|
+
* hold a live tile. `detail` is the browser's own payload, kept as-is.
|
|
1027
|
+
*
|
|
1028
|
+
* Returns an unsubscribe function; `off(type, cb)` does the same. Inert (returns a no-op)
|
|
1029
|
+
* on a browser without the API.
|
|
1030
|
+
*
|
|
1031
|
+
* @param {'renderingmodechange'|'hardwaredisplaystatechange'} type
|
|
1032
|
+
* @param {(e:object) => void} cb
|
|
1033
|
+
* @returns {() => void}
|
|
1034
|
+
*/
|
|
1035
|
+
on: (type, cb) => this.on(type, cb),
|
|
1036
|
+
/** Drop a listener registered with {@link on}. */
|
|
1037
|
+
off: (type, cb) => this.off(type, cb),
|
|
1038
|
+
/**
|
|
1039
|
+
* BOTH display events through one callback — the older shape, kept because pages use it.
|
|
1040
|
+
* The callback gets the same normalised object `on()` delivers (`{type, ...}` plus
|
|
1041
|
+
* `detail`). Returns an unsubscribe function.
|
|
1042
|
+
*
|
|
1043
|
+
* @param {(e:{type:string, detail:any}) => void} cb
|
|
1044
|
+
* @returns {() => void}
|
|
1045
|
+
*/
|
|
1046
|
+
onDisplayModeChange: (cb) => this._onDisplayModeChange(cb),
|
|
636
1047
|
// Read-only counters, for pages that want to see the load-induced mono fallback rather
|
|
637
1048
|
// than wait for a bug report about "blinking". Scene windows only; 0/0 elsewhere.
|
|
638
1049
|
stats: () => ({ frames: win.frames, monoFrames: win.monoFrames }),
|
|
639
1050
|
};
|
|
640
1051
|
}
|
|
641
1052
|
|
|
1053
|
+
// ── the display (wall level) ──────────────────────────────────────────────────────────
|
|
1054
|
+
//
|
|
1055
|
+
// The panel is the DOCUMENT's, not a tile's: one display, one active rendering mode, one
|
|
1056
|
+
// hardware state. These four are the same calls the tile handles carry (kept there because
|
|
1057
|
+
// pages use them), routed through whichever window currently holds a live layer — so they
|
|
1058
|
+
// keep working while a lazy tile is scrolled away, as long as ANY tile is live.
|
|
1059
|
+
|
|
1060
|
+
/** The panel: physical size, pixel size, the view scale it recommends. Null where there is none. */
|
|
1061
|
+
getDisplayInfo() {
|
|
1062
|
+
return this._layerCall(this._liveWindow(null), 'getDisplayInfo', 'getDisplayInfo()');
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
/** Every rendering mode the runtime can put this display in. See the handle's doc comment. */
|
|
1066
|
+
getRenderingModes() {
|
|
1067
|
+
return this._getRenderingModes(null);
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
/** Ask the runtime to switch the display to `modeIndex`. Pass-through; see the handle's doc. */
|
|
1071
|
+
requestRenderingMode(modeIndex) {
|
|
1072
|
+
return this._requestRenderingModeEased(modeIndex, null);
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
/** Sugar over {@link requestRenderingMode}: false -> a 1-view mode, true -> the 2-view mode. */
|
|
1076
|
+
setStereoEnabled(enabled) {
|
|
1077
|
+
return this._setStereoEnabled(enabled, null);
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
/**
|
|
1081
|
+
* The hardware display state as last REPORTED by `hardwaredisplaystatechange` — `'2d'`,
|
|
1082
|
+
* `'3d'`, or `null` when the browser has not said yet. Never what was last requested.
|
|
1083
|
+
*/
|
|
1084
|
+
get hardwareDisplayState() {
|
|
1085
|
+
return this._hardwareDisplayState;
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
/** The active mode's index and view count as last read/reported. `viewCount` 0 = not read yet. */
|
|
1089
|
+
get activeMode() {
|
|
1090
|
+
return { modeIndex: this._activeModeIndex, viewCount: this._activeViewCount };
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
/** True while the SDK is holding every window's rig flat because a 1-view mode is active. */
|
|
1094
|
+
get stereoCollapsed() {
|
|
1095
|
+
return this._stereoCollapsed;
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
/**
|
|
1099
|
+
* The eased 2D<->3D transition, live: `{active, factor}`.
|
|
1100
|
+
*
|
|
1101
|
+
* `factor` is what every window's `ipdFactor`/`parallaxFactor` is being multiplied by on the way
|
|
1102
|
+
* to the layer — `1` in 3D, `0` flat, in between mid-ramp. `active` is true while a
|
|
1103
|
+
* page-initiated switch is in any of its phases: ramping the disparity out, holding the ->2D
|
|
1104
|
+
* request until it lands, waiting for the panel to report 3D, or easing back in.
|
|
1105
|
+
*
|
|
1106
|
+
* Read-only and purely informational — a page that wants to grey a button or cross-fade some 2D
|
|
1107
|
+
* chrome alongside the panel can, and one that does not care never has to look. The SDK adds no
|
|
1108
|
+
* UI of its own for this, and never will: which key or button toggles the display is the page's.
|
|
1109
|
+
*/
|
|
1110
|
+
get modeSwitch() {
|
|
1111
|
+
return { active: this._msTransitionActive(), factor: this._stereoFactor };
|
|
1112
|
+
}
|
|
1113
|
+
|
|
642
1114
|
close() {
|
|
643
1115
|
try {
|
|
644
1116
|
this.session.end();
|
|
@@ -650,6 +1122,615 @@ class Inline3D {
|
|
|
650
1122
|
|
|
651
1123
|
// ── internals ───────────────────────────────────────────────────────────────────────
|
|
652
1124
|
|
|
1125
|
+
// ── view rig + display modes ──────────────────────────────────────────────────────────
|
|
1126
|
+
|
|
1127
|
+
/**
|
|
1128
|
+
* The rig this window's layer should actually be holding right now: what the page asked for,
|
|
1129
|
+
* scaled by the manager's current stereo factor (0 while a 1-view mode is active, 1 in 3D, and
|
|
1130
|
+
* everything between during an eased transition). Null means "say nothing" — leave the browser
|
|
1131
|
+
* on the `virtualDisplayHeight` shorthand it was built with.
|
|
1132
|
+
*
|
|
1133
|
+
* Used in BOTH directions (push at a live layer, build a new one), which is the point: a tile
|
|
1134
|
+
* that scrolls away and rebuilds while stereo is off must not come back in 3D.
|
|
1135
|
+
*/
|
|
1136
|
+
_effectiveViewRig(win) {
|
|
1137
|
+
if (this._stereoFactor >= 1) {
|
|
1138
|
+
// `stereoSynthRig`: this window never had a rig of its own, so going flat had to SEND one
|
|
1139
|
+
// (there is no way to say "the default, but flat" as a scalar). Coming back therefore has
|
|
1140
|
+
// to send the un-flat version explicitly too — returning null here would leave the layer
|
|
1141
|
+
// holding the flattened rig forever. The descriptor it sends is the exact equivalent of the
|
|
1142
|
+
// `virtualDisplayHeight` the layer was built with, so nothing about the framing moves.
|
|
1143
|
+
return win.viewRig || (win.stereoSynthRig ? defaultDisplayRig(win) : null);
|
|
1144
|
+
}
|
|
1145
|
+
// Going flat has to SEND a descriptor even for a window that never had a rig of its own
|
|
1146
|
+
// (there is no way to say "the default, but flat" as a scalar), and that fact has to be
|
|
1147
|
+
// remembered: coming back must then send the un-flat version explicitly, or the layer would
|
|
1148
|
+
// hold the flattened rig forever. Recorded here rather than in the collapse itself because a
|
|
1149
|
+
// window CREATED while the panel is already flat goes down this path on its first activate.
|
|
1150
|
+
if (!win.viewRig) win.stereoSynthRig = true;
|
|
1151
|
+
return scaledRig(win.viewRig || defaultDisplayRig(win), this._stereoFactor);
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
/**
|
|
1155
|
+
* Latch (or release) the manager-wide rig collapse and push every window's rig again.
|
|
1156
|
+
*
|
|
1157
|
+
* Driven ONLY by what the display reports — the first `getRenderingModes()` read and every
|
|
1158
|
+
* `renderingmodechange` — never by a request. That is what makes a refused request a no-op in
|
|
1159
|
+
* both directions: nothing here runs unless the mode actually changed.
|
|
1160
|
+
*
|
|
1161
|
+
* THE REPORT OWNS THE FACTOR ONLY WHEN THE SEQUENCER DOES NOT. A mode change the page did not
|
|
1162
|
+
* ask for (another tab, the shell, a panel that opened flat) snaps, because there is nothing to
|
|
1163
|
+
* ramp FROM — the transition is a page-initiated aesthetic, not a correctness step. The two
|
|
1164
|
+
* exceptions are the two halves of a page-initiated switch: while a ramp is in flight it owns
|
|
1165
|
+
* the factor outright, and a report of 3D that a `->3D` request armed starts the up-ramp here
|
|
1166
|
+
* rather than snapping (the whole reason that request fires first and eases second).
|
|
1167
|
+
*/
|
|
1168
|
+
_setStereoCollapsed(collapsed) {
|
|
1169
|
+
const next = !!collapsed;
|
|
1170
|
+
if (this._stereoCollapsed === next) return;
|
|
1171
|
+
this._stereoCollapsed = next;
|
|
1172
|
+
if (next) noteAutoCollapse();
|
|
1173
|
+
if (!next && this._msArmedUp) {
|
|
1174
|
+
this._msArmedUp = false;
|
|
1175
|
+
this._startUpRamp(); // the panel is in 3D at last — ease the disparity back in
|
|
1176
|
+
} else if (!this._modeSwitch.active()) {
|
|
1177
|
+
this._stereoFactor = next ? 0 : 1;
|
|
1178
|
+
}
|
|
1179
|
+
for (const win of this._windows.values()) {
|
|
1180
|
+
// Diagnostics only: the factors that were in force when the panel went flat. The restore
|
|
1181
|
+
// itself just re-pushes `win.viewRig`, which was never mutated.
|
|
1182
|
+
if (next) {
|
|
1183
|
+
const rig = win.viewRig || defaultDisplayRig(win);
|
|
1184
|
+
win.stereoSaved = { ipdFactor: rig.ipdFactor, parallaxFactor: rig.parallaxFactor };
|
|
1185
|
+
} else {
|
|
1186
|
+
win.stereoSaved = null;
|
|
1187
|
+
}
|
|
1188
|
+
this._pushViewRig(win);
|
|
1189
|
+
}
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
/**
|
|
1193
|
+
* Push the effective rig at the live layer. Returns whether it reached one — the boolean
|
|
1194
|
+
* `handle.setViewRig` documents ("false = stored, and it will build the next layer").
|
|
1195
|
+
*/
|
|
1196
|
+
_pushViewRig(win) {
|
|
1197
|
+
if (!hasViewRig()) {
|
|
1198
|
+
noteNoViewRig();
|
|
1199
|
+
return false;
|
|
1200
|
+
}
|
|
1201
|
+
if (!win.layer) return false;
|
|
1202
|
+
const rig = this._effectiveViewRig(win);
|
|
1203
|
+
try {
|
|
1204
|
+
win.layer.setViewRig(rig);
|
|
1205
|
+
return true;
|
|
1206
|
+
} catch {
|
|
1207
|
+
// A closed layer or a descriptor the browser refused. Neither is worth throwing over in a
|
|
1208
|
+
// per-frame call — the window keeps weaving on the rig it already has.
|
|
1209
|
+
return false;
|
|
1210
|
+
}
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
/**
|
|
1214
|
+
* Forward one display-mode call to this window's live layer, as a promise.
|
|
1215
|
+
*
|
|
1216
|
+
* Two ways it cannot proceed, and they are DIFFERENT failures worth different messages: the
|
|
1217
|
+
* browser has no such API at all (nothing will ever make this work — check
|
|
1218
|
+
* `inline3dDisplayModesSupported()` first), or the API is there but this window has no live
|
|
1219
|
+
* layer yet (lazy mode, scrolled away, or called before the first activation — try again once
|
|
1220
|
+
* the tile is on screen). Neither throws synchronously: these sit behind click handlers and a
|
|
1221
|
+
* rejected promise is what a page can actually handle.
|
|
1222
|
+
*/
|
|
1223
|
+
_layerCall(win, method, label, args = []) {
|
|
1224
|
+
if (!hasDisplayModes()) {
|
|
1225
|
+
return Promise.reject(
|
|
1226
|
+
new Error(
|
|
1227
|
+
`[inline3d] ${label} needs a DisplayXR Browser with the display-mode API ` +
|
|
1228
|
+
'(XRDisplayLayer.getDisplayInfo/getRenderingModes/requestRenderingMode). ' +
|
|
1229
|
+
'Gate on inline3dDisplayModesSupported().'
|
|
1230
|
+
)
|
|
1231
|
+
);
|
|
1232
|
+
}
|
|
1233
|
+
// `win` is null when the WALL-level call ran with no window live at all — the same failure
|
|
1234
|
+
// as a window whose layer is closed, and the same message covers both.
|
|
1235
|
+
if (!win || !win.layer) {
|
|
1236
|
+
return Promise.reject(
|
|
1237
|
+
new Error(
|
|
1238
|
+
`[inline3d] ${label} needs a live weave layer, and this window has none right now ` +
|
|
1239
|
+
'(lazy mode closes the layer while the tile is off screen). Call it once the tile ' +
|
|
1240
|
+
'is visible, or create the manager with { lazy: false }.'
|
|
1241
|
+
)
|
|
1242
|
+
);
|
|
1243
|
+
}
|
|
1244
|
+
// Wrapped so a SYNCHRONOUS throw from the browser (requestRenderingMode raises TypeError
|
|
1245
|
+
// that way for a non-2-view mode) arrives as a rejection like every other failure.
|
|
1246
|
+
try {
|
|
1247
|
+
return Promise.resolve(win.layer[method](...args));
|
|
1248
|
+
} catch (e) {
|
|
1249
|
+
return Promise.reject(e);
|
|
1250
|
+
}
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
/** The first window currently holding a live layer, or null. Every display call needs one. */
|
|
1254
|
+
_liveWindow(preferred) {
|
|
1255
|
+
if (preferred && preferred.layer) return preferred;
|
|
1256
|
+
for (const win of this._windows.values()) if (win.layer) return win;
|
|
1257
|
+
return preferred || null;
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
/**
|
|
1261
|
+
* `getRenderingModes()` with the manager's cache kept honest — every read updates the list the
|
|
1262
|
+
* event path looks `viewCount` up in, and the first one PRIMES the display state (a page can
|
|
1263
|
+
* open with the panel already flat, and the rig has to be collapsed for that too).
|
|
1264
|
+
*/
|
|
1265
|
+
async _getRenderingModes(win) {
|
|
1266
|
+
const list = await this._layerCall(
|
|
1267
|
+
this._liveWindow(win),
|
|
1268
|
+
'getRenderingModes',
|
|
1269
|
+
'getRenderingModes()'
|
|
1270
|
+
);
|
|
1271
|
+
const modes = Array.isArray(list) ? list : [];
|
|
1272
|
+
this._modes = modes;
|
|
1273
|
+
const active = modes.find((m) => m.isActive);
|
|
1274
|
+
if (active) {
|
|
1275
|
+
this._activeModeIndex = active.modeIndex;
|
|
1276
|
+
this._activeViewCount = active.viewCount;
|
|
1277
|
+
if (active.viewCount === 1 || active.viewCount === 2) {
|
|
1278
|
+
this._setStereoCollapsed(active.viewCount === 1);
|
|
1279
|
+
}
|
|
1280
|
+
}
|
|
1281
|
+
this._primedDisplayState = true;
|
|
1282
|
+
return list;
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
/** `requestRenderingMode()` — a pass-through; see the handle's doc comment for the contract. */
|
|
1286
|
+
_requestRenderingMode(modeIndex, win) {
|
|
1287
|
+
return this._layerCall(
|
|
1288
|
+
this._liveWindow(win),
|
|
1289
|
+
'requestRenderingMode',
|
|
1290
|
+
'requestRenderingMode()',
|
|
1291
|
+
[modeIndex]
|
|
1292
|
+
);
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
// ── the eased 2D<->3D transition ──────────────────────────────────────────────────────
|
|
1296
|
+
//
|
|
1297
|
+
// Every PAGE-INITIATED mode request goes through here; a mode change reported from elsewhere
|
|
1298
|
+
// does not (see _setStereoCollapsed). The asymmetry below is the whole helper, and it is the
|
|
1299
|
+
// native `dxr::ModeSwitch` contract, unchanged:
|
|
1300
|
+
//
|
|
1301
|
+
// -> 2D : ramp the disparity out FIRST, and fire the request only when it lands, so the panel
|
|
1302
|
+
// flips on already-flat content instead of snapping a stereo image flat.
|
|
1303
|
+
// -> 3D : fire the request FIRST and ease the disparity in afterwards — and in the browser,
|
|
1304
|
+
// only once the panel REPORTS 3D, because until then the disparity would be going up
|
|
1305
|
+
// on a flat panel, which is the double-image the whole mode API exists to prevent.
|
|
1306
|
+
//
|
|
1307
|
+
// Everything else is fall-through: the sequencer disabled, a browser with no `setViewRig` (there
|
|
1308
|
+
// is nothing to ramp), an unknown target or current view count, a `viewCount > 2` mode the
|
|
1309
|
+
// browser will refuse anyway, and a same-dimensionality change (2D->2D, 3D->3D) which needs no
|
|
1310
|
+
// flatten at all.
|
|
1311
|
+
|
|
1312
|
+
/** True while a page-initiated transition is in flight in any of its phases. */
|
|
1313
|
+
_msTransitionActive() {
|
|
1314
|
+
return this._modeSwitch.active() || this._msArmedUp || this._msFire !== null;
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1317
|
+
/**
|
|
1318
|
+
* `requestRenderingMode()` with the transition applied. Resolves when the request has actually
|
|
1319
|
+
* been FORWARDED to the browser (so, for a ->2D switch, after the ramp) and rejects exactly as
|
|
1320
|
+
* the pass-through does — plus one new failure: an `Error` named `superseded` when a second
|
|
1321
|
+
* request replaced this one before it ever fired.
|
|
1322
|
+
*/
|
|
1323
|
+
async _requestRenderingModeEased(modeIndex, win) {
|
|
1324
|
+
if (!this._msEnabled || !hasViewRig()) return this._requestRenderingMode(modeIndex, win);
|
|
1325
|
+
// The mode table is what says whether this index is 2D or 3D. It is normally already cached
|
|
1326
|
+
// (the first activation primes it), and a read that fails just means the sequencer has no
|
|
1327
|
+
// opinion — the request still goes out.
|
|
1328
|
+
let modes = this._modes;
|
|
1329
|
+
if (!Array.isArray(modes) || modes.length === 0) {
|
|
1330
|
+
try {
|
|
1331
|
+
modes = await this._getRenderingModes(win);
|
|
1332
|
+
} catch {
|
|
1333
|
+
modes = null;
|
|
1334
|
+
}
|
|
1335
|
+
}
|
|
1336
|
+
const target = (Array.isArray(modes) ? modes : []).find((m) => m && m.modeIndex === modeIndex);
|
|
1337
|
+
const targetViews = target && Number.isFinite(target.viewCount) ? target.viewCount : 0;
|
|
1338
|
+
const currentViews = this._activeViewCount;
|
|
1339
|
+
if (targetViews < 1 || targetViews > 2 || currentViews < 1) {
|
|
1340
|
+
return this._requestRenderingMode(modeIndex, win);
|
|
1341
|
+
}
|
|
1342
|
+
if (targetViews === 1) {
|
|
1343
|
+
if (currentViews === 1) return this._requestRenderingMode(modeIndex, win); // 2D -> 2D
|
|
1344
|
+
return this._rampDownThenRequest(modeIndex, win);
|
|
1345
|
+
}
|
|
1346
|
+
return this._requestThenRampUp(modeIndex, win);
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
/**
|
|
1350
|
+
* 3D -> 2D. Ramp the disparity to 0, THEN forward the request (see _advanceModeSwitch, which is
|
|
1351
|
+
* what actually fires it). The returned promise is the page's, and it settles on the forwarded
|
|
1352
|
+
* request — so `await wall.setStereoEnabled(false)` still means "the browser has it".
|
|
1353
|
+
*
|
|
1354
|
+
* A second ->2D request for the SAME mode mid-ramp is idempotent: the page gets the promise
|
|
1355
|
+
* already in flight rather than a superseded rejection, because mashing one button twice is not
|
|
1356
|
+
* an error. A different target retargets from the CURRENT disparity, seamlessly.
|
|
1357
|
+
*/
|
|
1358
|
+
_rampDownThenRequest(modeIndex, win) {
|
|
1359
|
+
if (this._msFire && this._msFire.modeIndex === modeIndex) return this._msFire.promise;
|
|
1360
|
+
this._settlePendingDown('superseded', `a request for mode ${modeIndex} replaced it`);
|
|
1361
|
+
const pending = { modeIndex, win, resolve: null, reject: null, promise: null };
|
|
1362
|
+
pending.promise = new Promise((resolve, reject) => {
|
|
1363
|
+
pending.resolve = resolve;
|
|
1364
|
+
pending.reject = reject;
|
|
1365
|
+
});
|
|
1366
|
+
this._msFire = pending;
|
|
1367
|
+
this._modeSwitch.request({
|
|
1368
|
+
targetMode: modeIndex,
|
|
1369
|
+
targetViewCount: 1,
|
|
1370
|
+
currentMode: this._activeModeIndex,
|
|
1371
|
+
currentViewCount: this._activeViewCount,
|
|
1372
|
+
// The value ON SCREEN right now: the ramp's own output mid-flight, and the page's steady
|
|
1373
|
+
// rig (factor 1) when idle. Passing the sequencer's internal 0 while idle is the classic
|
|
1374
|
+
// first-press snap — there would be nothing to ramp down from.
|
|
1375
|
+
current: this._stereoFactor,
|
|
1376
|
+
steady: 1,
|
|
1377
|
+
});
|
|
1378
|
+
this._msLastMs = nowMs();
|
|
1379
|
+
this._armModeSwitchTick();
|
|
1380
|
+
return pending.promise;
|
|
1381
|
+
}
|
|
1382
|
+
|
|
1383
|
+
/**
|
|
1384
|
+
* -> 3D. Forward the request NOW (the browser needs the panel moving before the disparity can
|
|
1385
|
+
* mean anything), then ease the disparity in — starting only when the panel REPORTS 3D, which
|
|
1386
|
+
* is `_setStereoCollapsed(false)` releasing the latch.
|
|
1387
|
+
*
|
|
1388
|
+
* The one case that does not wait: a REVERSAL of a ramp-down that never fired. The panel never
|
|
1389
|
+
* left 3D, so there is no report coming; the disparity just walks back up from wherever the
|
|
1390
|
+
* ramp got to, and the stale 2D request is dropped rather than fired.
|
|
1391
|
+
*/
|
|
1392
|
+
_requestThenRampUp(modeIndex, win) {
|
|
1393
|
+
const reversal = this._msFire !== null;
|
|
1394
|
+
const noopReversal = reversal && modeIndex === this._activeModeIndex;
|
|
1395
|
+
this._settlePendingDown('superseded', `a request for mode ${modeIndex} reversed it`);
|
|
1396
|
+
// A reversal back to the mode that is STILL active asks the browser for nothing: the runtime
|
|
1397
|
+
// never changed mode, so the only thing owed is the disparity.
|
|
1398
|
+
const forwarded = noopReversal
|
|
1399
|
+
? Promise.resolve(undefined)
|
|
1400
|
+
: this._requestRenderingMode(modeIndex, win);
|
|
1401
|
+
if (this._stereoFactor < 1) {
|
|
1402
|
+
if (this._stereoCollapsed) {
|
|
1403
|
+
// The panel is really flat: hold at 0 and wait for it to say otherwise.
|
|
1404
|
+
this._msArmedUp = true;
|
|
1405
|
+
this._modeSwitch.cancel();
|
|
1406
|
+
this._stereoFactor = 0;
|
|
1407
|
+
} else {
|
|
1408
|
+
this._startUpRamp();
|
|
1409
|
+
}
|
|
1410
|
+
}
|
|
1411
|
+
return forwarded.catch((err) => {
|
|
1412
|
+
// Refused. Nothing about the panel moved, so neither may the disparity: drop the armed
|
|
1413
|
+
// up-ramp and settle back on whatever the display last REPORTED.
|
|
1414
|
+
if (this._msArmedUp) {
|
|
1415
|
+
this._msArmedUp = false;
|
|
1416
|
+
if (!this._modeSwitch.active()) this._stereoFactor = this._stereoCollapsed ? 0 : 1;
|
|
1417
|
+
}
|
|
1418
|
+
throw err;
|
|
1419
|
+
});
|
|
1420
|
+
}
|
|
1421
|
+
|
|
1422
|
+
/**
|
|
1423
|
+
* Start (or restart) the up-ramp from the current disparity to the page's steady rig. Used both
|
|
1424
|
+
* when the panel reports 3D after a `->3D` request and when a ->2D request was REFUSED — a
|
|
1425
|
+
* refusal must leave the page in 3D, not flat.
|
|
1426
|
+
*
|
|
1427
|
+
* No request is ever fired from here: whatever there was to send went out before the ramp
|
|
1428
|
+
* started, which is why `_msFire` is empty by construction.
|
|
1429
|
+
*/
|
|
1430
|
+
_startUpRamp() {
|
|
1431
|
+
this._settlePendingDown('superseded', 'the display returned to 3D');
|
|
1432
|
+
this._modeSwitch.request({
|
|
1433
|
+
targetMode: this._activeModeIndex,
|
|
1434
|
+
targetViewCount: 2,
|
|
1435
|
+
currentMode: this._activeModeIndex, // equal ⇒ the sequencer fires nothing
|
|
1436
|
+
currentViewCount: 2,
|
|
1437
|
+
current: this._stereoFactor,
|
|
1438
|
+
steady: 1,
|
|
1439
|
+
});
|
|
1440
|
+
this._msLastMs = nowMs();
|
|
1441
|
+
this._armModeSwitchTick();
|
|
1442
|
+
}
|
|
1443
|
+
|
|
1444
|
+
/** Settle a held ->2D request that will now never fire. Never throws into the caller. */
|
|
1445
|
+
_settlePendingDown(name, why) {
|
|
1446
|
+
const pending = this._msFire;
|
|
1447
|
+
if (!pending) return;
|
|
1448
|
+
this._msFire = null;
|
|
1449
|
+
const err = new Error(
|
|
1450
|
+
`[inline3d] the request for rendering mode ${pending.modeIndex} was never forwarded: ${why}. ` +
|
|
1451
|
+
'A ->2D switch is held until the disparity has ramped out, so a request that is reversed ' +
|
|
1452
|
+
'or replaced in that window is dropped rather than fired late.'
|
|
1453
|
+
);
|
|
1454
|
+
err.name = name;
|
|
1455
|
+
pending.reject(err);
|
|
1456
|
+
}
|
|
1457
|
+
|
|
1458
|
+
/**
|
|
1459
|
+
* Advance the transition by WALL-CLOCK dt and act on what it says. Called from the session's
|
|
1460
|
+
* frame loop and from the fallback tick; both are safe because the ramp is time-based, so a
|
|
1461
|
+
* double advance in one frame moves it by dt = 0.
|
|
1462
|
+
*
|
|
1463
|
+
* Rigs are pushed only when the factor actually MOVED — an idle manager must not re-push every
|
|
1464
|
+
* frame, and a landed ramp pushes its last value once.
|
|
1465
|
+
*/
|
|
1466
|
+
_advanceModeSwitch() {
|
|
1467
|
+
if (!this._modeSwitch.active()) {
|
|
1468
|
+
this._msLastMs = null;
|
|
1469
|
+
this._disarmModeSwitchTick();
|
|
1470
|
+
return;
|
|
1471
|
+
}
|
|
1472
|
+
const now = nowMs();
|
|
1473
|
+
const dt = typeof this._msLastMs === 'number' ? Math.max(0, (now - this._msLastMs) / 1000) : 0;
|
|
1474
|
+
this._msLastMs = now;
|
|
1475
|
+
const out = this._modeSwitch.update(dt);
|
|
1476
|
+
if (out.factor !== this._stereoFactor) {
|
|
1477
|
+
this._stereoFactor = out.factor;
|
|
1478
|
+
for (const win of this._windows.values()) this._pushViewRig(win);
|
|
1479
|
+
}
|
|
1480
|
+
if (out.fire && this._msFire) {
|
|
1481
|
+
const pending = this._msFire;
|
|
1482
|
+
this._msFire = null;
|
|
1483
|
+
this._requestRenderingMode(pending.modeIndex, pending.win).then(
|
|
1484
|
+
(v) => pending.resolve(v),
|
|
1485
|
+
(err) => {
|
|
1486
|
+
// The panel refused to go flat, so the page must not be left flat either — ease the
|
|
1487
|
+
// disparity back to steady before handing the rejection on.
|
|
1488
|
+
this._startUpRamp();
|
|
1489
|
+
pending.reject(err);
|
|
1490
|
+
}
|
|
1491
|
+
);
|
|
1492
|
+
}
|
|
1493
|
+
if (!this._modeSwitch.active()) {
|
|
1494
|
+
this._msLastMs = null;
|
|
1495
|
+
this._disarmModeSwitchTick();
|
|
1496
|
+
}
|
|
1497
|
+
}
|
|
1498
|
+
|
|
1499
|
+
/**
|
|
1500
|
+
* A timer that advances the ramp when SESSION FRAMES are not arriving. The frame loop is the
|
|
1501
|
+
* normal driver, but a held ->2D request must not sit forever because every tile scrolled away,
|
|
1502
|
+
* the tab went background, or the page simply has no live layer — the page awaited a promise
|
|
1503
|
+
* and the browser is owed a request.
|
|
1504
|
+
*/
|
|
1505
|
+
_armModeSwitchTick() {
|
|
1506
|
+
if (this._msTick !== null || typeof setTimeout !== 'function') return;
|
|
1507
|
+
this._msTick = setTimeout(() => {
|
|
1508
|
+
this._msTick = null;
|
|
1509
|
+
if (!this._running) return;
|
|
1510
|
+
this._advanceModeSwitch();
|
|
1511
|
+
if (this._modeSwitch.active()) this._armModeSwitchTick();
|
|
1512
|
+
}, MODE_SWITCH_TICK_MS);
|
|
1513
|
+
}
|
|
1514
|
+
|
|
1515
|
+
_disarmModeSwitchTick() {
|
|
1516
|
+
if (this._msTick === null) return;
|
|
1517
|
+
if (typeof clearTimeout === 'function') clearTimeout(this._msTick);
|
|
1518
|
+
this._msTick = null;
|
|
1519
|
+
}
|
|
1520
|
+
|
|
1521
|
+
/**
|
|
1522
|
+
* The sugar behind `setStereoEnabled` — pick a mode by view count and request it. Nothing
|
|
1523
|
+
* else: the rig follows the resulting `renderingmodechange` (eased, when a transition is
|
|
1524
|
+
* configured), not this call.
|
|
1525
|
+
*/
|
|
1526
|
+
async _setStereoEnabled(enabled, win) {
|
|
1527
|
+
const want = enabled ? 2 : 1;
|
|
1528
|
+
const modes = await this._getRenderingModes(win);
|
|
1529
|
+
const list = Array.isArray(modes) ? modes : [];
|
|
1530
|
+
const mode = list.find((m) => m.viewCount === want && m.isRequestable);
|
|
1531
|
+
if (!mode) {
|
|
1532
|
+
throw new Error(
|
|
1533
|
+
`[inline3d] setStereoEnabled(${!!enabled}) found no requestable ${want}-view mode on ` +
|
|
1534
|
+
`this display (${list.length} mode(s) listed). It is sugar over requestRenderingMode() ` +
|
|
1535
|
+
'and cannot invent one — read getRenderingModes() and drive the list yourself.'
|
|
1536
|
+
);
|
|
1537
|
+
}
|
|
1538
|
+
// Already there ⇒ the request would be a no-op... UNLESS a transition is in flight, in which
|
|
1539
|
+
// case this is the user reversing the toggle and the disparity still has to walk back. Taking
|
|
1540
|
+
// the early-out there would leave a page that pressed 2D then 3D stuck part-way flat.
|
|
1541
|
+
if (mode.isActive && !this._msTransitionActive()) return !!enabled;
|
|
1542
|
+
await this._requestRenderingModeEased(mode.modeIndex, win);
|
|
1543
|
+
return !!enabled;
|
|
1544
|
+
}
|
|
1545
|
+
|
|
1546
|
+
// ── display events ────────────────────────────────────────────────────────────────────
|
|
1547
|
+
|
|
1548
|
+
/**
|
|
1549
|
+
* Subscribe the manager ONCE to each session event and fan out from there. One subscription
|
|
1550
|
+
* per document rather than one per caller, because the SDK has to act on these itself (the
|
|
1551
|
+
* automatic rig collapse) whether or not the page is listening.
|
|
1552
|
+
*/
|
|
1553
|
+
_bindDisplayEvents() {
|
|
1554
|
+
const session = this.session;
|
|
1555
|
+
if (!session || typeof session.addEventListener !== 'function') return;
|
|
1556
|
+
if (!hasDisplayModes()) return;
|
|
1557
|
+
session.addEventListener('renderingmodechange', (e) => {
|
|
1558
|
+
this._onRenderingModeChange(e).catch(() => {});
|
|
1559
|
+
});
|
|
1560
|
+
session.addEventListener('hardwaredisplaystatechange', (e) => {
|
|
1561
|
+
this._onHardwareDisplayStateChange(e).catch(() => {});
|
|
1562
|
+
});
|
|
1563
|
+
}
|
|
1564
|
+
|
|
1565
|
+
/**
|
|
1566
|
+
* The hardware display state moved. The browser's event is payload-free (an XRSessionEvent),
|
|
1567
|
+
* so the state is READ, not parsed: under the mode-only contract the page-facing active mode's
|
|
1568
|
+
* `hardwareDisplay3D` IS the hardware state by construction (a 1-view mode is presented for
|
|
1569
|
+
* exactly as long as the hardware is in the state it declares; a 2-view mode's default state
|
|
1570
|
+
* is what the runtime restored). A payload, if a future browser adds one, wins over the read.
|
|
1571
|
+
*/
|
|
1572
|
+
_onHardwareDisplayStateChange(e) {
|
|
1573
|
+
const stated = eventHardwareState(e);
|
|
1574
|
+
const detail = eventDetail(e);
|
|
1575
|
+
const deliver = (state) => {
|
|
1576
|
+
if (state) this._hardwareDisplayState = state;
|
|
1577
|
+
this._emitDisplay({
|
|
1578
|
+
type: 'hardwaredisplaystatechange',
|
|
1579
|
+
state: state || this._hardwareDisplayState,
|
|
1580
|
+
detail,
|
|
1581
|
+
});
|
|
1582
|
+
};
|
|
1583
|
+
// A stated payload is delivered synchronously (a page can act in the same task); the
|
|
1584
|
+
// browser's payload-free event is delivered once the table has been read below.
|
|
1585
|
+
if (stated) deliver(stated);
|
|
1586
|
+
// Re-read the table either way and adopt what it says the active mode is. This is what
|
|
1587
|
+
// carries the 1-view -> 2-view RETURN: the browser presents a 1-view mode on top of the
|
|
1588
|
+
// runtime's unchanged 2-view mode, so when the page asks for the 2-view mode back the
|
|
1589
|
+
// runtime has no mode change to report and fires no renderingmodechange - only the
|
|
1590
|
+
// hardware moves. Read here, that return still restores the rig, and in the right order
|
|
1591
|
+
// (hardware back in 3D first, then the parallax comes back).
|
|
1592
|
+
// _getRenderingModes adopts the active mode (and moves the rig) itself; remember what was
|
|
1593
|
+
// active BEFORE the read so a change can still be told to the page afterwards.
|
|
1594
|
+
const prevIndex = this._activeModeIndex;
|
|
1595
|
+
const prevViews = this._activeViewCount;
|
|
1596
|
+
return this._getRenderingModes(null)
|
|
1597
|
+
.then((list) => {
|
|
1598
|
+
const active = (Array.isArray(list) ? list : []).find((m) => m.isActive) || null;
|
|
1599
|
+
if (active && (active.modeIndex !== prevIndex || active.viewCount !== prevViews)) {
|
|
1600
|
+
this._emitDisplay({
|
|
1601
|
+
type: 'renderingmodechange',
|
|
1602
|
+
modeIndex: this._activeModeIndex,
|
|
1603
|
+
viewCount: this._activeViewCount || null,
|
|
1604
|
+
mode: active,
|
|
1605
|
+
detail: null,
|
|
1606
|
+
});
|
|
1607
|
+
}
|
|
1608
|
+
if (!stated) {
|
|
1609
|
+
const read = active && typeof active.hardwareDisplay3D === 'boolean' ? (active.hardwareDisplay3D ? '3d' : '2d') : null;
|
|
1610
|
+
deliver(read);
|
|
1611
|
+
}
|
|
1612
|
+
})
|
|
1613
|
+
.catch(() => {
|
|
1614
|
+
// no live layer / no API: the stated payload already went out; a payload-free event is
|
|
1615
|
+
// still delivered with whatever was last known.
|
|
1616
|
+
if (!stated) deliver(null);
|
|
1617
|
+
});
|
|
1618
|
+
}
|
|
1619
|
+
|
|
1620
|
+
/**
|
|
1621
|
+
* A rendering mode went active. Two jobs, in this order: learn its VIEW COUNT (which is what
|
|
1622
|
+
* the rig collapse turns on, and which only the mode list carries), then tell the page.
|
|
1623
|
+
*
|
|
1624
|
+
* The list is re-read rather than trusted from cache — it is the runtime's, and a mode can
|
|
1625
|
+
* change under us — but the read is best-effort: with no live layer the index the event
|
|
1626
|
+
* carried is all there is, and the event is still worth delivering.
|
|
1627
|
+
*/
|
|
1628
|
+
async _onRenderingModeChange(e) {
|
|
1629
|
+
const detail = eventDetail(e);
|
|
1630
|
+
const evIndex = eventModeIndex(e);
|
|
1631
|
+
let mode = null;
|
|
1632
|
+
try {
|
|
1633
|
+
const list = await this._getRenderingModes(null);
|
|
1634
|
+
const arr = Array.isArray(list) ? list : [];
|
|
1635
|
+
mode = (evIndex >= 0 ? arr.find((m) => m.modeIndex === evIndex) : null) || arr.find((m) => m.isActive) || null;
|
|
1636
|
+
} catch {
|
|
1637
|
+
/* no live layer / no API — fall through to what the event itself said */
|
|
1638
|
+
}
|
|
1639
|
+
if (mode) {
|
|
1640
|
+
this._activeModeIndex = mode.modeIndex;
|
|
1641
|
+
this._activeViewCount = mode.viewCount;
|
|
1642
|
+
} else if (evIndex >= 0) {
|
|
1643
|
+
this._activeModeIndex = evIndex;
|
|
1644
|
+
}
|
|
1645
|
+
// Only ever driven by a REPORTED view count. An unknown one (the read failed) leaves the rig
|
|
1646
|
+
// exactly as it is — half-collapsing on a guess is worse than being one event late.
|
|
1647
|
+
if (this._activeViewCount === 1 || this._activeViewCount === 2) {
|
|
1648
|
+
this._setStereoCollapsed(this._activeViewCount === 1);
|
|
1649
|
+
}
|
|
1650
|
+
this._emitDisplay({
|
|
1651
|
+
type: 'renderingmodechange',
|
|
1652
|
+
modeIndex: this._activeModeIndex,
|
|
1653
|
+
viewCount: this._activeViewCount || null,
|
|
1654
|
+
mode,
|
|
1655
|
+
detail,
|
|
1656
|
+
});
|
|
1657
|
+
}
|
|
1658
|
+
|
|
1659
|
+
/** Deliver one normalised display event to every listener. A throwing page handler is contained. */
|
|
1660
|
+
_emitDisplay(ev) {
|
|
1661
|
+
const set = this._displayListeners.get(ev.type);
|
|
1662
|
+
if (!set) return;
|
|
1663
|
+
for (const cb of [...set]) {
|
|
1664
|
+
try {
|
|
1665
|
+
cb(ev);
|
|
1666
|
+
} catch (err) {
|
|
1667
|
+
console.error(`[inline3d] ${ev.type} listener threw`, err);
|
|
1668
|
+
}
|
|
1669
|
+
}
|
|
1670
|
+
}
|
|
1671
|
+
|
|
1672
|
+
/**
|
|
1673
|
+
* Listen for `renderingmodechange` / `hardwaredisplaystatechange` on this manager. See the
|
|
1674
|
+
* handle's `on()` doc for the payload shapes. Returns an unsubscribe function.
|
|
1675
|
+
*/
|
|
1676
|
+
on(type, cb) {
|
|
1677
|
+
if (typeof cb !== 'function') throw new TypeError('[inline3d] on() takes (type, function).');
|
|
1678
|
+
if (!DISPLAY_EVENTS.includes(type)) {
|
|
1679
|
+
throw new TypeError(
|
|
1680
|
+
`[inline3d] on() knows ${DISPLAY_EVENTS.join(' / ')}, got ${JSON.stringify(type)}.`
|
|
1681
|
+
);
|
|
1682
|
+
}
|
|
1683
|
+
let set = this._displayListeners.get(type);
|
|
1684
|
+
if (!set) this._displayListeners.set(type, (set = new Set()));
|
|
1685
|
+
set.add(cb);
|
|
1686
|
+
return () => this.off(type, cb);
|
|
1687
|
+
}
|
|
1688
|
+
|
|
1689
|
+
/** Drop a listener registered with {@link on}. */
|
|
1690
|
+
off(type, cb) {
|
|
1691
|
+
const set = this._displayListeners.get(type);
|
|
1692
|
+
if (set) set.delete(cb);
|
|
1693
|
+
}
|
|
1694
|
+
|
|
1695
|
+
/** Both display events through one callback — the older shape. Returns an unsubscribe. */
|
|
1696
|
+
_onDisplayModeChange(cb) {
|
|
1697
|
+
if (typeof cb !== 'function') {
|
|
1698
|
+
throw new TypeError('[inline3d] onDisplayModeChange() takes a function.');
|
|
1699
|
+
}
|
|
1700
|
+
const offs = DISPLAY_EVENTS.map((type) => this.on(type, cb));
|
|
1701
|
+
return () => {
|
|
1702
|
+
for (const off of offs) off();
|
|
1703
|
+
};
|
|
1704
|
+
}
|
|
1705
|
+
|
|
1706
|
+
// ── undock ────────────────────────────────────────────────────────────────────────────
|
|
1707
|
+
|
|
1708
|
+
/**
|
|
1709
|
+
* Re-read `layer.getUndockCapabilities()` into `wall.undock`.
|
|
1710
|
+
*
|
|
1711
|
+
* `undock` starts as `{model:false, splat:false}` on a browser that HAS the API, because the
|
|
1712
|
+
* capabilities can only be read off a live layer and there is none at create time — false is
|
|
1713
|
+
* the honest pre-read value ("not known to work"), and `null` is reserved for the thing a page
|
|
1714
|
+
* actually branches on: no `XRDisplayLayer.undock` at all. Called automatically on the first
|
|
1715
|
+
* layer activation; call it again whenever a page wants a fresh answer.
|
|
1716
|
+
*
|
|
1717
|
+
* @returns {Promise<{model:boolean, splat:boolean}|null>}
|
|
1718
|
+
*/
|
|
1719
|
+
async refreshUndock() {
|
|
1720
|
+
if (!hasUndock()) return (this.undock = null);
|
|
1721
|
+
const win = this._liveWindow(null);
|
|
1722
|
+
if (!win || !win.layer || typeof win.layer.getUndockCapabilities !== 'function') {
|
|
1723
|
+
return this.undock;
|
|
1724
|
+
}
|
|
1725
|
+
try {
|
|
1726
|
+
const caps = await win.layer.getUndockCapabilities();
|
|
1727
|
+
this.undock = { model: !!(caps && caps.model), splat: !!(caps && caps.splat) };
|
|
1728
|
+
} catch {
|
|
1729
|
+
/* a refusal is not a capability change — keep the last answer */
|
|
1730
|
+
}
|
|
1731
|
+
return this.undock;
|
|
1732
|
+
}
|
|
1733
|
+
|
|
653
1734
|
_register(canvas, kind, opts) {
|
|
654
1735
|
if (this._windows.has(canvas)) this._remove(canvas);
|
|
655
1736
|
// Own compositing layer: makes the canvas a distinct quad the weave can track. Harmless
|
|
@@ -675,6 +1756,13 @@ class Inline3D {
|
|
|
675
1756
|
// and rebuilds layers behind the page's back: a tile that scrolls away and back would
|
|
676
1757
|
// otherwise silently revert to the default display rig mid-scene.
|
|
677
1758
|
viewRig: opts.viewRig || null,
|
|
1759
|
+
// The automatic 1-view collapse (the latch itself is manager-wide — the mode is the
|
|
1760
|
+
// display's, not this window's). `stereoSaved` records the factors in force when the panel
|
|
1761
|
+
// went flat, for diagnostics; the restore just re-pushes `viewRig`, which the flattening
|
|
1762
|
+
// never touched. `stereoSynthRig` records that this window's LAYER has been handed an
|
|
1763
|
+
// explicit rig at least once, so coming back has to send the un-flat one explicitly.
|
|
1764
|
+
stereoSaved: null,
|
|
1765
|
+
stereoSynthRig: false,
|
|
678
1766
|
observeEl: opts.observe || canvas,
|
|
679
1767
|
ctx: kind === 'scene' ? null : canvas.getContext('2d'),
|
|
680
1768
|
repaint: () => this._paint(win, null),
|
|
@@ -742,17 +1830,42 @@ class Inline3D {
|
|
|
742
1830
|
// object, find no member it knows, and fall back to its own default height — so a page
|
|
743
1831
|
// that passed both (a camera rig plus the height an older browser should use) would get
|
|
744
1832
|
// neither. Gating here is what makes that fallback pair actually work.
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
1833
|
+
//
|
|
1834
|
+
// _effectiveViewRig, not win.viewRig: while setStereoEnabled(false) is latched the rig is
|
|
1835
|
+
// the FLAT one, and a window with no rig of its own still gets one (the exact descriptor
|
|
1836
|
+
// equivalent of its virtualDisplayHeight, with the factors zeroed) — otherwise a tile that
|
|
1837
|
+
// scrolled away in 2D would rebuild itself in 3D behind the page's back.
|
|
1838
|
+
const rig = hasViewRig() ? this._effectiveViewRig(win) : null;
|
|
1839
|
+
const init = rig
|
|
1840
|
+
? { viewRig: rig }
|
|
1841
|
+
: win.virtualDisplayHeight > 0
|
|
1842
|
+
? { virtualDisplayHeight: win.virtualDisplayHeight }
|
|
1843
|
+
: {};
|
|
751
1844
|
win.layer = new XRDisplayLayer(this.session, win.canvas, init);
|
|
752
1845
|
} catch {
|
|
753
1846
|
win.layer = null;
|
|
754
1847
|
return;
|
|
755
1848
|
}
|
|
1849
|
+
// Nothing about the hardware state is re-asserted here, and that is the point: the panel's
|
|
1850
|
+
// mode is the DISPLAY's, it survives a tile scrolling away, and this SDK never requests it
|
|
1851
|
+
// behind the page's back. The rig went into the init above already flattened if a 1-view
|
|
1852
|
+
// mode is active (_effectiveViewRig), which is the only half a new layer has to be told.
|
|
1853
|
+
//
|
|
1854
|
+
// FIRST LAYER, FIRST READ. The display's capabilities can only be read off a live layer, so
|
|
1855
|
+
// this is the earliest point the SDK can learn (a) which mode is active — a page can open
|
|
1856
|
+
// with the panel already flat, and the rig has to be collapsed for that too — and (b) what
|
|
1857
|
+
// this build can undock. Both best-effort and unawaited: they run inside the scroll-driven
|
|
1858
|
+
// activation path and must never break it.
|
|
1859
|
+
if (!this._primedDisplayState && hasDisplayModes()) {
|
|
1860
|
+
this._primedDisplayState = true; // one attempt per manager, not one per activation
|
|
1861
|
+
this._getRenderingModes(win).catch(() => {
|
|
1862
|
+
this._primedDisplayState = false; // the read failed; let the next activation try again
|
|
1863
|
+
});
|
|
1864
|
+
}
|
|
1865
|
+
if (hasUndock() && this.undock && !this._undockRead) {
|
|
1866
|
+
this._undockRead = true;
|
|
1867
|
+
this.refreshUndock().catch(() => {});
|
|
1868
|
+
}
|
|
756
1869
|
// First real layer: if the occlusion capability is per-instance, this is the earliest point
|
|
757
1870
|
// it can be read (see sampleDrawOrderOcclusion) — and if it says the browser occludes by
|
|
758
1871
|
// draw order, retire whatever legacy machinery already started before we could know.
|
|
@@ -1145,6 +2258,10 @@ class Inline3D {
|
|
|
1145
2258
|
_frame(t, f) {
|
|
1146
2259
|
if (!this._running) return;
|
|
1147
2260
|
this._requestFrame();
|
|
2261
|
+
// The 2D<->3D ramp, on WALL-CLOCK dt (never a frame count, so it lasts the same wall time at
|
|
2262
|
+
// 30 fps and 144 fps). Before the windows, so the rig this frame's views are located against
|
|
2263
|
+
// is the ramped one. No-op — and pushes nothing — when no transition is in flight.
|
|
2264
|
+
this._advanceModeSwitch();
|
|
1148
2265
|
const pose = this.refSpace ? f.getViewerPose(this.refSpace) : null;
|
|
1149
2266
|
const views = pose ? pose.views : null;
|
|
1150
2267
|
for (const win of this._windows.values()) {
|
|
@@ -1293,6 +2410,12 @@ class Inline3D {
|
|
|
1293
2410
|
if (liveManager === this) liveManager = null;
|
|
1294
2411
|
this._unbindLifecycle();
|
|
1295
2412
|
this._disarmDprWatch();
|
|
2413
|
+
// A transition in flight dies with the session: nothing will drive the ramp, and the held
|
|
2414
|
+
// request has nowhere to go — so the page's promise is settled rather than left pending.
|
|
2415
|
+
this._disarmModeSwitchTick();
|
|
2416
|
+
this._modeSwitch.cancel();
|
|
2417
|
+
this._msArmedUp = false;
|
|
2418
|
+
this._settlePendingDown('closed', 'the inline-3D session closed first');
|
|
1296
2419
|
if (this._observer) this._observer.disconnect();
|
|
1297
2420
|
for (const win of this._windows.values()) {
|
|
1298
2421
|
this._stopOverlayScan(win);
|
|
@@ -1307,6 +2430,9 @@ class Inline3D {
|
|
|
1307
2430
|
}
|
|
1308
2431
|
}
|
|
1309
2432
|
this._windows.clear();
|
|
2433
|
+
// Page listeners go with the session that fed them: a manager whose session has ended will
|
|
2434
|
+
// never emit again, and holding the callbacks would keep the page's closures alive.
|
|
2435
|
+
this._displayListeners.clear();
|
|
1310
2436
|
}
|
|
1311
2437
|
}
|
|
1312
2438
|
|
|
@@ -1343,8 +2469,8 @@ function drawEye(ctx, src, sx, sy, sw, sh, dx, dy, dw, dh, radius, feather) {
|
|
|
1343
2469
|
|
|
1344
2470
|
// Fade this EYE's outer edges to transparent, so the 3D window dissolves into the page
|
|
1345
2471
|
// instead of ending at a hard rectangle. Same spirit as the runtime feathering a 3D zone's
|
|
1346
|
-
// edge — but note that is the hardware WISH MASK (
|
|
1347
|
-
// content-side equivalent, and the two are independent.
|
|
2472
|
+
// edge — but note that is the hardware WISH MASK (it drives the hardware display state, never
|
|
2473
|
+
// content); this is the content-side equivalent, and the two are independent.
|
|
1348
2474
|
//
|
|
1349
2475
|
// Per-eye, like cornerRadius, and for the same reason: the weave splits the element's rect
|
|
1350
2476
|
// down the middle, so anything applied across the whole (side-by-side) buffer gets halved —
|