@displayxr/inline3d 1.22.0 → 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.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,22 @@ entry points (`.`, `./three`) are frozen for 1.x, while the **scene subpaths** (
5
5
  `./splat`, `./model`) are a preview tier whose options may change in any release. Entries below say
6
6
  which tier they touch, because that is what tells you whether an upgrade can move your pixels.
7
7
 
8
+ ## 1.23.0 — 2026-09-25
9
+
10
+ Touches the **preview tier** (`./splat`, `engine: 'playcanvas'` only). New API; no pixel change for a
11
+ page that does not call it.
12
+
13
+ - **`handle.setLayerRig(layer, 'display' | 'camera', { viewerDistance?, gain? })`** (and
14
+ `addSplat(…, { displayRigLayers: [...] })`): draw chosen engine layers through the display rig —
15
+ round objects with physical pop-out — while the splat and the declared view rig stay on the
16
+ photo's camera rig. The display-rig views are the runtime's own views times an exact shear that
17
+ fixes the convergence plane (a contact point never moves); the runtime's projection matrices are
18
+ used verbatim — no Kooima (docs/proposals/layer-display-rig.md). Mono / 2D is byte-identical to
19
+ today. Kill switch `?dxrdiag=nolayerrig`. `handle.layerRigState()` reports what it did.
20
+ - **`handle.makeSbsMaterial(texture, { format })`**: per-eye side-by-side (or top/bottom) sampling
21
+ on any quad — the left half to left views, the right half to right views, the left half in mono.
22
+ The eye split is a scene-wide uniform, `dxr_eye_split`, set every draw.
23
+
8
24
  ## 1.22.0 — 2026-09-25
9
25
 
10
26
  Touches the **preview tier** (`./splat`, `engine: 'playcanvas'` only). No API change; no pixel change.
@@ -28,6 +28,8 @@
28
28
  // nooverlay — record + console + window.__dxrDiag, but no on-screen overlay.
29
29
  // oldpick — handle.pick() always runs the full scan over every centre (the 1.21.1 path), no
30
30
  // pick index. Tests "the page's picks are what blocks the main thread".
31
+ // nolayerrig — handle.setLayerRig is recorded but never applied: every layer stays on the eye
32
+ // camera and the photo's camera rig (the pre-1.23 path, byte for byte).
31
33
  //
32
34
  // WHAT RUNS ON THE MAIN THREAD, per transition phase (the fifth cause: a stall at the swap's END
33
35
  // that is not the SDK's transition at all):
@@ -42,7 +44,7 @@
42
44
  // after it (the page's continuation of `await setSource`).
43
45
 
44
46
  /** The switches `diag` / `?dxrdiag` understand, besides the plain on values. */
45
- export const DIAG_SWITCHES = Object.freeze(['norig', 'frozen', 'nowarm', 'cold', 'nooverlay', 'oldpick']);
47
+ export const DIAG_SWITCHES = Object.freeze(['norig', 'frozen', 'nowarm', 'cold', 'nooverlay', 'oldpick', 'nolayerrig']);
46
48
  const ON_TOKENS = new Set(['1', 'on', 'true', 'yes']);
47
49
 
48
50
  /**
@@ -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
+ }
@@ -67,7 +67,8 @@ import {
67
67
  sequenceSpans,
68
68
  } from './inline3d-splat-effects.js';
69
69
  import { LiveOutgoing, resolveOutgoingOption, defaultOutgoing, yieldIdle } from './inline3d-splat-live.js';
70
- import { VideoPlane, validateSetVideo, PAGE_VIDEO_ERROR } from './inline3d-splat-video.js';
70
+ import { VideoPlane, validateSetVideo, PAGE_VIDEO_ERROR, eyeSplit, EYE_SPLIT_UNIFORM, makeSbsMaterial } from './inline3d-splat-video.js';
71
+ import { LayerRigCameras, validateLayerRig } from './inline3d-splat-layer-rig.js';
71
72
  import { resolveDiag, DiagRecorder, startDiagLoop, registerDiag, DIAG_SWITCHES } from './inline3d-splat-diag.js';
72
73
  import {
73
74
  clamp,
@@ -816,6 +817,13 @@ export class PlayCanvasSplatViewer {
816
817
  this.inputLocked = false;
817
818
  /** handle.setVideo's plane (./inline3d-splat-video.js) while a video is on, else null. */
