@displayxr/inline3d 1.22.0 → 1.24.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,369 @@
1
+ // inline3d-splat-rig-map.js — each photo through ITS OWN camera rig, whatever camera rig the
2
+ // runtime's views were located for. Internal to ./inline3d-splat-playcanvas.js (and
3
+ // ./inline3d-splat-live.js).
4
+ //
5
+ // THE BUG THIS FIXES. A live transition (crossfade, wavefront, the particle transitions) keeps the
6
+ // outgoing photo on screen while the incoming photo's rig is declared. The runtime's views are
7
+ // then the INCOMING rig's, and the outgoing photo was mapped back to its own framing by a node
8
+ // chain N = R_o·K_o·D_o·D_n⁻¹·K_n⁻¹, D = diag(c·t, c·t, c). That maps the incoming WINDOW onto
9
+ // the outgoing one exactly, but it scales the EYES with it: a camera rig's eyes do not scale with
10
+ // its window (they are the viewer's eyes, times metersToVirtual), so the outgoing photo was drawn
11
+ // from eyes scaled by (c_o·t_o)/(c_n·t_n) — its disparity and head parallax jumped by that factor
12
+ // the moment the rig switched. And a declaration reaches the views a frame or more later, so for
13
+ // those frames the chain mapped views that were still the OUTGOING rig's (and the incoming photo
14
+ // was drawn through them).
15
+ //
16
+ // NO KOOIMA HERE, and no frustum is built. The runtime's projection matrices are used verbatim
17
+ // (only their depth rows are rescaled, as clampProjectionDepth already does). What this does is
18
+ // exact algebra on the runtime's outputs and the descriptors we declared:
19
+ //
20
+ // 1. A camera rig is a PORTAL (displayxr-common's dxr_camera3d_compute_view): every view looks,
21
+ // from its own eye, through ONE window rectangle on the convergence plane — centre
22
+ // Wc = N0 + D·fwd, half-height t·D (t = tan(vfov/2)), half-width aspect·t·D — and its eye is
23
+ // N0 + R·m·(π·c̄ + ι·(q − c̄)) (q the viewer's eye relative to the nominal viewer, c̄ their
24
+ // centroid, π/ι the parallax/ipd factors, m metersToVirtual).
25
+ // 2. WHICH rig a view set was located for is read off the views: under the right descriptor
26
+ // every view's frustum crosses the window plane exactly in the window (`viewResidual`). The
27
+ // candidates are the descriptors declared recently (`RigTracker`); none within tolerance ⇒
28
+ // unknown ⇒ nothing is remapped (the caller keeps its old path).
29
+ // 3. From the located rig F to a target rig T, per view: the eye maps exactly (step 1 inverted
30
+ // and re-applied — the eye centroid and offsets come from the views themselves), and the
31
+ // affine map A_i that sends F's window onto T's (in-plane scale σ = t_T·D_T / t_F·D_F, the
32
+ // same for x and y: both windows have the canvas's aspect) and F's eye onto T's eye maps
33
+ // every ray from F's eye through a window point onto the ray from T's eye through the
34
+ // matching window point. So
35
+ //
36
+ // viewInv_T,i = A_i · viewInv_F,i proj_T,i = proj_F,i (depth rows rescaled)
37
+ //
38
+ // renders EXACTLY what T's own view i would (same pixel for every point: both frusta are
39
+ // the pyramid through the window, and A_i maps one pyramid onto the other with the window
40
+ // rectangle onto the window rectangle). Vertical FOV, convergence, metersToVirtual and the
41
+ // two factors may all differ. Only the depth mapping moves (A_i scales depth by a_z), which
42
+ // is why the near/far planes are divided by a_z: the target keeps the runtime's near/far.
43
+ //
44
+ // A display rig is NOT mapped: its eyes are absolute (the viewer's z includes the nominal
45
+ // distance, which the browser does not expose), so display ↔ camera would need that one scalar.
46
+ // Everything here is PLAIN ARITHMETIC on arrays, unit-testable without a GPU.
47
+
48
+ /** A view set matches a rig when every residual is below this, in window HALF-HEIGHTS (≈0.2 px at 720 px). */
49
+ export const RIG_MATCH_TOL = 5e-4;
50
+
51
+ /** How many recently declared rigs the tracker keeps as candidates. */
52
+ export const RIG_HISTORY = 8;
53
+
54
+ const FIELDS = ['convergenceDiopters', 'verticalFov', 'metersToVirtual', 'ipdFactor', 'parallaxFactor', 'virtualDisplayHeight', 'perspectiveFactor'];
55
+
56
+ /** Rotate v by unit quaternion q (xyzw). */
57
+ function rotate(q, v) {
58
+ const [x, y, z, w] = q;
59
+ const tx = 2 * (y * v[2] - z * v[1]);
60
+ const ty = 2 * (z * v[0] - x * v[2]);
61
+ const tz = 2 * (x * v[1] - y * v[0]);
62
+ 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)];
63
+ }
64
+
65
+ const dot = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
66
+
67
+ /** A value copy of a declared descriptor (the SDK rewrites its camera-rig object in place), or null. */
68
+ export function rigSnapshot(rig) {
69
+ if (!rig || typeof rig !== 'object') return null;
70
+ const p = rig.position || {};
71
+ const q = rig.orientation || {};
72
+ const o = {
73
+ type: rig.type,
74
+ position: { x: +p.x || 0, y: +p.y || 0, z: +p.z || 0 },
75
+ orientation: { x: +q.x || 0, y: +q.y || 0, z: +q.z || 0, w: q.w === undefined ? 1 : +q.w },
76
+ };
77
+ for (const k of FIELDS) if (rig[k] !== undefined) o[k] = rig[k];
78
+ return o;
79
+ }
80
+
81
+ /** Same declared values (snapshots)? */
82
+ export function sameRig(a, b) {
83
+ if (a === b) return true;
84
+ if (!a || !b || a.type !== b.type) return false;
85
+ for (const k of ['x', 'y', 'z']) if (a.position[k] !== b.position[k]) return false;
86
+ for (const k of ['x', 'y', 'z', 'w']) if (a.orientation[k] !== b.orientation[k]) return false;
87
+ for (const k of FIELDS) if (a[k] !== b[k]) return false;
88
+ return true;
89
+ }
90
+
91
+ /**
92
+ * The portal of a CAMERA-rig descriptor, or null (a display rig, convergence at infinity, a
93
+ * factor of 0 — nothing to invert — or a non-finite field).
94
+ * @returns {{ N0: number[], q: number[], right: number[], up: number[], back: number[], Wc: number[], D: number, t: number, m: number, ipd: number, par: number }|null}
95
+ */
96
+ export function cameraPortal(rig) {
97
+ if (!rig || rig.type !== 'camera') return null;
98
+ const inv = rig.convergenceDiopters;
99
+ const vfov = rig.verticalFov;
100
+ if (!(Number.isFinite(inv) && inv > 0) || !(Number.isFinite(vfov) && vfov > 0 && vfov < Math.PI)) return null;
101
+ const m = Number.isFinite(rig.metersToVirtual) && rig.metersToVirtual > 0 ? rig.metersToVirtual : 1;
102
+ const ipd = rig.ipdFactor ?? 1;
103
+ const par = rig.parallaxFactor ?? 1;
104
+ if (!(Number.isFinite(ipd) && ipd > 0) || !(Number.isFinite(par) && par > 0)) return null;
105
+ const p = rig.position || {};
106
+ const o = rig.orientation || {};
107
+ let q = [+o.x || 0, +o.y || 0, +o.z || 0, o.w === undefined ? 1 : +o.w];
108
+ const qn = Math.hypot(q[0], q[1], q[2], q[3]);
109
+ if (!(qn > 0)) return null;
110
+ q = q.map((c) => c / qn);
111
+ const N0 = [+p.x || 0, +p.y || 0, +p.z || 0];
112
+ const right = rotate(q, [1, 0, 0]);
113
+ const up = rotate(q, [0, 1, 0]);
114
+ const back = rotate(q, [0, 0, 1]);
115
+ const D = 1 / inv;
116
+ const Wc = [N0[0] - D * back[0], N0[1] - D * back[1], N0[2] - D * back[2]];
117
+ return { N0, q, right, up, back, Wc, D, t: Math.tan(vfov / 2), m, ipd, par };
118
+ }
119
+
120
+ /** Window-local coordinates (right, up, toward the viewer) of a point, about the window centre. */
121
+ function local(P, X) {
122
+ const d = [X[0] - P.Wc[0], X[1] - P.Wc[1], X[2] - P.Wc[2]];
123
+ return [dot(P.right, d), dot(P.up, d), dot(P.back, d)];
124
+ }
125
+
126
+ /**
127
+ * How far one runtime view is from being a view of portal P, in window half-heights: its
128
+ * rotation must be P's, and its frustum must cross P's window plane exactly in the window
129
+ * (centred on Wc, t·D tall). Reads the projection's off-axis tangents; builds nothing.
130
+ */
131
+ export function viewResidual(entry, P) {
132
+ const m = entry.pose;
133
+ const pr = entry.proj;
134
+ // rotation: the pose's columns against the rig's axes (unit vectors; 1e-7 is float32 noise)
135
+ const rot = Math.max(
136
+ Math.abs(m[0] - P.right[0]), Math.abs(m[1] - P.right[1]), Math.abs(m[2] - P.right[2]),
137
+ Math.abs(m[4] - P.up[0]), Math.abs(m[5] - P.up[1]), Math.abs(m[6] - P.up[2]),
138
+ Math.abs(m[8] - P.back[0]), Math.abs(m[9] - P.back[1]), Math.abs(m[10] - P.back[2]),
139
+ );
140
+ if (!(pr[0] > 0) || !(pr[5] > 0) || Math.abs(pr[11] + 1) > 1e-6 || Math.abs(pr[4]) > 1e-6) return Infinity;
141
+ const u = local(P, [m[12], m[13], m[14]]);
142
+ if (!(u[2] > 0)) return Infinity;
143
+ const tl = (pr[8] - 1) / pr[0];
144
+ const tr = (pr[8] + 1) / pr[0];
145
+ const tb = (pr[9] - 1) / pr[5];
146
+ const tt = (pr[9] + 1) / pr[5];
147
+ const h = P.t * P.D;
148
+ const cx = u[0] + 0.5 * (tl + tr) * u[2];
149
+ const cy = u[1] + 0.5 * (tb + tt) * u[2];
150
+ const hy = 0.5 * (tt - tb) * u[2];
151
+ return Math.max(rot, Math.abs(cx) / h, Math.abs(cy) / h, Math.abs(hy - h) / h);
152
+ }
153
+
154
+ /** The worst view's residual for portal P (Infinity when P is null). */
155
+ export function viewsResidual(entries, P) {
156
+ if (!P || !entries?.length) return Infinity;
157
+ let r = 0;
158
+ for (const e of entries) r = Math.max(r, viewResidual(e, P));
159
+ return r;
160
+ }
161
+
162
+ function mul4(a, b, out = new Float64Array(16)) {
163
+ const r = new Float64Array(16);
164
+ for (let c = 0; c < 4; c++) {
165
+ for (let row = 0; row < 4; row++) {
166
+ r[c * 4 + row] = a[row] * b[c * 4] + a[4 + row] * b[c * 4 + 1] + a[8 + row] * b[c * 4 + 2] + a[12 + row] * b[c * 4 + 3];
167
+ }
168
+ }
169
+ out.set(r);
170
+ return out;
171
+ }
172
+
173
+ /** Inverse of an affine column-major 4×4 (last row 0 0 0 1). Null when singular. */
174
+ export function invertAffine(m, out = new Float64Array(16)) {
175
+ const a = m[0], b = m[4], c = m[8];
176
+ const d = m[1], e = m[5], f = m[9];
177
+ const g = m[2], h = m[6], i = m[10];
178
+ const A = e * i - f * h, B = -(d * i - f * g), C = d * h - e * g;
179
+ const det = a * A + b * B + c * C;
180
+ if (!(Math.abs(det) > 1e-300)) return null;
181
+ const k = 1 / det;
182
+ const r00 = A * k, r01 = -(b * i - c * h) * k, r02 = (b * f - c * e) * k;
183
+ const r10 = B * k, r11 = (a * i - c * g) * k, r12 = -(a * f - c * d) * k;
184
+ const r20 = C * k, r21 = -(a * h - b * g) * k, r22 = (a * e - b * d) * k;
185
+ const tx = m[12], ty = m[13], tz = m[14];
186
+ out[0] = r00; out[1] = r10; out[2] = r20; out[3] = 0;
187
+ out[4] = r01; out[5] = r11; out[6] = r21; out[7] = 0;
188
+ out[8] = r02; out[9] = r12; out[10] = r22; out[11] = 0;
189
+ out[12] = -(r00 * tx + r01 * ty + r02 * tz);
190
+ out[13] = -(r10 * tx + r11 * ty + r12 * tz);
191
+ out[14] = -(r20 * tx + r21 * ty + r22 * tz);
192
+ out[15] = 1;
193
+ return out;
194
+ }
195
+
196
+ /**
197
+ * The eyes of target portal T for the same physical eyes that produced these views of portal F
198
+ * (dxr_display3d_apply_eye_factors_n inverted for F, re-applied for T). Rig space.
199
+ */
200
+ export function mapEyes(entries, F, T) {
201
+ const n = entries.length;
202
+ const l = [];
203
+ const c = [0, 0, 0];
204
+ for (const e of entries) {
205
+ const d = [e.pose[12] - F.N0[0], e.pose[13] - F.N0[1], e.pose[14] - F.N0[2]];
206
+ const v = [dot(F.right, d) / F.m, dot(F.up, d) / F.m, dot(F.back, d) / F.m];
207
+ l.push(v);
208
+ c[0] += v[0] / n; c[1] += v[1] / n; c[2] += v[2] / n;
209
+ }
210
+ const kc = T.par / F.par;
211
+ const ko = T.ipd / F.ipd;
212
+ return l.map((v) => {
213
+ const w = [0, 1, 2].map((k) => T.m * (kc * c[k] + ko * (v[k] - c[k])));
214
+ return [0, 1, 2].map((k) => T.N0[k] + T.right[k] * w[0] + T.up[k] * w[1] + T.back[k] * w[2]);
215
+ });
216
+ }
217
+
218
+ /**
219
+ * The affine map A (column-major 4×4) that sends F's window onto T's window (in-plane scale σ)
220
+ * and eye Ef onto eye Et. Null when either eye is not in front of its window.
221
+ */
222
+ export function portalMap(F, T, Ef, Et, sigma = (T.t * T.D) / (F.t * F.D)) {
223
+ const uf = local(F, Ef);
224
+ const ut = local(T, Et);
225
+ if (!(uf[2] > 0) || !(ut[2] > 0)) return null;
226
+ // L (window-local, F → T): [σ 0 ax; 0 σ ay; 0 0 az], with L·uf = ut.
227
+ const ax = (ut[0] - sigma * uf[0]) / uf[2];
228
+ const ay = (ut[1] - sigma * uf[1]) / uf[2];
229
+ const az = ut[2] / uf[2];
230
+ // A(X) = Wc_T + B_T · L · B_Fᵀ · (X − Wc_F), B = [right up back] (columns).
231
+ const BT = [T.right, T.up, T.back];
232
+ const BF = [F.right, F.up, F.back];
233
+ const Lc = [[sigma, 0, 0], [0, sigma, 0], [ax, ay, az]]; // columns of L
234
+ // M = B_T · L · B_Fᵀ: column j = Σ_k (B_T · L)_col_k · BF[k][j]
235
+ const TL = Lc.map((col) => [0, 1, 2].map((r) => BT[0][r] * col[0] + BT[1][r] * col[1] + BT[2][r] * col[2]));
236
+ const A = new Float64Array(16);
237
+ for (let j = 0; j < 3; j++) {
238
+ for (let r = 0; r < 3; r++) A[j * 4 + r] = TL[0][r] * BF[0][j] + TL[1][r] * BF[1][j] + TL[2][r] * BF[2][j];
239
+ }
240
+ for (let r = 0; r < 3; r++) A[12 + r] = T.Wc[r] - (A[r] * F.Wc[0] + A[4 + r] * F.Wc[1] + A[8 + r] * F.Wc[2]);
241
+ A[15] = 1;
242
+ return { A, az };
243
+ }
244
+
245
+ /** Rewrite a projection's depth rows for near/far divided by `k` (the map's depth scale). */
246
+ function scaleDepth(P, k, out) {
247
+ out.set(P);
248
+ if (!(k > 0) || k === 1) return out;
249
+ if (Math.abs(P[11] + 1) > 1e-6 || Math.abs(P[15]) > 1e-6) return out; // not a perspective: leave it
250
+ const n = P[14] / (P[10] - 1);
251
+ if (Math.abs(P[10] + 1) < 1e-9) {
252
+ out[14] = -2 * (n / k); // infinite far: only the near plane moves
253
+ return out;
254
+ }
255
+ const f = P[14] / (P[10] + 1);
256
+ const n2 = n / k;
257
+ const f2 = f / k;
258
+ out[10] = -(f2 + n2) / (f2 - n2);
259
+ out[14] = (-2 * f2 * n2) / (f2 - n2);
260
+ return out;
261
+ }
262
+
263
+ /**
264
+ * These views (located for portal F) as portal T's views. Per view: { proj, viewInv, view, eye }
265
+ * — what RenderView.setView takes, and the target eye (rig space) for the camera node. Null when
266
+ * there is nothing to do (F and T the same portal) or the map does not exist (an eye at or behind
267
+ * a window): the caller keeps the views as they are.
268
+ */
269
+ export function remapViews(entries, F, T, into = []) {
270
+ if (!F || !T || !entries?.length) return null;
271
+ if (F === T) return null;
272
+ const Et = mapEyes(entries, F, T);
273
+ const sigma = (T.t * T.D) / (F.t * F.D);
274
+ into.length = entries.length;
275
+ for (let i = 0; i < entries.length; i++) {
276
+ const e = entries[i];
277
+ const Ef = [e.pose[12], e.pose[13], e.pose[14]];
278
+ const pm = portalMap(F, T, Ef, Et[i], sigma);
279
+ if (!pm) return null;
280
+ const o = (into[i] ||= { proj: new Float64Array(16), viewInv: new Float64Array(16), view: new Float64Array(16), eye: [0, 0, 0] });
281
+ mul4(pm.A, e.pose, o.viewInv);
282
+ if (!invertAffine(o.viewInv, o.view)) return null;
283
+ scaleDepth(e.proj, pm.az, o.proj);
284
+ o.eye[0] = Et[i][0];
285
+ o.eye[1] = Et[i][1];
286
+ o.eye[2] = Et[i][2];
287
+ o.x = e.x;
288
+ o.y = e.y;
289
+ o.width = e.width;
290
+ o.height = e.height;
291
+ }
292
+ return into;
293
+ }
294
+
295
+ /** Two portals with the same window (pose, convergence, vertical FOV) — only their eyes can differ. */
296
+ export function sameWindow(a, b) {
297
+ return a.D === b.D && a.t === b.t && a.N0.every((x, k) => x === b.N0[k]) && a.q.every((x, k) => x === b.q[k]);
298
+ }
299
+
300
+ /** Rigid pose matrix (column-major) from a portal's orientation at `eye` — a camera node's pose. */
301
+ export function nodePose(T, eye, out = new Float64Array(16)) {
302
+ out[0] = T.right[0]; out[1] = T.right[1]; out[2] = T.right[2]; out[3] = 0;
303
+ out[4] = T.up[0]; out[5] = T.up[1]; out[6] = T.up[2]; out[7] = 0;
304
+ out[8] = T.back[0]; out[9] = T.back[1]; out[10] = T.back[2]; out[11] = 0;
305
+ out[12] = eye[0]; out[13] = eye[1]; out[14] = eye[2]; out[15] = 1;
306
+ return out;
307
+ }
308
+
309
+ /**
310
+ * The rigs this tile declared, newest last, and which one a view set was located for.
311
+ * `note(rig)` on every declaration; `locate(entries)` per drawn frame.
312
+ */
313
+ export class RigTracker {
314
+ constructor({ history = RIG_HISTORY, tol = RIG_MATCH_TOL } = {}) {
315
+ this.history = history;
316
+ this.tol = tol;
317
+ /** [{ id, rig (snapshot), portal (or null) }], oldest first. */
318
+ this.rigs = [];
319
+ this._seq = 0;
320
+ }
321
+
322
+ /** A declaration (the value, not the object: the SDK rewrites its descriptor in place). */
323
+ note(rig) {
324
+ const snap = rigSnapshot(rig);
325
+ const last = this.rigs[this.rigs.length - 1];
326
+ if (last && sameRig(last.rig, snap)) return last;
327
+ const entry = { id: ++this._seq, rig: snap, portal: cameraPortal(snap) };
328
+ this.rigs.push(entry);
329
+ if (this.rigs.length > this.history) this.rigs.splice(0, this.rigs.length - this.history);
330
+ return entry;
331
+ }
332
+
333
+ /** The rig declared last (what the drawing photo wants now), or null. */
334
+ get latest() {
335
+ return this.rigs[this.rigs.length - 1] || null;
336
+ }
337
+
338
+ /**
339
+ * The declared camera rig these views were located for, or null when none matches within
340
+ * tolerance (a display rig, a runtime clamp, a rig older than the history).
341
+ *
342
+ * The views pin down the WINDOW (pose, convergence, vertical FOV) exactly, but not the eye
343
+ * factors: any eyes are consistent with a window (metersToVirtual, ipdFactor and
344
+ * parallaxFactor only move the eyes). When several candidates match — rigs that differ ONLY in
345
+ * those — `prefer` wins if it is among them (the rig declared when these views were pulled:
346
+ * Blink's one frame of latency), else the newest.
347
+ */
348
+ locate(entries, prefer = null) {
349
+ let best = null;
350
+ let bestR = Infinity;
351
+ const matches = [];
352
+ for (let i = this.rigs.length - 1; i >= 0; i--) {
353
+ const c = this.rigs[i];
354
+ if (!c.portal) continue;
355
+ const r = viewsResidual(entries, c.portal);
356
+ if (r <= this.tol) matches.push(c); // newest first
357
+ if (r < bestR) {
358
+ bestR = r;
359
+ best = c;
360
+ }
361
+ }
362
+ this.lastResidual = bestR;
363
+ if (!best || !(bestR <= this.tol)) return null;
364
+ // Among the rigs sharing the best match's window (they differ only in the eye factors):
365
+ // `prefer` if it is one of them, else the newest.
366
+ const group = matches.filter((c) => sameWindow(c.portal, best.portal));
367
+ return group.includes(prefer) ? prefer : group[0];
368
+ }
369
+ }
@@ -303,3 +303,100 @@ export class VideoPlane {
303
303
  this.mesh.destroy?.();
304
304
  }
