@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.
@@ -67,7 +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';
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 { RigTracker, remapViews, nodePose, sameRig } from './inline3d-splat-rig-map.js';
71
73
  import { resolveDiag, DiagRecorder, startDiagLoop, registerDiag, DIAG_SWITCHES } from './inline3d-splat-diag.js';
72
74
  import {
73
75
  clamp,
@@ -816,6 +818,23 @@ export class PlayCanvasSplatViewer {
816
818
  this.inputLocked = false;
817
819
  /** handle.setVideo's plane (./inline3d-splat-video.js) while a video is on, else null. */
818
820
  this._videoPlane = null;
821
+ /** handle.setLayerRig's per-rig cameras (./inline3d-splat-layer-rig.js), made on first use. */
822
+ this.layerRigs = null;
823
+ /** () => the view-rig descriptor currently declared to the runtime (or null). */
824
+ this.layerRigSource = null;
825
+ /** That descriptor as it stood when THIS frame's views were pulled (snapshotted in onFrame). */
826
+ this._rigSnap = null;
827
+ /**
828
+ * The rigs this tile declared and which one each frame's views were located for
829
+ * (./inline3d-splat-rig-map.js). Null = the kill switch (diag 'oldrig'): views drawn as
830
+ * located, the live outgoing on its pre-1.24 node chain.
831
+ */
832
+ this.rigTrack = null;
833
+ /** This frame's rig mapping: { located, eye, remapped, entries } (see _mapViews). */
834
+ this.rigFrame = null;
835
+ this._eyeMap = [];
836
+ this._rigAtPull = null;
837
+ this._eyeSplit = NaN;
819
838
  this.boxAspect = 1;
820
839
  this.featherPx = feather > 0 ? feather : 0;
821
840
  this.captureFit = captureFit;
@@ -1125,6 +1144,11 @@ export class PlayCanvasSplatViewer {
1125
1144
  onFrame(views, layer) {
1126
1145
  if (this._disposed) return;
1127
1146
  if (this._mode !== '3d') this.stopMono();
1147
+ // The rig these views were located with: Blink chained the rig declared BEFORE this callback,
1148
+ // and the tick below may declare a new one (a focus ease) for the NEXT locate.
1149
+ if (this.layerRigs?.active) this._rigSnap = snapshotRig(this.layerRigSource?.(), this._rigSnap);
1150
+ // Same moment, for the rig map: the rig these views were most likely located for (a tie-break).
1151
+ this._rigAtPull = this.rigTrack ? this.rigTrack.latest : null;
1128
1152
  // BEFORE the tick: a pose the page sets in here is the one this very frame renders.
1129
1153
  this._beforeFrame?.(views || null);
1130
1154
  this._tick();
@@ -1185,6 +1209,7 @@ export class PlayCanvasSplatViewer {
1185
1209
  this._live = null;
1186
1210
  this._videoPlane?.destroy();
1187
1211
  this._videoPlane = null;
1212
+ this.layerRigs = null; // its cameras go with the app
1188
1213
  try {
1189
1214
  this.app?.destroy();
1190
1215
  } catch (err) {
@@ -1576,6 +1601,44 @@ export class PlayCanvasSplatViewer {
1576
1601
  }
1577
1602
  }
1578
1603
 
1604
+ /**
1605
+ * This frame's rig mapping (./inline3d-splat-rig-map.js): which declared rig the views were
1606
+ * located for (`located`), which the current photo wants (`eye`, the last declared), and — only
1607
+ * when those differ and both are camera rigs — the views remapped to `eye` (`entries`: proj,
1608
+ * viewInv, view per view; `cull`: the same as { proj, pose } entries; `node`: the first eye's
1609
+ * rigid pose). Otherwise `remapped` is false and the views are drawn exactly as located.
1610
+ */
1611
+ _mapViews(entries, prefer = null) {
1612
+ const rt = this.rigTrack;
1613
+ const rf = (this.rigFrame ||= { located: null, eye: null, remapped: false, entries: null, cull: [], node: new Float64Array(16) });
1614
+ rf.remapped = false;
1615
+ rf.located = rf.eye = null;
1616
+ if (!rt || this._mode !== '3d' || entries.length < 2) return rf;
1617
+ rf.located = rt.locate(entries, prefer);
1618
+ rf.eye = rt.latest;
1619
+ const L = rf.located;
1620
+ const T = rf.eye;
1621
+ if (!L || !T?.portal || L === T || sameRig(L.rig, T.rig)) return rf;
1622
+ const r = remapViews(entries, L.portal, T.portal, this._eyeMap);
1623
+ if (!r) return rf;
1624
+ rf.remapped = true;
1625
+ rf.entries = r;
1626
+ rf.cull.length = r.length;
1627
+ for (let i = 0; i < r.length; i++) {
1628
+ const c = (rf.cull[i] ||= {});
1629
+ c.proj = r[i].proj;
1630
+ c.pose = r[i].viewInv;
1631
+ c.x = r[i].x;
1632
+ c.y = r[i].y;
1633
+ c.width = r[i].width;
1634
+ c.height = r[i].height;
1635
+ c.node = null;
1636
+ }
1637
+ nodePose(T.portal, r[0].eye, rf.node);
1638
+ rf.cull[0].node = rf.node; // a rigid pose for a camera NODE (the views are affine)
1639
+ return rf;
1640
+ }
1641
+
1579
1642
  /**
1580
1643
  * The mono/capture camera as a lens frame, for the live outgoing's rig chain
1581
1644
  * (./inline3d-splat-live.js): the rig node's matrix, the camera pose in rig space, the
@@ -1600,6 +1663,8 @@ export class PlayCanvasSplatViewer {
1600
1663
  */
1601
1664
  cullViews(cam, entries, rect, into = []) {
1602
1665
  const parent = cam?.parent?.getWorldTransform?.()?.data ?? null;
1666
+ // A camera drawing remapped views (./inline3d-splat-rig-map.js) publishes them as _dxrViews.
1667
+ if (cam?._dxrViews?.length === entries.length) entries = cam._dxrViews;
1603
1668
  into.length = entries.length;
1604
1669
  for (let i = 0; i < entries.length; i++) {
1605
1670
  const e = entries[i];
@@ -1613,7 +1678,8 @@ export class PlayCanvasSplatViewer {
1613
1678
  o.W[0] = C[3]; o.W[1] = C[7]; o.W[2] = C[11]; o.W[3] = C[15];
1614
1679
  o.K[0] = w * e.proj[0];
1615
1680
  o.K[1] = 1 / w;
1616
- o.K[2] = V[0] * V[0] + V[1] * V[1] + V[2] * V[2];
1681
+ // The view's largest column scale² (1 for a rigid view; a remapped view is affine).
1682
+ o.K[2] = Math.max(V[0] * V[0] + V[1] * V[1] + V[2] * V[2], V[4] * V[4] + V[5] * V[5] + V[6] * V[6], V[8] * V[8] + V[9] * V[9] + V[10] * V[10]);
1617
1683
  o.K[3] = 0;
1618
1684
  }
1619
1685
  return into;
@@ -1845,12 +1911,18 @@ export class PlayCanvasSplatViewer {
1845
1911
  for (let i = 0; i < entries.length; i++) rvs.push(new pc.RenderView());
1846
1912
  this.eye.camera.camera.xrViews = rvs.slice();
1847
1913
  }
1914
+ // The current photo through ITS rig (the last declared): the views are remapped only while
1915
+ // they were located for another one (a declaration still in flight, ./inline3d-splat-rig-map.js).
1916
+ const rf = this._mapViews(entries, cache ? cache.rigAt : null);
1917
+ const eyeViews = rf.remapped ? rf.entries : null;
1848
1918
  for (let i = 0; i < entries.length; i++) {
1849
1919
  const e = entries[i];
1850
- rvs[i].setView(e.proj, e.pose);
1920
+ if (eyeViews) rvs[i].setView(eyeViews[i].proj, eyeViews[i].viewInv, eyeViews[i].view);
1921
+ else rvs[i].setView(e.proj, e.pose);
1851
1922
  const [x, y, w, h] = rect(e);
1852
1923
  rvs[i].setViewport(x, y, w, h);
1853
1924
  }
1925
+ this.eye._dxrViews = eyeViews ? rf.cull : null;
1854
1926
  // LOD and FOV-compensation read camera.fov/near/far, which under xrViews come from the
1855
1927
  // XR properties — the frustum the views actually have, as XrManager does it.
1856
1928
  const f = frustumFromProjection(entries[0].proj);
@@ -1861,11 +1933,16 @@ export class PlayCanvasSplatViewer {
1861
1933
  }
1862
1934
  // The camera NODE drives the sort direction and LOD distance; the views ignore it (they
1863
1935
  // compose the node's PARENT with their own pose). Park it on the first eye.
1864
- placeNode(this.eye, entries[0].pose);
1865
- // setSource's live outgoing: the same views on its own camera, into its own target.
1866
- if (this._live?.active || this._live?.warming) this._live.sync(entries, rect, f);
1936
+ placeNode(this.eye, eyeViews ? rf.node : entries[0].pose);
1937
+ // setSource's live outgoing: the same views on its own camera (through ITS photo's rig),
1938
+ // into its own target.
1939
+ if (this._live?.active || this._live?.warming) this._live.sync(entries, rect, f, rf);
1867
1940
  // setSource's wavefront: this frame's eye views, as the engine is about to compose them.
1868
1941
  this.onBeforeRender?.(entries, rect);
1942
+ // handle.setLayerRig: the display / post run cameras, on the same views (display: rounded).
1943
+ if (this.layerRigs && this.layerRigs.sync()) {
1944
+ this.layerRigs.frame(eyeViews ? rf.cull : entries, rect, f, eyeViews ? rf.eye.rig : rf.located ? rf.located.rig : cache ? cache.rig : null);
1945
+ }
1869
1946
  } else {
1870
1947
  // Fallback: one camera per view, `rect` + `calculateProjection`.
1871
1948
  const cams = this._views;
@@ -1899,6 +1976,12 @@ export class PlayCanvasSplatViewer {
1899
1976
  }
1900
1977
  }
1901
1978
  this._updateFeather(entries[0].width * sx, entries[0].height * sy);
1979
+ // handle.makeSbsMaterial: the eye split, scene-wide (a material only declares the uniform).
1980
+ const split = eyeSplit(entries, rect);
1981
+ if (split !== this._eyeSplit) {
1982
+ this._eyeSplit = split;
1983
+ app.graphicsDevice?.scope?.resolve?.(EYE_SPLIT_UNIFORM)?.setValue(split);
1984
+ }
1902
1985
  // handle.setVideo's plane: size, eye split, and a new frame's upload (./inline3d-splat-video.js).
1903
1986
  this._videoPlane?.beforeDraw(entries, rect);
1904
1987
  app.tick(now());
@@ -1917,6 +2000,8 @@ export class PlayCanvasSplatViewer {
1917
2000
  }
1918
2001
  g.bufW = el.width || 0;
1919
2002
  g.bufH = el.height || 0;
2003
+ g.rig = this._rigSnap ? { ...this._rigSnap } : null; // a replay re-uses the rig of its views
2004
+ g.rigAt = this._rigAtPull ?? null;
1920
2005
  for (let i = 0; i < views.length; i++) {
1921
2006
  const e = g.entries[i];
1922
2007
  const vp = vps[i];
@@ -2259,6 +2344,23 @@ export class PlayCanvasSplatViewer {
2259
2344
  }
2260
2345
  }
2261
2346
 
2347
+ /**
2348
+ * A copy of the declared rig's frame fields (the SDK rewrites its camera-rig descriptor in place),
2349
+ * or null. Only what the layer rig reads: kind, pose, convergence, metres-to-virtual.
2350
+ */
2351
+ export function snapshotRig(rig, into = null) {
2352
+ if (!rig || typeof rig !== 'object') return null;
2353
+ const o = into || { position: {}, orientation: {} };
2354
+ o.type = rig.type;
2355
+ const p = rig.position || {};
2356
+ const q = rig.orientation || {};
2357
+ o.position = { x: p.x || 0, y: p.y || 0, z: p.z || 0 };
2358
+ o.orientation = { x: q.x || 0, y: q.y || 0, z: q.z || 0, w: q.w === undefined ? 1 : q.w };
2359
+ o.convergenceDiopters = rig.convergenceDiopters;
2360
+ o.metersToVirtual = rig.metersToVirtual;
2361
+ return o;
2362
+ }
2363
+
2262
2364
  /** Put an engine node at a rigid display-space pose (position + rotation; no scale). */
2263
2365
  function placeNode(node, m) {
2264
2366
  node.setLocalPosition(m[12], m[13], m[14]);
@@ -2831,11 +2933,31 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
2831
2933
  let rigLockArmed = false;
2832
2934
  if (diag) {
2833
2935
  diag.imageState = () => viewer.diagImageState();
2936
+ diag.rigState = () => {
2937
+ const rf = viewer.rigFrame;
2938
+ if (!viewer.rigTrack || !rf) return null;
2939
+ return {
2940
+ at: rf.located ? rf.located.id : null,
2941
+ in: rf.eye ? `${rf.eye.id}${rf.remapped ? '+' : ''}` : null,
2942
+ out: viewer._live?.active ? viewer._live.path : null,
2943
+ };
2944
+ };
2834
2945
  diag.observeLongTasks();
2835
2946
  diag.observeLongFrames();
2836
2947
  diag.log(`on — switches [${[...diag.switches].join(', ') || 'none'}]; dump: copy(__dxrDiag.dump())`);
2837
2948
  }
2838
2949
  let removed = false;
2950
+ // handle.setLayerRig: the per-rig cameras and where they read the declared rig from.
2951
+ // Kill switch `nolayerrig`: requests are recorded but every layer stays on the eye camera.
2952
+ let warnedLayerRigPath = false;
2953
+ function layerRigs() {
2954
+ if (!viewer.layerRigs) {
2955
+ viewer.layerRigs = new LayerRigCameras(viewer);
2956
+ viewer.layerRigs.disabled = !!diag?.has('nolayerrig');
2957
+ viewer.layerRigSource = () => out.viewRig || null;
2958
+ }
2959
+ return viewer.layerRigs;
2960
+ }
2839
2961
  /** prepareSource: prepared handle → { loaded, state: 'ready' | 'used' | 'disposed', dispose }. */
2840
2962
  const preparedAssets = new WeakMap();
2841
2963
  const livePrepared = new Set();
@@ -2916,6 +3038,42 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
2916
3038
  prepareSource,
2917
3039
  setRig,
2918
3040
  setVideo,
3041
+ /**
3042
+ * Draw a layer of this tile's engine through the DISPLAY rig (round, physical-depth stage
3043
+ * objects) while the splat and the declared view rig stay on the photo's camera rig — or back
3044
+ * on the camera rig ('camera'). No second full pass: one extra camera over the named layers,
3045
+ * into the same target, in composition order. docs/playcanvas-adapter.md §setLayerRig and
3046
+ * docs/proposals/layer-display-rig.md (the exact mapping, and why it is not Kooima).
3047
+ * `viewerDistance` (m) / `gain` set the rounding for the whole tile (last call wins).
3048
+ */
3049
+ setLayerRig(layer, rig, o = {}) {
3050
+ const r = validateLayerRig(layer, rig, o);
3051
+ if (viewer._viewPath === 'cameras' && !warnedLayerRigPath) {
3052
+ warnedLayerRigPath = true;
3053
+ console.warn('[inline3d/splat] setLayerRig needs the engine RenderView path; on the N-camera fallback the layer stays on the photo rig.');
3054
+ }
3055
+ layerRigs().set(layer, r.rig, { viewerDistance: r.viewerDistance, gain: r.gain });
3056
+ return out;
3057
+ },
3058
+ /** The layers on the display rig (as the page named them), and what the last frame did. */
3059
+ layerRigState() {
3060
+ const lr = viewer.layerRigs;
3061
+ return {
3062
+ display: lr ? [...lr.requests.keys()] : [],
3063
+ disabled: !!lr?.disabled,
3064
+ rounded: !!lr?.last.rounded,
3065
+ gain: lr?.last.gain ?? null,
3066
+ };
3067
+ },
3068
+ /**
3069
+ * An unlit material showing the left half of `texture` to left-eye views and the right half to
3070
+ * right-eye views (format 'sbs' | 'tb' | 'mono'), on any mesh — mono / 2D: the left half. Needs
3071
+ * the engine (after `ready`). docs/playcanvas-adapter.md §makeSbsMaterial.
3072
+ */
3073
+ makeSbsMaterial(texture, o = {}) {
3074
+ if (!viewer.app || !viewer.pc) throw new Error('@displayxr/inline3d/splat: makeSbsMaterial() needs the engine — call it after `await handle.ready`.');
3075
+ return makeSbsMaterial(viewer.pc, texture, o);
3076
+ },
2919
3077
  /**
2920
3078
  * Play a transition effect (inflate, deflate, sweep, dissolve, fade, pulse, custom) — see
2921
3079
  * docs/splat-effects.md. Validated now; runs once the first asset is on screen. Resolves
@@ -3024,6 +3182,7 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
3024
3182
  // toward `target` at the viewer's FOCUS_EASE unless snapped.
3025
3183
  const pageFocus = { d: Number.NaN, target: Number.NaN, fired: Number.NaN, source: null };
3026
3184
 
3185
+ let initialViewRig = null; // the rig addScene is created with (controls:'page'), else the shorthand
3027
3186
  if (wall && wall.supported) {
3028
3187
  const onFrame = diag
3029
3188
  ? (views, layer) => {
@@ -3036,7 +3195,7 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
3036
3195
  // controls:'page' starts on a camera rig (attach, provisional FOV/convergence until the page
3037
3196
  // and the waterfall say otherwise); the display rig's height means nothing there.
3038
3197
  ...(pageMode
3039
- ? { viewRig: pageViewRig({ verticalFovDeg: viewer.page.fov, convergence: 2, comfortDepth: ctl.comfortDepth }) }
3198
+ ? { viewRig: (initialViewRig = pageViewRig({ verticalFovDeg: viewer.page.fov, convergence: 2, comfortDepth: ctl.comfortDepth })) }
3040
3199
  : { virtualDisplayHeight }),
3041
3200
  onLayerLost: viewer.onLayerLost,
3042
3201
  ...(observe ? { observe } : {}),
@@ -3045,6 +3204,20 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
3045
3204
  } else {
3046
3205
  viewer.startMono();
3047
3206
  }
3207
+ // Every rig this tile declares, by VALUE (./inline3d-splat-rig-map.js): which one a frame's
3208
+ // views were located for is read off the views, so each photo can be drawn through its own rig
3209
+ // while a declaration is in flight or while a live outgoing photo is still on screen.
3210
+ // `?dxrdiag=oldrig` turns it off (views drawn as located; the live outgoing's node chain).
3211
+ if (handle && typeof handle.setViewRig === 'function' && !diag?.has('oldrig')) {
3212
+ const rt = (viewer.rigTrack = new RigTracker());
3213
+ rt.note(initialViewRig || { type: 'display', virtualDisplayHeight });
3214
+ const push = handle.setViewRig;
3215
+ handle.setViewRig = (rig) => {
3216
+ const r = push.call(handle, rig);
3217
+ rt.note(rig);
3218
+ return r;
3219
+ };
3220
+ }
3048
3221
  let diagLoop = null;
3049
3222
  if (diag) {
3050
3223
  // Every rig push, logged with its values; under `norig`, every push after the first
@@ -3071,6 +3244,14 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
3071
3244
  delete out._resolveFirstWoven;
3072
3245
  }
3073
3246
 
3247
+ // addSplat's `displayRigLayers` sugar: [layer, …] or { layers: [...], viewerDistance, gain }.
3248
+ if (opts.displayRigLayers !== undefined) {
3249
+ const d = opts.displayRigLayers;
3250
+ const list = Array.isArray(d) ? d : d && Array.isArray(d.layers) ? d.layers : null;
3251
+ if (!list) throw new TypeError('@displayxr/inline3d/splat: displayRigLayers — expected an array of layers, or { layers, viewerDistance?, gain? }.');
3252
+ const o = Array.isArray(d) ? {} : { ...(d.viewerDistance !== undefined ? { viewerDistance: d.viewerDistance } : {}), ...(d.gain !== undefined ? { gain: d.gain } : {}) };
3253
+ for (const l of list) out.setLayerRig(l, 'display', o);
3254
+ }
3074
3255
  // Replay what the page did before this module arrived — exclude() above all, which a product
3075
3256
  // page calls on the very next line after addSplat.
3076
3257
  for (const [name, args] of pending) {