@displayxr/inline3d 1.21.1 → 1.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -67,8 +67,9 @@ 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';
71
- import { resolveDiag, DiagRecorder, startDiagLoop, registerDiag } from './inline3d-splat-diag.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';
72
+ import { resolveDiag, DiagRecorder, startDiagLoop, registerDiag, DIAG_SWITCHES } from './inline3d-splat-diag.js';
72
73
  import {
73
74
  clamp,
74
75
  finite,
@@ -614,6 +615,136 @@ export function nearestCentreToRay(xyz, origin, dir, coneRad = PICK_CONE_RAD, al
614
615
  return bestInCone || bestAny;
615
616
  }
616
617
 
618
+ /** Cells per side of a pick index (PICK_INDEX_GRID² cells over the view's direction square). */
619
+ export const PICK_INDEX_GRID = 128;
620
+
621
+ /**
622
+ * A pick INDEX over one centre set, for one eye position: every usable centre (opacity at or above
623
+ * RIG_MIN_OPACITY, in front of the eye) bucketed by its DIRECTION from the eye — its gnomonic
624
+ * coordinates (u, v) = ((p − eye)·right, (p − eye)·up) / ((p − eye)·fwd) on a PICK_INDEX_GRID² grid
625
+ * over [−U, U] × [−V, V] (centres outside land in the border cells). One pass over the set, about
626
+ * one full-scan pick's cost; every later pick from the same eye then reads only the few cells
627
+ * around its ray (queryPickIndex), not the whole set.
628
+ *
629
+ * Why (a photo slideshow app, on the panel): a page that picks in bursts — 24 rays to plan a
630
+ * companion's waypoints, then an 81-ray depth grid, on every photo swap — ran 105 full scans of a
631
+ * 1.18M-centre photo on the main thread, two long tasks of ~0.3 s and ~1 s right at the swap's
632
+ * end: no session frames, so the woven image stopped following the head.
633
+ *
634
+ * `f`, `r`, `up`: an orthonormal frame (any; the eye's own is the tightest). Returns null when
635
+ * nothing usable is in front of the eye.
636
+ */
637
+ export function buildPickIndex(xyz, count, alpha8, eye, f, r, up, U, V, G = PICK_INDEX_GRID) {
638
+ const n = count;
639
+ const cells = G * G;
640
+ const su = G / (2 * U);
641
+ const sv = G / (2 * V);
642
+ const minA = Math.ceil(RIG_MIN_OPACITY * 255);
643
+ const cellOf = new Uint16Array(n); // G ≤ 255: cells < 65535, which marks "not indexed"
644
+ const start = new Uint32Array(cells + 1);
645
+ const [ex, ey, ez] = eye;
646
+ const [f0, f1, f2] = f;
647
+ const [r0, r1, r2] = r;
648
+ const [u0, u1, u2] = up;
649
+ let m = 0;
650
+ for (let i = 0; i < n; i++) {
651
+ if (alpha8 && alpha8[i] < minA) {
652
+ cellOf[i] = 65535;
653
+ continue;
654
+ }
655
+ const rx = xyz[i * 3] - ex;
656
+ const ry = xyz[i * 3 + 1] - ey;
657
+ const rz = xyz[i * 3 + 2] - ez;
658
+ const z = rx * f0 + ry * f1 + rz * f2;
659
+ if (!(z > 0)) {
660
+ cellOf[i] = 65535;
661
+ continue;
662
+ }
663
+ // z > 0 held, so every coordinate is finite (a NaN or ∞ one makes z NaN). Truncation, not
664
+ // floor: below the grid's edge both land in the border cell after the clamp.
665
+ const iz = 1 / z;
666
+ let cu = ((rx * r0 + ry * r1 + rz * r2) * iz + U) * su;
667
+ let cv = ((rx * u0 + ry * u1 + rz * u2) * iz + V) * sv;
668
+ cu = cu < 0 ? 0 : cu >= G ? G - 1 : cu | 0;
669
+ cv = cv < 0 ? 0 : cv >= G ? G - 1 : cv | 0;
670
+ const c = cv * G + cu;
671
+ cellOf[i] = c;
672
+ start[c + 1]++;
673
+ m++;
674
+ }
675
+ if (!m) return null;
676
+ for (let c = 0; c < cells; c++) start[c + 1] += start[c];
677
+ const order = new Uint32Array(m);
678
+ const cursor = start.slice(0, cells);
679
+ for (let i = 0; i < n; i++) {
680
+ const c = cellOf[i];
681
+ if (c !== 65535) order[cursor[c]++] = i;
682
+ }
683
+ return { eye: [ex, ey, ez], f: [f0, f1, f2], r: [r0, r1, r2], up: [u0, u1, u2], U, V, G, su, sv, start, order, size: m };
684
+ }
685
+
686
+ /**
687
+ * nearestCentreToRay's IN-CONE answer from a pick index: the same point, exactly (the same
688
+ * arithmetic on the same centres, ties to the lowest index as the full scan's first-wins), read
689
+ * from the cells the cone can reach. The ray must pass through the index's eye (any pick ray of
690
+ * that view does: it runs from its near-plane point through the eye). Returns the point, or
691
+ * undefined when the index cannot answer — no centre inside the cone (the full scan's
692
+ * nearest-by-angle fallback needs every centre), a ray not through the eye, or one too far off
693
+ * the index's axis — and the caller runs the full scan.
694
+ *
695
+ * Why the cells suffice: along a ray through the eye, t from the near-plane origin is t from the
696
+ * eye minus the near distance, so perp / t (the cone test) ≥ perp / t_eye = tan of the angle at
697
+ * the eye. A centre inside the cone is within atan(coneRad) of the ray AT THE EYE, and a cap of
698
+ * angular radius θ around a direction φ0 off the axis projects (gnomonic) inside a radius of
699
+ * tan(φ0 + θ) − tan(φ0) around the ray's own (u, v). The search square takes 1.25× that plus a
700
+ * cell.
701
+ */
702
+ export function queryPickIndex(ix, xyz, origin, dir, coneRad = PICK_CONE_RAD) {
703
+ const { eye, f, r, up, U, V, G, su, sv, start, order } = ix;
704
+ // the ray must pass through the eye (a relative tolerance: numerical noise, not a design gap)
705
+ const ox = eye[0] - origin[0], oy = eye[1] - origin[1], oz = eye[2] - origin[2];
706
+ const along = ox * dir[0] + oy * dir[1] + oz * dir[2];
707
+ const off2 = ox * ox + oy * oy + oz * oz - along * along;
708
+ if (!(along < 0) || off2 > 1e-12 * (along * along) + 1e-18) return undefined; // the eye sits BEHIND the near-plane origin
709
+ const dz = dir[0] * f[0] + dir[1] * f[1] + dir[2] * f[2];
710
+ if (!(dz > 0)) return undefined;
711
+ const du = (dir[0] * r[0] + dir[1] * r[1] + dir[2] * r[2]) / dz;
712
+ const dv = (dir[0] * up[0] + dir[1] * up[1] + dir[2] * up[2]) / dz;
713
+ const rho = Math.hypot(du, dv);
714
+ const phi0 = Math.atan(rho);
715
+ const th = Math.atan(coneRad) * 1.05 + 1e-6;
716
+ if (phi0 + th >= 1.4) return undefined; // ~80°: the projection's stretch is no longer worth bounding
717
+ const R = (Math.tan(phi0 + th) - rho) * 1.25;
718
+ const clampCell = (x) => (x < 0 ? 0 : x >= G ? G - 1 : x);
719
+ const cu0 = clampCell(Math.floor((du - R + U) * su) - 1);
720
+ const cu1 = clampCell(Math.floor((du + R + U) * su) + 1);
721
+ const cv0 = clampCell(Math.floor((dv - R + V) * sv) - 1);
722
+ const cv1 = clampCell(Math.floor((dv + R + V) * sv) + 1);
723
+ if (!(cu0 <= cu1 && cv0 <= cv1)) return undefined;
724
+ let bestT = Infinity;
725
+ let bestI = -1;
726
+ for (let cv = cv0; cv <= cv1; cv++) {
727
+ const row = cv * G;
728
+ for (let k = start[row + cu0], k1 = start[row + cu1 + 1]; k < k1; k++) {
729
+ const i = order[k];
730
+ // nearestCentreToRay's own arithmetic, term for term
731
+ const rx = xyz[i * 3] - origin[0];
732
+ const ry = xyz[i * 3 + 1] - origin[1];
733
+ const rz = xyz[i * 3 + 2] - origin[2];
734
+ const t = rx * dir[0] + ry * dir[1] + rz * dir[2];
735
+ if (!(t > 0)) continue;
736
+ const perp = Math.sqrt(Math.max(0, rx * rx + ry * ry + rz * rz - t * t));
737
+ const angle = perp / t;
738
+ if (angle <= coneRad && (t < bestT || (t === bestT && i < bestI))) {
739
+ bestT = t;
740
+ bestI = i;
741
+ }
742
+ }
743
+ }
744
+ if (bestI < 0) return undefined;
745
+ return [xyz[bestI * 3], xyz[bestI * 3 + 1], xyz[bestI * 3 + 2]];
746
+ }
747
+
617
748
  // ── the viewer: pose state + frame loop + the engine ────────────────────────────────────────
618
749
 
619
750
  /**
@@ -686,6 +817,13 @@ export class PlayCanvasSplatViewer {
686
817
  this.inputLocked = false;
687
818
  /** handle.setVideo's plane (./inline3d-splat-video.js) while a video is on, else null. */
688
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;
689
827
  this.boxAspect = 1;
690
828
  this.featherPx = feather > 0 ? feather : 0;
691
829
  this.captureFit = captureFit;
@@ -995,6 +1133,9 @@ export class PlayCanvasSplatViewer {
995
1133
  onFrame(views, layer) {
996
1134
  if (this._disposed) return;
997
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);
998
1139
  // BEFORE the tick: a pose the page sets in here is the one this very frame renders.
999
1140
  this._beforeFrame?.(views || null);
1000
1141
  this._tick();
@@ -1055,6 +1196,7 @@ export class PlayCanvasSplatViewer {
1055
1196
  this._live = null;
1056
1197
  this._videoPlane?.destroy();
1057
1198
  this._videoPlane = null;
1199
+ this.layerRigs = null; // its cameras go with the app
1058
1200
  try {
1059
1201
  this.app?.destroy();
1060
1202
  } catch (err) {
@@ -1736,6 +1878,8 @@ export class PlayCanvasSplatViewer {
1736
1878
  if (this._live?.active || this._live?.warming) this._live.sync(entries, rect, f);
1737
1879
  // setSource's wavefront: this frame's eye views, as the engine is about to compose them.
1738
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);
1739
1883
  } else {
1740
1884
  // Fallback: one camera per view, `rect` + `calculateProjection`.
1741
1885
  const cams = this._views;
@@ -1769,6 +1913,12 @@ export class PlayCanvasSplatViewer {
1769
1913
  }
1770
1914
  }
1771
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
+ }
1772
1922
  // handle.setVideo's plane: size, eye split, and a new frame's upload (./inline3d-splat-video.js).
1773
1923
  this._videoPlane?.beforeDraw(entries, rect);
1774
1924
  app.tick(now());
@@ -1787,6 +1937,7 @@ export class PlayCanvasSplatViewer {
1787
1937
  }
1788
1938
  g.bufW = el.width || 0;
1789
1939
  g.bufH = el.height || 0;
1940
+ g.rig = this._rigSnap ? { ...this._rigSnap } : null; // a replay re-uses the rig of its views
1790
1941
  for (let i = 0; i < views.length; i++) {
1791
1942
  const e = g.entries[i];
1792
1943
  const vp = vps[i];
@@ -2129,6 +2280,23 @@ export class PlayCanvasSplatViewer {
2129
2280
  }
2130
2281
  }
2131
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
+
2132
2300
  /** Put an engine node at a rigid display-space pose (position + rotation; no scale). */
2133
2301
  function placeNode(node, m) {
2134
2302
  node.setLocalPosition(m[12], m[13], m[14]);
@@ -2695,16 +2863,28 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
2695
2863
 
2696
2864
  // ── diagnostics (diag / ?dxrdiag — ./inline3d-splat-diag.js) ──
2697
2865
  const diagCfg = resolveDiag(opts.diag);
2698
- if (diagCfg.unknown.length) console.warn(`[inline3d/splat] diag: unknown switch(es) ${diagCfg.unknown.join(', ')} — known: norig, frozen, nowarm, cold, nooverlay.`);
2866
+ if (diagCfg.unknown.length) console.warn(`[inline3d/splat] diag: unknown switch(es) ${diagCfg.unknown.join(', ')} — known: ${DIAG_SWITCHES.join(', ')}.`);
2699
2867
  const diag = diagCfg.on ? new DiagRecorder({ switches: diagCfg.switches }) : null;
2700
2868
  /** norig: armed by the first setSource — from then on the declared rig is kept. */
2701
2869
  let rigLockArmed = false;
2702
2870
  if (diag) {
2703
2871
  diag.imageState = () => viewer.diagImageState();
2704
2872
  diag.observeLongTasks();
2873
+ diag.observeLongFrames();
2705
2874
  diag.log(`on — switches [${[...diag.switches].join(', ') || 'none'}]; dump: copy(__dxrDiag.dump())`);
2706
2875
  }
2707
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
+ }
2708
2888
  /** prepareSource: prepared handle → { loaded, state: 'ready' | 'used' | 'disposed', dispose }. */
2709
2889
  const preparedAssets = new WeakMap();
2710
2890
  const livePrepared = new Set();
@@ -2785,6 +2965,42 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
2785
2965
  prepareSource,
2786
2966
  setRig,
2787
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
+ },
2788
3004
  /**
2789
3005
  * Play a transition effect (inflate, deflate, sweep, dissolve, fade, pulse, custom) — see
2790
3006
  * docs/splat-effects.md. Validated now; runs once the first asset is on screen. Resolves
@@ -2940,6 +3156,14 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
2940
3156
  delete out._resolveFirstWoven;
2941
3157
  }
2942
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
+ }
2943
3167
  // Replay what the page did before this module arrived — exclude() above all, which a product
2944
3168
  // page calls on the very next line after addSplat.
2945
3169
  for (const [name, args] of pending) {
@@ -3156,11 +3380,91 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
3156
3380
  return best;
3157
3381
  }
3158
3382
  const full = c.res?.centers;
3159
- if (full && full.length) return nearestCentreToRay(full, ray.o, ray.d, undefined, c.alpha8);
3383
+ if (full && full.length) return pickFull(c, full, ray);
3160
3384
  // The engine released its centres: fall back to the strided set kept at load.
3161
3385
  return c.pickCentres ? nearestCentreToRay(c.pickCentres, ray.o, ray.d) : null;
3162
3386
  }
3163
3387
 
3388
+ /**
3389
+ * The eye of the view on screen and an orthonormal frame at it, in MODEL space, plus the
3390
+ * direction extent of its frustum (gnomonic, per axis): what a pick index is built for. Every
3391
+ * pick ray of this view passes through `eye`. null when the view is not a plain perspective one.
3392
+ */
3393
+ function pickFrame() {
3394
+ const v = viewer.currentView();
3395
+ const invP = mat4Invert(v.proj);
3396
+ if (!invP) return null;
3397
+ const T = mat4Mul(viewer.rigMatrix(), v.pose);
3398
+ const at = (x, y, z) => contentToModel(transformPoint(T, x, y, z));
3399
+ const eye = at(0, 0, 0);
3400
+ const sub = (p) => [p[0] - eye[0], p[1] - eye[1], p[2] - eye[2]];
3401
+ const dot = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
3402
+ const norm = (a) => {
3403
+ const l = Math.hypot(a[0], a[1], a[2]);
3404
+ return l > 0 ? [a[0] / l, a[1] / l, a[2] / l] : null;
3405
+ };
3406
+ const f = norm(sub(at(0, 0, -1)));
3407
+ if (!f) return null;
3408
+ let r = sub(at(1, 0, 0));
3409
+ const rf = dot(r, f);
3410
+ r = norm([r[0] - rf * f[0], r[1] - rf * f[1], r[2] - rf * f[2]]);
3411
+ if (!r) return null;
3412
+ const up = [f[1] * r[2] - f[2] * r[1], f[2] * r[0] - f[0] * r[2], f[0] * r[1] - f[1] * r[0]];
3413
+ let U = 0;
3414
+ let V = 0;
3415
+ for (const [x, y] of [[-1, -1], [1, -1], [-1, 1], [1, 1]]) {
3416
+ const d = sub(at(...transformPoint(invP, x, y, 1)));
3417
+ const z = dot(d, f);
3418
+ if (!(z > 0)) return null;
3419
+ U = Math.max(U, Math.abs(dot(d, r) / z));
3420
+ V = Math.max(V, Math.abs(dot(d, up) / z));
3421
+ }
3422
+ if (!(U > 0 && V > 0)) return null;
3423
+ // a margin past the frustum: a pick at its edge reaches a little outside it
3424
+ U = U * 1.1 + 0.02;
3425
+ V = V * 1.1 + 0.02;
3426
+ return { eye, f, r, up, U, V, key: [...eye, ...f, ...r, U, V].join(',') };
3427
+ }
3428
+
3429
+ /**
3430
+ * A pick over the full centre set. The first pick from an eye position is the plain full scan
3431
+ * (a page picking once per frame pays what it always did); a SECOND pick from the same eye
3432
+ * builds a pick index for it (buildPickIndex: about one scan) and every further pick from there
3433
+ * reads a few cells — a burst of N picks costs ~2 scans, not N. Same answer as the full scan
3434
+ * (queryPickIndex falls back to it when it cannot answer exactly). `?dxrdiag=oldpick`: always
3435
+ * the full scan (the 1.21.1 path), for A/B.
3436
+ */
3437
+ function pickFull(c, full, ray) {
3438
+ const t0 = diag ? performance.now() : 0;
3439
+ let how = 'scan';
3440
+ let hit;
3441
+ if (!diag?.has('oldpick')) {
3442
+ const fr = pickFrame();
3443
+ if (fr) {
3444
+ const same = (p) => p && p.key === fr.key && p.xyz === full && p.alpha8 === c.alpha8;
3445
+ let ix = same(c.pickIx) ? c.pickIx.ix : null;
3446
+ if (!ix && same(c.pickPrev)) {
3447
+ const count = Math.floor(full.length / 3);
3448
+ ix = buildPickIndex(full, count, c.alpha8, fr.eye, fr.f, fr.r, fr.up, fr.U, fr.V);
3449
+ c.pickIx = ix ? { key: fr.key, xyz: full, alpha8: c.alpha8, ix } : null;
3450
+ how = 'build';
3451
+ }
3452
+ c.pickPrev = { key: fr.key, xyz: full, alpha8: c.alpha8 };
3453
+ if (ix) {
3454
+ hit = queryPickIndex(ix, full, ray.o, ray.d);
3455
+ if (hit !== undefined && how === 'scan') how = 'index';
3456
+ }
3457
+ }
3458
+ }
3459
+ if (hit === undefined) {
3460
+ hit = nearestCentreToRay(full, ray.o, ray.d, undefined, c.alpha8);
3461
+ if (how === 'build') how = 'build+scan';
3462
+ else if (how === 'index') how = 'scan';
3463
+ }
3464
+ if (diag) diag.pick(performance.now() - t0, how);
3465
+ return hit;
3466
+ }
3467
+
3164
3468
  function bindFocusInput() {
3165
3469
  // controls:'page' binds no input at all: gestures are the page's (pick + setFocus remain).
3166
3470
  if (pageMode || focusInput === false || unbindFocusInput) return;
@@ -3601,6 +3905,9 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
3601
3905
  });
