@displayxr/inline3d 1.30.0 → 1.32.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.
@@ -0,0 +1,360 @@
1
+ // inline3d-viewer-ease.js — ease the eye views across a tracking acquisition or loss.
2
+ //
3
+ // THE SNAP THIS REMOVES. While nobody is tracked the session's views come from the runtime's
4
+ // NOMINAL viewer (two eyes straight in front of the window at the nominal distance). The frame a
5
+ // viewer is acquired, the views jump to the tracked eyes — tens to hundreds of millimetres in one
6
+ // frame — and so does everything drawn from them: the per-eye cameras, head parallax, display-rig
7
+ // layers. The reverse happens on a loss wherever the vendor does not animate it. The browser
8
+ // passes the views through and the core SDK deliberately does too (inline3d.js `_frame`), so the
9
+ // ease lives in the SDK's RENDERERS (SceneViewer, the PlayCanvas splat and model viewers), on the
10
+ // matrices they already copy out of every view, and every page that renders through them gets it.
11
+ //
12
+ // WHAT IT DOES. On a trigger, the difference between what was DRAWN last frame and what the
13
+ // session reports now is frozen as an offset, and the offset decays to zero over `durationMs`
14
+ // (smoothstep by default) while the live views keep moving underneath — so the eyes leave from
15
+ // where they were and arrive on the tracked viewer without lagging behind it once the window ends.
16
+ //
17
+ // pose_out = pose_now + w · A_now · d_local d_local = A_then⁻¹ · (eye_drawn − eye_now)
18
+ // proj_out = proj_now + w · (proj_drawn − proj_now) w = 1 − ease(t / duration)
19
+ //
20
+ // The position offset is held in the VIEW's own frame (A = the pose's 3×3), so a rig the page
21
+ // moves during the ease (orbit, zoom) carries the offset with it. The projection is offset
22
+ // element-wise, and that is exact, not an approximation: every DisplayXR frustum (the display
23
+ // rig's window-relative Kooima and the camera rig's portal, displayxr-common dxr_view_math.c) has
24
+ // its off-axis terms and its focal terms AFFINE in the eye position, with fixed near/far — so the
25
+ // eased (pose, proj) pair is the view the runtime itself would report for the eased eye. A view's
26
+ // frustum still crosses its rig's window exactly, which is what ./inline3d-splat-rig-map.js
27
+ // reads the located rig off.
28
+ //
29
+ // TRIGGERS, in order of trust:
30
+ // 1. `trackingState` edge ('searching' <-> 'tracking', DisplayXR Browser patch 0195+). For
31
+ // `armMs` after it (the edge frame included) any frame whose eyes move more than `armedJump`
32
+ // (in eye separations) starts — or restarts — the ease from what was drawn, because the edge
33
+ // and the jump need not land on the same frame. An edge without a jump changes nothing.
34
+ // 2. Where the browser reports no state ('unknown'), a jump of more than `jump` eye separations
35
+ // in one frame. A seated viewer's head moves well under that per frame; a nominal ↔ tracked
36
+ // switch is a multiple of it.
37
+ // Eye positions for the jump test are read off the PROJECTIONS (focal and off-axis terms), in
38
+ // eye-separation units, so the test is blind to rig changes, scene scale and canvas size.
39
+ //
40
+ // WHAT IT LEAVES ALONE. A view-count change (a 2D <-> 3D rendering-mode switch: the eased mode
41
+ // switch, ./inline3d-mode-switch.js, owns that) and a buffer reallocation reset it. One view
42
+ // (mono) is passed through. Replayed (last-good) frames are not re-eased. The panel's own optical
43
+ // switch (lens/backlight state on acquisition) is hardware and is not touched by anything here.
44
+ //
45
+ // Pure arithmetic on Float32Array(16) pairs; no WebXR, no DOM. test/viewer-ease.test.mjs.
46
+
47
+ /** Default ease window (ms). Long enough to read as a glide, short enough not to feel laggy. */
48
+ export const VIEWER_EASE_DEFAULT_MS = 300;
49
+ /** Default curve: smoothstep (the mode switch's own default; zero velocity at both ends). */
50
+ export const VIEWER_EASE_DEFAULT_EASING = 'smoothstep';
51
+ /** Untracked-state jump detector: eye movement in one frame, in eye separations. */
52
+ export const VIEWER_EASE_JUMP = 0.5;
53
+ /** After a tracking edge: the (lower) per-frame movement that restarts the ease, in separations. */
54
+ export const VIEWER_EASE_ARMED_JUMP = 0.15;
55
+ /** How long after a tracking edge the lower threshold applies (ms). */
56
+ export const VIEWER_EASE_ARM_MS = 600;
57
+
58
+ const EASINGS = {
59
+ linear: (t) => t,
60
+ smoothstep: (t) => t * t * (3 - 2 * t),
61
+ easeoutcubic: (t) => 1 - (1 - t) ** 3,
62
+ };
63
+
64
+ const now = () => (typeof performance !== 'undefined' && performance.now ? performance.now() : Date.now());
65
+
66
+ /**
67
+ * Resolve a page's `viewerEase` option. `false` / `{ enabled:false }` / `{ durationMs:0 }`
68
+ * disables; `true` / undefined is the default; an object overrides fields.
69
+ *
70
+ * @param {boolean|{enabled?:boolean,durationMs?:number,easing?:string}} [opt]
71
+ * @returns {{enabled:boolean,durationMs:number,easing:string}}
72
+ */
73
+ export function resolveViewerEaseOption(opt) {
74
+ const out = { enabled: true, durationMs: VIEWER_EASE_DEFAULT_MS, easing: VIEWER_EASE_DEFAULT_EASING };
75
+ if (opt === false) out.enabled = false;
76
+ else if (opt && typeof opt === 'object') {
77
+ if (opt.enabled === false) out.enabled = false;
78
+ if (Number.isFinite(opt.durationMs) && opt.durationMs >= 0) out.durationMs = opt.durationMs;
79
+ if (typeof opt.easing === 'string') {
80
+ const k = opt.easing.toLowerCase().replace(/[-_\s]/g, '');
81
+ if (EASINGS[k]) out.easing = k;
82
+ }
83
+ }
84
+ if (!(out.durationMs > 0)) out.enabled = false;
85
+ return out;
86
+ }
87
+
88
+ /** Eye position in window-width units, read off a projection's focal and off-axis terms. */
89
+ function eyeFromProj(P, out) {
90
+ const p0 = P[0];
91
+ const p5 = P[5];
92
+ const aspect = p5 !== 0 ? p0 / p5 : 1; // = H / W for a window-relative frustum
93
+ out[0] = -P[8] / 2;
94
+ out[1] = (-P[9] / 2) * aspect;
95
+ out[2] = p0 / 2;
96
+ return out;
97
+ }
98
+
99
+ const dist3 = (a, b) => Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2]);
100
+
101
+ /** Inverse of the upper-left 3×3 of a column-major 4×4, into a 9-array (column-major). False if singular. */
102
+ function inv3(M, out) {
103
+ const a = M[0], b = M[4], c = M[8];
104
+ const d = M[1], e = M[5], f = M[9];
105
+ const g = M[2], h = M[6], i = M[10];
106
+ const A = e * i - f * h;
107
+ const B = -(d * i - f * g);
108
+ const C = d * h - e * g;
109
+ const det = a * A + b * B + c * C;
110
+ if (!(Math.abs(det) > 1e-12)) return false;
111
+ const k = 1 / det;
112
+ // row-major inverse entries
113
+ const r00 = A * k, r01 = -(b * i - c * h) * k, r02 = (b * f - c * e) * k;
114
+ const r10 = B * k, r11 = (a * i - c * g) * k, r12 = -(a * f - c * d) * k;
115
+ const r20 = C * k, r21 = -(a * h - b * g) * k, r22 = (a * e - b * d) * k;
116
+ out[0] = r00; out[3] = r01; out[6] = r02;
117
+ out[1] = r10; out[4] = r11; out[7] = r12;
118
+ out[2] = r20; out[5] = r21; out[8] = r22;
119
+ return true;
120
+ }
121
+
122
+ /**
123
+ * One renderer's ease state. Feed it the matrices it is about to draw, once per LIVE frame,
124
+ * in place; it rewrites them while an ease is running and leaves them untouched otherwise.
125
+ */
126
+ export class ViewerEase {
127
+ /**
128
+ * @param {object} [o] resolveViewerEaseOption() fields, plus test knobs (`clock`, `jump`,
129
+ * `armedJump`, `armMs`).
130
+ */
131
+ constructor(o = {}) {
132
+ const r = resolveViewerEaseOption(o);
133
+ this.enabled = r.enabled;
134
+ this.durationMs = r.durationMs;
135
+ this.easing = r.easing;
136
+ this.jump = Number.isFinite(o.jump) ? o.jump : VIEWER_EASE_JUMP;
137
+ this.armedJump = Number.isFinite(o.armedJump) ? o.armedJump : VIEWER_EASE_ARMED_JUMP;
138
+ this.armMs = Number.isFinite(o.armMs) ? o.armMs : VIEWER_EASE_ARM_MS;
139
+ this.clock = typeof o.clock === 'function' ? o.clock : now;
140
+ this.reset();
141
+ /** What the last apply() did, for diagnostics: { weight, reason: null|'armed-jump'|'jump'|'easing', jump }. */
142
+ this.last = { weight: 0, reason: null, jump: 0 };
143
+ }
144
+
145
+ /** Change the tuning at run time (the same shapes the option takes). */
146
+ configure(opt) {
147
+ const r = resolveViewerEaseOption(opt);
148
+ this.enabled = r.enabled;
149
+ this.durationMs = r.durationMs;
150
+ this.easing = r.easing;
151
+ if (!this.enabled) this._active = false;
152
+ }
153
+
154
+ /** Forget everything (a view-count change, a buffer reallocation, a mono stretch). */
155
+ reset() {
156
+ this._n = 0;
157
+ this._drawn = []; // per view: { proj, pose } as DRAWN last frame
158
+ this._seen = []; // per view: eye (window units) as REPORTED last frame
159
+ this._off = []; // per view: { d: [3] local, dp: Float64Array(16) }
160
+ this._active = false;
161
+ this._t0 = 0;
162
+ this._state = null; // last tracking state seen
163
+ this._armedUntil = -Infinity;
164
+ this._eye = [0, 0, 0];
165
+ this._inv = new Float64Array(9);
166
+ }
167
+
168
+ /** Is an ease running right now? */
169
+ get active() {
170
+ return this._active;
171
+ }
172
+
173
+ /**
174
+ * Ease these entries in place. `entries[i].proj` / `.pose` are column-major Float32Array(16)
175
+ * (or any 16-array), already holding THIS frame's reported matrices.
176
+ *
177
+ * @param {Array<{proj:ArrayLike<number>,pose:ArrayLike<number>}>} entries
178
+ * @param {'tracking'|'searching'|'unknown'|string|null} [trackingState]
179
+ * @param {number} [t] ms; defaults to the clock.
180
+ * @returns {number} the weight applied this frame (0 = passed through, 1 = fully the old views).
181
+ */
182
+ apply(entries, trackingState, t = this.clock()) {
183
+ const L = this.last;
184
+ L.weight = 0;
185
+ L.reason = null;
186
+ L.jump = 0;
187
+ const n = entries ? entries.length : 0;
188
+ if (!this.enabled || n < 2) {
189
+ if (n !== this._n) this.reset();
190
+ this._remember(entries, n);
191
+ return 0;
192
+ }
193
+ if (n !== this._n) {
194
+ // A view-count change is a rendering-mode switch: the mode switch's ramp owns it.
195
+ this.reset();
196
+ this._remember(entries, n);
197
+ this._state = trackingState || null;
198
+ return 0;
199
+ }
200
+
201
+ // ── triggers ──
202
+ const state = trackingState === 'tracking' || trackingState === 'searching' ? trackingState : null;
203
+ const edge = state !== null && this._state !== null && state !== this._state;
204
+ if (state !== null) this._state = state;
205
+ if (edge) this._armedUntil = t + this.armMs;
206
+
207
+ const jump = this._jump(entries, n);
208
+ L.jump = jump;
209
+ // An edge only ARMS: it is the jump that is eased, on whichever frame it lands (the state can
210
+ // lead the views by a frame). An edge with no jump — a vendor that already animates the eyes —
211
+ // changes nothing.
212
+ let trigger = null;
213
+ if (t <= this._armedUntil && jump > this.armedJump) trigger = 'armed-jump';
214
+ else if (state === null && jump > this.jump) trigger = 'jump';
215
+
216
+ if (trigger) {
217
+ this._start(entries, n, t);
218
+ L.reason = trigger;
219
+ }
220
+
221
+ // ── apply ──
222
+ let w = 0;
223
+ if (this._active) {
224
+ const u = this.durationMs > 0 ? (t - this._t0) / this.durationMs : 1;
225
+ if (u >= 1) this._active = false;
226
+ else {
227
+ w = 1 - EASINGS[this.easing](Math.max(0, u));
228
+ for (let i = 0; i < n; i++) {
229
+ const e = entries[i];
230
+ const o = this._off[i];
231
+ const P = e.pose;
232
+ const d = o.d;
233
+ // A_now · d_local, added to the live position.
234
+ P[12] += w * (P[0] * d[0] + P[4] * d[1] + P[8] * d[2]);
235
+ P[13] += w * (P[1] * d[0] + P[5] * d[1] + P[9] * d[2]);
236
+ P[14] += w * (P[2] * d[0] + P[6] * d[1] + P[10] * d[2]);
237
+ const Q = e.proj;
238
+ for (let k = 0; k < 16; k++) Q[k] += w * o.dp[k];
239
+ }
240
+ if (!L.reason) L.reason = 'easing';
241
+ }
242
+ }
243
+ L.weight = w;
244
+ this._remember(entries, n);
245
+ return w;
246
+ }
247
+
248
+ /** Largest per-view eye move since the last frame, in eye separations (0 when unknown). */
249
+ _jump(entries, n) {
250
+ if (this._seen.length !== n) return 0;
251
+ const eye = this._eye;
252
+ let sep = 0;
253
+ const a = eyeFromProj(entries[0].proj, [0, 0, 0]);
254
+ const b = eyeFromProj(entries[n - 1].proj, [0, 0, 0]);
255
+ sep = dist3(a, b);
256
+ const prevSep = dist3(this._seen[0], this._seen[n - 1]);
257
+ // Floor: a typical IPD at this distance (~0.1 of the eye depth), so a vendor pair collapsed
258
+ // onto one point (separation ~0) does not turn every millimetre into a "jump".
259
+ const s = Math.max(sep, prevSep, 0.1 * Math.abs((a[2] + b[2]) / 2));
260
+ if (!(s > 1e-6)) return 0;
261
+ let m = 0;
262
+ for (let i = 0; i < n; i++) {
263
+ eyeFromProj(entries[i].proj, eye);
264
+ m = Math.max(m, dist3(eye, this._seen[i]));
265
+ }
266
+ return m / s;
267
+ }
268
+
269
+ /** Freeze the offset between what was drawn last frame and what is reported now. */
270
+ _start(entries, n, t) {
271
+ if (this._drawn.length !== n) return;
272
+ for (let i = 0; i < n; i++) {
273
+ const e = entries[i];
274
+ const D = this._drawn[i];
275
+ const o = (this._off[i] ||= { d: [0, 0, 0], dp: new Float64Array(16) });
276
+ const dx = D.pose[12] - e.pose[12];
277
+ const dy = D.pose[13] - e.pose[13];
278
+ const dz = D.pose[14] - e.pose[14];
279
+ if (inv3(e.pose, this._inv)) {
280
+ const I = this._inv;
281
+ o.d[0] = I[0] * dx + I[3] * dy + I[6] * dz;
282
+ o.d[1] = I[1] * dx + I[4] * dy + I[7] * dz;
283
+ o.d[2] = I[2] * dx + I[5] * dy + I[8] * dz;
284
+ } else {
285
+ o.d[0] = dx;
286
+ o.d[1] = dy;
287
+ o.d[2] = dz;
288
+ }
289
+ for (let k = 0; k < 16; k++) o.dp[k] = D.proj[k] - e.proj[k];
290
+ }
291
+ this._active = true;
292
+ this._t0 = t;
293
+ }
294
+
295
+ /** Keep this frame's drawn matrices and reported eyes for the next one. */
296
+ _remember(entries, n) {
297
+ this._n = n;
298
+ if (!this.enabled || n < 2) {
299
+ this._drawn.length = 0;
300
+ this._seen.length = 0;
301
+ return;
302
+ }
303
+ for (let i = 0; i < n; i++) {
304
+ const e = entries[i];
305
+ const D = (this._drawn[i] ||= { proj: new Float64Array(16), pose: new Float64Array(16) });
306
+ for (let k = 0; k < 16; k++) {
307
+ D.proj[k] = e.proj[k];
308
+ D.pose[k] = e.pose[k];
309
+ }
310
+ }
311
+ this._drawn.length = n;
312
+ this._seen.length = n;
313
+ // The REPORTED eyes: undo this frame's offset from the projection before keeping it.
314
+ const w = this.last.weight;
315
+ const raw = (this._raw ||= new Float64Array(16));
316
+ for (let i = 0; i < n; i++) {
317
+ const Q = entries[i].proj;
318
+ const o = this._off[i];
319
+ if (w > 0 && o) for (let k = 0; k < 16; k++) raw[k] = Q[k] - w * o.dp[k];
320
+ this._seen[i] = eyeFromProj(w > 0 && o ? raw : Q, this._seen[i] || [0, 0, 0]);
321
+ }
322
+ }
323
+ }
324
+
325
+ /** The session's tracking state as the frame sees it, or null (no frame / old browser). */
326
+ export function frameTrackingState(frame) {
327
+ try {
328
+ const s = frame && frame.session ? frame.session.trackingState : undefined;
329
+ return s === 'tracking' || s === 'searching' || s === 'unknown' ? s : null;
330
+ } catch {
331
+ return null;
332
+ }
333
+ }
334
+
335
+ // ── session-wide default ──
336
+ // `createInline3D({ viewerEase })` sets the default for every renderer drawing through that
337
+ // session; a renderer's own `viewerEase` option overrides it. Keyed by the XRSession object, so
338
+ // renderers find it from the XRFrame they are handed and need no reference to the manager.
339
+ const SESSION_OPTS = new WeakMap();
340
+
341
+ /** Record a session's `viewerEase` option (createInline3D does this). */
342
+ export function setSessionViewerEase(session, opt) {
343
+ if (session && (typeof session === 'object' || typeof session === 'function')) SESSION_OPTS.set(session, opt);
344
+ }
345
+
346
+ /**
347
+ * The ViewerEase a renderer should use for frames of this session: its own option when it has
348
+ * one (anything but undefined), else the session's, else the default (on).
349
+ */
350
+ export function viewerEaseFor(frame, windowOpt) {
351
+ let opt = windowOpt;
352
+ if (opt === undefined) {
353
+ try {
354
+ opt = frame && frame.session ? SESSION_OPTS.get(frame.session) : undefined;
355
+ } catch {
356
+ opt = undefined;
357
+ }
358
+ }
359
+ return new ViewerEase(opt === true ? undefined : opt);
360
+ }
@@ -72,6 +72,7 @@ import {
72
72
  scaleViewport,
73
73
  mismatchWarning,
74
74
  } from './inline3d-buffer-limit.js';