305
305
  }
306
+
307
+ // ── handle.makeSbsMaterial(): per-eye stereo on ANY quad (docs/proposals/layer-display-rig.md) ──
308
+ //
309
+ // setVideo's eye pick, lifted out of the full-screen plane: the eye viewports sit side by side in
310
+ // the buffer, so `gl_FragCoord.x >= split` is the right eye. The viewer publishes that split every
311
+ // draw as a SCENE-WIDE uniform (EYE_SPLIT_UNIFORM, 1e9 in mono / the 2D tier / a 1-view mode), so a
312
+ // material only has to declare it — the SDK never has to know which materials are stereo.
313
+ // PlayCanvas's own `view_index` is set per RenderView on the single-camera path too, but it is 0
314
+ // for every camera on the N-camera fallback path; the split is right on both.
315
+
316
+ /** The scene-wide uniform the viewer sets every draw: first right-eye pixel column (1e9 = mono). */
317
+ export const EYE_SPLIT_UNIFORM = 'dxr_eye_split';
318
+
319
+ /**
320
+ * GLSL for a custom shader that wants the same pick: declare the uniform, call dxrEyeRegion() with
321
+ * the left and right regions ([s0, t0, ds, dt], t = image rows from the top).
322
+ */
323
+ export const SBS_EYE_GLSL = `
324
+ uniform float ${EYE_SPLIT_UNIFORM};
325
+ vec4 dxrEyeRegion(vec4 left, vec4 right) { return gl_FragCoord.x >= ${EYE_SPLIT_UNIFORM} ? right : left; }`;
326
+
327
+ const SBS_OPTION_KEYS = new Set(['format', 'opacity', 'flipY', 'depthTest', 'depthWrite', 'cull', 'name']);
328
+
329
+ /** makeSbsMaterial's options, validated. */
330
+ export function validateSbsOptions(texture, o = {}) {
331
+ if (!texture || typeof texture !== 'object') throw new TypeError('@displayxr/inline3d/splat: makeSbsMaterial(texture) — expected a pc.Texture.');
332
+ if (o === null || typeof o !== 'object') throw new TypeError('@displayxr/inline3d/splat: makeSbsMaterial options must be an object.');
333
+ const unknown = Object.keys(o).filter((k) => !SBS_OPTION_KEYS.has(k));
334
+ if (unknown.length) throw new Error(`@displayxr/inline3d/splat: makeSbsMaterial — unknown option(s) ${unknown.join(', ')}.`);
335
+ const format = o.format === undefined ? 'sbs' : o.format;
336
+ if (!VIDEO_FORMATS.includes(format)) throw new Error(`@displayxr/inline3d/splat: makeSbsMaterial — format "${format}", expected ${VIDEO_FORMATS.join(' | ')}.`);
337
+ const opacity = o.opacity === undefined ? 1 : o.opacity;
338
+ if (!Number.isFinite(opacity) || opacity < 0 || opacity > 1) throw new Error(`@displayxr/inline3d/splat: makeSbsMaterial — bad opacity: ${o.opacity}.`);
339
+ return { format, opacity, flipY: o.flipY === true, depthTest: o.depthTest !== false, depthWrite: o.depthWrite !== false, cull: o.cull === true, name: o.name };
340
+ }
341
+
342
+ const SBS_VERT = `
343
+ attribute vec3 vertex_position;
344
+ attribute vec2 vertex_texCoord0;
345
+ uniform mat4 matrix_model;
346
+ uniform mat4 matrix_viewProjection;
347
+ varying vec2 vUv;
348
+ void main() {
349
+ vUv = vertex_texCoord0;
350
+ gl_Position = matrix_viewProjection * matrix_model * vec4(vertex_position, 1.0);
351
+ }`;
352
+ const SBS_FRAG = `
353
+ varying vec2 vUv;
354
+ uniform sampler2D dxrSbsTex;
355
+ uniform vec4 dxrSbsL; // left eye's region: s0, t0, ds, dt (t = image rows, top = 0)
356
+ uniform vec4 dxrSbsR; // right eye's
357
+ uniform vec2 dxrSbsTexel; // half a texel, per axis
358
+ uniform float dxrSbsOpacity;
359
+ uniform float dxrSbsFlipY; // 1: the texture's row 0 is the image's BOTTOM
360
+ ${SBS_EYE_GLSL}
361
+ void main() {
362
+ vec4 r = dxrEyeRegion(dxrSbsL, dxrSbsR);
363
+ vec2 st = r.xy + vec2(vUv.x, 1.0 - vUv.y) * r.zw;
364
+ st = clamp(st, r.xy + dxrSbsTexel, r.xy + r.zw - dxrSbsTexel);
365
+ if (dxrSbsFlipY > 0.5) st.y = 1.0 - st.y;
366
+ gl_FragColor = vec4(texture2D(dxrSbsTex, st).rgb, dxrSbsOpacity);
367
+ }`;
368
+
369
+ /**
370
+ * An unlit material that shows the LEFT half of `texture` to left-eye views and the RIGHT half to
371
+ * right-eye views (or top/bottom, or the same picture: `format`), on whatever mesh it is put on.
372
+ * The mesh's geometry is untouched — put the quad at the screen plane and the clip's own disparity
373
+ * is the only depth. Mono: every fragment samples the left region at full resolution. The texture
374
+ * is the page's (a `<video>`'s frames uploaded by the page, an image); nothing is decoded here.
375
+ */
376
+ export function makeSbsMaterial(pc, texture, o = {}) {
377
+ const opt = validateSbsOptions(texture, o);
378
+ const mat = new pc.ShaderMaterial({
379
+ uniqueName: 'inline3dSbsQuad',
380
+ attributes: { vertex_position: pc.SEMANTIC_POSITION, vertex_texCoord0: pc.SEMANTIC_TEXCOORD0 },
381
+ vertexGLSL: SBS_VERT,
382
+ fragmentGLSL: SBS_FRAG,
383
+ });
384
+ if (opt.name) mat.name = opt.name;
385
+ mat.cull = opt.cull ? pc.CULLFACE_BACK : pc.CULLFACE_NONE;
386
+ mat.depthTest = opt.depthTest;
387
+ mat.depthWrite = opt.depthWrite;
388
+ if (opt.opacity < 1 && pc.BlendState && pc.BLENDMODE_SRC_ALPHA !== undefined) {
389
+ mat.blendState = new pc.BlendState(true, pc.BLENDEQUATION_ADD, pc.BLENDMODE_SRC_ALPHA, pc.BLENDMODE_ONE_MINUS_SRC_ALPHA);
390
+ }
391
+ const r = eyeRegions(opt.format);
392
+ mat.setParameter('dxrSbsL', r.L);
393
+ mat.setParameter('dxrSbsR', r.R);
394
+ mat.setParameter('dxrSbsOpacity', opt.opacity);
395
+ mat.setParameter('dxrSbsFlipY', opt.flipY ? 1 : 0);
396
+ mat.setParameter('dxrSbsTex', texture);
397
+ const w = Math.max(1, texture.width || 1);
398
+ const h = Math.max(1, texture.height || 1);
399
+ mat.setParameter('dxrSbsTexel', [0.5 / w, 0.5 / h]);
400
+ mat.update();
401
+ return mat;
402
+ }
@@ -381,6 +381,14 @@ export function addSplat(wall, canvas, src, opts = {}) {
381
381
  "(addSplat(…, { engine:'playcanvas' })); with Spark, use wall.addVideo on its own canvas.",
382
382
  );
