@displayxr/inline3d 1.21.1 → 1.23.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,423 @@
1
+ // inline3d-splat-layer-rig.js — handle.setLayerRig(): draw chosen PlayCanvas layers through the
2
+ // DISPLAY rig while the splat (and the runtime's declared rig) stay on the photo's CAMERA rig.
3
+ // Internal to ./inline3d-splat-playcanvas.js. Design: docs/proposals/layer-display-rig.md.
4
+ //
5
+ // NO KOOIMA HERE. The runtime's projection matrices are used verbatim; nothing below builds a
6
+ // frustum, a tangent or an off-axis matrix. What it does instead is an exact identity:
7
+ //
8
+ // A camera rig is a portal: every view looks through ONE window rectangle on the convergence
9
+ // plane (centre Wc = N0 + D·fwd), from its own eye E_i. The round (display-rig) views look
10
+ // through the SAME window from E'_i = N0 + k·(E_i − N0) — the runtime's ipd/parallax factors
11
+ // are linear about the nominal viewpoint, and the photo's camera rig is the display rig with
12
+ // both factors at n/D (displayxr-common's rig conversion). Two portals through one window
13
+ // differ only by the eye, and the affine shear M_i that fixes the window plane pointwise and
14
+ // sends E'_i to E_i turns one picture into the other:
15
+ //
16
+ // view_round_i = view_photo_i · M_i, proj_round_i = proj_photo_i
17
+ //
18
+ // k = D / (m·n): D the declared convergence (world units), m the declared metersToVirtual, n the
19
+ // nominal viewer distance in metres — the one quantity the browser does not expose (see the
20
+ // design note §5). Everything here is PLAIN ARITHMETIC on arrays, unit-testable without a GPU.
21
+
22
+ /** handle.setLayerRig's rigs. 'camera' is the default every layer starts on. */
23
+ export const LAYER_RIGS = Object.freeze(['display', 'camera']);
24
+
25
+ /** Nominal viewer distance (m) when neither the page nor the platform says: the browser's own. */
26
+ export const DEFAULT_VIEWER_DISTANCE_M = 0.6;
27
+
28
+ /** A round eye may not reach the window plane: h(E') is floored at this fraction of D. */
29
+ const MIN_EYE_HEIGHT_FRAC = 1e-3;
30
+
31
+ /** Rotate v by unit quaternion q (xyzw). */
32
+ function rotate(q, v) {
33
+ const [x, y, z, w] = q;
34
+ const tx = 2 * (y * v[2] - z * v[1]);
35
+ const ty = 2 * (z * v[0] - x * v[2]);
36
+ const tz = 2 * (x * v[1] - y * v[0]);
37
+ return [v[0] + w * tx + (y * tz - z * ty), v[1] + w * ty + (z * tx - x * tz), v[2] + w * tz + (x * ty - y * tx)];
38
+ }
39
+
40
+ /**
41
+ * setLayerRig's arguments, validated (throws at the call).
42
+ * @returns {{ rig: 'display'|'camera', viewerDistance: number|null, gain: number|null }}
43
+ */
44
+ export function validateLayerRig(layer, rig, o = {}) {
45
+ if (!(typeof layer === 'string' && layer.length) && !(Number.isInteger(layer) && layer >= 0) && !(layer && typeof layer === 'object' && Number.isInteger(layer.id))) {
46
+ throw new TypeError('@displayxr/inline3d/splat: setLayerRig(layer, …) — expected a layer name, a layer id, or a pc.Layer.');
47
+ }
48
+ if (!LAYER_RIGS.includes(rig)) {
49
+ throw new Error(`@displayxr/inline3d/splat: setLayerRig — rig "${rig}", expected ${LAYER_RIGS.map((r) => `'${r}'`).join(' or ')}.`);
50
+ }
51
+ if (o === null || typeof o !== 'object') throw new TypeError('@displayxr/inline3d/splat: setLayerRig options must be an object.');
52
+ const num = (k) => {
53
+ if (o[k] === undefined) return null;
54
+ if (!Number.isFinite(o[k]) || !(o[k] > 0)) throw new Error(`@displayxr/inline3d/splat: setLayerRig — bad ${k}: ${o[k]}.`);
55
+ return o[k];
56
+ };
57
+ return { rig, viewerDistance: num('viewerDistance'), gain: num('gain') };
58
+ }
59
+
60
+ /**
61
+ * The frame of a declared CAMERA-rig descriptor (XRViewRigInit shape), or null for anything else
62
+ * (a display rig, no rig, a convergence at infinity — no finite window to hold fixed).
63
+ * @returns {{ N0: number[], fwd: number[], D: number, m: number }|null}
64
+ */
65
+ export function cameraRigFrame(rig) {
66
+ if (!rig || rig.type !== 'camera') return null;
67
+ const inv = rig.convergenceDiopters;
68
+ if (!Number.isFinite(inv) || !(inv > 0)) return null;
69
+ const p = rig.position || { x: 0, y: 0, z: 0 };
70
+ const o = rig.orientation || { x: 0, y: 0, z: 0, w: 1 };
71
+ const m = Number.isFinite(rig.metersToVirtual) && rig.metersToVirtual > 0 ? rig.metersToVirtual : 1;
72
+ const fwd = rotate([o.x || 0, o.y || 0, o.z || 0, o.w === undefined ? 1 : o.w], [0, 0, -1]);
73
+ return { N0: [p.x || 0, p.y || 0, p.z || 0], fwd, D: 1 / inv, m };
74
+ }
75
+
76
+ /** k = D / (m·n), or the caller's explicit gain. */
77
+ export function layerRigGain(frame, { viewerDistance = null, gain = null } = {}) {
78
+ if (gain !== null) return gain;
79
+ const n = viewerDistance !== null ? viewerDistance : DEFAULT_VIEWER_DISTANCE_M;
80
+ return frame.D / (frame.m * n);
81
+ }
82
+
83
+ /**
84
+ * The window-fixing shear for one eye, as a column-major 4×4 (and its inverse), in the space the
85
+ * declared rig and the views share.
86
+ *
87
+ * M(X) = X + v · (ẑ·X − c), ẑ = −fwd, c = ẑ·Wc, v = (E − E') / h(E'), h(X) = ẑ·X − c
88
+ * M⁻¹ = the same form with v' = −v / (1 + ẑ·v)
89
+ *
90
+ * @param {number[]} E the runtime's eye position (the view pose's translation).
91
+ * @returns {{ M: Float64Array, Minv: Float64Array, Eround: number[] }}
92
+ */
93
+ export function windowShear(E, frame, k, M = new Float64Array(16), Minv = new Float64Array(16)) {
94
+ const { N0, fwd, D } = frame;
95
+ const z = [-fwd[0], -fwd[1], -fwd[2]];
96
+ const Wc = [N0[0] + D * fwd[0], N0[1] + D * fwd[1], N0[2] + D * fwd[2]];
97
+ const c = z[0] * Wc[0] + z[1] * Wc[1] + z[2] * Wc[2];
98
+ const Er = [N0[0] + k * (E[0] - N0[0]), N0[1] + k * (E[1] - N0[1]), N0[2] + k * (E[2] - N0[2])];
99
+ let h = z[0] * Er[0] + z[1] * Er[1] + z[2] * Er[2] - c;
100
+ const hMin = MIN_EYE_HEIGHT_FRAC * D;
101
+ if (!(h > hMin)) {
102
+ // Leaning into the glass with a large gain: keep the round eye in front of the window.
103
+ const dh = hMin - h;
104
+ Er[0] += dh * z[0];
105
+ Er[1] += dh * z[1];
106
+ Er[2] += dh * z[2];
107
+ h = hMin;
108
+ }
109
+ const v = [(E[0] - Er[0]) / h, (E[1] - Er[1]) / h, (E[2] - Er[2]) / h];
110
+ shearMatrix(v, z, c, M);
111
+ const s = 1 + z[0] * v[0] + z[1] * v[1] + z[2] * v[2];
112
+ shearMatrix([-v[0] / s, -v[1] / s, -v[2] / s], z, c, Minv);
113
+ return { M, Minv, Eround: Er };
114
+ }
115
+
116
+ /** X ↦ X + v·(z·X − c), column-major. */
117
+ function shearMatrix(v, z, c, out) {
118
+ for (let col = 0; col < 3; col++) {
119
+ for (let row = 0; row < 3; row++) out[col * 4 + row] = (row === col ? 1 : 0) + v[row] * z[col];
120
+ out[col * 4 + 3] = 0;
121
+ }
122
+ out[12] = -v[0] * c;
123
+ out[13] = -v[1] * c;
124
+ out[14] = -v[2] * c;
125
+ out[15] = 1;
126
+ return out;
127
+ }
128
+
129
+ /** Unit quaternion (xyzw) of a rigid column-major 4×4's rotation. */
130
+ function quatOf(m) {
131
+ const m11 = m[0], m12 = m[4], m13 = m[8], m21 = m[1], m22 = m[5], m23 = m[9], m31 = m[2], m32 = m[6], m33 = m[10];
132
+ const tr = m11 + m22 + m33;
133
+ if (tr > 0) {
134
+ const s = 0.5 / Math.sqrt(tr + 1);
135
+ return [(m32 - m23) * s, (m13 - m31) * s, (m21 - m12) * s, 0.25 / s];
136
+ }
137
+ if (m11 > m22 && m11 > m33) {
138
+ const s = 2 * Math.sqrt(1 + m11 - m22 - m33);
139
+ return [0.25 * s, (m12 + m21) / s, (m13 + m31) / s, (m32 - m23) / s];
140
+ }
141
+ if (m22 > m33) {
142
+ const s = 2 * Math.sqrt(1 + m22 - m11 - m33);
143
+ return [(m12 + m21) / s, 0.25 * s, (m23 + m32) / s, (m13 - m31) / s];
144
+ }
145
+ const s = 2 * Math.sqrt(1 + m33 - m11 - m22);
146
+ return [(m13 + m31) / s, (m23 + m32) / s, 0.25 * s, (m21 - m12) / s];
147
+ }
148
+
149
+ /** a × b, column-major (local, so this module stays standalone). */
150
+ export function mul4(a, b, out = new Float64Array(16)) {
151
+ const r = new Float64Array(16);
152
+ for (let col = 0; col < 4; col++) {
153
+ for (let row = 0; row < 4; row++) {
154
+ r[col * 4 + row] = a[row] * b[col * 4] + a[4 + row] * b[col * 4 + 1] + a[8 + row] * b[col * 4 + 2] + a[12 + row] * b[col * 4 + 3];
155
+ }
156
+ }
157
+ out.set(r);
158
+ return out;
159
+ }
160
+
161
+ /** Inverse of a rigid (rotation + translation) column-major 4×4. */
162
+ export function invertRigid(m, out = new Float64Array(16)) {
163
+ const r = [m[0], m[1], m[2], m[4], m[5], m[6], m[8], m[9], m[10]];
164
+ out[0] = r[0]; out[1] = r[3]; out[2] = r[6]; out[3] = 0;
165
+ out[4] = r[1]; out[5] = r[4]; out[6] = r[7]; out[7] = 0;
166
+ out[8] = r[2]; out[9] = r[5]; out[10] = r[8]; out[11] = 0;
167
+ const tx = m[12], ty = m[13], tz = m[14];
168
+ out[12] = -(out[0] * tx + out[4] * ty + out[8] * tz);
169
+ out[13] = -(out[1] * tx + out[5] * ty + out[9] * tz);
170
+ out[14] = -(out[2] * tx + out[6] * ty + out[10] * tz);
171
+ out[15] = 1;
172
+ return out;
173
+ }
174
+
175
+ /**
176
+ * The round views for one frame: for each runtime view pose P_i (camera → rig space, rigid),
177
+ * `viewInv = M_i⁻¹ · P_i` and `view = P_i⁻¹ · M_i` — what RenderView.setView takes. Null (the
178
+ * caller keeps the photo views) when there is nothing to round: no camera rig, fewer than two
179
+ * views, or a gain of exactly 1.
180
+ */
181
+ export function roundViews(entries, frame, k, into = []) {
182
+ if (!frame || !entries || entries.length < 2 || !(k > 0) || k === 1) return null;
183
+ into.length = entries.length;
184
+ for (let i = 0; i < entries.length; i++) {
185
+ const P = entries[i].pose;
186
+ const o = (into[i] ||= { viewInv: new Float64Array(16), view: new Float64Array(16), M: new Float64Array(16), Minv: new Float64Array(16), eye: [0, 0, 0] });
187
+ const { Eround } = windowShear([P[12], P[13], P[14]], frame, k, o.M, o.Minv);
188
+ o.eye[0] = Eround[0];
189
+ o.eye[1] = Eround[1];
190
+ o.eye[2] = Eround[2];
191
+ mul4(o.Minv, P, o.viewInv);
192
+ mul4(invertRigid(P), o.M, o.view);
193
+ }
194
+ return into;
195
+ }
196
+
197
+ /**
198
+ * Split a layer composition into contiguous RUNS for per-rig cameras. `entries` is the
199
+ * composition's sublayer list as layer ids, in draw order (opaque and transparent sublayers both
200
+ * appear); `display` the set of display-rig layer ids; `eyeLayers` the ids the eye camera draws.
201
+ * PlayCanvas orders render actions by CAMERA, then layer — so keeping today's order with a second
202
+ * rig needs one camera per run:
203
+ * pre — eye-camera layers with any sublayer before the first display sublayer (stay put);
204
+ * display — the display layers the eye camera drew;
205
+ * post — eye-camera layers whose every sublayer comes after the last display sublayer.
206
+ * A non-display layer INTERLEAVED between display sublayers stays in `pre` (it would otherwise
207
+ * split a run) and is reported in `interleaved`.
208
+ */
209
+ export function layerRuns(entries, display, eyeLayers) {
210
+ const eye = new Set(eyeLayers);
211
+ let first = -1;
212
+ let last = -1;
213
+ for (let i = 0; i < entries.length; i++) {
214
+ if (display.has(entries[i]) && eye.has(entries[i])) {
215
+ if (first < 0) first = i;
216
+ last = i;
217
+ }
218
+ }
219
+ const out = { pre: [], display: [], post: [], interleaved: [] };
220
+ if (first < 0) {
221
+ out.pre = [...eye];
222
+ return out;
223
+ }
224
+ const seen = new Set();
225
+ for (const id of eyeLayers) {
226
+ if (seen.has(id)) continue;
227
+ seen.add(id);
228
+ if (display.has(id)) {
229
+ out.display.push(id);
230
+ continue;
231
+ }
232
+ const at = [];
233
+ for (let i = 0; i < entries.length; i++) if (entries[i] === id) at.push(i);
234
+ if (at.length && at.every((i) => i > last)) out.post.push(id);
235
+ else {
236
+ out.pre.push(id);
237
+ if (at.some((i) => i > first && i < last)) out.interleaved.push(id);
238
+ }
239
+ }
240
+ // Keep the run cameras' layer lists in draw order (the composition's, not the eye's list).
241
+ const order = (ids) => ids.sort((a, b) => entries.indexOf(a) - entries.indexOf(b));
242
+ order(out.display);
243
+ order(out.post);
244
+ return out;
245
+ }
246
+
247
+ // ── the engine side ──────────────────────────────────────────────────────────────────────────
248
+
249
+ /**
250
+ * The per-rig cameras of one tile. Owned by the viewer; `sync` + `frame` are called by its draw.
251
+ * The eye camera keeps its `layers` minus what moved; `restore()` puts them back.
252
+ */
253
+ export class LayerRigCameras {
254
+ constructor(viewer) {
255
+ this.viewer = viewer;
256
+ /** What the page asked for: name or id → { viewerDistance, gain }. */
257
+ this.requests = new Map();
258
+ /** Those, resolved: layer id → options (the ones present in the composition right now). */
259
+ this.layers = new Map();
260
+ /** The gain options in force (the last setLayerRig call's — one gain per tile). */
261
+ this.opts = {};
262
+ this.disabled = false; // the kill switch (diag 'nolayerrig')
263
+ this.cams = { display: null, post: null };
264
+ this.moved = { display: [], post: [] };
265
+ this._key = '';
266
+ this._rvs = { display: [], post: [] };
267
+ this._round = [];
268
+ /** Diagnostics: what the last frame did. */
269
+ this.last = { rounded: false, gain: null, views: 0 };
270
+ }
271
+
272
+ get active() {
273
+ return !this.disabled && this.requests.size > 0;
274
+ }
275
+
276
+ /** Resolve a name / id / pc.Layer to an id in this tile's composition (null if unknown). */
277
+ resolve(layer) {
278
+ const comp = this.viewer.app?.scene?.layers;
279
+ if (!comp) return null;
280
+ if (layer && typeof layer === 'object') return layer.id;
281
+ if (Number.isInteger(layer)) return layer;
282
+ const l = comp.getLayerByName?.(layer);
283
+ return l ? l.id : null;
284
+ }
285
+
286
+ /**
287
+ * Record a request by the caller's key (name, id): resolved against the composition on every
288
+ * sync, so a layer the page adds AFTER calling setLayerRig is picked up when it appears.
289
+ */
290
+ set(key, rig, o) {
291
+ const k = key && typeof key === 'object' ? key.id : key;
292
+ if (rig === 'display') this.requests.set(k, o);
293
+ else this.requests.delete(k);
294
+ this.opts = o;
295
+ this._key = ''; // re-split on the next draw
296
+ }
297
+
298
+ _resolveAll() {
299
+ this.layers.clear();
300
+ for (const [k, o] of this.requests) {
301
+ const id = this.resolve(k);
302
+ if (id !== null && id !== undefined) this.layers.set(id, o);
303
+ }
304
+ }
305
+
306
+ _makeCam(name, priority) {
307
+ const v = this.viewer;
308
+ const cam = v._makeCamera(name, null);
309
+ cam.camera.priority = priority;
310
+ cam.camera.clearColorBuffer = false;
311
+ cam.camera.clearDepthBuffer = false;
312
+ cam.camera.clearStencilBuffer = false;
313
+ cam.camera.frustumCulling = false;
314
+ cam.camera.layers = [];
315
+ return cam;
316
+ }
317
+
318
+ /** Re-split the composition when the display set, the eye's layers or the composition moved. */
319
+ sync() {
320
+ const v = this.viewer;
321
+ const eyeCam = v.eye?.camera;
322
+ const comp = v.app?.scene?.layers;
323
+ if (!eyeCam || !comp) return false;
324
+ if (!this.active) {
325
+ if (this.moved.display.length || this.moved.post.length) this.restore();
326
+ return false;
327
+ }
328
+ this._resolveAll();
329
+ const entries = (comp.layerList || []).map((l) => l.id);
330
+ const eyeLayers = [...(eyeCam.layers || []), ...this.moved.display, ...this.moved.post];
331
+ const key = `${[...this.layers.keys()].join(',')}|${entries.join(',')}|${(eyeCam.layers || []).join(',')}`;
332
+ if (key === this._key) return this.moved.display.length > 0;
333
+ const runs = layerRuns(entries, new Set(this.layers.keys()), eyeLayers);
334
+ if (runs.interleaved.length && !this._warnedInterleaved) {
335
+ this._warnedInterleaved = true;
336
+ console.warn(
337
+ `[inline3d/splat] setLayerRig: layer(s) ${runs.interleaved.join(', ')} sit BETWEEN display-rig ` +
338
+ 'sublayers; they are drawn before them (with the eye camera). Move them before or after the display layers.',
339
+ );
340
+ }
341
+ this.moved.display = runs.display;
342
+ this.moved.post = runs.post;
343
+ eyeCam.layers = runs.pre;
344
+ if (runs.display.length) {
345
+ this.cams.display ||= this._makeCam('inline3d-eye-display', 1);
346
+ this.cams.display.camera.layers = runs.display;
347
+ }
348
+ if (runs.post.length) {
349
+ this.cams.post ||= this._makeCam('inline3d-eye-post', 2);
350
+ this.cams.post.camera.layers = runs.post;
351
+ }
352
+ if (this.cams.display) this.cams.display.enabled = runs.display.length > 0;
353
+ if (this.cams.post) this.cams.post.enabled = runs.post.length > 0;
354
+ this._key = `${[...this.layers.keys()].join(',')}|${entries.join(',')}|${eyeCam.layers.join(',')}`;
355
+ return runs.display.length > 0;
356
+ }
357
+
358
+ /** Every moved layer back on the eye camera, run cameras off (the kill switch / last layer off). */
359
+ restore() {
360
+ const eyeCam = this.viewer.eye?.camera;
361
+ if (eyeCam) {
362
+ const back = [...this.moved.display, ...this.moved.post];
363
+ eyeCam.layers = [...(eyeCam.layers || []), ...back.filter((id) => !(eyeCam.layers || []).includes(id))];
364
+ }
365
+ this.moved = { display: [], post: [] };
366
+ if (this.cams.display) this.cams.display.enabled = false;
367
+ if (this.cams.post) this.cams.post.enabled = false;
368
+ this._key = '';
369
+ }
370
+
371
+ /**
372
+ * Per drawn frame, after the eye camera's RenderViews are set. `rig` is the descriptor the
373
+ * runtime located these views with (the viewer snapshots it before the tick can declare anew).
374
+ */
375
+ frame(entries, rect, f, rig) {
376
+ const pc = this.viewer.pc;
377
+ this.last.rounded = false;
378
+ this.last.views = entries.length;
379
+ const frame = cameraRigFrame(rig);
380
+ const k = frame ? layerRigGain(frame, this.opts || {}) : null;
381
+ const round = frame ? roundViews(entries, frame, k, this._round) : null;
382
+ this.last.gain = k;
383
+ for (const run of ['display', 'post']) {
384
+ const cam = this.cams[run];
385
+ if (!cam || !cam.enabled) continue;
386
+ const rvs = this._rvs[run];
387
+ if (rvs.length !== entries.length) {
388
+ rvs.length = 0;
389
+ for (let i = 0; i < entries.length; i++) rvs.push(new pc.RenderView());
390
+ cam.camera.camera.xrViews = rvs.slice();
391
+ }
392
+ const r = run === 'display' ? round : null;
393
+ for (let i = 0; i < entries.length; i++) {
394
+ const e = entries[i];
395
+ if (r) rvs[i].setView(e.proj, r[i].viewInv, r[i].view);
396
+ else rvs[i].setView(e.proj, e.pose);
397
+ const [x, y, w, h] = rect(e);
398
+ rvs[i].setViewport(x, y, w, h);
399
+ }
400
+ if (cam._frustumKey !== this.viewer._frustumKey) {
401
+ cam._frustumKey = this.viewer._frustumKey;
402
+ cam.camera.camera.setXrProperties({ ...f, horizontalFov: false });
403
+ }
404
+ // The node drives transparent sorting (the views ignore it): on the (round) first eye.
405
+ const p = entries[0].pose;
406
+ const q = quatOf(p);
407
+ cam.setLocalRotation(q[0], q[1], q[2], q[3]);
408
+ if (r) cam.setLocalPosition(r[0].eye[0], r[0].eye[1], r[0].eye[2]);
409
+ else cam.setLocalPosition(p[12], p[13], p[14]);
410
+ if (run === 'display') {
411
+ // Two camera spaces never share a depth test; in mono (and at gain 1) they are one space.
412
+ cam.camera.clearDepthBuffer = !!r;
413
+ this.last.rounded = !!r;
414
+ }
415
+ }
416
+ }
417
+
418
+ destroy() {
419
+ this.restore();
420
+ for (const c of Object.values(this.cams)) c?.destroy?.();
421
+ this.cams = { display: null, post: null };
422
+ }
423
+ }