@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.
- package/CHANGELOG.md +112 -0
- package/README.md +5 -5
- package/call.d.ts +53 -4
- package/dist/call.js +4 -4
- package/index.d.ts +92 -0
- package/js/call/signaling.js +66 -9
- package/js/inline3d-call.js +31 -1
- package/js/inline3d-display-metrics.js +51 -0
- package/js/inline3d-model-playcanvas.js +1 -0
- package/js/inline3d-model.js +1 -0
- package/js/inline3d-splat-deferred.js +12 -0
- package/js/inline3d-splat-effects.js +92 -2
- package/js/inline3d-splat-playcanvas.js +284 -6
- package/js/inline3d-splat.js +15 -0
- package/js/inline3d-viewer-ease.js +360 -0
- package/js/inline3d-viewer.js +15 -4
- package/js/inline3d.js +34 -0
- package/model.d.ts +8 -1
- package/package.json +5 -2
- package/splat.d.ts +76 -1
- package/viewer.d.ts +9 -0
|
@@ -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
|
+
}
|
package/js/inline3d-viewer.js
CHANGED
|
@@ -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.
|
|
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.
|
|
4
|
-
"description": "Turn any HTML <canvas> into a
|
|
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
|