@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.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,44 @@ 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.24.0 — 2026-09-25
9
+
10
+ Touches the **preview tier** (`./splat`, `engine: 'playcanvas'` only). No API change. Pixels change
11
+ during a live transition between two camera-rig photos (they were wrong), and for the frames in
12
+ which a newly declared rig has not yet reached the runtime's views.
13
+
14
+ - **No camera jump at the start of a live transition.** Crossfade, wavefront and the particle
15
+ transitions keep the outgoing photo alive next to the incoming one. Between two photos with
16
+ camera rigs (SOG camera blocks: each its own convergence and vertical FOV), the outgoing photo
17
+ was drawn from eyes scaled by the two rigs' window ratio from the swap on, a jump in its
18
+ disparity and head parallax. For the frame(s) before the declaration reached the views, both
19
+ photos were drawn through the wrong rig. Each photo is now drawn through its own rig for the whole
20
+ window. The SDK reads off the views which declared rig they were located for, and remaps them
21
+ exactly (window onto window, eye onto eye; the runtime's projection verbatim, no Kooima) where
22
+ they are not the photo's own. `reassemble` was never affected. Headless: 102–208 px of
23
+ reference-point error → < 0.005 px, and the live outgoing image equals the frame before the swap
24
+ (MAE 0.000). Docs: `docs/playcanvas-adapter.md` § Each photo through its own rig.
25
+ - **Kill switch `?dxrdiag=oldrig`** (the 1.23 behaviour). The diag's per-frame record gains
26
+ `rigAt` / `rigIn` / `rigOut`: the rig the views were located for, the rig the current photo was
27
+ drawn through (`+` = remapped), and how the live outgoing photo was drawn.
28
+ - `tools/rig-swap-capture`: the headless harness behind the numbers.
29
+
30
+ ## 1.23.0 — 2026-09-25
31
+
32
+ Touches the **preview tier** (`./splat`, `engine: 'playcanvas'` only). New API; no pixel change for a
33
+ page that does not call it.
34
+
35
+ - **`handle.setLayerRig(layer, 'display' | 'camera', { viewerDistance?, gain? })`** (and
36
+ `addSplat(…, { displayRigLayers: [...] })`): draw chosen engine layers through the display rig —
37
+ round objects with physical pop-out — while the splat and the declared view rig stay on the
38
+ photo's camera rig. The display-rig views are the runtime's own views times an exact shear that
39
+ fixes the convergence plane (a contact point never moves); the runtime's projection matrices are
40
+ used verbatim — no Kooima (docs/proposals/layer-display-rig.md). Mono / 2D is byte-identical to
41
+ today. Kill switch `?dxrdiag=nolayerrig`. `handle.layerRigState()` reports what it did.
42
+ - **`handle.makeSbsMaterial(texture, { format })`**: per-eye side-by-side (or top/bottom) sampling
43
+ on any quad — the left half to left views, the right half to right views, the left half in mono.
44
+ The eye split is a scene-wide uniform, `dxr_eye_split`, set every draw.
45
+
8
46
  ## 1.22.0 — 2026-09-25
9
47
 
10
48
  Touches the **preview tier** (`./splat`, `engine: 'playcanvas'` only). No API change; no pixel change.
@@ -28,6 +28,17 @@
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).
33
+ // oldrig — no rig tracking (./inline3d-splat-rig-map.js): the views are drawn as the runtime
34
+ // located them, and a live outgoing photo goes back on the pre-1.24 node chain (which
35
+ // scales its disparity by the two rigs' window ratio at the swap). A/B for "the
36
+ // outgoing photo jumps when the incoming rig is declared".
37
+ //
38
+ // Per frame, `rigAt` / `rigIn` / `rigOut` say which declared rig the views were located for
39
+ // (the tracker's id; null = none matched), which rig the current photo was drawn through
40
+ // (`+` = remapped to it) and how the live outgoing photo was drawn ('own' = its rig as located,
41
+ // 'remapped', 'eye', 'chain'; null = no live outgoing).
31
42
  //
32
43
  // WHAT RUNS ON THE MAIN THREAD, per transition phase (the fifth cause: a stall at the swap's END
33
44
  // that is not the SDK's transition at all):
@@ -42,7 +53,7 @@
42
53
  // after it (the page's continuation of `await setSource`).
43
54
 
44
55
  /** The switches `diag` / `?dxrdiag` understand, besides the plain on values. */
45
- export const DIAG_SWITCHES = Object.freeze(['norig', 'frozen', 'nowarm', 'cold', 'nooverlay', 'oldpick']);
56
+ export const DIAG_SWITCHES = Object.freeze(['norig', 'frozen', 'nowarm', 'cold', 'nooverlay', 'oldpick', 'nolayerrig', 'oldrig']);
46
57
  const ON_TOKENS = new Set(['1', 'on', 'true', 'yes']);
