@displayxr/inline3d 1.12.1 → 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.
- package/CHANGELOG.md +40 -0
- package/README.md +11 -0
- package/THIRD_PARTY_NOTICES.md +76 -0
- package/js/inline3d-splat-effects.js +1116 -0
- package/js/inline3d-splat-perf.js +3 -0
- package/js/inline3d-splat-playcanvas.js +338 -103
- package/js/inline3d-splat.js +28 -0
- package/package.json +4 -2
- package/splat.d.ts +149 -4
|
@@ -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 -
|
|
1297
|
-
part('inline3dSnapshotAdd', 'gl_FragColor = texture2D(dxrSnap, gl_FragCoord.xy * dxrSnapInvSize) *
|
|
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
|
-
|
|
2002
|
-
|
|
2003
|
-
|
|
2004
|
-
|
|
2005
|
-
|
|
2006
|
-
|
|
2007
|
-
|
|
2008
|
-
|
|
2009
|
-
|
|
2010
|
-
|
|
2011
|
-
|
|
2012
|
-
|
|
2013
|
-
if (
|
|
2014
|
-
|
|
2015
|
-
}
|
|
2016
|
-
|
|
2017
|
-
|
|
2018
|
-
|
|
2019
|
-
|
|
2020
|
-
|
|
2021
|
-
|
|
2022
|
-
if (
|
|
2023
|
-
|
|
2024
|
-
|
|
2025
|
-
|
|
2026
|
-
|
|
2027
|
-
|
|
2028
|
-
|
|
2029
|
-
|
|
2030
|
-
|
|
2031
|
-
|
|
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,
|
|
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
|
|
2661
|
-
*
|
|
2662
|
-
*
|
|
2663
|
-
*
|
|
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,
|
|
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
|
|
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 (!
|
|
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
|
-
|
|
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
|
-
|
|
2711
|
-
app.assets.remove(
|
|
2712
|
-
|
|
2855
|
+
p.entity.destroy?.();
|
|
2856
|
+
app.assets.remove(p.asset);
|
|
2857
|
+
p.asset.unload?.();
|
|
2713
2858
|
return false;
|
|
2714
2859
|
});
|
|
2715
2860
|
};
|
|
2716
|
-
|
|
2717
|
-
|
|
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
|
-
|
|
2721
|
-
|
|
2722
|
-
|
|
2723
|
-
|
|
2724
|
-
|
|
2725
|
-
|
|
2726
|
-
|
|
2727
|
-
|
|
2728
|
-
|
|
2729
|
-
|
|
2730
|
-
|
|
2731
|
-
|
|
2732
|
-
|
|
2733
|
-
|
|
2734
|
-
}
|
|
2735
|
-
|
|
2736
|
-
|
|
2737
|
-
|
|
2738
|
-
|
|
2739
|
-
|
|
2740
|
-
|
|
2741
|
-
|
|
2742
|
-
if (
|
|
2743
|
-
|
|
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
|
|
package/js/inline3d-splat.js
CHANGED
|
@@ -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.
|