3602
3906
  if (!app || removed) return null;
3603
3907
  pcModule = pc;
3908
+ // diag: count the GL calls that can block (compile, link, status queries, readbacks, syncs),
3909
+ // from before the first asset's compile on
3910
+ if (diag) diag.instrumentGl(app.graphicsDevice?.gl);
3604
3911
  /**
3605
3912
  * ADVANCED, not covered by the semver promise: the engine objects behind this window.
3606
3913
  * `app` is the tile's `pc.AppBase`; `root` the content root, in the splat's content space
@@ -4217,10 +4524,13 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
4217
4524
  const finish = () => {
4218
4525
  if (finished) return;
4219
4526
  finished = true;
4527
+ const ts = performance.now();
4220
4528
  for (const f of finishers) f();
4529
+ perfSpan('settle:teardown', ts);
4221
4530
  if (gen === sourceGen) diag?.setPhase('settle');
4222
4531
  if (pendingSwap?.finish === finish) pendingSwap = null;
4223
4532
  settle();
4533
+ if (gen === sourceGen) diag?.settled(performance.now() - ts);
4224
4534
  };
4225
4535
  pendingSwap = { finish };
4226
4536
 
@@ -4429,12 +4739,15 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
4429
4739
  const finish = () => {
4430
4740
  if (finished) return;
4431
4741
  finished = true;
4742
+ const ts = performance.now();
4432
4743
  for (const f of finishers) f();
4744
+ perfSpan('settle:teardown', ts);
4433
4745
  for (const w of [...wakers]) w();
4434
4746
  wakers.clear();
4435
4747
  viewer._transitionState = null;
4436
4748
  if (gen === sourceGen) diag?.setPhase('settle');
4437
4749
  if (pendingSwap?.finish === finish) pendingSwap = null;
4750
+ if (gen === sourceGen) diag?.settled(performance.now() - ts);
4438
4751
  };
4439
4752
  const ac = typeof AbortController === 'function' ? new AbortController() : null;
4440
4753
  finishers.push(() => ac?.abort());
@@ -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.21.1",
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",