@displayxr/inline3d 1.12.0 → 1.13.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.
@@ -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,
@@ -84,6 +91,7 @@ import {
84
91
  streamedBytesError,
85
92
  resolveControls,
86
93
  normalizeCameraPose,
94
+ coverageExponent,
87
95
  } from './inline3d-splat-shared.js';
88
96
 
89
97
  /**
@@ -500,6 +508,9 @@ export function capturePose(rest, flipY) {
500
508
  * (epic #36, P4d); until then the anchors are regexes (the npm build re-indents chunks with
501
509
  * tabs) and a miss warns once and renders unpatched rather than failing.
502
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
+ *
503
514
  * @returns {{src:string, ok:boolean}}
504
515
  */
505
516
  export function patchGsplatFootprint(src) {
@@ -653,6 +664,10 @@ export class PlayCanvasSplatViewer {
653
664
  this.onCaptureFov = null;
654
665
  /** Per-frame hooks, `(tMs) => boolean` — return false to be removed. setSource's crossfade. */
655
666
  this._hooks = [];
667
+ /** captureFrame() callers waiting for the next drawn frame. */
668
+ this._captureWaiters = [];
669
+ /** The crossfade's frame snapshot: { w, h, tex, rt, mi, mat } once captured. */
670
+ this._snap = null;
656
671
  this.vH = virtualDisplayHeight;
657
672
  this.fit = fit;
658
673
  this.margin = margin;
@@ -968,6 +983,10 @@ export class PlayCanvasSplatViewer {
968
983
  if (this._ro) this._ro.disconnect();
969
984
  else if (typeof removeEventListener === 'function') removeEventListener('resize', this._onResize);
970
985
  this._unbindOrbit();
986
+ for (const w of this._captureWaiters.splice(0)) {
987
+ clearTimeout(w.timer);
988
+ w.resolve(false);
989
+ }
971
990
  try {
972
991
  this.app?.destroy();
973
992
  } catch (err) {
@@ -1177,8 +1196,171 @@ export class PlayCanvasSplatViewer {
1177
1196
  if (this.app.scene.gsplat.splatBudget !== per) this.app.scene.gsplat.splatBudget = per;
1178
1197
  }
1179
1198
 
1199
+ /**
1200
+ * Copy the frame just drawn (the whole buffer: both eyes in 3D) into a texture, on the next
1201
+ * drawn frame. Resolves true once copied, false if no frame is drawn within `timeoutMs` (a
1202
+ * hidden tab) or the engine cannot copy. setSource's crossfade (FRAME_SNAPSHOT).
1203
+ */
1204
+ captureFrame(timeoutMs = 250) {
1205
+ return new Promise((resolve) => {
1206
+ if (!this.app || this._disposed || !this.pc?.RenderTarget) return resolve(false);
1207
+ const waiter = { resolve, timer: 0 };
1208
+ waiter.timer = setTimeout(() => {
1209
+ const i = this._captureWaiters.indexOf(waiter);
1210
+ if (i >= 0) this._captureWaiters.splice(i, 1);
1211
+ resolve(false);
1212
+ }, timeoutMs);
1213
+ this._captureWaiters.push(waiter);
1214
+ });
1215
+ }
1216
+
1217
+ _doCapture() {
1218
+ const run = this._captureWaiters.splice(0);
1219
+ let ok = false;
1220
+ try {
1221
+ const pc = this.pc;
1222
+ const device = this.app.graphicsDevice;
1223
+ const w = this.canvas.width;
1224
+ const h = this.canvas.height;
1225
+ let s = this._snap;
1226
+ if (!s || s.w !== w || s.h !== h) {
1227
+ this._destroySnapshot();
1228
+ const tex = new pc.Texture(device, {
1229
+ name: 'inline3d-snapshot',
1230
+ width: w,
1231
+ height: h,
1232
+ format: pc.PIXELFORMAT_RGBA8,
1233
+ mipmaps: false,
1234
+ minFilter: pc.FILTER_NEAREST,
1235
+ magFilter: pc.FILTER_NEAREST,
1236
+ addressU: pc.ADDRESS_CLAMP_TO_EDGE,
1237
+ addressV: pc.ADDRESS_CLAMP_TO_EDGE,
1238
+ });
1239
+ s = this._snap = { w, h, tex, rt: new pc.RenderTarget({ colorBuffer: tex, depth: false }), parts: null };
1240
+ }
1241
+ // null source = the back buffer, still holding this frame (same task, before compositing).
1242
+ ok = device.copyRenderTarget(null, s.rt, true, false) !== false;
1243
+ if (ok) this._ensureSnapshotOverlay();
1244
+ } catch (err) {
1245
+ console.warn('[inline3d/splat] frame snapshot failed; the crossfade falls back to one pass', err);
1246
+ ok = false;
1247
+ }
1248
+ for (const w of run) {
1249
+ clearTimeout(w.timer);
1250
+ w.resolve(ok);
1251
+ }
1252
+ }
1253
+
1254
+ /**
1255
+ * FRAME_SNAPSHOT: the captured frame lerped with the live scene, in two clip-space quads in the
1256
+ * UI layer (under the edge feather), drawn once per view like the feather. Each samples the
1257
+ * capture at gl_FragCoord, so an eye's viewport reads its own half. Quad 1 scales what the scene
1258
+ * drew by (1 − α) (blend ZERO / SRC_ALPHA, colour AND alpha); quad 2 adds α·A (ONE / ONE). The
1259
+ * result is the plain lerp of the two premultiplied images, `α·A + (1 − α)·B` — alpha included,
1260
+ * so a region only one of the two photos covers fades against the page, not against black.
1261
+ */
1262
+ _ensureSnapshotOverlay() {
1263
+ const s = this._snap;
1264
+ if (s.parts) {
1265
+ for (const p of s.parts) {
1266
+ p.mat.setParameter('dxrSnap', s.tex);
1267
+ p.mat.setParameter('dxrSnapInvSize', [1 / s.w, 1 / s.h]);
1268
+ p.mat.update();
1269
+ }
1270
+ return;
1271
+ }
1272
+ const pc = this.pc;
1273
+ const mesh = new pc.Mesh(this.app.graphicsDevice);
1274
+ mesh.setPositions(new Float32Array([-1, -1, 0, 1, -1, 0, 1, 1, 0, -1, 1, 0]));
1275
+ mesh.setIndices([0, 1, 2, 0, 2, 3]);
1276
+ mesh.update();
1277
+ const part = (name, body, src, dst, drawOrder) => {
1278
+ const mat = new pc.ShaderMaterial({
1279
+ uniqueName: name,
1280
+ attributes: { vertex_position: pc.SEMANTIC_POSITION },
1281
+ vertexGLSL: `
1282
+ attribute vec3 vertex_position;
1283
+ void main() { gl_Position = vec4(vertex_position.xy, 0.0, 1.0); }`,
1284
+ fragmentGLSL: `
1285
+ uniform sampler2D dxrSnap;
1286
+ uniform vec2 dxrSnapInvSize;
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
+ }
1297
+ void main() { ${body} }`,
1298
+ });
1299
+ mat.blendState = new pc.BlendState(true, pc.BLENDEQUATION_ADD, src, dst, pc.BLENDEQUATION_ADD, src, dst);
1300
+ mat.depthTest = false;
1301
+ mat.depthWrite = false;
1302
+ mat.cull = pc.CULLFACE_NONE;
1303
+ mat.setParameter('dxrSnap', s.tex);
1304
+ mat.setParameter('dxrSnapInvSize', [1 / s.w, 1 / s.h]);
1305
+ mat.setParameter('dxrSnapAlpha', 0);
1306
+ mat.setParameter('dxrSnapWipe', [-2, 0.1, 1]);
1307
+ mat.update();
1308
+ const mi = new pc.MeshInstance(mesh, mat, new pc.GraphNode(name));
1309
+ mi.cull = false;
1310
+ mi.drawOrder = drawOrder; // both before the edge feather (drawOrder 0)
1311
+ mi.visible = false;
1312
+ return { mat, mi };
1313
+ };
1314
+ s.parts = [
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),
1317
+ ];
1318
+ this.app.scene.layers.getLayerById(pc.LAYERID_UI).addMeshInstances(s.parts.map((p) => p.mi));
1319
+ }
1320
+
1321
+ /**
1322
+ * Lerp the snapshot over the scene at `alpha` (0 hides it). Returns false when the canvas buffer
1323
+ * no longer matches the capture (resize, 2D/3D switch): the caller ends its fade.
1324
+ */
1325
+ setSnapshotAlpha(alpha, wipe = null) {
1326
+ const s = this._snap;
1327
+ if (!s?.parts) return false;
1328
+ const fits = s.w === this.canvas.width && s.h === this.canvas.height;
1329
+ const show = alpha > 0 && fits;
1330
+ for (const p of s.parts) {
1331
+ p.mi.visible = show;
1332
+ if (show) {
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]);
1338
+ p.mat.update();
1339
+ }
1340
+ }
1341
+ return alpha <= 0 || fits;
1342
+ }
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
+
1349
+ _destroySnapshot() {
1350
+ const s = this._snap;
1351
+ if (!s) return;
1352
+ this._snap = null;
1353
+ if (s.parts) {
1354
+ this.app?.scene?.layers?.getLayerById(this.pc.LAYERID_UI)?.removeMeshInstances?.(s.parts.map((p) => p.mi));
1355
+ s.parts[0].mi.mesh?.destroy?.();
1356
+ }
1357
+ s.rt?.destroy?.();
1358
+ s.tex?.destroy?.();
1359
+ }
1360
+
1180
1361
  /** Read back what the tick just did. Cheap: two numbers off the renderer. */
1181
1362
  _afterTick() {
1363
+ if (this._captureWaiters.length) this._doCapture();
1182
1364
  const n = this.app?.renderer?._gsplatCount ?? 0;
1183
1365
  this.resident = n;
1184
1366
  if (n > this.peakResident) this.peakResident = n;
@@ -1450,9 +1632,44 @@ export class PlayCanvasSplatViewer {
1450
1632
  const keep = run.filter((h) => h(t) !== false);
1451
1633
  this._hooks = keep.concat(this._hooks);
1452
1634
  }
1635
+ // Splat effects (./inline3d-splat-effects.js): clocks + uniforms, before the engine renders.
1636
+ this.onEffectsTick?.(t);
1453
1637
  this.onTick?.();
1454
1638
  }
1455
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
+
1456
1673
  _easeFocus() {
1457
1674
  if (this._focusSettled) return;
1458
1675
  const f = this._focus;
@@ -1820,39 +2037,61 @@ export function describeResource(res) {
1820
2037
  // ── the handle ──────────────────────────────────────────────────────────────────────────────
1821
2038
 
1822
2039
  /**
1823
- * The per-splat crossfade for `setSource`: a work-buffer modifier multiplying each splat's alpha
1824
- * by this ENTITY's `dxrFade` parameter.
2040
+ * setSource's crossfade.
1825
2041
  *
1826
- * Per entity, not per material: the engine's unified renderer composites every splat of a tile
1827
- * through ONE material and ONE work buffer, so a material uniform cannot tell the outgoing asset
1828
- * from the incoming one. `setWorkBufferModifier` + `setParameter` is the engine's supported
1829
- * per-component hook for exactly this; during a fade the work buffer is re-rendered each frame
1830
- * (WORKBUFFER_UPDATE_ALWAYS), and the modifier is removed when the fade ends.
2042
+ * The 1.11 fade multiplied each splat's alpha by t and drew both assets in ONE sorted pass. That
2043
+ * is not a crossfade: whichever photo sits in FRONT wins. Measured on SHARP photo pairs (the blend
2044
+ * fraction c, least squares of the frame between A-alone and B-alone): the incoming photo in front
2045
+ * reached c = 0.64 at t = 0.2 (a snap), behind it c = 0.18 at t = 0.5 (a late pop). No per-splat
2046
+ * alpha remap fixes that, because a pixel's coverage depends on how many splats stack there and
2047
+ * which asset is nearer — both vary across the picture and between photos (docs/splat-effects.md
2048
+ * §Crossfade has the numbers).
2049
+ *
2050
+ * FRAME_SNAPSHOT: blend IMAGES instead. The last frame of the outgoing asset (both eyes) is copied
2051
+ * into a texture, the outgoing asset is released at once, and the incoming one is drawn untouched;
2052
+ * two overlay quads then lerp the frozen frame with it, `out = (1 − t)·A + t·B` per premultiplied
2053
+ * pixel, alpha included (PlayCanvasSplatViewer._ensureSnapshotOverlay). Linear by construction,
2054
+ * whatever the depth order, coverage or framing; each eye samples its own half of the capture, so
2055
+ * the eyes stay consistent. End state: overlay hidden — exactly the untouched incoming asset.
2056
+ * Trade-offs: the outgoing frame is frozen for the fade (camera and head motion stop for it), and
2057
+ * the new asset's rig applies at once (each photo is seen through its own rig).
2058
+ *
2059
+ * Fallback (no frame drawn within 250 ms — a hidden tab — or no copy on this engine build): the
2060
+ * one-pass fade, with the coverage-linear alpha remap on both assets.
1831
2061
  */
1832
- const FADE_MODIFIER_GLSL = `
1833
- uniform float dxrFade;
1834
- void modifySplatCenter(inout vec3 center) {}
1835
- void modifySplatRotationScale(vec3 originalCenter, vec3 modifiedCenter, inout vec4 rotation, inout vec3 scale) {}
1836
- void modifySplatColor(vec3 center, inout vec4 color) { color.a *= dxrFade; }
1837
- `;
1838
-
1839
- /** Set (or clear, with null) the crossfade alpha on one splat entity. */
1840
- function setFade(pc, entity, value) {
1841
- const g = entity?.gsplat;
1842
- if (!g) return;
1843
- if (value === null) {
1844
- g._dxrFading = false;
1845
- g.setWorkBufferModifier?.(null);
1846
- g.deleteParameter?.('dxrFade');
1847
- if ('workBufferUpdate' in g) g.workBufferUpdate = pc.WORKBUFFER_UPDATE_ONCE ?? 1;
1848
- return;
1849
- }
1850
- if (!g._dxrFading) {
1851
- g._dxrFading = true;
1852
- g.setWorkBufferModifier?.({ glsl: FADE_MODIFIER_GLSL });
1853
- if ('workBufferUpdate' in g) g.workBufferUpdate = pc.WORKBUFFER_UPDATE_ALWAYS ?? 2;
1854
- }
1855
- g.setParameter?.('dxrFade', value);
2062
+
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 };
1856
2095
  }
1857
2096
 
1858
2097
  /**
@@ -1993,9 +2232,35 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
1993
2232
  return pickModel(clientX, clientY);
1994
2233
  },
1995
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() : []),
1996
2259
  remove() {
1997
2260
  removed = true;
1998
2261
  current = null;
2262
+ fx?.dispose();
2263
+ fx = null;
1999
2264
  unbindFocusInput?.();
2000
2265
  viewer.onFocusChange = null;
2001
2266
  handle?.remove();
@@ -2017,6 +2282,52 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
2017
2282
  return flipY ? [c[0], -c[1], -c[2]] : [c[0], c[1], c[2]];
2018
2283
  }
2019
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
+
2020
2331
  // ── controls:'page' state (null/unused on the viewer's own controls) ──
2021
2332
  let lastPagePose = null; // what the page last handed setCameraPose, validated
2022
2333
  let warnedNonUniform = false;
@@ -2470,20 +2781,42 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
2470
2781
  };
2471
2782
  const kept = applyLoaded(loaded);
2472
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
+ }
2473
2795
  bindFocusInput();
2474
2796
  return out;
2475
2797
  });
2476
2798
 
2477
- // ── setSource: swap the asset, optionally crossfading ──
2799
+ // ── setSource: swap the asset — a cut, a crossfade, or a transition ──
2478
2800
  let sourceGen = 0;
2479
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 });
2480
2804
  /**
2481
- * Load `next` behind the current asset, then crossfade to it over `fadeMs` and release the old
2482
- * one. The rig waterfall re-runs for the new file (rig, lens, focus, frame; `onFocusChange`
2483
- * fires). The pose (yaw/pitch/zoom/depth) is kept unless `resetPose: true`. A newer call
2484
- * 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.
2485
2816
  */
2486
- async function setSource(next, { fadeMs = 0, resetPose = false } = {}) {
2817
+ async function setSource(next, o = {}) {
2818
+ const plan = resolveSwap(o);
2819
+ const { resetPose = false } = o;
2487
2820
  const gen = ++sourceGen;
2488
2821
  const app = await booted;
2489
2822
  await first.catch(() => null); // a failed first asset may be replaced
@@ -2496,69 +2829,171 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
2496
2829
  return out;
2497
2830
  }
2498
2831
  const prev = current;
2499
- const entity = viewer.addSplatAsset(loaded.asset);
2500
- const fade = Number.isFinite(fadeMs) && fadeMs > 0 ? fadeMs : 0;
2501
- if (fade && prev) setFade(pc, entity, 0);
2502
- out.mesh = {
2503
- numSplats: loaded.desc.numSplats || loaded.cloud?.sourceTotal || 0,
2504
- entity,
2505
- asset: loaded.asset,
2506
- resource: loaded.res,
2507
- };
2508
- const kept = applyLoaded(loaded);
2509
- current = { asset: loaded.asset, entity, res: loaded.res, kind: loaded.desc.kind, ...kept };
2510
- if (resetPose && !pageMode) viewer.resetPose(); // the page owns the pose on controls:'page'
2832
+ pendingSwap?.finish();
2833
+ const transition = prev ? plan.transition : 'cut';
2834
+ // FRAME_SNAPSHOT: freeze the outgoing frame BEFORE anything of the new asset (or its rig) is
2835
+ // drawn. The overlay goes up in the same task, so no frame shows neither photo.
2836
+ const wantsSnapshot = transition === 'crossfade' || transition === 'wavefront';
2837
+ const snapped = wantsSnapshot ? await viewer.captureFrame() : false;
2838
+ if (removed || gen !== sourceGen) {
2839
+ app.assets.remove(loaded.asset);
2840
+ loaded.asset.unload?.();
2841
+ return out;
2842
+ }
2843
+ if (snapped) viewer.setSnapshotAlpha(1, transition === 'wavefront' ? { t: 0, band: plan.band, views: viewer.viewsAcross } : null);
2511
2844
 
2512
- const release = () => {
2513
- if (!prev) return;
2845
+ const release = (p) => {
2846
+ if (!p) return;
2514
2847
  // Disable first, destroy + unload a few frames LATER: the engine's gsplat world keeps the
2515
2848
  // old placement in its list until its next rebuild, and tearing the resource down in the
2516
2849
  // same frame throws inside the engine's update (found by running the swap).
2517
- prev.entity.enabled = false;
2850
+ p.entity.enabled = false;
2851
+ fx?.dropEntity(p.entity);
2518
2852
  let frames = 0;
2519
2853
  viewer._hooks.push(() => {
2520
2854
  if (++frames < 4) return true;
2521
- prev.entity.destroy?.();
2522
- app.assets.remove(prev.asset);
2523
- prev.asset.unload?.();
2855
+ p.entity.destroy?.();
2856
+ app.assets.remove(p.asset);
2857
+ p.asset.unload?.();
2524
2858
  return false;
2525
2859
  });
2526
2860
  };
2527
- if (!fade || !prev) {
2528
- 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
+ }
2529
2916
  return out;
2530
2917
  }
2531
- pendingSwap?.finish();
2532
- await new Promise((resolve) => {
2533
- // The fade clock starts on the SECOND tick after the swap, not now: the first frame that
2534
- // draws the new asset also builds its work buffer (hundreds of ms for a 1M-splat file), and
2535
- // a clock started before it would spend the whole fade inside that one frame — a cut, not a
2536
- // crossfade (found by screenshotting mid-fade).
2537
- let t0 = null;
2538
- let ticks = 0;
2539
- const finish = () => {
2540
- setFade(pc, entity, null);
2541
- release();
2542
- pendingSwap = null;
2543
- resolve();
2544
- };
2545
- pendingSwap = { finish };
2546
- setFade(pc, prev.entity, 1);
2547
- viewer._hooks.push((t) => {
2548
- if (removed) return (resolve(), false);
2549
- if (pendingSwap?.finish !== finish) return false; // superseded: already finished
2550
- if (++ticks < 2) return true;
2551
- if (t0 === null) t0 = t;
2552
- const k = Math.min(1, Math.max(0, (t - t0) / fade));
2553
- setFade(pc, entity, k);
2554
- setFade(pc, prev.entity, 1 - k);
2555
- 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 });
2556
2942
  finish();
2557
2943
  return false;
2558
2944
  }
2559
2945
  return true;
2560
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;
2561
2995
  });
2996
+ await done;
2562
2997
  return out;
2563
2998
  }
2564
2999