@displayxr/inline3d 1.12.1 → 1.13.1

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.
@@ -470,6 +470,9 @@ const PC_CLIP_ANCHOR = /float\s+clip\s*=\s*min\(\s*1\.0\s*,/;
470
470
  * profile squashed. Returns the source unchanged (and `ok:false`) when the anchor is missing;
471
471
  * the caller warns once and renders at the engine's own extent.
472
472
  *
473
+ * The rewritten line is adapted from PlayCanvas engine src/scene/shader-lib/glsl/chunks/gsplat/vert/
474
+ * gsplatCommon.js (the `gsplatCommonVS` chunk's clipCorner) @ v2.22.3, MIT (THIRD_PARTY_NOTICES.md).
475
+ *
473
476
  * @param {string} src the current `gsplatCommonVS` chunk.
474
477
  * @param {number} k in (0, 1).
475
478
  * @returns {{src:string, ok:boolean}}
@@ -51,6 +51,13 @@ import {
51
51
  } from './inline3d-splat-perf.js';
52
52
  import { boundsFromPositions } from './inline3d-viewer.js';
53
53
  import { cameraRigFromPose } from './inline3d-three.js';
54
+ import {
55
+ SplatEffects,
56
+ EFFECTS,
57
+ EASINGS,
58
+ resolveRevealOption,
59
+ validateEffectCall,
60
+ } from './inline3d-splat-effects.js';
54
61
  import {
55
62
  clamp,
56
63
  finite,
@@ -85,7 +92,6 @@ import {
85
92
  resolveControls,
86
93
  normalizeCameraPose,
87
94
  coverageExponent,
88
- FADE_TRANSMITTANCE_FLOOR,
89
95
  } from './inline3d-splat-shared.js';
90
96
 
91
97
  /**
@@ -502,6 +508,9 @@ export function capturePose(rest, flipY) {
502
508
  * (epic #36, P4d); until then the anchors are regexes (the npm build re-indents chunks with
503
509
  * tabs) and a miss warns once and renders unpatched rather than failing.
504
510
  *
511
+ * The rewritten lines are adapted from PlayCanvas engine src/scene/shader-lib/glsl/chunks/gsplat/vert/
512
+ * gsplatCorner.js (the `gsplatCornerVS` chunk) @ v2.22.3, MIT (THIRD_PARTY_NOTICES.md).
513
+ *
505
514
  * @returns {{src:string, ok:boolean}}
506
515
  */
507
516
  export function patchGsplatFootprint(src) {
@@ -1276,6 +1285,15 @@ export class PlayCanvasSplatViewer {
1276
1285
  uniform sampler2D dxrSnap;
1277
1286
  uniform vec2 dxrSnapInvSize;
1278
1287
  uniform float dxrSnapAlpha;
1288
+ uniform vec3 dxrSnapWipe; // t, band, views across (t < -1: no wipe)
1289
+ float dxrSnapWeight() {
1290
+ if (dxrSnapWipe.x < -1.0) return dxrSnapAlpha;
1291
+ float vw = 1.0 / (dxrSnapInvSize.x * dxrSnapWipe.z);
1292
+ float u = mod(gl_FragCoord.x, vw) / vw;
1293
+ // wavefrontCommit(): column u has committed lt of the way to the new photo
1294
+ float lt = clamp((dxrSnapWipe.x - u * (1.0 - dxrSnapWipe.y)) / dxrSnapWipe.y, 0.0, 1.0);
1295
+ return dxrSnapAlpha * (1.0 - smoothstep(0.0, 1.0, lt));
1296
+ }
1279
1297
  void main() { ${body} }`,
1280
1298
  });
1281
1299
  mat.blendState = new pc.BlendState(true, pc.BLENDEQUATION_ADD, src, dst, pc.BLENDEQUATION_ADD, src, dst);
@@ -1285,6 +1303,7 @@ export class PlayCanvasSplatViewer {
1285
1303
  mat.setParameter('dxrSnap', s.tex);
1286
1304
  mat.setParameter('dxrSnapInvSize', [1 / s.w, 1 / s.h]);
1287
1305
  mat.setParameter('dxrSnapAlpha', 0);
1306
+ mat.setParameter('dxrSnapWipe', [-2, 0.1, 1]);
1288
1307
  mat.update();
1289
1308
  const mi = new pc.MeshInstance(mesh, mat, new pc.GraphNode(name));
1290
1309
  mi.cull = false;
@@ -1293,8 +1312,8 @@ export class PlayCanvasSplatViewer {
1293
1312
  return { mat, mi };
1294
1313
  };
1295
1314
  s.parts = [
1296
- part('inline3dSnapshotScale', 'gl_FragColor = vec4(0.0, 0.0, 0.0, 1.0 - dxrSnapAlpha);', pc.BLENDMODE_ZERO, pc.BLENDMODE_SRC_ALPHA, -2),
1297
- part('inline3dSnapshotAdd', 'gl_FragColor = texture2D(dxrSnap, gl_FragCoord.xy * dxrSnapInvSize) * dxrSnapAlpha;', pc.BLENDMODE_ONE, pc.BLENDMODE_ONE, -1),
1315
+ part('inline3dSnapshotScale', 'gl_FragColor = vec4(0.0, 0.0, 0.0, 1.0 - dxrSnapWeight());', pc.BLENDMODE_ZERO, pc.BLENDMODE_SRC_ALPHA, -2),
1316
+ part('inline3dSnapshotAdd', 'gl_FragColor = texture2D(dxrSnap, gl_FragCoord.xy * dxrSnapInvSize) * dxrSnapWeight();', pc.BLENDMODE_ONE, pc.BLENDMODE_ONE, -1),
1298
1317
  ];
1299
1318
  this.app.scene.layers.getLayerById(pc.LAYERID_UI).addMeshInstances(s.parts.map((p) => p.mi));
1300
1319
  }
@@ -1303,7 +1322,7 @@ export class PlayCanvasSplatViewer {
1303
1322
  * Lerp the snapshot over the scene at `alpha` (0 hides it). Returns false when the canvas buffer
1304
1323
  * no longer matches the capture (resize, 2D/3D switch): the caller ends its fade.
1305
1324
  */
1306
- setSnapshotAlpha(alpha) {
1325
+ setSnapshotAlpha(alpha, wipe = null) {
1307
1326
  const s = this._snap;
1308
1327
  if (!s?.parts) return false;
1309
1328
  const fits = s.w === this.canvas.width && s.h === this.canvas.height;
@@ -1312,12 +1331,21 @@ export class PlayCanvasSplatViewer {
1312
1331
  p.mi.visible = show;
1313
1332
  if (show) {
1314
1333
  p.mat.setParameter('dxrSnapAlpha', Math.min(1, alpha));
1334
+ // WIPE (setSource's wavefront): the frozen frame gives way behind a vertical front at the
1335
+ // SAME viewport-relative x in every eye — a front at the zero-disparity plane, so both
1336
+ // eyes agree. `views` = eye viewports side by side across the buffer.
1337
+ p.mat.setParameter('dxrSnapWipe', wipe ? [wipe.t, wipe.band, wipe.views] : [-2, 0.1, 1]);
1315
1338
  p.mat.update();
1316
1339
  }
1317
1340
  }
1318
1341
  return alpha <= 0 || fits;
1319
1342
  }
1320
1343
 
1344
+ /** How many eye viewports sit side by side in the buffer right now (1 in mono). */
1345
+ get viewsAcross() {
1346
+ return this._mode === '3d' && this._lastGood ? this._lastGood.entries.length : 1;
1347
+ }
1348
+
1321
1349
  _destroySnapshot() {
1322
1350
  const s = this._snap;
1323
1351
  if (!s) return;
@@ -1604,9 +1632,44 @@ export class PlayCanvasSplatViewer {
1604
1632
  const keep = run.filter((h) => h(t) !== false);
1605
1633
  this._hooks = keep.concat(this._hooks);
1606
1634
  }
1635
+ // Splat effects (./inline3d-splat-effects.js): clocks + uniforms, before the engine renders.
1636
+ this.onEffectsTick?.(t);
1607
1637
  this.onTick?.();
1608
1638
  }
1609
1639
 
1640
+ /**
1641
+ * The eyes' frame in WORLD (content) space, for effects keyed on "where the viewer is":
1642
+ * `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.
1644
+ */
1645
+ eyeFrame() {
1646
+ const R = this.rigMatrix();
1647
+ const es = this._mode === '3d' && this._lastGood ? this._lastGood.entries : [{ pose: this.mono.pose, proj: this.mono.proj }];
1648
+ const o = [0, 0, 0];
1649
+ for (const e of es) {
1650
+ const M = mat4Mul(R, e.pose);
1651
+ o[0] += M[12] / es.length;
1652
+ o[1] += M[13] / es.length;
1653
+ o[2] += M[14] / es.length;
1654
+ }
1655
+ const M = mat4Mul(R, es[0].pose);
1656
+ const unit = (x, y, z) => {
1657
+ const l = Math.hypot(x, y, z) || 1;
1658
+ return [x / l, y / l, z / l];
1659
+ };
1660
+ const p0 = es[0].proj?.[0];
1661
+ const Mn = mat4Mul(R, es[es.length - 1].pose);
1662
+ return {
1663
+ origin: o,
1664
+ // first ↔ last eye, world units (0 in 2D)
1665
+ separation: es.length > 1 ? Math.hypot(Mn[12] - M[12], Mn[13] - M[13], Mn[14] - M[14]) : 0,
1666
+ axis: unit(-M[8], -M[9], -M[10]),
1667
+ right: unit(M[0], M[1], M[2]),
1668
+ up: unit(M[4], M[5], M[6]),
1669
+ tanHalfFovX: p0 > 0 ? 1 / p0 : 0.5,
1670
+ };
1671
+ }
1672
+
1610
1673
  _easeFocus() {
1611
1674
  if (this._focusSettled) return;
1612
1675
  const f = this._focus;
@@ -1997,41 +2060,38 @@ export function describeResource(res) {
1997
2060
  * one-pass fade, with the coverage-linear alpha remap on both assets.
1998
2061
  */
1999
2062
 
2000
- /**
2001
- * Per-entity work-buffer modifier: each splat's alpha becomes `1 − (1 − α)^k`, k = `dxrFade`
2002
- * (coverageExponent). Per entity, not per material: the unified renderer composites every splat of
2003
- * a tile through ONE material, so only `setWorkBufferModifier` + `setParameter` can tell the two
2004
- * assets apart. During a fade the work buffer re-renders each frame (WORKBUFFER_UPDATE_ALWAYS);
2005
- * the modifier is removed when the fade ends. The transmittance floor keeps a fully opaque splat
2006
- * (SOG alpha 255/255, common in photos) fadeable at all: pow(0, k) is 0 for every k.
2007
- */
2008
- const FADE_MODIFIER_GLSL = `
2009
- uniform float dxrFade;
2010
- void modifySplatCenter(inout vec3 center) {}
2011
- void modifySplatRotationScale(vec3 originalCenter, vec3 modifiedCenter, inout vec4 rotation, inout vec3 scale) {}
2012
- void modifySplatColor(vec3 center, inout vec4 color) {
2013
- if (dxrFade >= 1.0) return;
2014
- color.a = dxrFade <= 0.0 ? 0.0 : 1.0 - pow(max(1.0 - color.a, ${FADE_TRANSMITTANCE_FLOOR.toFixed(4)}), dxrFade);
2015
- }
2016
- `;
2017
-
2018
- /** Set (or clear, with null) the crossfade alpha exponent on one splat entity. */
2019
- function setFade(pc, entity, value) {
2020
- const g = entity?.gsplat;
2021
- if (!g) return;
2022
- if (value === null) {
2023
- g._dxrFading = false;
2024
- g.setWorkBufferModifier?.(null);
2025
- g.deleteParameter?.('dxrFade');
2026
- if ('workBufferUpdate' in g) g.workBufferUpdate = pc.WORKBUFFER_UPDATE_ONCE ?? 1;
2027
- return;
2028
- }
2029
- if (!g._dxrFading) {
2030
- g._dxrFading = true;
2031
- g.setWorkBufferModifier?.({ glsl: FADE_MODIFIER_GLSL });
2032
- if ('workBufferUpdate' in g) g.workBufferUpdate = pc.WORKBUFFER_UPDATE_ALWAYS ?? 2;
2033
- }
2034
- g.setParameter?.('dxrFade', value);
2063
+ /** setSource's transitions and their defaults. */
2064
+ export const SOURCE_TRANSITIONS = Object.freeze({
2065
+ cut: {},
2066
+ crossfade: {},
2067
+ flip: { durationMs: 2200, easing: 'easeInOutSine' },
2068
+ wavefront: { durationMs: 2000, easing: 'easeInOutSine', band: 0.18, ridge: 0.03, ridgeMaxDisparity: 0.004 },
2069
+ });
2070
+
2071
+ /** Validate setSource's options into a plan (throws on a page bug, before anything loads). */
2072
+ export function resolveSwap(o = {}) {
2073
+ if (o === null || typeof o !== 'object') throw new TypeError('@displayxr/inline3d/splat: setSource options must be an object.');
2074
+ const fadeMs = Number.isFinite(o.fadeMs) && o.fadeMs > 0 ? o.fadeMs : 0;
2075
+ const transition = o.transition ?? (fadeMs > 0 ? 'crossfade' : 'cut');
2076
+ if (!(transition in SOURCE_TRANSITIONS)) {
2077
+ throw new Error(`@displayxr/inline3d/splat: setSource transition '${transition}' — expected one of ${Object.keys(SOURCE_TRANSITIONS).join(', ')}.`);
2078
+ }
2079
+ const d = SOURCE_TRANSITIONS[transition];
2080
+ const durationMs = o.durationMs ?? (transition === 'crossfade' ? fadeMs || 800 : d.durationMs ?? 0);
2081
+ if (!Number.isFinite(durationMs) || durationMs < 0) throw new RangeError(`@displayxr/inline3d/splat: setSource durationMs must be ≥ 0, got ${o.durationMs}.`);
2082
+ const easing = o.easing ?? d.easing ?? 'linear';
2083
+ if (typeof easing !== 'function' && !EASINGS[easing]) throw new Error(`@displayxr/inline3d/splat: unknown easing '${easing}'.`);
2084
+ const reveal = resolveRevealOption(o.reveal);
2085
+ if (reveal && (transition === 'flip' || transition === 'wavefront')) {
2086
+ throw new Error(`@displayxr/inline3d/splat: setSource reveal plays with transition 'cut' or 'crossfade'; '${transition}' is its own reveal.`);
2087
+ }
2088
+ const band = o.band ?? d.band ?? 0.18;
2089
+ const ridge = o.ridge ?? d.ridge ?? 0.03;
2090
+ const ridgeMaxDisparity = o.ridgeMaxDisparity ?? d.ridgeMaxDisparity ?? 0.004;
2091
+ if (transition === 'wavefront') {
2092
+ EFFECTS.wavefront.validate({ band, ridge, ridgeMaxDisparity });
2093
+ }
2094
+ return { transition, durationMs: transition === 'crossfade' && durationMs === 0 ? 800 : durationMs, easing, reveal, band, ridge, ridgeMaxDisparity };
2035
2095
  }
2036
2096
 
2037
2097
  /**
@@ -2172,9 +2232,35 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
2172
2232
  return pickModel(clientX, clientY);
2173
2233
  },
2174
2234
  setSource,
2235
+ /**
2236
+ * Play a transition effect (inflate, deflate, sweep, dissolve, fade, pulse, custom) — see
2237
+ * docs/splat-effects.md. Validated now; runs once the first asset is on screen. Resolves
2238
+ * { finished } (false when stopped or replaced).
2239
+ */
2240
+ playEffect(name, o = {}) {
2241
+ validateEffectCall(name, o, 'play');
2242
+ return first.then(() => (fx && !removed ? fx.play(name, o) : { finished: false }));
2243
+ },
2244
+ /** Set a persistent effect (grade, clip, custom), hold a transition at `progress`, or null to remove. */
2245
+ setEffect(name, params) {
2246
+ validateEffectCall(name, params, 'set');
2247
+ if (fx) fx.set(name, params);
2248
+ else fxPending.push(['set', name, params]);
2249
+ return out;
2250
+ },
2251
+ /** Stop one effect (or all): `finish: true` jumps to its end state, else removes it. */
2252
+ stopEffect(name, o = {}) {
2253
+ if (fx) fx.stop(name, o);
2254
+ else fxPending.push(['stop', name, o]);
2255
+ return out;
2256
+ },
2257
+ /** What is on: [{ name, scope, stage, playing, waiting, progress }]. */
2258
+ effects: () => (fx ? fx.list() : []),
2175
2259
  remove() {
2176
2260
  removed = true;
2177
2261
  current = null;
2262
+ fx?.dispose();
2263
+ fx = null;
2178
2264
  unbindFocusInput?.();
2179
2265
  viewer.onFocusChange = null;
2180
2266
  handle?.remove();
@@ -2196,6 +2282,52 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
2196
2282
  return flipY ? [c[0], -c[1], -c[2]] : [c[0], c[1], c[2]];
2197
2283
  }
2198
2284
 
2285
+ // ── splat effects (./inline3d-splat-effects.js) ──
2286
+ let fx = null;
2287
+ const fxPending = []; // setEffect / stopEffect before the first asset landed
2288
+ const revealSpec = resolveRevealOption(opts.reveal); // throws on a bad one (./splat did too)
2289
+ /** A promise resolved after `n` more viewer ticks: the first draw of a new asset builds its
2290
+ * work buffer (hundreds of ms on a big file) and must not eat an effect's clock. */
2291
+ const afterTicks = (n) =>
2292
+ new Promise((resolve) => {
2293
+ let k = 0;
2294
+ viewer._hooks.push(() => {
2295
+ if (++k < n) return true;
2296
+ resolve();
2297
+ return false;
2298
+ });
2299
+ });
2300
+ function makeEffects(pc) {
2301
+ const ctx = {
2302
+ pc: () => pc,
2303
+ app: () => viewer.app,
2304
+ now: () => now(),
2305
+ eyes: () => viewer.eyeFrame(),
2306
+ focus: () => {
2307
+ if (pageMode) {
2308
+ const f = pageFocusPoint();
2309
+ return f ? modelToContent(f) : viewer.eyeFrame().origin;
2310
+ }
2311
+ const f = viewer.getFocus();
2312
+ return [f.x, f.y, f.z];
2313
+ },
2314
+ framing: () => out.frame,
2315
+ pick: (x, y) => {
2316
+ const m = pickModel(x, y);
2317
+ return m ? modelToContent(m) : null;
2318
+ },
2319
+ modelToContent,
2320
+ entity: () => current?.entity ?? null,
2321
+ };
2322
+ const f = new SplatEffects(ctx);
2323
+ viewer.onEffectsTick = (t) => {
2324
+ if (!f.active) return;
2325
+ f.tick(t);
2326
+ f.flush();
2327
+ };
2328
+ return f;
2329
+ }
2330
+
2199
2331
  // ── controls:'page' state (null/unused on the viewer's own controls) ──
2200
2332
  let lastPagePose = null; // what the page last handed setCameraPose, validated
2201
2333
  let warnedNonUniform = false;
@@ -2649,20 +2781,42 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
2649
2781
  };
2650
2782
  const kept = applyLoaded(loaded);
2651
2783
  current = { asset: loaded.asset, entity, res: loaded.res, kind: loaded.desc.kind, ...kept };
2784
+ fx = makeEffects(pcModule);
2785
+ // `reveal`: installed at its START state before the asset's first frame, played once the
2786
+ // tile is woven (handle.firstWoven — which is immediate in 2D) and the first frames are built.
2787
+ if (revealSpec) {
2788
+ const gate = Promise.resolve(out.firstWoven).then(() => afterTicks(2));
2789
+ fx.play(revealSpec.type, revealSpec.raw, { gate });
2790
+ }
2791
+ for (const [what, name, arg] of fxPending.splice(0)) {
2792
+ if (what === 'set') fx.set(name, arg);
2793
+ else fx.stop(name, arg);
2794
+ }
2652
2795
  bindFocusInput();
2653
2796
  return out;
2654
2797
  });
2655
2798
 
2656
- // ── setSource: swap the asset, optionally crossfading ──
2799
+ // ── setSource: swap the asset — a cut, a crossfade, or a transition ──
2657
2800
  let sourceGen = 0;
2658
2801
  let pendingSwap = null;
2802
+ /** setSource's crossfade FALLBACK: the coverage remap on one entity (null clears it). */
2803
+ const setFade = (entity, k) => fx?.setInternal(entity, 'xfade', k === null ? null : { k });
2659
2804
  /**
2660
- * Load `next` behind the current asset, then crossfade to it over `fadeMs` and release the old
2661
- * one. The rig waterfall re-runs for the new file (rig, lens, focus, frame; `onFocusChange`
2662
- * fires). The pose (yaw/pitch/zoom/depth) is kept unless `resetPose: true`. A newer call
2663
- * supersedes an older one still loading. Resolves to the handle once the fade has finished.
2805
+ * Load `next` behind the current asset, then swap to it:
2806
+ * transition 'cut' (fadeMs 0, the default) — at once;
2807
+ * 'crossfade' (fadeMs > 0) — the IMAGES lerp over fadeMs (FRAME_SNAPSHOT);
2808
+ * 'flip' — the outgoing photo flattens onto its convergence plane (zero disparity), the swap
2809
+ * happens there, the incoming one inflates out of its own plane;
2810
+ * 'wavefront' — a soft front crosses the picture left → right; behind it the new photo, with
2811
+ * a thin depth ridge riding the front.
2812
+ * `reveal` (cut/crossfade only) plays an entity-scope reveal on the INCOMING asset while the
2813
+ * outgoing one fades. The rig waterfall re-runs for the new file (rig, lens, focus, frame;
2814
+ * `onFocusChange` fires); the pose is kept unless `resetPose: true`. A newer call supersedes an
2815
+ * older one still loading. Resolves to the handle once the swap has finished.
2664
2816
  */
2665
- async function setSource(next, { fadeMs = 0, resetPose = false } = {}) {
2817
+ async function setSource(next, o = {}) {
2818
+ const plan = resolveSwap(o);
2819
+ const { resetPose = false } = o;
2666
2820
  const gen = ++sourceGen;
2667
2821
  const app = await booted;
2668
2822
  await first.catch(() => null); // a failed first asset may be replaced
@@ -2675,90 +2829,171 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
2675
2829
  return out;
2676
2830
  }
2677
2831
  const prev = current;
2678
- const fade = Number.isFinite(fadeMs) && fadeMs > 0 ? fadeMs : 0;
2679
2832
  pendingSwap?.finish();
2833
+ const transition = prev ? plan.transition : 'cut';
2680
2834
  // FRAME_SNAPSHOT: freeze the outgoing frame BEFORE anything of the new asset (or its rig) is
2681
2835
  // drawn. The overlay goes up in the same task, so no frame shows neither photo.
2682
- const snapped = fade && prev ? await viewer.captureFrame() : false;
2836
+ const wantsSnapshot = transition === 'crossfade' || transition === 'wavefront';
2837
+ const snapped = wantsSnapshot ? await viewer.captureFrame() : false;
2683
2838
  if (removed || gen !== sourceGen) {
2684
2839
  app.assets.remove(loaded.asset);
2685
2840
  loaded.asset.unload?.();
2686
2841
  return out;
2687
2842
  }
2688
- if (snapped) viewer.setSnapshotAlpha(1);
2689
- const entity = viewer.addSplatAsset(loaded.asset);
2690
- if (fade && prev && !snapped) setFade(pc, entity, 0);
2691
- out.mesh = {
2692
- numSplats: loaded.desc.numSplats || loaded.cloud?.sourceTotal || 0,
2693
- entity,
2694
- asset: loaded.asset,
2695
- resource: loaded.res,
2696
- };
2697
- const kept = applyLoaded(loaded);
2698
- current = { asset: loaded.asset, entity, res: loaded.res, kind: loaded.desc.kind, ...kept };
2699
- if (resetPose && !pageMode) viewer.resetPose(); // the page owns the pose on controls:'page'
2843
+ if (snapped) viewer.setSnapshotAlpha(1, transition === 'wavefront' ? { t: 0, band: plan.band, views: viewer.viewsAcross } : null);
2700
2844
 
2701
- const release = () => {
2702
- if (!prev) return;
2845
+ const release = (p) => {
2846
+ if (!p) return;
2703
2847
  // Disable first, destroy + unload a few frames LATER: the engine's gsplat world keeps the
2704
2848
  // old placement in its list until its next rebuild, and tearing the resource down in the
2705
2849
  // same frame throws inside the engine's update (found by running the swap).
2706
- prev.entity.enabled = false;
2850
+ p.entity.enabled = false;
2851
+ fx?.dropEntity(p.entity);
2707
2852
  let frames = 0;
2708
2853
  viewer._hooks.push(() => {
2709
2854
  if (++frames < 4) return true;
2710
- prev.entity.destroy?.();
2711
- app.assets.remove(prev.asset);
2712
- prev.asset.unload?.();
2855
+ p.entity.destroy?.();
2856
+ app.assets.remove(p.asset);
2857
+ p.asset.unload?.();
2713
2858
  return false;
2714
2859
  });
2715
2860
  };
2716
- if (!fade || !prev) {
2717
- release();
2861
+ /** The incoming asset becomes THE asset: waterfall, rig, handle fields. */
2862
+ const adopt = (entity) => {
2863
+ out.mesh = {
2864
+ numSplats: loaded.desc.numSplats || loaded.cloud?.sourceTotal || 0,
2865
+ entity,
2866
+ asset: loaded.asset,
2867
+ resource: loaded.res,
2868
+ };
2869
+ const kept = applyLoaded(loaded);
2870
+ current = { asset: loaded.asset, entity, res: loaded.res, kind: loaded.desc.kind, ...kept };
2871
+ if (resetPose && !pageMode) viewer.resetPose(); // the page owns the pose on controls:'page'
2872
+ };
2873
+
2874
+ const entity = viewer.addSplatAsset(loaded.asset);
2875
+ let settle = () => {};
2876
+ const done = new Promise((resolve) => (settle = resolve));
2877
+ let finished = false;
2878
+ const finishers = [];
2879
+ const finish = () => {
2880
+ if (finished) return;
2881
+ finished = true;
2882
+ for (const f of finishers) f();
2883
+ if (pendingSwap?.finish === finish) pendingSwap = null;
2884
+ settle();
2885
+ };
2886
+ pendingSwap = { finish };
2887
+
2888
+ if (transition === 'flip') {
2889
+ // Phase 1: the outgoing photo flattens onto ITS convergence plane under ITS rig; the new
2890
+ // asset is resident but hidden (its first frames build its work buffer meanwhile), held
2891
+ // flat for phase 2. The rig switches at the flat moment — zero disparity on both sides.
2892
+ const half = plan.durationMs / 2;
2893
+ setFade(entity, 0);
2894
+ let openGate = () => {};
2895
+ const gate = new Promise((r) => (openGate = r));
2896
+ const flatOut = fx.play('deflate', { durationMs: half, easing: plan.easing, scope: 'entity' }, { entity: prev.entity });
2897
+ const inflateIn = fx.play('inflate', { durationMs: half, easing: plan.easing, scope: 'entity' }, { entity, gate });
2898
+ finishers.push(() => {
2899
+ openGate();
2900
+ fx?.stop('inflate', { finish: true, entity });
2901
+ });
2902
+ flatOut.then(() => {
2903
+ if (finished || removed) return;
2904
+ setFade(entity, null);
2905
+ release(prev);
2906
+ adopt(entity);
2907
+ afterTicks(2).then(openGate);
2908
+ });
2909
+ inflateIn.then(finish);
2910
+ await done;
2911
+ if (current?.entity !== entity && !removed) {
2912
+ // superseded before the flat moment: this asset never showed
2913
+ release(prev);
2914
+ adopt(entity);
2915
+ }
2718
2916
  return out;
2719
2917
  }
2720
- if (snapped) release(); // the snapshot shows it from here on
2721
- else setFade(pc, prev.entity, 1);
2722
- await new Promise((resolve) => {
2723
- // The fade clock starts on the SECOND tick after the swap, not now: the first frame that
2724
- // draws the new asset also builds its work buffer (hundreds of ms for a 1M-splat file), and
2725
- // a clock started before it would spend the whole fade inside that one frame — a cut, not a
2726
- // crossfade (found by screenshotting mid-fade).
2727
- let t0 = null;
2728
- let ticks = 0;
2729
- const finish = () => {
2730
- if (snapped) viewer.setSnapshotAlpha(0);
2731
- else {
2732
- setFade(pc, entity, null);
2733
- release();
2734
- }
2735
- pendingSwap = null;
2736
- resolve();
2737
- };
2738
- pendingSwap = { finish };
2739
- viewer._hooks.push((t) => {
2740
- if (removed) return (resolve(), false);
2741
- if (pendingSwap?.finish !== finish) return false; // superseded: already finished
2742
- if (++ticks < 2) return true;
2743
- if (t0 === null) t0 = t;
2744
- const k = Math.min(1, Math.max(0, (t - t0) / fade));
2745
- if (snapped) {
2746
- // A resize or a 2D/3D switch mid-fade: the capture no longer fits the buffer — end now.
2747
- if (!viewer.setSnapshotAlpha(1 - k)) {
2748
- finish();
2749
- return false;
2750
- }
2751
- } else {
2752
- setFade(pc, entity, coverageExponent(k));
2753
- setFade(pc, prev.entity, coverageExponent(1 - k));
2754
- }
2755
- if (k >= 1) {
2918
+
2919
+ adopt(entity);
2920
+ if (transition === 'cut') {
2921
+ release(prev);
2922
+ if (plan.reveal) fx.play(plan.reveal.type, { ...plan.reveal.raw, scope: 'entity' }, { entity, gate: afterTicks(2) });
2923
+ finish();
2924
+ return out;
2925
+ }
2926
+
2927
+ if (transition === 'wavefront' && snapped) {
2928
+ release(prev); // the snapshot shows it from here on
2929
+ const band = plan.band;
2930
+ const ridge = fx.play(
2931
+ 'wavefront',
2932
+ { durationMs: plan.durationMs, easing: plan.easing, band, ridge: plan.ridge, ridgeMaxDisparity: plan.ridgeMaxDisparity, scope: 'entity' },
2933
+ { entity, gate: afterTicks(2), internal: true },
2934
+ );
2935
+ finishers.push(() => viewer.setSnapshotAlpha(0));
2936
+ viewer._hooks.push(() => {
2937
+ if (finished || removed) return false;
2938
+ const inst = fx?.scopes.get(entity)?.get('wavefront');
2939
+ const amount = inst ? inst.amount : 1;
2940
+ if (!viewer.setSnapshotAlpha(1, { t: amount, band, views: viewer.viewsAcross })) {
2941
+ fx?.stop('wavefront', { finish: true, entity });
2756
2942
  finish();
2757
2943
  return false;
2758
2944
  }
2759
2945
  return true;
2760
2946
  });
2947
+ ridge.then(finish);
2948
+ await done;
2949
+ return out;
2950
+ }
2951
+
2952
+ // crossfade — also the wavefront's fallback when no frame could be captured (a hidden tab):
2953
+ // the 1.12.1 one-pass fade, over the same duration.
2954
+ const fade = plan.durationMs;
2955
+ 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 {
2958
+ setFade(entity, 0);
2959
+ setFade(prev.entity, 1);
2960
+ }
2961
+ finishers.push(() => {
2962
+ if (snapped) viewer.setSnapshotAlpha(0);
2963
+ else {
2964
+ setFade(entity, null);
2965
+ release(prev);
2966
+ }
2967
+ });
2968
+ // The fade clock starts on the SECOND tick after the swap, not now: the first frame that
2969
+ // draws the new asset also builds its work buffer (hundreds of ms for a 1M-splat file), and
2970
+ // a clock started before it would spend the whole fade inside that one frame — a cut, not a
2971
+ // crossfade (found by screenshotting mid-fade).
2972
+ let t0 = null;
2973
+ let ticks = 0;
2974
+ viewer._hooks.push((t) => {
2975
+ if (removed) return (finish(), false);
2976
+ if (finished) return false; // superseded: already finished
2977
+ if (++ticks < 2) return true;
2978
+ if (t0 === null) t0 = t;
2979
+ const k = Math.min(1, Math.max(0, (t - t0) / fade));
2980
+ if (snapped) {
2981
+ // A resize or a 2D/3D switch mid-fade: the capture no longer fits the buffer — end now.
2982
+ if (!viewer.setSnapshotAlpha(1 - k)) {
2983
+ finish();
2984
+ return false;
2985
+ }
2986
+ } else {
2987
+ setFade(entity, coverageExponent(k));
2988
+ setFade(prev.entity, coverageExponent(1 - k));
2989
+ }
2990
+ if (k >= 1) {
2991
+ finish();
2992
+ return false;
2993
+ }
2994
+ return true;
2761
2995
  });
2996
+ await done;
2762
2997
  return out;
2763
2998
  }
2764
2999
 
@@ -23,6 +23,7 @@ import { SparkRenderer, SplatMesh } from '@sparkjsdev/spark';
23
23
  import { EyeCamera, EdgeFeather, cameraRigFromCamera } from './inline3d-three.js';
24
24
  import { SceneViewer, boundsFromPositions } from './inline3d-viewer.js';
25
25
  import { readSogCamera } from './inline3d-sog.js';
26
+ import { resolveRevealOption, effectsNotOnSpark, validateEffectCall } from './inline3d-splat-effects.js';
26
27
  import { applySplatPerf, splatPerfMeshOptions } from './inline3d-splat-perf.js';
27
28
  import {
28
29
  toArray3,
@@ -172,6 +173,8 @@ export function addSplat(wall, canvas, src, opts = {}) {
172
173
  // ./inline3d-splat-playcanvas.js, imported DYNAMICALLY so a page that never asks for it never
173
174
  // resolves `playcanvas` — see addSplatDeferred.
174
175
  if (resolveSplatEngine(opts) === 'playcanvas') {
176
+ // A bad `reveal` is a page bug true of every call: throw at the call.
177
+ resolveRevealOption(opts.reveal);
175
178
  // A format that engine provably cannot read (a .spz URL, gzip bytes, a Spark-only fileType)
176
179
  // is a page bug: say so NOW rather than fail inside a loader later.
177
180
  const why = playcanvasCannotRead(src, opts);
@@ -182,6 +185,7 @@ export function addSplat(wall, canvas, src, opts = {}) {
182
185
  // controls:'page' is a PlayCanvas-backend feature (docs/playcanvas-adapter.md §controls:'page'):
183
186
  // on Spark it would need SceneViewer — which ./viewer and ./model share — to take an external
184
187
  // camera in both its mono and eye paths. Refused by name rather than half-supported.
188
+ if (opts.reveal !== undefined && opts.reveal !== false) throw effectsNotOnSpark('reveal');
185
189
  if (pageControls) {
186
190
  throw new Error(
187
191
  "@displayxr/inline3d/splat: controls:'page' is not supported on Spark (the default engine) — " +
@@ -365,6 +369,17 @@ export function addSplat(wall, canvas, src, opts = {}) {
365
369
  "only; with the Spark backend, remove() this handle and addSplat() the new asset.",
366
370
  );
367
371
  },
372
+ /** Not on this backend (splat effects are PlayCanvas-only in this version). */
373
+ playEffect() {
374
+ throw effectsNotOnSpark('playEffect()');
375
+ },
376
+ setEffect() {
377
+ throw effectsNotOnSpark('setEffect()');
378
+ },
379
+ stopEffect() {
380
+ throw effectsNotOnSpark('stopEffect()');
381
+ },
382
+ effects: () => [],
368
383
  };
369
384
 
370
385
  // `src` may be a URL or the bytes themselves.
@@ -691,6 +706,19 @@ function addSplatDeferred(wall, canvas, src, opts) {
691
706
  // A swap requested before the first asset has landed runs once it has (the adapter's own
692
707
  // setSource replaces this stub on the same object by then).
693
708
  setSource: (...args) => out.ready.then(() => out.setSource(...args)),
709
+ // Effects before the adapter has loaded: validated NOW (a bad call throws at its own line),
710
+ // then run once the first asset is on screen.
711
+ playEffect: (name, o) => {
712
+ validateEffectCall(name, o, 'play');
713
+ return out.ready.then(() => out.playEffect(name, o));
714
+ },
715
+ setEffect: (name, params) => {
716
+ validateEffectCall(name, params, 'set');
717
+ pending.push(['setEffect', [name, params]]);
718
+ return out;
719
+ },
720
+ stopEffect: queue('stopEffect'),
721
+ effects: () => [],
694
722
  getFocus: () => null,
695
723
  // A plain data slot the adapter reads at call time, so a callback assigned on the very next
696
724
  // line after addSplat — before the module has loaded — is the one that fires.