47
58
 
48
59
  /**
@@ -189,6 +200,7 @@ export class DiagRecorder {
189
200
  this._frameNo = 0;
190
201
  this._open = null; // the transition being recorded
191
202
  this.imageState = null; // () => { overlay: 'none' | 'frozen' | 'live', w }
203
+ this.rigState = null; // () => { at, in, out } (see the header)
192
204
  this.longTasks = [];
193
205
  this._lto = null;
194
206
  this.rigLocked = null; // norig: the summary of the rig kept
@@ -371,6 +383,7 @@ export class DiagRecorder {
371
383
  this._lastFrameAt = t;
372
384
  this._frameNo++;
373
385
  const img = this.imageState ? safe(this.imageState) : null;
386
+ const rs = this.rigState ? safe(this.rigState) : null;
374
387
  const f = {
375
388
  n: this._frameNo,
376
389
  t: round1(t),
@@ -386,6 +399,9 @@ export class DiagRecorder {
386
399
  afterRig: this._frameNo - this._rigFrame <= 3,
387
400
  img: img ? img.overlay : 'none',
388
401
  imgW: img ? round4(img.w) : 0,
402
+ rigAt: rs ? rs.at : null,
403
+ rigIn: rs ? rs.in : null,
404
+ rigOut: rs ? rs.out : null,
389
405
  };
390
406
  this.frames.push(f);
391
407
  if (this.frames.length > FRAME_CAP) this.frames.splice(0, this.frames.length - FRAME_CAP);
@@ -0,0 +1,425 @@
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
+ import { invertAffine } from './inline3d-splat-rig-map.js';
23
+
24
+ /** handle.setLayerRig's rigs. 'camera' is the default every layer starts on. */
25
+ export const LAYER_RIGS = Object.freeze(['display', 'camera']);
26
+
27
+ /** Nominal viewer distance (m) when neither the page nor the platform says: the browser's own. */
28
+ export const DEFAULT_VIEWER_DISTANCE_M = 0.6;
29
+
30
+ /** A round eye may not reach the window plane: h(E') is floored at this fraction of D. */
31
+ const MIN_EYE_HEIGHT_FRAC = 1e-3;
32
+
33
+ /** Rotate v by unit quaternion q (xyzw). */
34
+ function rotate(q, v) {
35
+ const [x, y, z, w] = q;
36
+ const tx = 2 * (y * v[2] - z * v[1]);
37
+ const ty = 2 * (z * v[0] - x * v[2]);
38
+ const tz = 2 * (x * v[1] - y * v[0]);
39
+ 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)];
40
+ }
41
+
42
+ /**
43
+ * setLayerRig's arguments, validated (throws at the call).
44
+ * @returns {{ rig: 'display'|'camera', viewerDistance: number|null, gain: number|null }}
45
+ */
46
+ export function validateLayerRig(layer, rig, o = {}) {
47
+ if (!(typeof layer === 'string' && layer.length) && !(Number.isInteger(layer) && layer >= 0) && !(layer && typeof layer === 'object' && Number.isInteger(layer.id))) {
48
+ throw new TypeError('@displayxr/inline3d/splat: setLayerRig(layer, …) — expected a layer name, a layer id, or a pc.Layer.');
49
+ }
50
+ if (!LAYER_RIGS.includes(rig)) {
51
+ throw new Error(`@displayxr/inline3d/splat: setLayerRig — rig "${rig}", expected ${LAYER_RIGS.map((r) => `'${r}'`).join(' or ')}.`);
52
+ }
53
+ if (o === null || typeof o !== 'object') throw new TypeError('@displayxr/inline3d/splat: setLayerRig options must be an object.');
54
+ const num = (k) => {
55
+ if (o[k] === undefined) return null;
56
+ if (!Number.isFinite(o[k]) || !(o[k] > 0)) throw new Error(`@displayxr/inline3d/splat: setLayerRig — bad ${k}: ${o[k]}.`);
57
+ return o[k];
58
+ };
59
+ return { rig, viewerDistance: num('viewerDistance'), gain: num('gain') };
60
+ }
61
+
62
+ /**
63
+ * The frame of a declared CAMERA-rig descriptor (XRViewRigInit shape), or null for anything else
64
+ * (a display rig, no rig, a convergence at infinity — no finite window to hold fixed).
65
+ * @returns {{ N0: number[], fwd: number[], D: number, m: number }|null}
66
+ */
67
+ export function cameraRigFrame(rig) {
68
+ if (!rig || rig.type !== 'camera') return null;
69
+ const inv = rig.convergenceDiopters;
70
+ if (!Number.isFinite(inv) || !(inv > 0)) return null;
71
+ const p = rig.position || { x: 0, y: 0, z: 0 };
72
+ const o = rig.orientation || { x: 0, y: 0, z: 0, w: 1 };
73
+ const m = Number.isFinite(rig.metersToVirtual) && rig.metersToVirtual > 0 ? rig.metersToVirtual : 1;
74
+ const fwd = rotate([o.x || 0, o.y || 0, o.z || 0, o.w === undefined ? 1 : o.w], [0, 0, -1]);
75
+ return { N0: [p.x || 0, p.y || 0, p.z || 0], fwd, D: 1 / inv, m };
76
+ }
77
+
78
+ /** k = D / (m·n), or the caller's explicit gain. */
79
+ export function layerRigGain(frame, { viewerDistance = null, gain = null } = {}) {
80
+ if (gain !== null) return gain;
81
+ const n = viewerDistance !== null ? viewerDistance : DEFAULT_VIEWER_DISTANCE_M;
82
+ return frame.D / (frame.m * n);
83
+ }
84
+
85
+ /**
86
+ * The window-fixing shear for one eye, as a column-major 4×4 (and its inverse), in the space the
87
+ * declared rig and the views share.
88
+ *
89
+ * M(X) = X + v · (ẑ·X − c), ẑ = −fwd, c = ẑ·Wc, v = (E − E') / h(E'), h(X) = ẑ·X − c
90
+ * M⁻¹ = the same form with v' = −v / (1 + ẑ·v)
91
+ *
92
+ * @param {number[]} E the runtime's eye position (the view pose's translation).
93
+ * @returns {{ M: Float64Array, Minv: Float64Array, Eround: number[] }}
94
+ */
95
+ export function windowShear(E, frame, k, M = new Float64Array(16), Minv = new Float64Array(16)) {
96
+ const { N0, fwd, D } = frame;
97
+ const z = [-fwd[0], -fwd[1], -fwd[2]];
98
+ const Wc = [N0[0] + D * fwd[0], N0[1] + D * fwd[1], N0[2] + D * fwd[2]];
99
+ const c = z[0] * Wc[0] + z[1] * Wc[1] + z[2] * Wc[2];
100
+ const Er = [N0[0] + k * (E[0] - N0[0]), N0[1] + k * (E[1] - N0[1]), N0[2] + k * (E[2] - N0[2])];
101
+ let h = z[0] * Er[0] + z[1] * Er[1] + z[2] * Er[2] - c;
102
+ const hMin = MIN_EYE_HEIGHT_FRAC * D;
103
+ if (!(h > hMin)) {
104
+ // Leaning into the glass with a large gain: keep the round eye in front of the window.
105
+ const dh = hMin - h;
106
+ Er[0] += dh * z[0];
107
+ Er[1] += dh * z[1];
108
+ Er[2] += dh * z[2];
109
+ h = hMin;
110
+ }
111
+ const v = [(E[0] - Er[0]) / h, (E[1] - Er[1]) / h, (E[2] - Er[2]) / h];
112
+ shearMatrix(v, z, c, M);
113
+ const s = 1 + z[0] * v[0] + z[1] * v[1] + z[2] * v[2];
114
+ shearMatrix([-v[0] / s, -v[1] / s, -v[2] / s], z, c, Minv);
115
+ return { M, Minv, Eround: Er };
116
+ }
117
+
118
+ /** X ↦ X + v·(z·X − c), column-major. */
119
+ function shearMatrix(v, z, c, out) {
120
+ for (let col = 0; col < 3; col++) {
121
+ for (let row = 0; row < 3; row++) out[col * 4 + row] = (row === col ? 1 : 0) + v[row] * z[col];
122
+ out[col * 4 + 3] = 0;
123
+ }
124
+ out[12] = -v[0] * c;
125
+ out[13] = -v[1] * c;
126
+ out[14] = -v[2] * c;
127
+ out[15] = 1;
128
+ return out;
129
+ }
130
+
131
+ /** Unit quaternion (xyzw) of a rigid column-major 4×4's rotation. */
132
+ function quatOf(m) {
133
+ 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];
134
+ const tr = m11 + m22 + m33;
135
+ if (tr > 0) {
136
+ const s = 0.5 / Math.sqrt(tr + 1);
137
+ return [(m32 - m23) * s, (m13 - m31) * s, (m21 - m12) * s, 0.25 / s];
138
+ }
139
+ if (m11 > m22 && m11 > m33) {
140
+ const s = 2 * Math.sqrt(1 + m11 - m22 - m33);
141
+ return [0.25 * s, (m12 + m21) / s, (m13 + m31) / s, (m32 - m23) / s];
142
+ }
143
+ if (m22 > m33) {
144
+ const s = 2 * Math.sqrt(1 + m22 - m11 - m33);
145
+ return [(m12 + m21) / s, 0.25 * s, (m23 + m32) / s, (m13 - m31) / s];
146
+ }
147
+ const s = 2 * Math.sqrt(1 + m33 - m11 - m22);
148
+ return [(m13 + m31) / s, (m23 + m32) / s, 0.25 * s, (m21 - m12) / s];
149
+ }
150
+
151
+ /** a × b, column-major (local, so this module stays standalone). */
152
+ export function mul4(a, b, out = new Float64Array(16)) {
153
+ const r = new Float64Array(16);
154
+ for (let col = 0; col < 4; col++) {
155
+ for (let row = 0; row < 4; row++) {
156
+ 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];
157
+ }
158
+ }
159
+ out.set(r);
160
+ return out;
161
+ }
162
+
163
+ /** Inverse of a rigid (rotation + translation) column-major 4×4. */
164
+ export function invertRigid(m, out = new Float64Array(16)) {
165
+ const r = [m[0], m[1], m[2], m[4], m[5], m[6], m[8], m[9], m[10]];
166
+ out[0] = r[0]; out[1] = r[3]; out[2] = r[6]; out[3] = 0;
167
+ out[4] = r[1]; out[5] = r[4]; out[6] = r[7]; out[7] = 0;
168
+ out[8] = r[2]; out[9] = r[5]; out[10] = r[8]; out[11] = 0;
169
+ const tx = m[12], ty = m[13], tz = m[14];
170
+ out[12] = -(out[0] * tx + out[4] * ty + out[8] * tz);
171
+ out[13] = -(out[1] * tx + out[5] * ty + out[9] * tz);
172
+ out[14] = -(out[2] * tx + out[6] * ty + out[10] * tz);
173
+ out[15] = 1;
174
+ return out;
175
+ }
176
+
177
+ /**
178
+ * The round views for one frame: for each runtime view pose P_i (camera → rig space, rigid),
179
+ * `viewInv = M_i⁻¹ · P_i` and `view = P_i⁻¹ · M_i` — what RenderView.setView takes. Null (the
180
+ * caller keeps the photo views) when there is nothing to round: no camera rig, fewer than two
181
+ * views, or a gain of exactly 1.
182
+ */
183
+ export function roundViews(entries, frame, k, into = []) {
184
+ if (!frame || !entries || entries.length < 2 || !(k > 0) || k === 1) return null;
185
+ into.length = entries.length;
186
+ for (let i = 0; i < entries.length; i++) {
187
+ const P = entries[i].pose;
188
+ const o = (into[i] ||= { viewInv: new Float64Array(16), view: new Float64Array(16), M: new Float64Array(16), Minv: new Float64Array(16), eye: [0, 0, 0] });
189
+ const { Eround } = windowShear([P[12], P[13], P[14]], frame, k, o.M, o.Minv);
190
+ o.eye[0] = Eround[0];
191
+ o.eye[1] = Eround[1];
192
+ o.eye[2] = Eround[2];
193
+ mul4(o.Minv, P, o.viewInv);
194
+ mul4(invertAffine(P), o.M, o.view); // P is rigid, or affine once remapped to the declared rig (./inline3d-splat-rig-map.js)
195
+ }
196
+ return into;
197
+ }
198
+
199
+ /**
200
+ * Split a layer composition into contiguous RUNS for per-rig cameras. `entries` is the
201
+ * composition's sublayer list as layer ids, in draw order (opaque and transparent sublayers both
202
+ * appear); `display` the set of display-rig layer ids; `eyeLayers` the ids the eye camera draws.
203
+ * PlayCanvas orders render actions by CAMERA, then layer — so keeping today's order with a second
204
+ * rig needs one camera per run:
205
+ * pre — eye-camera layers with any sublayer before the first display sublayer (stay put);
206
+ * display — the display layers the eye camera drew;
207
+ * post — eye-camera layers whose every sublayer comes after the last display sublayer.
208
+ * A non-display layer INTERLEAVED between display sublayers stays in `pre` (it would otherwise
209
+ * split a run) and is reported in `interleaved`.
210
+ */
211
+ export function layerRuns(entries, display, eyeLayers) {
212
+ const eye = new Set(eyeLayers);
213
+ let first = -1;
214
+ let last = -1;
215
+ for (let i = 0; i < entries.length; i++) {
216
+ if (display.has(entries[i]) && eye.has(entries[i])) {
217
+ if (first < 0) first = i;
218
+ last = i;
219
+ }
220
+ }
221
+ const out = { pre: [], display: [], post: [], interleaved: [] };
222
+ if (first < 0) {
223
+ out.pre = [...eye];
224
+ return out;
225
+ }
226
+ const seen = new Set();
227
+ for (const id of eyeLayers) {
228
+ if (seen.has(id)) continue;
229
+ seen.add(id);
230
+ if (display.has(id)) {
231
+ out.display.push(id);
232
+ continue;
233
+ }
234
+ const at = [];
235
+ for (let i = 0; i < entries.length; i++) if (entries[i] === id) at.push(i);
236
+ if (at.length && at.every((i) => i > last)) out.post.push(id);
237
+ else {
238
+ out.pre.push(id);
239
+ if (at.some((i) => i > first && i < last)) out.interleaved.push(id);
240
+ }
241
+ }
242
+ // Keep the run cameras' layer lists in draw order (the composition's, not the eye's list).
243
+ const order = (ids) => ids.sort((a, b) => entries.indexOf(a) - entries.indexOf(b));
244
+ order(out.display);
245
+ order(out.post);
246
+ return out;
247
+ }
248
+
249
+ // ── the engine side ──────────────────────────────────────────────────────────────────────────
250
+
251
+ /**
252
+ * The per-rig cameras of one tile. Owned by the viewer; `sync` + `frame` are called by its draw.
253
+ * The eye camera keeps its `layers` minus what moved; `restore()` puts them back.
254
+ */
255
+ export class LayerRigCameras {
256
+ constructor(viewer) {
257
+ this.viewer = viewer;
258
+ /** What the page asked for: name or id → { viewerDistance, gain }. */
259
+ this.requests = new Map();
260
+ /** Those, resolved: layer id → options (the ones present in the composition right now). */
261
+ this.layers = new Map();
262
+ /** The gain options in force (the last setLayerRig call's — one gain per tile). */
263
+ this.opts = {};
264
+ this.disabled = false; // the kill switch (diag 'nolayerrig')
265
+ this.cams = { display: null, post: null };
266
+ this.moved = { display: [], post: [] };
267
+ this._key = '';
268
+ this._rvs = { display: [], post: [] };
269
+ this._round = [];
270
+ /** Diagnostics: what the last frame did. */
271
+ this.last = { rounded: false, gain: null, views: 0 };
272
+ }
273
+
274
+ get active() {
275
+ return !this.disabled && this.requests.size > 0;
276
+ }
277
+
278
+ /** Resolve a name / id / pc.Layer to an id in this tile's composition (null if unknown). */
279
+ resolve(layer) {
280
+ const comp = this.viewer.app?.scene?.layers;
281
+ if (!comp) return null;
282
+ if (layer && typeof layer === 'object') return layer.id;
283
+ if (Number.isInteger(layer)) return layer;
284
+ const l = comp.getLayerByName?.(layer);
285
+ return l ? l.id : null;
286
+ }
287
+
288
+ /**
289
+ * Record a request by the caller's key (name, id): resolved against the composition on every
290
+ * sync, so a layer the page adds AFTER calling setLayerRig is picked up when it appears.
291
+ */
292
+ set(key, rig, o) {
293
+ const k = key && typeof key === 'object' ? key.id : key;
294
+ if (rig === 'display') this.requests.set(k, o);
295
+ else this.requests.delete(k);
296
+ this.opts = o;
297
+ this._key = ''; // re-split on the next draw
298
+ }
299
+
300
+ _resolveAll() {
301
+ this.layers.clear();
302
+ for (const [k, o] of this.requests) {
303
+ const id = this.resolve(k);
304
+ if (id !== null && id !== undefined) this.layers.set(id, o);
305
+ }
306
+ }
307
+
308
+ _makeCam(name, priority) {
309
+ const v = this.viewer;
310
+ const cam = v._makeCamera(name, null);
311
+ cam.camera.priority = priority;
312
+ cam.camera.clearColorBuffer = false;
313
+ cam.camera.clearDepthBuffer = false;
314
+ cam.camera.clearStencilBuffer = false;
315
+ cam.camera.frustumCulling = false;
316
+ cam.camera.layers = [];
317
+ return cam;
318
+ }
319
+
320
+ /** Re-split the composition when the display set, the eye's layers or the composition moved. */
321
+ sync() {
322
+ const v = this.viewer;
323
+ const eyeCam = v.eye?.camera;
324
+ const comp = v.app?.scene?.layers;
325
+ if (!eyeCam || !comp) return false;
326
+ if (!this.active) {
327
+ if (this.moved.display.length || this.moved.post.length) this.restore();
328
+ return false;
329
+ }
330
+ this._resolveAll();
331
+ const entries = (comp.layerList || []).map((l) => l.id);
332
+ const eyeLayers = [...(eyeCam.layers || []), ...this.moved.display, ...this.moved.post];
333
+ const key = `${[...this.layers.keys()].join(',')}|${entries.join(',')}|${(eyeCam.layers || []).join(',')}`;
334
+ if (key === this._key) return this.moved.display.length > 0;
335
+ const runs = layerRuns(entries, new Set(this.layers.keys()), eyeLayers);
336
+ if (runs.interleaved.length && !this._warnedInterleaved) {
337
+ this._warnedInterleaved = true;
338
+ console.warn(
339
+ `[inline3d/splat] setLayerRig: layer(s) ${runs.interleaved.join(', ')} sit BETWEEN display-rig ` +
340
+ 'sublayers; they are drawn before them (with the eye camera). Move them before or after the display layers.',
341
+ );
342
+ }
343
+ this.moved.display = runs.display;
344
+ this.moved.post = runs.post;
345
+ eyeCam.layers = runs.pre;
346
+ if (runs.display.length) {
347
+ this.cams.display ||= this._makeCam('inline3d-eye-display', 1);
348
+ this.cams.display.camera.layers = runs.display;
349
+ }
350
+ if (runs.post.length) {
351
+ this.cams.post ||= this._makeCam('inline3d-eye-post', 2);
352
+ this.cams.post.camera.layers = runs.post;
353
+ }
354
+ if (this.cams.display) this.cams.display.enabled = runs.display.length > 0;
355
+ if (this.cams.post) this.cams.post.enabled = runs.post.length > 0;
356
+ this._key = `${[...this.layers.keys()].join(',')}|${entries.join(',')}|${eyeCam.layers.join(',')}`;
357
+ return runs.display.length > 0;
358
+ }
359
+
360
+ /** Every moved layer back on the eye camera, run cameras off (the kill switch / last layer off). */
361
+ restore() {
362
+ const eyeCam = this.viewer.eye?.camera;
363
+ if (eyeCam) {
364
+ const back = [...this.moved.display, ...this.moved.post];
365
+ eyeCam.layers = [...(eyeCam.layers || []), ...back.filter((id) => !(eyeCam.layers || []).includes(id))];
366
+ }
367
+ this.moved = { display: [], post: [] };
368
+ if (this.cams.display) this.cams.display.enabled = false;
369
+ if (this.cams.post) this.cams.post.enabled = false;
370
+ this._key = '';
371
+ }
372
+
373
+ /**
374
+ * Per drawn frame, after the eye camera's RenderViews are set. `rig` is the descriptor the
375
+ * runtime located these views with (the viewer snapshots it before the tick can declare anew).
376
+ */
377
+ frame(entries, rect, f, rig) {
378
+ const pc = this.viewer.pc;
379
+ this.last.rounded = false;
380
+ this.last.views = entries.length;
381
+ const frame = cameraRigFrame(rig);
382
+ const k = frame ? layerRigGain(frame, this.opts || {}) : null;
383
+ const round = frame ? roundViews(entries, frame, k, this._round) : null;
384
+ this.last.gain = k;
385
+ for (const run of ['display', 'post']) {
386
+ const cam = this.cams[run];
387
+ if (!cam || !cam.enabled) continue;
388
+ const rvs = this._rvs[run];
389
+ if (rvs.length !== entries.length) {
390
+ rvs.length = 0;
391
+ for (let i = 0; i < entries.length; i++) rvs.push(new pc.RenderView());
392
+ cam.camera.camera.xrViews = rvs.slice();
393
+ }
394
+ const r = run === 'display' ? round : null;
395
+ for (let i = 0; i < entries.length; i++) {
396
+ const e = entries[i];
397
+ if (r) rvs[i].setView(e.proj, r[i].viewInv, r[i].view);
398
+ else rvs[i].setView(e.proj, e.pose);
399
+ const [x, y, w, h] = rect(e);
400
+ rvs[i].setViewport(x, y, w, h);
401
+ }
402
+ if (cam._frustumKey !== this.viewer._frustumKey) {
403
+ cam._frustumKey = this.viewer._frustumKey;
404
+ cam.camera.camera.setXrProperties({ ...f, horizontalFov: false });
405
+ }
406
+ // The node drives transparent sorting (the views ignore it): on the (round) first eye.
407
+ const p = entries[0].node || entries[0].pose; // `node`: a rigid pose when the views are remapped
408
+ const q = quatOf(p);
409
+ cam.setLocalRotation(q[0], q[1], q[2], q[3]);
410
+ if (r) cam.setLocalPosition(r[0].eye[0], r[0].eye[1], r[0].eye[2]);
411
+ else cam.setLocalPosition(p[12], p[13], p[14]);
412
+ if (run === 'display') {
413
+ // Two camera spaces never share a depth test; in mono (and at gain 1) they are one space.
414
+ cam.camera.clearDepthBuffer = !!r;
415
+ this.last.rounded = !!r;
416
+ }
417
+ }
418
+ }
419
+
420
+ destroy() {
421
+ this.restore();
422
+ for (const c of Object.values(this.cams)) c?.destroy?.();
423
+ this.cams = { display: null, post: null };
424
+ }
425
+ }
@@ -25,14 +25,29 @@
25
25
  //
