@displayxr/inline3d 1.13.1 → 1.14.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.
@@ -49,7 +49,7 @@ import {
49
49
  tileSplatBudget,
50
50
  budgetPerManager,
51
51
  } from './inline3d-splat-perf.js';
52
- import { boundsFromPositions } from './inline3d-viewer.js';
52
+ import { boundsFromPositions, boundsFromPositionsAsync } from './inline3d-viewer.js';
53
53
  import { cameraRigFromPose } from './inline3d-three.js';
54
54
  import {
55
55
  SplatEffects,
@@ -58,6 +58,7 @@ import {
58
58
  resolveRevealOption,
59
59
  validateEffectCall,
60
60
  } from './inline3d-splat-effects.js';
61
+ import { LiveOutgoing, resolveOutgoingOption, defaultOutgoing, yieldIdle } from './inline3d-splat-live.js';
61
62
  import {
62
63
  clamp,
63
64
  finite,
@@ -668,6 +669,8 @@ export class PlayCanvasSplatViewer {
668
669
  this._captureWaiters = [];
669
670
  /** The crossfade's frame snapshot: { w, h, tex, rt, mi, mat } once captured. */
670
671
  this._snap = null;
672
+ /** setSource's live outgoing asset (./inline3d-splat-live.js), made on first use. */
673
+ this._live = null;
671
674
  this.vH = virtualDisplayHeight;
672
675
  this.fit = fit;
673
676
  this.margin = margin;
@@ -987,6 +990,8 @@ export class PlayCanvasSplatViewer {
987
990
  clearTimeout(w.timer);
988
991
  w.resolve(false);
989
992
  }
993
+ this._live?.destroy();
994
+ this._live = null;
990
995
  try {
991
996
  this.app?.destroy();
992
997
  } catch (err) {
@@ -1223,8 +1228,12 @@ export class PlayCanvasSplatViewer {
1223
1228
  const w = this.canvas.width;
1224
1229
  const h = this.canvas.height;
1225
1230
  let s = this._snap;
1226
- if (!s || s.w !== w || s.h !== h) {
1227
- this._destroySnapshot();
1231
+ if (!s || !s.tex || s.w !== w || s.h !== h) {
1232
+ // A new size, or the texture was released after the last transition: a new texture.
1233
+ // The overlay quads (and their compiled materials) are kept.
1234
+ const parts = s?.parts || null;
1235
+ s?.rt?.destroy?.();
1236
+ s?.tex?.destroy?.();
1228
1237
  const tex = new pc.Texture(device, {
1229
1238
  name: 'inline3d-snapshot',
1230
1239
  width: w,
@@ -1236,7 +1245,7 @@ export class PlayCanvasSplatViewer {
1236
1245
  addressU: pc.ADDRESS_CLAMP_TO_EDGE,
1237
1246
  addressV: pc.ADDRESS_CLAMP_TO_EDGE,
1238
1247
  });
1239
- s = this._snap = { w, h, tex, rt: new pc.RenderTarget({ colorBuffer: tex, depth: false }), parts: null };
1248
+ s = this._snap = { w, h, tex, rt: new pc.RenderTarget({ colorBuffer: tex, depth: false }), parts };
1240
1249
  }
1241
1250
  // null source = the back buffer, still holding this frame (same task, before compositing).
1242
1251
  ok = device.copyRenderTarget(null, s.rt, true, false) !== false;
@@ -1325,7 +1334,7 @@ export class PlayCanvasSplatViewer {
1325
1334
  setSnapshotAlpha(alpha, wipe = null) {
1326
1335
  const s = this._snap;
1327
1336
  if (!s?.parts) return false;
1328
- const fits = s.w === this.canvas.width && s.h === this.canvas.height;
1337
+ const fits = !!s.tex && s.w === this.canvas.width && s.h === this.canvas.height;
1329
1338
  const show = alpha > 0 && fits;
1330
1339
  for (const p of s.parts) {
1331
1340
  p.mi.visible = show;
@@ -1341,6 +1350,67 @@ export class PlayCanvasSplatViewer {
1341
1350
  return alpha <= 0 || fits;
1342
1351
  }
1343
1352
 
1353
+ /**
1354
+ * The overlay's source: the live outgoing target, or null for the frozen capture. The same two
1355
+ * quads, the same lerp / wipe — only the texture changes.
1356
+ */
1357
+ setSnapshotSource(tex) {
1358
+ const s = this._snap;
1359
+ if (!s?.parts) return;
1360
+ for (const p of s.parts) {
1361
+ p.mat.setParameter('dxrSnap', tex || s.tex);
1362
+ p.mat.update();
1363
+ }
1364
+ }
1365
+
1366
+ /**
1367
+ * The mono/capture camera as a lens frame, for the live outgoing's rig chain
1368
+ * (./inline3d-splat-live.js): the rig node's matrix, the camera pose in rig space, the
1369
+ * convergence distance c (along the view axis to the focus, which the pivot puts at the orbit
1370
+ * centre) and t = tan(vertical fov / 2).
1371
+ */
1372
+ lensFrame() {
1373
+ const pose = Float64Array.from(this.mono.pose);
1374
+ const o = this._orbitCentre;
1375
+ const fz = o.z + this._depthOffset;
1376
+ let c = -((o.x - pose[12]) * pose[8] + (o.y - pose[13]) * pose[9] + (fz - pose[14]) * pose[10]);
1377
+ if (!(c > 1e-6)) c = 1;
1378
+ return { rig: this.rigMatrix(), pose, c, t: Math.tan((this.mono.fov * DEG) / 2) };
1379
+ }
1380
+
1381
+ /** Can this tile render a live outgoing asset (the single-camera RenderView path)? */
1382
+ get canLiveOutgoing() {
1383
+ return LiveOutgoing.supported(this);
1384
+ }
1385
+
1386
+ /** Start rendering `entity` live into its own target (see ./inline3d-splat-live.js). */
1387
+ startLiveOutgoing(entity, oldFrame) {
1388
+ this._live ||= new LiveOutgoing(this);
1389
+ return this._live.start(entity, oldFrame) ? this._live : null;
1390
+ }
1391
+
1392
+ /** End the live window: overlay back on the frozen capture, camera off, target freed. */
1393
+ stopLiveOutgoing() {
1394
+ if (!this._live?.active) return;
1395
+ this.setSnapshotSource(null);
1396
+ this._live.stop();
1397
+ }
1398
+
1399
+ /**
1400
+ * A transition has ended: free the frozen frame's texture (7 MB at 2560×720; it used to stay
1401
+ * allocated until the next swap). The overlay quads stay, hidden; the next capture makes a new
1402
+ * texture.
1403
+ */
1404
+ releaseSnapshot() {
1405
+ const s = this._snap;
1406
+ if (!s?.tex) return;
1407
+ for (const p of s.parts || []) p.mi.visible = false;
1408
+ s.rt?.destroy?.();
1409
+ s.tex.destroy?.();
1410
+ s.rt = s.tex = null;
1411
+ s.w = s.h = 0;
1412
+ }
1413
+
1344
1414
  /** How many eye viewports sit side by side in the buffer right now (1 in mono). */
1345
1415
  get viewsAcross() {
1346
1416
  return this._mode === '3d' && this._lastGood ? this._lastGood.entries.length : 1;
@@ -1500,6 +1570,8 @@ export class PlayCanvasSplatViewer {
1500
1570
  // The camera NODE drives the sort direction and LOD distance; the views ignore it (they
1501
1571
  // compose the node's PARENT with their own pose). Park it on the first eye.
1502
1572
  placeNode(this.eye, entries[0].pose);
1573
+ // setSource's live outgoing: the same views on its own camera, into its own target.
1574
+ if (this._live?.active) this._live.sync(entries, rect, f);
1503
1575
  } else {
1504
1576
  // Fallback: one camera per view, `rect` + `calculateProjection`.
1505
1577
  const cams = this._views;
@@ -1640,7 +1712,7 @@ export class PlayCanvasSplatViewer {
1640
1712
  /**
1641
1713
  * The eyes' frame in WORLD (content) space, for effects keyed on "where the viewer is":
1642
1714
  * `origin` = the midpoint of the eyes drawn last (the mono camera in 2D), `axis`/`right`/`up` =
1643
- * the first eye's forward/right/up, `tanHalfFovX` its lens half-width.
1715
+ * the first eye's forward/right/up, `tanHalfFovX` / `tanHalfFovY` its lens half-extents.
1644
1716
  */
1645
1717
  eyeFrame() {
1646
1718
  const R = this.rigMatrix();
@@ -1667,6 +1739,7 @@ export class PlayCanvasSplatViewer {
1667
1739
  right: unit(M[0], M[1], M[2]),
1668
1740
  up: unit(M[4], M[5], M[6]),
1669
1741
  tanHalfFovX: p0 > 0 ? 1 / p0 : 0.5,
1742
+ tanHalfFovY: es[0].proj?.[5] > 0 ? 1 / es[0].proj[5] : 0,
1670
1743
  };
1671
1744
  }
1672
1745
 
@@ -1859,7 +1932,7 @@ function perfSpan(name, t0) {
1859
1932
  }
1860
1933
  }
1861
1934
 
1862
- export async function readCloud(resource) {
1935
+ export async function readCloud(resource, yielder = yieldToMain) {
1863
1936
  if (!resource) return null;
1864
1937
  const centers = resource.centers;
1865
1938
  const data = resource.gsplatData;
@@ -1869,7 +1942,7 @@ export async function readCloud(resource) {
1869
1942
  const total = Math.ceil(sourceTotal / stride);
1870
1943
  // Not in the task that delivered the asset: the engine's own end-of-load work (its centre
1871
1944
  // readback unpack) runs there, and stacking ours on it made one long task.
1872
- await yieldToMain();
1945
+ await yielder();
1873
1946
  let t0 = performance.now();
1874
1947
  const xyz = new Float32Array(total * 3);
1875
1948
  for (let j = 0, i = 0; j < total; j++, i += stride) {
@@ -1878,7 +1951,7 @@ export async function readCloud(resource) {
1878
1951
  xyz[j * 3 + 2] = centers[i * 3 + 2];
1879
1952
  }
1880
1953
  perfSpan('readCloud:copy', t0);
1881
- await yieldToMain();
1954
+ await yielder();
1882
1955
  let opacity = null;
1883
1956
  // Full-resolution peak opacity, ONE BYTE per splat, kept for the exact pick (which walks the
1884
1957
  // engine's own full centre set at pick time): 1.18 MB on the 1.18M bench asset.
@@ -1888,7 +1961,7 @@ export async function readCloud(resource) {
1888
1961
  t0 = performance.now();
1889
1962
  const px = await data.sh0.read(0, 0, data.sh0.width, data.sh0.height, { mipLevel: 0, face: 0, immediate: true });
1890
1963
  perfSpan('readCloud:sh0-readback(async)', t0);
1891
- await yieldToMain();
1964
+ await yielder();
1892
1965
  t0 = performance.now();
1893
1966
  if (px && px.length >= sourceTotal * 4) {
1894
1967
  const v2 = data.meta?.version === 2;
@@ -2091,7 +2164,8 @@ export function resolveSwap(o = {}) {
2091
2164
  if (transition === 'wavefront') {
2092
2165
  EFFECTS.wavefront.validate({ band, ridge, ridgeMaxDisparity });
2093
2166
  }
2094
- return { transition, durationMs: transition === 'crossfade' && durationMs === 0 ? 800 : durationMs, easing, reveal, band, ridge, ridgeMaxDisparity };
2167
+ const outgoing = resolveOutgoingOption(o.outgoing);
2168
+ return { transition, durationMs: transition === 'crossfade' && durationMs === 0 ? 800 : durationMs, easing, reveal, band, ridge, ridgeMaxDisparity, outgoing };
2095
2169
  }
2096
2170
 
2097
2171
  /**
@@ -2164,6 +2238,9 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
2164
2238
  let handle = null;
2165
2239
  let unbindFocusInput = null;
2166
2240
  let removed = false;
2241
+ /** prepareSource: prepared handle → { loaded, state: 'ready' | 'used' | 'disposed', dispose }. */
2242
+ const preparedAssets = new WeakMap();
2243
+ const livePrepared = new Set();
2167
2244
  /** The asset on screen: its resource, kind, the pick data it keeps. */
2168
2245
  let current = null; // { asset, entity, res, kind, streamedBounds, alpha8, pickCentres }
2169
2246
 
@@ -2232,6 +2309,7 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
2232
2309
  return pickModel(clientX, clientY);
2233
2310
  },
2234
2311
  setSource,
2312
+ prepareSource,
2235
2313
  /**
2236
2314
  * Play a transition effect (inflate, deflate, sweep, dissolve, fade, pulse, custom) — see
2237
2315
  * docs/splat-effects.md. Validated now; runs once the first asset is on screen. Resolves
@@ -2259,6 +2337,7 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
2259
2337
  remove() {
2260
2338
  removed = true;
2261
2339
  current = null;
2340
+ for (const p of [...livePrepared]) p.dispose();
2262
2341
  fx?.dispose();
2263
2342
  fx = null;
2264
2343
  unbindFocusInput?.();
@@ -2587,7 +2666,9 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
2587
2666
  // ── load ──
2588
2667
  let byteSeqLocal = 0;
2589
2668
  /** Fetch/parse one source into an engine asset, and read what the waterfall needs from it. */
2590
- async function loadOne(pc, app, source) {
2669
+ async function loadOne(pc, app, source, { background = false } = {}) {
2670
+ // prepareSource: our own passes run in idle periods; setSource: at the task's own priority.
2671
+ const y = background ? yieldIdle : yieldToMain;
2591
2672
  let bytes = null;
2592
2673
  if (typeof source !== 'string') {
2593
2674
  const buf = source instanceof Blob ? await source.arrayBuffer() : source;
@@ -2634,7 +2715,7 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
2634
2715
  // A URL `.sog` carries its meta in the resource (the engine keeps unknown keys); a Streamed
2635
2716
  // SOG carries it at the top level of lod-meta.json. Both validated by the same reader.
2636
2717
  if (!bytes && rigOpts.rig !== 'display') camera = sogCameraFromMeta(desc.meta);
2637
- const cloud = desc.kind === 'flat' ? await readCloud(res) : null;
2718
+ const cloud = desc.kind === 'flat' ? await readCloud(res, y) : null;
2638
2719
  // The cloud passes, each in its OWN task: framing, the rest-space sample and the pick set
2639
2720
  // were one ~60 ms main-thread block on a 1.18M-gaussian swap — pointer input waited on it.
2640
2721
  // Split with a yield between them (and a linear-time percentile in boundsFromPositions),
@@ -2642,20 +2723,26 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
2642
2723
  const pre = { local: null, rest: null, pickCentres: null };
2643
2724
  if (cloud) {
2644
2725
  const walk = centresVisitor(cloud.xyz, cloud.opacity, cloud.total);
2645
- await yieldToMain();
2726
+ await y();
2646
2727
  let t = performance.now();
2647
- pre.local = boundsFromPositions(sampleCloudCentres(cloud.total, walk) || []);
2728
+ const framed = sampleCloudCentres(cloud.total, walk) || [];
2729
+ perfSpan('cloud:frame-sample', t);
2730
+ await y();
2731
+ // The percentile core per axis + the window pass, each in its own task (bit-identical to
2732
+ // boundsFromPositions): one ~60-80 ms task per 1.18M gaussians at 4× CPU before.
2733
+ t = performance.now();
2734
+ pre.local = await boundsFromPositionsAsync(framed, undefined, y);
2648
2735
  perfSpan('cloud:bounds', t);
2649
- await yieldToMain();
2736
+ await y();
2650
2737
  t = performance.now();
2651
2738
  if (rigNeedsCloud(camera)) pre.rest = sampleCloudRestSpace(cloud.total, walk, camera?.rest);
2652
2739
  perfSpan('cloud:rest-sample', t);
2653
- await yieldToMain();
2740
+ await y();
2654
2741
  t = performance.now();
2655
2742
  const s = sampleCloudCentres(cloud.total, walk, { cap: RIG_SAMPLE_CAP });
2656
2743
  pre.pickCentres = s ? s.slice() : null;
2657
2744
  perfSpan('cloud:pick-set', t);
2658
- await yieldToMain();
2745
+ await y();
2659
2746
  }
2660
2747
  return { asset, res, desc, camera, cloud, pre };
2661
2748
  }
@@ -2796,6 +2883,49 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
2796
2883
  return out;
2797
2884
  });
2798
2885
 
2886
+ // ── prepareSource: load the next asset in the background, for a setSource with no load ──
2887
+ /**
2888
+ * Fetch, decode and upload `src` now, without rendering it: the engine's load (its own unpack
2889
+ * runs when it must) and our cloud passes (framing, rig sample, pick set — in IDLE periods).
2890
+ * Resolves to an opaque handle for setSource(prepared, opts), which then starts on the next
2891
+ * frame with no load on the transition frame. `dispose()` if the page changes its mind.
2892
+ * Memory: the prepared asset is fully resident (a 1.18M-gaussian SOG: its GPU textures plus the
2893
+ * engine's centre array) alongside the current one until it is used or disposed.
2894
+ */
2895
+ async function prepareSource(src) {
2896
+ const app = await booted;
2897
+ await first.catch(() => null);
2898
+ if (!app || removed) throw new Error('@displayxr/inline3d/splat: prepareSource on a removed tile.');
2899
+ const t0 = performance.now();
2900
+ const loaded = await loadOne(pcModule, app, src, { background: true });
2901
+ perfSpan('prepareSource', t0);
2902
+ const entry = { loaded, state: 'ready', dispose: null };
2903
+ const prepared = {
2904
+ [PREPARED_TAG]: true,
2905
+ /** The asset's own count (every splat of a flat source). */
2906
+ numSplats: loaded.desc.numSplats || loaded.cloud?.sourceTotal || 0,
2907
+ /** 'ready' until setSource uses it ('used') or dispose() drops it ('disposed'). */
2908
+ get state() {
2909
+ return entry.state;
2910
+ },
2911
+ dispose() {
2912
+ if (entry.state !== 'ready') return;
2913
+ entry.state = 'disposed';
2914
+ livePrepared.delete(entry);
2915
+ app.assets.remove(loaded.asset);
2916
+ loaded.asset.unload?.();
2917
+ },
2918
+ };
2919
+ entry.dispose = prepared.dispose;
2920
+ preparedAssets.set(prepared, entry);
2921
+ if (removed) {
2922
+ prepared.dispose();
2923
+ throw new Error('@displayxr/inline3d/splat: prepareSource on a removed tile.');
2924
+ }
2925
+ livePrepared.add(entry);
2926
+ return prepared;
2927
+ }
2928
+
2799
2929
  // ── setSource: swap the asset — a cut, a crossfade, or a transition ──
2800
2930
  let sourceGen = 0;
2801
2931
  let pendingSwap = null;
@@ -2818,11 +2948,22 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
2818
2948
  const plan = resolveSwap(o);
2819
2949
  const { resetPose = false } = o;
2820
2950
  const gen = ++sourceGen;
2951
+ // A prepareSource() result: already fetched, decoded and uploaded — no load on this path.
2952
+ const prep = preparedAssets.get(next) || null;
2953
+ if (prep) {
2954
+ if (prep.state !== 'ready') {
2955
+ throw new Error(`@displayxr/inline3d/splat: setSource got a prepared source that was already ${prep.state === 'used' ? 'used' : 'disposed'}.`);
2956
+ }
2957
+ prep.state = 'used';
2958
+ livePrepared.delete(prep);
2959
+ } else if (next && typeof next === 'object' && next[PREPARED_TAG]) {
2960
+ throw new Error('@displayxr/inline3d/splat: setSource got a prepared source from another handle.');
2961
+ }
2821
2962
  const app = await booted;
2822
2963
  await first.catch(() => null); // a failed first asset may be replaced
2823
2964
  if (!app || removed) return out;
2824
2965
  const pc = pcModule;
2825
- const loaded = await loadOne(pc, app, next);
2966
+ const loaded = prep ? prep.loaded : await loadOne(pc, app, next);
2826
2967
  if (removed || gen !== sourceGen) {
2827
2968
  app.assets.remove(loaded.asset);
2828
2969
  loaded.asset.unload?.();
@@ -2841,6 +2982,19 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
2841
2982
  return out;
2842
2983
  }
2843
2984
  if (snapped) viewer.setSnapshotAlpha(1, transition === 'wavefront' ? { t: 0, band: plan.band, views: viewer.viewsAcross } : null);
2985
+ // LIVE outgoing (default in 3D): the old asset stays resident and keeps rendering, through the
2986
+ // same eye views, into its own target; the frozen capture above bridges the first frames until
2987
+ // its camera has drawn a sorted frame. Its lens frame is read BEFORE adopt() switches the rig.
2988
+ const outgoingMode = plan.outgoing || defaultOutgoing(viewer.is3D);
2989
+ const live =
2990
+ snapped && outgoingMode === 'live' && viewer.canLiveOutgoing ? viewer.startLiveOutgoing(prev.entity, viewer.lensFrame()) : null;
2991
+ /** Each frame of the window: once the live camera is ready, the overlay samples it. */
2992
+ let liveShown = false;
2993
+ const pumpLive = () => {
2994
+ if (!live || liveShown || !live.active || !live.ready) return;
2995
+ viewer.setSnapshotSource(live.texture);
2996
+ liveShown = true;
2997
+ };
2844
2998
 
2845
2999
  const release = (p) => {
2846
3000
  if (!p) return;
@@ -2925,16 +3079,30 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
2925
3079
  }
2926
3080
 
2927
3081
  if (transition === 'wavefront' && snapped) {
2928
- release(prev); // the snapshot shows it from here on
3082
+ if (live) {
3083
+ finishers.push(() => {
3084
+ viewer.stopLiveOutgoing();
3085
+ release(prev);
3086
+ });
3087
+ } else release(prev); // the snapshot shows it from here on
2929
3088
  const band = plan.band;
2930
3089
  const ridge = fx.play(
2931
3090
  'wavefront',
2932
3091
  { durationMs: plan.durationMs, easing: plan.easing, band, ridge: plan.ridge, ridgeMaxDisparity: plan.ridgeMaxDisparity, scope: 'entity' },
2933
3092
  { entity, gate: afterTicks(2), internal: true },
2934
3093
  );
2935
- finishers.push(() => viewer.setSnapshotAlpha(0));
3094
+ finishers.push(() => {
3095
+ viewer.setSnapshotAlpha(0);
3096
+ viewer.releaseSnapshot();
3097
+ });
2936
3098
  viewer._hooks.push(() => {
2937
3099
  if (finished || removed) return false;
3100
+ if (live && !live.fits) {
3101
+ fx?.stop('wavefront', { finish: true, entity });
3102
+ finish();
3103
+ return false;
3104
+ }
3105
+ pumpLive();
2938
3106
  const inst = fx?.scopes.get(entity)?.get('wavefront');
2939
3107
  const amount = inst ? inst.amount : 1;
2940
3108
  if (!viewer.setSnapshotAlpha(1, { t: amount, band, views: viewer.viewsAcross })) {
@@ -2953,14 +3121,21 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
2953
3121
  // the 1.12.1 one-pass fade, over the same duration.
2954
3122
  const fade = plan.durationMs;
2955
3123
  if (plan.reveal) fx.play(plan.reveal.type, { ...plan.reveal.raw, scope: 'entity' }, { entity, gate: afterTicks(2) });
2956
- if (snapped) release(prev); // the snapshot shows it from here on
2957
- else {
3124
+ if (snapped) {
3125
+ if (!live) release(prev); // the snapshot shows it from here on
3126
+ } else {
2958
3127
  setFade(entity, 0);
2959
3128
  setFade(prev.entity, 1);
2960
3129
  }
2961
3130
  finishers.push(() => {
2962
- if (snapped) viewer.setSnapshotAlpha(0);
2963
- else {
3131
+ if (snapped) {
3132
+ viewer.setSnapshotAlpha(0);
3133
+ viewer.releaseSnapshot();
3134
+ if (live) {
3135
+ viewer.stopLiveOutgoing();
3136
+ release(prev);
3137
+ }
3138
+ } else {
2964
3139
  setFade(entity, null);
2965
3140
  release(prev);
2966
3141
  }
@@ -2974,12 +3149,13 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
2974
3149
  viewer._hooks.push((t) => {
2975
3150
  if (removed) return (finish(), false);
2976
3151
  if (finished) return false; // superseded: already finished
3152
+ pumpLive();
2977
3153
  if (++ticks < 2) return true;
2978
3154
  if (t0 === null) t0 = t;
2979
3155
  const k = Math.min(1, Math.max(0, (t - t0) / fade));
2980
3156
  if (snapped) {
2981
3157
  // A resize or a 2D/3D switch mid-fade: the capture no longer fits the buffer — end now.
2982
- if (!viewer.setSnapshotAlpha(1 - k)) {
3158
+ if (!viewer.setSnapshotAlpha(1 - k) || (live && !live.fits)) {
2983
3159
  finish();
2984
3160
  return false;
2985
3161
  }
@@ -3008,3 +3184,5 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
3008
3184
  }
3009
3185
 
3010
3186
  let byteSeq = 0;
3187
+ /** Marks a prepareSource() result (so one from another handle is caught, not loaded as bytes). */
3188
+ const PREPARED_TAG = Symbol.for('@displayxr/inline3d/splat.prepared');
@@ -369,6 +369,10 @@ export function addSplat(wall, canvas, src, opts = {}) {
369
369
  "only; with the Spark backend, remove() this handle and addSplat() the new asset.",
370
370
  );
371
371
  },
372
+ /** Not on this backend: prepareSource() feeds setSource(), a PlayCanvas-backend feature. */
373
+ prepareSource() {
374
+ throw new Error("@displayxr/inline3d/splat: prepareSource() is implemented on the PlayCanvas backend only.");
375
+ },
372
376
  /** Not on this backend (splat effects are PlayCanvas-only in this version). */
373
377
  playEffect() {
374
378
  throw effectsNotOnSpark('playEffect()');
@@ -706,6 +710,7 @@ function addSplatDeferred(wall, canvas, src, opts) {
706
710
  // A swap requested before the first asset has landed runs once it has (the adapter's own
707
711
  // setSource replaces this stub on the same object by then).
708
712
  setSource: (...args) => out.ready.then(() => out.setSource(...args)),
713
+ prepareSource: (...args) => out.ready.then(() => out.prepareSource(...args)),
709
714
  // Effects before the adapter has loaded: validated NOW (a bad call throws at its own line),
710
715
  // then run once the first asset is on screen.
711
716
  playEffect: (name, o) => {
@@ -152,7 +152,30 @@ function swap(a, i, j) {
152
152
  * Set 0 to get the old percentile-only box back.
153
153
  * @returns {{center:number[], extent:number[]}|null} null if there is nothing to measure.
154
154
  */
155
- export function boundsFromPositions(xyz, { lo = 0.05, hi = 0.95, expand = 2.5 } = {}) {
155
+ export function boundsFromPositions(xyz, opts) {
156
+ const it = boundsSteps(xyz, opts);
157
+ let r = it.next();
158
+ while (!r.done) r = it.next();
159
+ return r.value;
160
+ }
161
+
162
+ /**
163
+ * boundsFromPositions, in steps: the same code, the same numbers bit for bit, but a caller can
164
+ * give the main thread back between the per-axis selections and the window pass. On a 1.18M
165
+ * gaussian cloud under 4× CPU throttling the one-shot version was a single 60-80 ms task.
166
+ * `await boundsFromPositionsAsync(xyz, opts, yielder)` runs it with `yielder()` between steps.
167
+ */
168
+ export async function boundsFromPositionsAsync(xyz, opts, yielder) {
169
+ const it = boundsSteps(xyz, opts);
170
+ let r = it.next();
171
+ while (!r.done) {
172
+ await yielder();
173
+ r = it.next();
174
+ }
175
+ return r.value;
176
+ }
177
+
178
+ function* boundsSteps(xyz, { lo = 0.05, hi = 0.95, expand = 2.5 } = {}) {
156
179
  const n = Math.floor(xyz.length / 3);
157
180
  if (n < 1) return null;
158
181
  // Below a few hundred points the percentiles are noise — just use the true box.
@@ -171,6 +194,7 @@ export function boundsFromPositions(xyz, { lo = 0.05, hi = 0.95, expand = 2.5 }
171
194
  const hiV = selectKth(axisVals, n, kHi);
172
195
  center[axis] = 0.5 * (loV + hiV);
173
196
  extent[axis] = Math.max(hiV - loV, 1e-6);
197
+ yield;
174
198
  }
175
199
  // Untrimmed already IS the true box, and expand 0 asks for the old behaviour.
176
200
  if (!trim || expand <= 0) return { center, extent };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@displayxr/inline3d",
3
- "version": "1.13.1",
3
+ "version": "1.14.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",
@@ -51,6 +51,7 @@
51
51
  "js/inline3d-splat-playcanvas.js",
52
52
  "js/inline3d-splat-shared.js",
53
53
  "js/inline3d-splat-effects.js",
54
+ "js/inline3d-splat-live.js",
54
55
  "js/inline3d-model.js",
55
56
  "js/inline3d-model-entry.js",
56
57
  "js/inline3d-model-playcanvas.js",
package/splat.d.ts CHANGED
@@ -336,7 +336,62 @@ export interface SplatOptions {
336
336
  }
337
337
 
338
338
  /** The transition effects `reveal` accepts. */
339
- export type SplatRevealName = 'inflate' | 'sweep' | 'dissolve' | 'fade';
339
+ export type SplatRevealName = 'inflate' | 'sweep' | 'dissolve' | 'fade' | SplatParticleRevealName;
340
+
341
+ /** The particle reveals: every gaussian is a particle with its own start time (docs/splat-effects.md §Particle reveals). */
342
+ export type SplatParticleRevealName = 'assemble' | 'dissolve-in' | 'converge' | 'shimmer';
343
+
344
+ /**
345
+ * Options every particle reveal takes (plus its timing). All sizes are in PICTURE units, so the
346
+ * look is the same on a 2 cm object and a 40 m street.
347
+ */
348
+ export interface SplatParticleOptions {
349
+ /**
350
+ * What staggers the particles: `'radial'` (distance in the picture from the origin), `'depth'`
351
+ * (near first), `'noise'` (fbm patches), `'random'`, or `'layers'` (a SHARP photo's grid order:
352
+ * layer 0, the visible surface, then layer 1, the disocclusion fill, each outward from the
353
+ * origin — reads `splat.index`, so it forces `scope: 'entity'`).
354
+ */
355
+ order?: 'radial' | 'depth' | 'noise' | 'random' | 'layers';
356
+ /** Fraction of the duration spent launching particles, 0..0.95. */
357
+ stagger?: number;
358
+ /** How much of each particle's key is random, 0..1. */
359
+ jitter?: number;
360
+ /** A particle in flight is a dot this size (a fraction of the view width). */
361
+ dotSize?: number;
362
+ /** A particle grows back to its own splat over the last (1 − grow) of its flight. */
363
+ grow?: number;
364
+ /** In-flight alpha multiplier, 0..1. */
365
+ flightAlpha?: number;
366
+ /** In-flight tint, added as `color · glow`. */
367
+ color?: [number, number, number];
368
+ glow?: number;
369
+ /** Size of the noise patches, per half-view-width. */
370
+ noiseScale?: number;
371
+ /**
372
+ * Comfort cap: no particle is ever nearer the eyes than its home depth by more than this much
373
+ * extra disparity, a fraction of the eye view's width (default 0.004; 0 = never nearer than
374
+ * home; max 0.05). In 2D a nominal 64 mm-at-1.7 m separation is assumed.
375
+ */
376
+ maxDisparity?: number;
377
+ /** `order: 'layers'`: gaussians per layer (default 768² = 589 824, SHARP). */
378
+ layerSize?: number;
379
+ /** `assemble`: how far out the swarm starts (half-view-widths), its spiral (rad), its curl turbulence, how coherent its start field is (0..1), and how much of it reaches back in depth (0..1). */
380
+ spread?: number;
381
+ swirl?: number;
382
+ turbulence?: number;
383
+ coherence?: number;
384
+ depth?: number;
385
+ /** `dissolve-in`: the wind's lift and the random drift (half-view-widths). */
386
+ lift?: number;
387
+ drift?: number;
388
+ /** `converge`: the spiral (rad) and the launch ball's radius (half-view-widths). */
389
+ spin?: number;
390
+ burst?: number;
391
+ /** `shimmer`: twinkle rate (rad/s) and sparkle strength. */
392
+ twinkle?: number;
393
+ sparkle?: number;
394
+ }
340
395
 
341
396
  /** Named easings; a function `(x) => y` on [0, 1] also works. */
342
397
  export type SplatEasing =
@@ -397,7 +452,7 @@ export interface SplatCustomEffect extends SplatEffectTiming {
397
452
  hold?: boolean;
398
453
  }
399
454
 
400
- export type SplatEffectName = 'inflate' | 'deflate' | 'sweep' | 'dissolve' | 'fade' | 'pulse' | 'grade' | 'clip' | 'custom' | `custom:${string}`;
455
+ export type SplatEffectName = 'inflate' | 'deflate' | 'sweep' | 'dissolve' | 'fade' | SplatParticleRevealName | 'pulse' | 'grade' | 'clip' | 'custom' | `custom:${string}`;
401
456
 
402
457
  /** `setEffect('grade', …)`. */
403
458
  export interface SplatGradeParams {
@@ -454,6 +509,29 @@ export interface SplatSourceOptions {
454
509
  ridge?: number;
455
510
  /** `wavefront`: the ridge's disparity cap, a fraction of the eye view's width (default 0.004, max 0.05). */
456
511
  ridgeMaxDisparity?: number;
512
+ /**
513
+ * `crossfade` / `wavefront`: how the OUTGOING photo is shown during the window.
514
+ * `'live'` (the default in a woven 3D session): it stays resident and keeps rendering every frame
515
+ * through the same eye views (head motion included) into its own target, blended per eye with
516
+ * the live incoming one — about 2× draw for the window, both assets resident. `'frozen'` (the
517
+ * default in 2D): its last frame, frozen into a texture (1.12.1); one draw, but no head parallax
518
+ * on the outgoing photo — on a tracked panel it reads as tracking pausing. Engine builds without
519
+ * the single-camera RenderView path always use `'frozen'`.
520
+ */
521
+ outgoing?: 'live' | 'frozen';
522
+ }
523
+
524
+ /**
525
+ * What `prepareSource()` resolves to: an opaque, single-use handle for `setSource`. The asset is
526
+ * fully resident (GPU textures + the engine's centre array) until used or disposed.
527
+ */
528
+ export interface SplatPreparedSource {
529
+ /** The asset's own count (every splat of a flat source). */
530
+ readonly numSplats: number;
531
+ /** `'ready'` until `setSource` uses it (`'used'`) or `dispose()` drops it (`'disposed'`). */
532
+ readonly state: 'ready' | 'used' | 'disposed';
533
+ /** Release the prepared asset (no-op once used or disposed). */
534
+ dispose(): void;
457
535
  }
458
536
 
459
537
  /** What `onBeforeFrame` receives. */
@@ -592,20 +670,32 @@ export interface SplatHandle {
592
670
  /**
593
671
  * Swap the asset (URL or bytes) in place. PlayCanvas backend only — throws on Spark.
594
672
  *
595
- * The new file loads BEHIND the current one; then the two crossfade over `fadeMs` (0 = a cut)
596
- * and the old one is released. The crossfade blends the two IMAGES linearly: the last frame of
597
- * the old asset is frozen (both eyes) and lerped with the live new one, so the mix is exactly
598
- * `t` whatever the two photos' depth order (the old asset's own motion stops during the fade). The rig waterfall re-runs for the new file (rig, lens, focus and
673
+ * The new file loads BEHIND the current one (or pass a `prepareSource()` result: no load at all
674
+ * on this path); then the two crossfade over `fadeMs` (0 = a cut) and the old one is released.
675
+ * The crossfade blends the two IMAGES linearly, per eye, so the mix is exactly `t` whatever the
676
+ * two photos' depth order. The old image is LIVE in a woven 3D session (it keeps rendering with
677
+ * head motion; `outgoing: 'frozen'` restores the 1.12.1 frozen last frame, the 2D default). The
678
+ * end state is exactly a plain swap. The rig waterfall re-runs for the new file (rig, lens, focus and
599
679
  * frame update; `onFocusChange` fires). The pose (yaw/pitch/zoom/depth) is kept unless
600
680
  * `resetPose`. A newer call supersedes an older one still loading. Resolves once the fade has
601
681
  * finished; rejects if the new asset cannot be loaded (the current one stays on screen).
602
682
  */
603
- setSource(src: string | Blob | ArrayBuffer | Uint8Array, opts?: SplatSourceOptions): Promise<SplatHandle>;
683
+ setSource(src: string | Blob | ArrayBuffer | Uint8Array | SplatPreparedSource, opts?: SplatSourceOptions): Promise<SplatHandle>;
684
+ /**
685
+ * PlayCanvas backend only — throws on Spark. Fetch, decode and upload `src` now, in the
686
+ * background (the SDK's own passes in idle periods; the engine's end-of-load unpack runs when
687
+ * it must), without rendering it. `setSource(prepared, opts)` then starts on the next frame with
688
+ * no load on the transition path. Single use; `dispose()` it if the page changes its mind.
689
+ * Memory: two full assets resident until the swap (≈ +70 MB of GPU textures per 1.18M-gaussian
690
+ * SOG). `remove()` disposes any still unused.
691
+ */
692
+ prepareSource(src: string | Blob | ArrayBuffer | Uint8Array): Promise<SplatPreparedSource>;
604
693
  /**
605
694
  * `engine: 'playcanvas'` only (throws on Spark). Play a transition/pulse/custom effect;
606
695
  * validated at the call, run once the first asset is on screen. Resolves `{ finished }` —
607
696
  * false when stopped or replaced. docs/splat-effects.md.
608
697
  */
698
+ playEffect(name: SplatParticleRevealName, opts?: Omit<SplatEffectTiming, 'direction'> & SplatParticleOptions): Promise<{ finished: boolean }>;
609
699
  playEffect(
610
700
  name: Exclude<SplatEffectName, 'grade' | 'clip'>,
611
701
  opts?: SplatEffectTiming & Record<string, unknown>,