383
383
  },
384
+ /** Not on this backend: per-layer rigs are a PlayCanvas-backend feature. */
385
+ setLayerRig() {
386
+ throw new Error("@displayxr/inline3d/splat: setLayerRig() is implemented on the PlayCanvas backend only (addSplat(…, { engine:'playcanvas' })).");
387
+ },
388
+ /** Not on this backend: per-eye SBS materials are a PlayCanvas-backend feature. */
389
+ makeSbsMaterial() {
390
+ throw new Error("@displayxr/inline3d/splat: makeSbsMaterial() is implemented on the PlayCanvas backend only (addSplat(…, { engine:'playcanvas' })).");
391
+ },
384
392
  /** Not on this backend: the live rig switch is a PlayCanvas-backend feature. */
385
393
  setRig() {
386
394
  throw new Error(
@@ -759,6 +767,16 @@ function addSplatDeferred(wall, canvas, src, opts) {
759
767
  return out;
760
768
  },
761
769
  stopEffect: queue('stopEffect'),
770
+ // A layer-rig request before the adapter has loaded: queued, replayed in order once it has
771
+ // (it needs no asset, only the engine's layers — which the adapter resolves lazily).
772
+ setLayerRig: (...args) => {
773
+ pending.push(['setLayerRig', args]);
774
+ return out;
775
+ },
776
+ layerRigState: () => ({ display: [], disabled: false, rounded: false, gain: null }),
777
+ makeSbsMaterial: () => {
778
+ throw new Error('@displayxr/inline3d/splat: makeSbsMaterial() needs the engine — call it after `await handle.ready`.');
779
+ },
762
780
  effects: () => [],
763
781
  getFocus: () => null,
764
782
  // A plain data slot the adapter reads at call time, so a callback assigned on the very next
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@displayxr/inline3d",
3
- "version": "1.22.0",
3
+ "version": "1.24.0",
4
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).",
5
5
  "type": "module",
6
6
  "types": "./index.d.ts",
@@ -54,6 +54,8 @@
54
54
  "js/inline3d-splat-live.js",
55
55
  "js/inline3d-splat-diag.js",
56
56
  "js/inline3d-splat-video.js",
57
+ "js/inline3d-splat-layer-rig.js",
58
+ "js/inline3d-splat-rig-map.js",
57
59
  "js/inline3d-model.js",
58
60
  "js/inline3d-model-entry.js",
59
61
  "js/inline3d-model-playcanvas.js",
package/splat.d.ts CHANGED
@@ -178,6 +178,47 @@ export interface ResolvedRig {
178
178
  parallaxFactor: number;
179
179
  }
180
180
 
181
+ /**
182
+ * `handle.setLayerRig` options — how ROUND the display-rig layers are, for the whole tile (last
183
+ * call wins). The rounding gain is k = D / (metersToVirtual · viewerDistance): the photo rig's
184
+ * convergence distance over the viewer's. docs/proposals/layer-display-rig.md.
185
+ */
186
+ export interface SplatLayerRigOptions {
187
+ /** Nominal viewer distance in metres (default 0.6, the browser's own nominal). */
188
+ viewerDistance?: number;
189
+ /** An explicit gain k instead (1 = exactly the photo rig; larger = rounder). */
190
+ gain?: number;
191
+ }
192
+
193
+ /** `handle.layerRigState()`. */
194
+ export interface SplatLayerRigState {
195
+ /** The layers on the display rig, as the page named them. */
196
+ display: Array<string | number>;
197
+ /** The `nolayerrig` kill switch is on (requests recorded, never applied). */
198
+ disabled: boolean;
199
+ /** The last drawn frame drew the display layers through rounded views (false in mono). */
200
+ rounded: boolean;
201
+ /** The gain the last frame used (null off a camera rig). */
202
+ gain: number | null;
203
+ }
204
+
205
+ /** `handle.makeSbsMaterial` options. */
206
+ export interface SplatSbsMaterialOptions {
207
+ /** How the texture holds the two eyes. Default `'sbs'` (left half = left eye). */
208
+ format?: 'sbs' | 'tb' | 'mono';
209
+ /** 0–1; below 1 the material alpha-blends. Default 1. */
210
+ opacity?: number;
211
+ /** The texture's row 0 is the image's BOTTOM (flip the sampling). Default false. */
212
+ flipY?: boolean;
213
+ /** Default true. */
214
+ depthTest?: boolean;
215
+ /** Default true. */
216
+ depthWrite?: boolean;
217
+ /** Back-face culling. Default false (both faces). */
218
+ cull?: boolean;
219
+ name?: string;
220
+ }
221
+
181
222
  /** `handle.setVideo` options. */
182
223
  export interface SplatVideoOptions {
183
224
  /** Stereo layout of the frame: `'sbs'` (default, left eye = left half), `'tb'` (left = top), `'mono'` (both eyes the whole frame). */
@@ -219,6 +260,11 @@ export interface SplatOptions {
219
260
  * URL with `engine: 'spark'`.
220
261
  */
221
262
  engine?: 'spark' | 'playcanvas';
263
+ /**
264
+ * `engine: 'playcanvas'` only. Sugar for `handle.setLayerRig(layer, 'display', opts)` on each
265
+ * layer (a name, an id or a `pc.Layer`), applied as soon as the engine boots.
266
+ */
267
+ displayRigLayers?: Array<string | number | { id: number }> | ({ layers: Array<string | number | { id: number }> } & SplatLayerRigOptions);
222
268
  /**
223
269
  * `engine: 'playcanvas'` only: TRANSITION DIAGNOSTICS (#36). Off by default; `true` (or the page
224
270
  * URL's `?dxrdiag=1`) records every woven frame — its interval, whether its eye poses are
@@ -231,11 +277,12 @@ export interface SplatOptions {
231
277
  * A string / array adds A/B kill switches (also accepted as `?dxrdiag=norig,frozen`): `'norig'`
232
278
  * keeps the rig declared before the first setSource (drops every re-declaration), `'frozen'`
233
279
  * forces `outgoing: 'frozen'`, `'nowarm'` skips the transition shader pre-warm, `'cold'` skips
234
- * the live outgoing pre-sort, `'nooverlay'` records without the overlay. The option wins over the
280
+ * the live outgoing pre-sort, `'nooverlay'` records without the overlay, `'oldrig'` draws the
281
+ * views as located (no rig tracking; the live outgoing photo on the pre-1.24 chain). The option wins over the
235
282
  * URL; `false` turns it off even with `?dxrdiag` present. See docs/playcanvas-adapter.md
236
283
  * § Diagnosing transition stalls.
237
284
  */
238
- diag?: boolean | string | ReadonlyArray<'norig' | 'frozen' | 'nowarm' | 'cold' | 'nooverlay' | '1'>;
285
+ diag?: boolean | string | ReadonlyArray<'norig' | 'frozen' | 'nowarm' | 'cold' | 'nooverlay' | 'oldpick' | 'nolayerrig' | 'oldrig' | '1'>;
239
286
  /**
240
287
  * `engine: 'playcanvas'` only: the WebGL context's `preserveDrawingBuffer` (default false) —
241
288
  * the knob for the weave's zero-copy read race on large canvases.
@@ -910,6 +957,27 @@ export interface SplatHandle {
910
957
  */
911
958
  setVideo(src: string | HTMLVideoElement, opts?: SplatVideoOptions): Promise<SplatVideo>;
912
959
  setVideo(src: null): Promise<null>;
960
+ /**
961
+ * `engine: 'playcanvas'` only (throws on Spark). Draw `layer` (a name, an id or a `pc.Layer` of
962
+ * `handle.engine.app`) through the DISPLAY rig — round, physical-depth stage objects — while the
963
+ * splat and the view rig declared to the runtime stay on the photo's camera rig; `'camera'` puts
964
+ * it back. One extra camera over the named layers, into the same target, in composition order;
965
+ * the photo's projection matrices are used verbatim and the views are right-multiplied by the
966
+ * shear that fixes the photo's convergence plane (so a z = 0 contact point never moves).
967
+ * Identity in mono / the 2D tier, on a display rig (`setRig('display')`, `setVideo`), and under
968
+ * `?dxrdiag=nolayerrig`. Callable before `ready`. docs/proposals/layer-display-rig.md.
969
+ */
970
+ setLayerRig(layer: string | number | { id: number }, rig: 'display' | 'camera', opts?: SplatLayerRigOptions): SplatHandle;
971
+ /** `engine: 'playcanvas'` only. What `setLayerRig` is doing. */
972
+ layerRigState(): SplatLayerRigState;
973
+ /**
974
+ * `engine: 'playcanvas'` only, after `ready`. An unlit `pc.ShaderMaterial` that shows the left
975
+ * half of `texture` to left-eye views and the right half to right-eye views (`format`), on any
976
+ * mesh — mono / the 2D tier / a 1-view mode: the left half. The eye is picked from the scene-wide
977
+ * uniform `dxr_eye_split` the SDK sets every draw (declare it in your own shader to do the same).
978
+ * Nothing is decoded: the page uploads its one `<video>` into `texture`.
979
+ */
980
+ makeSbsMaterial(texture: unknown, opts?: SplatSbsMaterialOptions): unknown;
913
981
  /**
914
982
  * `engine: 'playcanvas'` only (throws on Spark). Play a transition/pulse/custom effect;
915
983
  * validated at the call, run once the first asset is on screen. Resolves `{ finished }` —