26
26
  // THE RIG. The incoming asset's rig (camera rig: its capture pose, lens and convergence; display
27
27
  // rig: its fit and focus) is adopted at once, as in the frozen path, so the views the runtime
28
- // hands us after that are the INCOMING photo's. The outgoing camera is parented under a node
29
- // chain that maps them back to the OUTGOING photo's framing:
28
+ // hands us — once the declaration has reached them, a frame or more later — are the INCOMING
29
+ // photo's. The outgoing photo must keep being drawn through ITS OWN rig for the whole window.
30
+ //
31
+ // Two camera rigs (a photo slideshow: every photo a SOG with a camera block): the views are
32
+ // remapped, per frame, from the rig they were LOCATED for to the outgoing photo's rig, by the
33
+ // exact portal map of ./inline3d-splat-rig-map.js — window onto window, eye onto eye, the
34
+ // runtime's projection verbatim — under the outgoing photo's rig node R_o. Before the
35
+ // declaration arrives the located rig IS the outgoing one and the views pass through untouched.
36
+ //
37
+ // Otherwise (a display rig on either side, views no declared rig explains, the kill switch
38
+ // `?dxrdiag=oldrig`): the pre-1.24 node chain, which maps the incoming views back to the outgoing
39
+ // photo's framing:
30
40
  // N = R_o · K_o · D_o · D_n⁻¹ · K_n⁻¹