75
+ import { viewerEaseFor, frameTrackingState } from './inline3d-viewer-ease.js';
75
76
 
76
77
  const clamp = (v, lo, hi) => (v < lo ? lo : v > hi ? hi : v);
77
78
  // NaN/Infinity into a transform silently blanks the tile — three propagates it into the
@@ -281,9 +282,14 @@ export class SceneViewer {
281
282
  renderScale = 1,
282
283
  feather = 0,
283
284
  pitchLimit = PITCH_LIMIT,
285
+ viewerEase,
284
286
  } = opts;
285
287
 
286
288
  this._THREE = THREE;
289
+ // The tracking-acquisition ease (./inline3d-viewer-ease.js): undefined = the session's
290
+ // createInline3D({ viewerEase }) default. Built on the first 3D frame, which knows the session.
291
+ this._viewerEaseOpt = viewerEase;
292
+ this.viewerEase = null;
287
293
  this.canvas = canvas;
288
294
  this.vH = virtualDisplayHeight;
289
295
  this.fit = fit;
@@ -681,7 +687,7 @@ export class SceneViewer {
681
687
  * stale sub-rect, which smears). A one-frame-stale eye pose is imperceptible; a smear and a
682
688
  * black frame are not.
683
689
  */
684
- onFrame(views, layer) {
690
+ onFrame(views, layer, frame) {
685
691
  if (this._disposed) return;
686
692
  // A lazily-activated tile can start weaving after the page already fell back to mono (or
687
693
  // after a scroll-away/scroll-back). Take the buffer back to the SBS shape when that happens
@@ -725,7 +731,12 @@ export class SceneViewer {
725
731
  vps.push(vp);
726
732
  }
727
733
 
728
- // Validated: this frame WILL draw over everything it clears.
734
+ // Validated: this frame WILL draw over everything it clears. Copy the matrices first (the
735
+ // last-good cache), ease a tracking acquisition/loss on the copies, and draw from them, so
736
+ // the live draw and a later replay are the same numbers.
737
+ this._cacheGood(views, vps, !eye);
738
+ const g = this._lastGood;
739
+ if (eye) (this.viewerEase ||= viewerEaseFor(frame, this._viewerEaseOpt)).apply(g.entries, frameTrackingState(frame));
729
740
  const r = this.renderer;
730
741
  // getViewport() splits canvas.width; a drawing buffer the browser clamped behind our back
731
742
  // (bufferScale().mismatch) gets the same split mapped onto its real size, never canvas.width/2.
@@ -737,7 +748,7 @@ export class SceneViewer {
737
748
  r.setViewport(vp.x, vp.y, vp.width, vp.height);
738
749
  r.setScissor(vp.x, vp.y, vp.width, vp.height);
739
750
  if (eye) {
740
- eye.setFromView(views[i]);
751
+ eye.setFromMatrices(g.entries[i].proj, g.entries[i].pose);
741
752
  r.render(this.scene, eye.camera);
742
753
  } else {
743
754
  r.render(this.scene, this.monoCamera);
@@ -745,7 +756,6 @@ export class SceneViewer {
745
756
  if (this._feather) this._feather.render(r, vp);
746
757
  }
747
758
  r.setScissorTest(false);
748
- this._cacheGood(views, vps, !eye);
749
759
  }
750
760
 
751
761
  /**
@@ -977,6 +987,7 @@ export class SceneViewer {
977
987
  if (this._disposed) return;
978
988
  const box = this.canvas.getBoundingClientRect();
979
989
  if (box.width < 1 || box.height < 1) return;
990
+ this.viewerEase?.reset(); // a new window size moves every projection: not a viewer jump
980
991
  const dpr = Math.min(window.devicePixelRatio || 1, 2) * this.renderScale;
981
992
  // Clamp to the device's GL limits BEFORE sizing (./inline3d-buffer-limit.js): a store past
982
993
  // MAX_TEXTURE_SIZE is silently clamped by the browser while getViewport() keeps splitting
package/js/inline3d.js CHANGED
@@ -31,6 +31,11 @@
31
31
  // three entry points are re-exported below so a page has a single import site.
32
32
  import { undock, undockAvailable, undockUrl, tileScreenRect, setUndockLayerResolver } from './inline3d-undock.js';
33
33
  export { undock, undockAvailable, undockUrl, tileScreenRect };
34
+ import { setSessionViewerEase } from './inline3d-viewer-ease.js';
35
+ // The tracking-acquisition ease, for pages that render their own addScene from the views.
36
+ export { ViewerEase, resolveViewerEaseOption, frameTrackingState, VIEWER_EASE_DEFAULT_MS } from './inline3d-viewer-ease.js';
37
+ export { DISPLAY_METRICS_DEFAULTS, displayMetricsFrom } from './inline3d-display-metrics.js';
38
+ import { displayMetricsFrom } from './inline3d-display-metrics.js';
34
39
 
35
40
  // The eased 2D<->3D transition. A pure state machine (no DOM, no WebXR) ported from the native
36
41
  // `dxr::ModeSwitch`, so the browser eases the disparity around a mode switch the same way — and in
@@ -130,6 +135,7 @@ const hasViewRig = () => {
130
135
  // required — a browser with a partial set is a browser mid-implementation, and treating it as
131
136
  // supported would hand a page a `requestRenderingMode is not a function` at the worst moment.
132
137
  const DISPLAY_MODE_METHODS = ['getDisplayInfo', 'getRenderingModes', 'requestRenderingMode'];
138
+
133
139
  // The two display events, fired on the XRSession rather than the layer — so a page hears about a
134
140
  // mode or hardware-state change even while its tile's layer is closed (lazy mode), and one
135
141
  // subscription covers every window in the document.
@@ -558,6 +564,14 @@ function noteRigWinsOverHeight() {
558
564
  * layer; no buffer reallocation) and back on 'tracking', over the mode switch's duration;
559
565
  * 'unknown' leaves it where it is. Scene windows are never touched: the page owns those
560
566
  * pixels — listen for `trackingstatechange` and do the same for a scene.
567
+ * @param {boolean|{enabled?:boolean,durationMs?:number,easing?:string}} [opts.viewerEase] The
568
+ * default, for every SDK renderer drawing through this session (`./viewer`'s SceneViewer,
569
+ * `./splat`, `./model`, both engines), of the TRACKING EASE: when a viewer is acquired
570
+ * (the views jump from the runtime's nominal viewer to the tracked eyes) or lost, the eye
571
+ * views glide from where they were drawn to the new ones over `durationMs` (300) on
572
+ * `easing` ('smoothstep' | 'linear' | 'easeOutCubic') instead of snapping in one frame.
573
+ * On by default; `false` restores the snap. A renderer's own `viewerEase` option
574
+ * overrides this. A page rendering its own addScene uses the exported `ViewerEase`.
561
575
  * @returns {Promise<Inline3D | {supported:false, trackingState:'unknown', error?:Error}>} the
562
576
  * manager, which also carries
563
577
  * the display API (`getDisplayInfo` / `getRenderingModes` / `requestRenderingMode` /
@@ -572,6 +586,7 @@ export async function createInline3D(opts = {}) {
572
586
  autoChrome = true,
573
587
  modeSwitch = null,
574
588
  untrackedFallback = 'none',
589
+ viewerEase,
575
590
  } = opts;
576
591
  // The unsupported shapes carry `trackingState` too, so a page can read it without branching on
577
592
  // `supported` first. There is no on() here to pair with it: nothing would ever fire.
@@ -584,6 +599,7 @@ export async function createInline3D(opts = {}) {
584
599
  } catch (e) {
585
600
  return { supported: false, trackingState: 'unknown', error: e };
586
601
  }
602
+ if (viewerEase !== undefined) setSessionViewerEase(session, viewerEase);
587
603
  let refSpace = null;
588
604
  try {
589
605
  refSpace = await session.requestReferenceSpace(referenceSpace);
@@ -1075,6 +1091,24 @@ class Inline3D {
1075
1091
  * @returns {Promise<object|null>}
1076
1092
  */
1077
1093
  getDisplayInfo: () => this._layerCall(win, 'getDisplayInfo', 'getDisplayInfo()'),
1094
+ /**
1095
+ * This window in PHYSICAL units: the canvas's size in metres, metres per CSS px, the panel,
1096
+ * the nominal viewer distance and the eye separation — from getDisplayInfo(), with every
1097
+ * value that could not be measured filled from a stated default (see `source`). Never
1098
+ * rejects: no display API, no live layer, no glasses-free display all resolve the defaults.
1099
+ * The canvas size is re-read at each call (call it again after a resize).
1100
+ *
1101
+ * @returns {Promise<object>}
1102
+ */
1103
+ displayMetrics: async () => {
1104
+ let info = null;
1105
+ try {
1106
+ info = await this._layerCall(win, 'getDisplayInfo', 'getDisplayInfo()');
1107
+ } catch {
1108
+ /* no display API / no live layer: the defaults */
1109
+ }
1110
+ return displayMetricsFrom(info, canvas.getBoundingClientRect(), globalThis.devicePixelRatio || 1);
1111
+ },
1078
1112
  /**
1079
1113
  * Every rendering mode the runtime can put this display in, as reported by the runtime:
1080
1114
  * `{modeIndex, modeName, viewCount, viewScaleX, viewScaleY, tileColumns, tileRows,
package/model.d.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  // EXPERIMENTAL — not covered by the 1.x semver promise. See docs/sdk-stability.md.
3
3
 
4
4
  import type { SceneViewer, SubjectBounds, OrbitPose } from './viewer.js';
5
- import type { FirstWovenResult } from './index.js';
5
+ import type { FirstWovenResult, ViewerEaseOption } from './index.js';
6
6
  import type { PlayCanvasSplatViewer, SplatCameraPose, SplatFrameInfo } from './splat.js';
7
7
 
8
8
  export interface ModelOptions {
@@ -41,6 +41,13 @@ export interface ModelOptions {
41
41
  */
42
42
  renderScale?: number;
43
43
  feather?: number;
44
+ /**
45
+ * The tracking ease (default on): when a viewer is acquired or lost and the views jump (the
46
+ * runtime's nominal viewer <-> the tracked eyes) the eye cameras glide from where they were
47
+ * drawn to the new views instead of snapping in one frame. Unset: the session's
48
+ * `createInline3D({ viewerEase })`, else on. `false` restores the snap.
49
+ */
50
+ viewerEase?: ViewerEaseOption;
44
51
  /**
45
52
  * How the mesh is lit.
46
53
  *
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@displayxr/inline3d",
3
- "version": "1.30.0",
4
- "description": "Turn any HTML <canvas> into a glasses-free-3D window on a DisplayXR display, inside an ordinary web page. Dependency-free; progressive enhancement (falls back to plain 2D everywhere else).",
3
+ "version": "1.32.0",
4
+ "description": "Turn any HTML <canvas> into a 3D window on a spatial display, inside an ordinary web page, for the DisplayXR Browser. Dependency-free; progressive enhancement (falls back to plain 2D everywhere else).",
5
5
  "type": "module",
6
6
  "types": "./index.d.ts",
7
7
  "main": "./js/inline3d.js",
@@ -65,8 +65,10 @@
65
65
  "files": [
66
66
  "js/inline3d.js",
67
67
  "js/inline3d-undock.js",
68
+ "js/inline3d-display-metrics.js",
68
69
  "js/inline3d-mode-switch.js",
69
70
  "js/inline3d-buffer-limit.js",
71
+ "js/inline3d-viewer-ease.js",
70
72
  "js/inline3d-three.js",
71
73
  "js/inline3d-viewer.js",
72
74
  "js/inline3d-splat.js",
@@ -116,6 +118,7 @@
116
118
  "webxr",
117
119
  "inline-3d",
118
120
  "glasses-free-3d",
121
+ "spatial-display",
119
122
  "stereo",
120
123
  "3d-display",
121
124
  "lightfield",
package/splat.d.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  // EXPERIMENTAL — not covered by the 1.x semver promise. See docs/sdk-stability.md.
3
3
 
4
4
  import type { SceneViewer, SubjectBounds, OrbitPose } from './viewer.js';
5
- import type { FirstWovenResult } from './index.js';
5
+ import type { FirstWovenResult, ViewerEaseOption, DisplayMetrics } from './index.js';
6
6
 
7
7
  /**
8
8
  * The knobs behind `SplatOptions.perf`. Every one is a Spark 2.1.0 setting except `alphaRadius`,
@@ -174,8 +174,11 @@ export interface ResolvedRig {
174
174
  focusDistances: { subject_m: number | null; near_m: number | null; far_m: number | null } | null;
175
175
  /** Focus distance along the rest camera's view axis — the zero-disparity PLANE. */
176
176
  convergence: number;
177
+ /** In force — `setStereo`'s values when set, else `stereoDefault`'s. */
177
178
  ipdFactor: number;
178
179
  parallaxFactor: number;
180
+ /** The asset's own scalars (block `dxr`, else addSplat's options, else 1) — what `setStereo(null)` restores. PlayCanvas backend. */
181
+ stereoDefault?: { ipdFactor: number; parallaxFactor: number };
179
182
  }
180
183
 
181
184
  /**
@@ -462,6 +465,13 @@ export interface SplatOptions {
462
465
  sky?: boolean;
463
466
  /** PlayCanvas: a CAP on the projection's far plane (only ever lowers it). Unset: untouched. */
464
467
  farClip?: number;
468
+ /**
469
+ * The tracking ease (default on): when a viewer is acquired or lost and the views jump (the
470
+ * runtime's nominal viewer <-> the tracked eyes) the eye cameras glide from where they were
471
+ * drawn to the new views instead of snapping in one frame. Unset: the session's
472
+ * `createInline3D({ viewerEase })`, else on. `false` restores the snap.
473
+ */
474
+ viewerEase?: ViewerEaseOption;
465
475
  /** Camera rig only: the distance in world metres that sits ON the glass. */
466
476
  convergence?: number;
467
477
  /**
@@ -724,6 +734,41 @@ export interface SplatSetRigDisplayOptions {
724
734
  frame?: { center: number[] | { x: number; y: number; z: number }; extent: number[] | { x: number; y: number; z: number } };
725
735
  }
726
736
 
737
+ /** A rectangle in canvas fractions: origin top-left, y DOWN (a DOM rect / canvas size). */
738
+ export interface SplatCanvasRect {
739
+ x: number;
740
+ y: number;
741
+ w: number;
742
+ h: number;
743
+ }
744
+
745
+ /** `handle.setDepthEnvelope`'s options. Metres are on the panel. */
746
+ export interface SplatDepthEnvelopeOptions {
747
+ /** The out-of-glass cap well inside the frame. Default 0.02. */
748
+ maxFrontM?: number;
749
+ /** The cap AT a stage edge / rect edge. Default 0.001 (clamped to `maxFrontM`). */
750
+ edgeM?: number;
751
+ /** Ramp width from `edgeM` to `maxFrontM`. Default 0.016. */
752
+ bandM?: number;
753
+ /** The window content may come out in. Default (or `null`): the whole canvas. */
754
+ stage?: SplatCanvasRect | null;
755
+ /** 2D elements over the canvas to keep flat around; `weight` 0..1 (default 1) fades one in/out. The 8 heaviest are kept. */
756
+ rects?: Array<SplatCanvasRect & { weight?: number }> | null;
757
+ /** Viewer distance override; default `nominalViewerPosition.z`, else 0.65. */
758
+ viewerM?: number | null;
759
+ /** Canvas physical size override, [w, h]; default the CSS box × the panel's metres per CSS px. */
760
+ canvasSizeM?: [number, number] | null;
761
+ }
762
+
763
+ /** `handle.setStereo`'s options. Each a finite number >= 0; `null` = back to the asset's own. */
764
+ export interface SplatStereoOptions {
765
+ ipdFactor?: number | null;
766
+ parallaxFactor?: number | null;
767
+ /** RELATIVE to the asset's own (`rig.stereoDefault`): re-applied to each asset `setSource` brings in. Exclusive with `ipdFactor`; the last one given wins. */
768
+ ipdScale?: number | null;
769
+ parallaxScale?: number | null;
770
+ }
771
+
727
772
  /** `setSource`'s options. */
728
773
  export interface SplatSourceOptions {
729
774
  /** > 0 = a crossfade of this length (the 1.10 option; same as `transition: 'crossfade'`). */
@@ -1010,6 +1055,36 @@ export interface SplatHandle {
1010
1055
  */
1011
1056
  setRig(type: 'display', opts?: SplatSetRigDisplayOptions): Promise<SplatHandle>;
1012
1057
  setRig(type: 'camera' | 'auto'): Promise<SplatHandle>;
1058
+ /**
1059
+ * PlayCanvas backend only — throws on Spark. Live stereo strength: re-declares the rig in force
1060
+ * with these scalars on the next frame, with NO cut (pose, focus and framing stay) — a depth
1061
+ * slider. A key given replaces; a key set to `null` returns to the asset's own value (the SOG
1062
+ * camera block's `dxr`, else addSplat's options, else 1); `setStereo(null)` clears both. Sticky
1063
+ * across `setSource` and `setRig`. On a camera rig the values are ABSOLUTE, exactly like
1064
+ * addSplat's `ipdFactor`/`parallaxFactor`; on a display rig they replace the declared display
1065
+ * rig's factors. `controls:'page'` applies them from the next tick. A stereo video holds its own
1066
+ * display rig; a value set meanwhile applies when the video exits. `handle.rig.ipdFactor` /
1067
+ * `parallaxFactor` read what is in force. The 2D↔3D mode-switch ramp still multiplies on top.
1068
+ */
1069
+ setStereo(opts: SplatStereoOptions | null): SplatHandle;
1070
+ /** This tile in physical units — the core `TileHandle.displayMetrics()`, on the splat handle. Never rejects. */
1071
+ displayMetrics(): Promise<DisplayMetrics>;
1072
+ /**
1073
+ * PlayCanvas backend only — throws on Spark. THE FRAME ENVELOPE: content may come OUT of the
1074
+ * glass only well inside the frame. At each screen spot the out-of-glass cap is
1075
+ * `edgeM + (maxFrontM − edgeM) · smoothstep(0, bandM, distance)`, where `distance` (metres on the
1076
+ * panel) is to the nearest `stage` edge or to any `rects` entry (weighted). A gaussian beyond the
1077
+ * cap slides back along its own ray from the declared camera rig (centre and scale together), so
1078
+ * the 2D picture is unchanged, the sort stays valid and both eyes agree; behind the glass is never
1079
+ * touched. Tile-wide and LIVE: every asset — an incoming one from its first frame, both sides of
1080
+ * a crossfade — is judged through the rig declared now (its convergence, lens, `ipdFactor` ×
1081
+ * `metersToVirtual`, so `setStereo` is accounted for). Camera rigs only (incl. `controls:'page'`);
1082
+ * inert on a display rig and while a stereo video holds the rig. Physical size comes from
1083
+ * `getDisplayInfo()` (see `displayMetrics()`), overridable with `canvasSizeM` / `viewerM`.
1084
+ * A key given replaces; `null` turns it off. Cheap to call per layout change: uniforms only.
1085
+ * docs/splat-effects.md §Depth envelope.
1086
+ */
1087
+ setDepthEnvelope(opts: SplatDepthEnvelopeOptions | null): SplatHandle;
1013
1088
  /**
1014
1089
  * PlayCanvas backend only — throws on Spark, and with `controls:'page'`. Play a stereo video ON
1015
1090
  * this handle: no new canvas, layer or session (docs/playcanvas-adapter.md §setVideo). The splat