818
819
  this._videoPlane = null;
820
+ /** handle.setLayerRig's per-rig cameras (./inline3d-splat-layer-rig.js), made on first use. */
821
+ this.layerRigs = null;
822
+ /** () => the view-rig descriptor currently declared to the runtime (or null). */
823
+ this.layerRigSource = null;
824
+ /** That descriptor as it stood when THIS frame's views were pulled (snapshotted in onFrame). */
825
+ this._rigSnap = null;
826
+ this._eyeSplit = NaN;
819
827
  this.boxAspect = 1;
820
828
  this.featherPx = feather > 0 ? feather : 0;
821
829
  this.captureFit = captureFit;
@@ -1125,6 +1133,9 @@ export class PlayCanvasSplatViewer {
1125
1133
  onFrame(views, layer) {
1126
1134
  if (this._disposed) return;
1127
1135
  if (this._mode !== '3d') this.stopMono();
1136
+ // The rig these views were located with: Blink chained the rig declared BEFORE this callback,
1137
+ // and the tick below may declare a new one (a focus ease) for the NEXT locate.
1138
+ if (this.layerRigs?.active) this._rigSnap = snapshotRig(this.layerRigSource?.(), this._rigSnap);
1128
1139
  // BEFORE the tick: a pose the page sets in here is the one this very frame renders.
1129
1140
  this._beforeFrame?.(views || null);
1130
1141
  this._tick();
@@ -1185,6 +1196,7 @@ export class PlayCanvasSplatViewer {
1185
1196
  this._live = null;
1186
1197
  this._videoPlane?.destroy();
1187
1198
  this._videoPlane = null;
1199
+ this.layerRigs = null; // its cameras go with the app
1188
1200
  try {
1189
1201
  this.app?.destroy();
1190
1202
  } catch (err) {
@@ -1866,6 +1878,8 @@ export class PlayCanvasSplatViewer {
1866
1878
  if (this._live?.active || this._live?.warming) this._live.sync(entries, rect, f);
1867
1879
  // setSource's wavefront: this frame's eye views, as the engine is about to compose them.
1868
1880
  this.onBeforeRender?.(entries, rect);
1881
+ // handle.setLayerRig: the display / post run cameras, on the same views (display: rounded).
1882
+ if (this.layerRigs && this.layerRigs.sync()) this.layerRigs.frame(entries, rect, f, cache ? cache.rig : null);
1869
1883
  } else {
1870
1884
  // Fallback: one camera per view, `rect` + `calculateProjection`.
1871
1885
  const cams = this._views;
@@ -1899,6 +1913,12 @@ export class PlayCanvasSplatViewer {
1899
1913
  }
1900
1914
  }
1901
1915
  this._updateFeather(entries[0].width * sx, entries[0].height * sy);
1916
+ // handle.makeSbsMaterial: the eye split, scene-wide (a material only declares the uniform).
1917
+ const split = eyeSplit(entries, rect);
1918
+ if (split !== this._eyeSplit) {
1919
+ this._eyeSplit = split;
1920
+ app.graphicsDevice?.scope?.resolve?.(EYE_SPLIT_UNIFORM)?.setValue(split);
1921
+ }
1902
1922
  // handle.setVideo's plane: size, eye split, and a new frame's upload (./inline3d-splat-video.js).
1903
1923
  this._videoPlane?.beforeDraw(entries, rect);
1904
1924
  app.tick(now());
@@ -1917,6 +1937,7 @@ export class PlayCanvasSplatViewer {
1917
1937
  }
1918
1938
  g.bufW = el.width || 0;
1919
1939
  g.bufH = el.height || 0;
1940
+ g.rig = this._rigSnap ? { ...this._rigSnap } : null; // a replay re-uses the rig of its views
1920
1941
  for (let i = 0; i < views.length; i++) {
1921
1942
  const e = g.entries[i];
1922
1943
  const vp = vps[i];
@@ -2259,6 +2280,23 @@ export class PlayCanvasSplatViewer {
2259
2280
  }
2260
2281
  }
2261
2282
 
2283
+ /**
2284
+ * A copy of the declared rig's frame fields (the SDK rewrites its camera-rig descriptor in place),
2285
+ * or null. Only what the layer rig reads: kind, pose, convergence, metres-to-virtual.
2286
+ */
2287
+ export function snapshotRig(rig, into = null) {
2288
+ if (!rig || typeof rig !== 'object') return null;
2289
+ const o = into || { position: {}, orientation: {} };
2290
+ o.type = rig.type;
2291
+ const p = rig.position || {};
2292
+ const q = rig.orientation || {};
2293
+ o.position = { x: p.x || 0, y: p.y || 0, z: p.z || 0 };
2294
+ o.orientation = { x: q.x || 0, y: q.y || 0, z: q.z || 0, w: q.w === undefined ? 1 : q.w };
2295
+ o.convergenceDiopters = rig.convergenceDiopters;
2296
+ o.metersToVirtual = rig.metersToVirtual;
2297
+ return o;
2298
+ }
2299
+
2262
2300
  /** Put an engine node at a rigid display-space pose (position + rotation; no scale). */
2263
2301
  function placeNode(node, m) {
2264
2302
  node.setLocalPosition(m[12], m[13], m[14]);
@@ -2836,6 +2874,17 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
2836
2874
  diag.log(`on — switches [${[...diag.switches].join(', ') || 'none'}]; dump: copy(__dxrDiag.dump())`);
2837
2875
  }
2838
2876
  let removed = false;
2877
+ // handle.setLayerRig: the per-rig cameras and where they read the declared rig from.
2878
+ // Kill switch `nolayerrig`: requests are recorded but every layer stays on the eye camera.
2879
+ let warnedLayerRigPath = false;
2880
+ function layerRigs() {
2881
+ if (!viewer.layerRigs) {
2882
+ viewer.layerRigs = new LayerRigCameras(viewer);
2883
+ viewer.layerRigs.disabled = !!diag?.has('nolayerrig');
2884
+ viewer.layerRigSource = () => out.viewRig || null;
2885
+ }
2886
+ return viewer.layerRigs;
2887
+ }
2839
2888
  /** prepareSource: prepared handle → { loaded, state: 'ready' | 'used' | 'disposed', dispose }. */
2840
2889
  const preparedAssets = new WeakMap();
2841
2890
  const livePrepared = new Set();
@@ -2916,6 +2965,42 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
2916
2965
  prepareSource,
2917
2966
  setRig,
2918
2967
  setVideo,
2968
+ /**
2969
+ * Draw a layer of this tile's engine through the DISPLAY rig (round, physical-depth stage
2970
+ * objects) while the splat and the declared view rig stay on the photo's camera rig — or back
2971
+ * on the camera rig ('camera'). No second full pass: one extra camera over the named layers,
2972
+ * into the same target, in composition order. docs/playcanvas-adapter.md §setLayerRig and
2973
+ * docs/proposals/layer-display-rig.md (the exact mapping, and why it is not Kooima).
2974
+ * `viewerDistance` (m) / `gain` set the rounding for the whole tile (last call wins).
2975
+ */
2976
+ setLayerRig(layer, rig, o = {}) {
2977
+ const r = validateLayerRig(layer, rig, o);
2978
+ if (viewer._viewPath === 'cameras' && !warnedLayerRigPath) {
2979
+ warnedLayerRigPath = true;
2980
+ console.warn('[inline3d/splat] setLayerRig needs the engine RenderView path; on the N-camera fallback the layer stays on the photo rig.');
2981
+ }
2982
+ layerRigs().set(layer, r.rig, { viewerDistance: r.viewerDistance, gain: r.gain });
2983
+ return out;
2984
+ },
2985
+ /** The layers on the display rig (as the page named them), and what the last frame did. */
2986
+ layerRigState() {
2987
+ const lr = viewer.layerRigs;
2988
+ return {
2989
+ display: lr ? [...lr.requests.keys()] : [],
2990
+ disabled: !!lr?.disabled,
2991
+ rounded: !!lr?.last.rounded,
2992
+ gain: lr?.last.gain ?? null,
2993
+ };
2994
+ },
2995
+ /**
2996
+ * An unlit material showing the left half of `texture` to left-eye views and the right half to
2997
+ * right-eye views (format 'sbs' | 'tb' | 'mono'), on any mesh — mono / 2D: the left half. Needs
2998
+ * the engine (after `ready`). docs/playcanvas-adapter.md §makeSbsMaterial.
2999
+ */
3000
+ makeSbsMaterial(texture, o = {}) {
3001
+ if (!viewer.app || !viewer.pc) throw new Error('@displayxr/inline3d/splat: makeSbsMaterial() needs the engine — call it after `await handle.ready`.');
3002
+ return makeSbsMaterial(viewer.pc, texture, o);
3003
+ },
2919
3004
  /**
2920
3005
  * Play a transition effect (inflate, deflate, sweep, dissolve, fade, pulse, custom) — see
2921
3006
  * docs/splat-effects.md. Validated now; runs once the first asset is on screen. Resolves
@@ -3071,6 +3156,14 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
3071
3156
  delete out._resolveFirstWoven;
3072
3157
  }
3073
3158
 
3159
+ // addSplat's `displayRigLayers` sugar: [layer, …] or { layers: [...], viewerDistance, gain }.
3160
+ if (opts.displayRigLayers !== undefined) {
3161
+ const d = opts.displayRigLayers;
3162
+ const list = Array.isArray(d) ? d : d && Array.isArray(d.layers) ? d.layers : null;
3163
+ if (!list) throw new TypeError('@displayxr/inline3d/splat: displayRigLayers — expected an array of layers, or { layers, viewerDistance?, gain? }.');
3164
+ const o = Array.isArray(d) ? {} : { ...(d.viewerDistance !== undefined ? { viewerDistance: d.viewerDistance } : {}), ...(d.gain !== undefined ? { gain: d.gain } : {}) };
3165
+ for (const l of list) out.setLayerRig(l, 'display', o);
3166
+ }
3074
3167
  // Replay what the page did before this module arrived — exclude() above all, which a product
3075
3168
  // page calls on the very next line after addSplat.
3076
3169
  for (const [name, args] of pending) {
@@ -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.23.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,7 @@
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",
57
58
  "js/inline3d-model.js",
58
59
  "js/inline3d-model-entry.js",
59
60
  "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
@@ -910,6 +956,27 @@ export interface SplatHandle {
910
956
  */
911
957
  setVideo(src: string | HTMLVideoElement, opts?: SplatVideoOptions): Promise<SplatVideo>;
912
958
  setVideo(src: null): Promise<null>;
959
+ /**
960
+ * `engine: 'playcanvas'` only (throws on Spark). Draw `layer` (a name, an id or a `pc.Layer` of
961
+ * `handle.engine.app`) through the DISPLAY rig — round, physical-depth stage objects — while the
962
+ * splat and the view rig declared to the runtime stay on the photo's camera rig; `'camera'` puts
963
+ * it back. One extra camera over the named layers, into the same target, in composition order;
964
+ * the photo's projection matrices are used verbatim and the views are right-multiplied by the
965
+ * shear that fixes the photo's convergence plane (so a z = 0 contact point never moves).
966
+ * Identity in mono / the 2D tier, on a display rig (`setRig('display')`, `setVideo`), and under
967
+ * `?dxrdiag=nolayerrig`. Callable before `ready`. docs/proposals/layer-display-rig.md.
968
+ */
969
+ setLayerRig(layer: string | number | { id: number }, rig: 'display' | 'camera', opts?: SplatLayerRigOptions): SplatHandle;
970
+ /** `engine: 'playcanvas'` only. What `setLayerRig` is doing. */
971
+ layerRigState(): SplatLayerRigState;
972
+ /**
973
+ * `engine: 'playcanvas'` only, after `ready`. An unlit `pc.ShaderMaterial` that shows the left
974
+ * half of `texture` to left-eye views and the right half to right-eye views (`format`), on any
975
+ * mesh — mono / the 2D tier / a 1-view mode: the left half. The eye is picked from the scene-wide
976
+ * uniform `dxr_eye_split` the SDK sets every draw (declare it in your own shader to do the same).
977
+ * Nothing is decoded: the page uploads its one `<video>` into `texture`.
978
+ */
979
+ makeSbsMaterial(texture: unknown, opts?: SplatSbsMaterialOptions): unknown;
913
980
  /**
914
981
  * `engine: 'playcanvas'` only (throws on Spark). Play a transition/pulse/custom effect;
915
982
  * validated at the call, run once the first asset is on screen. Resolves `{ finished }` —