31
41
  // with R the rig node (inverse pivot), K the mono/capture pose in rig space and
32
42
  // D = diag(c·t, c·t, c) (c = convergence distance, t = tan(vfov/2)). The eye poses (head
33
43
  // motion) pass through untouched; the outgoing photo keeps its own window and zero-disparity
34
44
  // plane. Two display-rig assets cancel to N = R_o (the old fit and focus); in controls:'page' the
35
- // page owns the camera and N is the rig node itself.
45
+ // page owns the camera and N is the rig node itself. For two CAMERA rigs this chain is not exact:
46
+ // it scales the eyes with the window, by (c_o·t_o)/(c_n·t_n) — the outgoing photo's disparity and
47
+ // head parallax jumped by that factor at the swap (the "camera change right before the
48
+ // transition" report) — and it assumed the views were already the incoming rig's.
49
+
50
+ import { remapViews, nodePose, sameRig } from './inline3d-splat-rig-map.js';
36
51
 
37
52
  /** setSource's `outgoing` option. */
38
53
  export const OUTGOING_MODES = Object.freeze(['live', 'frozen']);
@@ -151,6 +166,12 @@ export class LiveOutgoing {
151
166
  /** PRE-SORT (warm): the current asset is ALSO on the live layer, before the swap. */
152
167
  this.warming = false;
153
168
  this._warmLayers = null;
169
+ /** The outgoing photo's own declared rig (a RigTracker entry), taken at start(). */
170
+ this.ownRig = null;
171
+ this._map = [];
172
+ this._node = new Float64Array(16);
173
+ /** What the last sync drew through: 'eye' (the eye's views), 'own' (the photo's rig, as located), 'remapped', 'chain'. */
174
+ this.path = null;
154
175
  }
