@displayxr/inline3d 1.29.0 → 1.31.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,9 @@
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';
34
37
 
35
38
  // The eased 2D<->3D transition. A pure state machine (no DOM, no WebXR) ported from the native
36
39
  // `dxr::ModeSwitch`, so the browser eases the disparity around a mode switch the same way — and in
@@ -558,6 +561,14 @@ function noteRigWinsOverHeight() {
558
561
  * layer; no buffer reallocation) and back on 'tracking', over the mode switch's duration;
559
562
  * 'unknown' leaves it where it is. Scene windows are never touched: the page owns those
560
563
  * pixels — listen for `trackingstatechange` and do the same for a scene.
564
+ * @param {boolean|{enabled?:boolean,durationMs?:number,easing?:string}} [opts.viewerEase] The
565
+ * default, for every SDK renderer drawing through this session (`./viewer`'s SceneViewer,
566
+ * `./splat`, `./model`, both engines), of the TRACKING EASE: when a viewer is acquired
567
+ * (the views jump from the runtime's nominal viewer to the tracked eyes) or lost, the eye
568
+ * views glide from where they were drawn to the new ones over `durationMs` (300) on
569
+ * `easing` ('smoothstep' | 'linear' | 'easeOutCubic') instead of snapping in one frame.
570
+ * On by default; `false` restores the snap. A renderer's own `viewerEase` option
571
+ * overrides this. A page rendering its own addScene uses the exported `ViewerEase`.
561
572
  * @returns {Promise<Inline3D | {supported:false, trackingState:'unknown', error?:Error}>} the
562
573
  * manager, which also carries
563
574
  * the display API (`getDisplayInfo` / `getRenderingModes` / `requestRenderingMode` /
@@ -572,6 +583,7 @@ export async function createInline3D(opts = {}) {
572
583
  autoChrome = true,
573
584
  modeSwitch = null,
574
585
  untrackedFallback = 'none',
586
+ viewerEase,
575
587
  } = opts;
576
588
  // The unsupported shapes carry `trackingState` too, so a page can read it without branching on
577
589
  // `supported` first. There is no on() here to pair with it: nothing would ever fire.
@@ -584,6 +596,7 @@ export async function createInline3D(opts = {}) {
584
596
  } catch (e) {
585
597
  return { supported: false, trackingState: 'unknown', error: e };
586
598
  }
599
+ if (viewerEase !== undefined) setSessionViewerEase(session, viewerEase);
587
600
  let refSpace = null;
588
601
  try {
589
602
  refSpace = await session.requestReferenceSpace(referenceSpace);
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.29.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.31.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",
@@ -23,6 +23,10 @@
23
23
  "types": "./splat.d.ts",
24
24
  "import": "./js/inline3d-splat.js"
25
25
  },
26
+ "./splat/playcanvas": {
27
+ "types": "./splat-playcanvas.d.ts",
28
+ "import": "./js/inline3d-splat-pc-entry.js"
29
+ },
26
30
  "./model": {
27
31
  "types": "./model.d.ts",
28
32
  "import": "./js/inline3d-model-entry.js"
@@ -36,6 +40,10 @@
36
40
  "types": "./player.d.ts",
37
41
  "import": "./js/inline3d-player.js"
38
42
  },
43
+ "./camera": {
44
+ "types": "./camera.d.ts",
45
+ "import": "./js/inline3d-camera.js"
46
+ },
39
47
  "./call": {
40
48
  "types": "./call.d.ts",
41
49
  "import": "./js/inline3d-call.js"
@@ -59,9 +67,12 @@
59
67
  "js/inline3d-undock.js",
60
68
  "js/inline3d-mode-switch.js",
61
69
  "js/inline3d-buffer-limit.js",
70
+ "js/inline3d-viewer-ease.js",
62
71
  "js/inline3d-three.js",
63
72
  "js/inline3d-viewer.js",
64
73
  "js/inline3d-splat.js",
74
+ "js/inline3d-splat-pc-entry.js",
75
+ "js/inline3d-splat-deferred.js",
65
76
  "js/inline3d-sog.js",
66
77
  "js/inline3d-splat-perf.js",
67
78
  "js/inline3d-splat-rig.js",
@@ -79,8 +90,10 @@
79
90
  "js/inline3d-pc-look.js",
80
91
  "js/inline3d-playcanvas-engine.js",
81
92
  "js/inline3d-player.js",
93
+ "js/inline3d-camera.js",
82
94
  "js/inline3d-call.js",
83
95
  "js/inline3d-call-full.js",
96
+ "js/camera/",
84
97
  "js/call/",
85
98
  "js/lift/",
86
99
  "dist/",
@@ -88,8 +101,10 @@
88
101
  "three.d.ts",
89
102
  "viewer.d.ts",
90
103
  "splat.d.ts",
104
+ "splat-playcanvas.d.ts",
91
105
  "model.d.ts",
92
106
  "player.d.ts",
107
+ "camera.d.ts",
93
108
  "call.d.ts",
94
109
  "call-full.d.ts",
95
110
  "CHANGELOG.md",
@@ -102,6 +117,7 @@
102
117
  "webxr",
103
118
  "inline-3d",
104
119
  "glasses-free-3d",
120
+ "spatial-display",
105
121
  "stereo",
106
122
  "3d-display",
107
123
  "lightfield",
@@ -149,6 +165,10 @@
149
165
  "build:dist": "node tools/dist/build.mjs",
150
166
  "prepack": "node tools/dist/build.mjs",
151
167
  "test:e2e": "node test/e2e/call-element.e2e.mjs",
168
+ "test:e2e:camera": "node test/e2e/camera.e2e.mjs",
169
+ "api:check": "node tools/api-surface.mjs --check",
170
+ "api:update": "node tools/api-surface.mjs --update",
171
+ "check:snippets": "node tools/check-snippets.mjs",
152
172
  "test:bundlers": "node test/bundlers/run.mjs"
153
173
  },
154
174
  "devDependencies": {
@@ -0,0 +1,4 @@
1
+ // Types for `@displayxr/inline3d/splat/playcanvas`: ./splat's addSplat with the engine fixed to
2
+ // 'playcanvas', without the Spark/three peers. Same handle and options as ./splat.
3
+ export { addSplat } from './splat';
4
+ export type * from './splat';
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 } 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`,
@@ -462,6 +462,13 @@ export interface SplatOptions {
462
462
  sky?: boolean;
463
463
  /** PlayCanvas: a CAP on the projection's far plane (only ever lowers it). Unset: untouched. */
464
464
  farClip?: number;
465
+ /**
466
+ * The tracking ease (default on): when a viewer is acquired or lost and the views jump (the
467
+ * runtime's nominal viewer <-> the tracked eyes) the eye cameras glide from where they were
468
+ * drawn to the new views instead of snapping in one frame. Unset: the session's
469
+ * `createInline3D({ viewerEase })`, else on. `false` restores the snap.
470
+ */
471
+ viewerEase?: ViewerEaseOption;
465
472
  /** Camera rig only: the distance in world metres that sits ON the glass. */
466
473
  convergence?: number;
467
474
  /**
package/viewer.d.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  // Type definitions for @displayxr/inline3d/viewer.
2
2
  // EXPERIMENTAL — not covered by the 1.x semver promise. See docs/sdk-stability.md.
3
3
 
4
+ import type { ViewerEaseOption } from './index.js';
5
+
4
6
  /** Model-space bounds of the subject a viewer frames on. */
5
7
  export interface SubjectBounds {
6
8
  center: number[];
@@ -39,6 +41,13 @@ export interface SceneViewerOptions {
39
41
  feather?: number;
40
42
  /** Pitch clamp in degrees (default [-60, 60]). */
41
43
  pitchLimit?: 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;
42
51
  }
43
52
 
44
53
  export interface OrbitPose {