155
176
 
156
177
  /** Engine support for this path: RenderViews on one camera, render targets, layers. */
@@ -299,6 +320,8 @@ export class LiveOutgoing {
299
320
  entity.gsplat.layers = [this.layer.id];
300
321
  this.entity = entity;
301
322
  this.oldFrame = oldFrame;
323
+ // Read BEFORE the incoming asset's rig is declared: the last declared rig is the outgoing's.
324
+ this.ownRig = v.rigTrack?.latest ?? null;
302
325
  if (!warmed) this.frames = 0;
303
326
  this.warming = false;
304
327
  this._warmLayers = null;
@@ -347,10 +370,11 @@ export class LiveOutgoing {
347
370
  }
348
371
 
349
372
  /**
350
- * Per frame, before the engine tick: the eye's views on the outgoing camera (same proj, pose,
351
- * viewport), the eye's node pose, and the rig chain.
373
+ * Per frame, before the engine tick: the views on the outgoing camera — through the outgoing
374
+ * photo's own rig (see the header) — the viewports, and the node chain. `rf` is the viewer's rig
375
+ * mapping for this frame (viewer._mapViews), or undefined.
352
376
  */
353
- sync(entries, rect, frustum) {
377
+ sync(entries, rect, frustum, rf) {
354
378
  if (!this.active && !this.warming) return;
355
379
  const v = this.viewer;
356
380
  const pc = v.pc;
@@ -360,11 +384,50 @@ export class LiveOutgoing {
360
384
  for (let i = 0; i < entries.length; i++) rvs.push(new pc.RenderView());
361
385
  this.cam.camera.camera.xrViews = rvs.slice();
362
386
  }
387
+ // Which views: the pre-sort (and controls:'page', where both photos are the page camera's)
388
+ // draws what the eye draws; an active window draws the outgoing photo's own rig.
389
+ let mapped = null; // remapped views, or null = the entries as they are
390
+ let exact = false; // the rig map applies: no node chain
391
+ let nodeRig = null; // the portal the remapped views belong to (for the camera node)
392
+ const own = this.ownRig;
393
+ if (rf && v.rigTrack && (!this.active || v.pageCamera)) {
394
+ exact = true;
395
+ mapped = rf.remapped ? rf.entries : null;
396
+ nodeRig = rf.eye?.portal ?? null;
397
+ this.path = 'eye';
398
+ } else if (rf?.located && own?.portal && this.active && !v.pageCamera) {
399
+ if (rf.located === own || sameRig(rf.located.rig, own.rig)) {
400
+ exact = true;
401
+ this.path = 'own';
402
+ } else {
403
+ mapped = remapViews(entries, rf.located.portal, own.portal, this._map);
404
+ exact = !!mapped;
405
+ nodeRig = own.portal;
406
+ if (mapped) this.path = 'remapped';
407
+ }
408
+ }
409
+ if (!exact) this.path = 'chain';
363
410
  for (let i = 0; i < entries.length; i++) {
364
- rvs[i].setView(entries[i].proj, entries[i].pose);
411
+ if (mapped) rvs[i].setView(mapped[i].proj, mapped[i].viewInv, mapped[i].view);
412
+ else rvs[i].setView(entries[i].proj, entries[i].pose);
365
413
  const [x, y, w, h] = rect(entries[i]);
366
414
  rvs[i].setViewport(x, y, w, h);
367
415
  }
416
+ // The wavefront's cull reads the views this camera really draws (viewer.cullViews).
417
+ if (mapped) {
418
+ const cull = (this._cull ||= []);
419
+ cull.length = mapped.length;
420
+ for (let i = 0; i < mapped.length; i++) {
421
+ const c = (cull[i] ||= {});
422
+ c.proj = mapped[i].proj;
423
+ c.pose = mapped[i].viewInv;
424
+ c.x = mapped[i].x;
425
+ c.y = mapped[i].y;
426
+ c.width = mapped[i].width;
427
+ c.height = mapped[i].height;
428
+ }
429
+ this.cam._dxrViews = cull;
430
+ } else this.cam._dxrViews = null;
368
431
  if (frustum) {
369
432
  const key = `${frustum.fov.toFixed(4)}|${frustum.aspectRatio.toFixed(4)}|${frustum.nearClip}|${frustum.farClip}`;
370
433
  if (key !== this._frustumKey) {
@@ -374,6 +437,20 @@ export class LiveOutgoing {
374
437
  }
375
438
  // The node chain (see the header). controls:'page': the page's camera, for both assets.
376
439
  const { n1, n2, n3 } = this.nodes;
440
+ if (exact) {
441
+ // The views are in rig space already: only the photo's rig node above them — the one the
442
+ // outgoing photo had at the swap (the pre-sort and controls:'page': the current one).
443
+ setTRS(n1, similarityTRS(this.active && !v.pageCamera && this.oldFrame ? this.oldFrame.rig : v.rigMatrix()));
444
+ n2.setLocalScale(1, 1, 1);
445
+ setTRS(n3, { position: [0, 0, 0], rotation: [0, 0, 0, 1], scale: 1 });
446
+ // The camera NODE (sort direction, LOD distance) on the first eye this camera draws from.
447
+ const pose = mapped && nodeRig ? nodePose(nodeRig, mapped[0].eye, this._node) : entries[0].pose;
448
+ const q = similarityTRS(pose).rotation;
449
+ this.cam.setLocalPosition(pose[12], pose[13], pose[14]);
450
+ this.cam.setLocalRotation(q[0], q[1], q[2], q[3]);
451
+ this.frames++;
452
+ return;
453
+ }
377
454
  if (v.pageCamera || !this.oldFrame) {
378
455
  const r = similarityTRS(v.rigMatrix());
379
456
  setTRS(n1, r);
@@ -401,6 +478,9 @@ export class LiveOutgoing {
401
478
  if (this.warming) return this.cancelWarm();
402
479
  this.entity = null;
403
480
  this.oldFrame = null;
481
+ this.ownRig = null;
482
+ this.path = null;
483
+ if (this.cam) this.cam._dxrViews = null;
404
484
  this.active = false;
405
485
  this.warmed = false;
406
486
  if (this.cam) this.cam.enabled = false;