@displayxr/inline3d 1.19.3 → 1.21.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 +51 -0
- package/js/inline3d-splat-diag.js +599 -0
- package/js/inline3d-splat-effects.js +101 -0
- package/js/inline3d-splat-live.js +81 -3
- package/js/inline3d-splat-playcanvas.js +493 -26
- package/package.json +2 -1
- package/splat.d.ts +61 -8
|
@@ -61,9 +61,14 @@ import {
|
|
|
61
61
|
PARTICLE_TRANSITIONS,
|
|
62
62
|
PARTICLE_TRANSITION_OPTIONS,
|
|
63
63
|
particleSpan,
|
|
64
|
+
SEQUENCE_REVEALS,
|
|
65
|
+
SEQUENCE_TRANSITIONS,
|
|
66
|
+
emptyAtZero,
|
|
67
|
+
sequenceSpans,
|
|
64
68
|
} from './inline3d-splat-effects.js';
|
|
65
69
|
import { LiveOutgoing, resolveOutgoingOption, defaultOutgoing, yieldIdle } from './inline3d-splat-live.js';
|
|
66
70
|
import { VideoPlane, validateSetVideo, PAGE_VIDEO_ERROR } from './inline3d-splat-video.js';
|
|
71
|
+
import { resolveDiag, DiagRecorder, startDiagLoop, registerDiag } from './inline3d-splat-diag.js';
|
|
67
72
|
import {
|
|
68
73
|
clamp,
|
|
69
74
|
finite,
|
|
@@ -1406,6 +1411,11 @@ export class PlayCanvasSplatViewer {
|
|
|
1406
1411
|
if (!s?.parts) return false;
|
|
1407
1412
|
const fits = !!s.tex && s.w === this.canvas.width && s.h === this.canvas.height;
|
|
1408
1413
|
const show = alpha > 0 && fits;
|
|
1414
|
+
// What the overlay shows, for diagnostics (diagImageState): its weight on screen.
|
|
1415
|
+
const st = (this._snapState ||= { alpha: 0, wipe: -2, over: false, live: false });
|
|
1416
|
+
st.alpha = show ? Math.min(1, alpha) : 0;
|
|
1417
|
+
st.wipe = wipe ? wipe.t : -2;
|
|
1418
|
+
st.over = !!mix?.over;
|
|
1409
1419
|
for (const p of s.parts) {
|
|
1410
1420
|
p.mi.visible = show;
|
|
1411
1421
|
if (show) {
|
|
@@ -1429,6 +1439,7 @@ export class PlayCanvasSplatViewer {
|
|
|
1429
1439
|
setSnapshotSource(tex) {
|
|
1430
1440
|
const s = this._snap;
|
|
1431
1441
|
if (!s?.parts) return;
|
|
1442
|
+
(this._snapState ||= { alpha: 0, wipe: -2, over: false, live: false }).live = !!tex;
|
|
1432
1443
|
for (const p of s.parts) {
|
|
1433
1444
|
p.mat.setParameter('dxrSnap', tex || s.tex);
|
|
1434
1445
|
p.mat.update();
|
|
@@ -1489,6 +1500,55 @@ export class PlayCanvasSplatViewer {
|
|
|
1489
1500
|
return this._live.start(entity, oldFrame) ? this._live : null;
|
|
1490
1501
|
}
|
|
1491
1502
|
|
|
1503
|
+
/**
|
|
1504
|
+
* Diagnostics: what the transition overlay puts on screen this frame — 'none', or the 'frozen'
|
|
1505
|
+
* capture / the 'live' outgoing target with `w`, the share of the picture it covers (the wipe's
|
|
1506
|
+
* uncommitted part times the lerp weight).
|
|
1507
|
+
*/
|
|
1508
|
+
diagImageState() {
|
|
1509
|
+
const st = this._snapState;
|
|
1510
|
+
if (!st || !(st.alpha > 0)) return { overlay: 'none', w: 0 };
|
|
1511
|
+
const left = st.wipe < -1 ? 1 : Math.min(1, Math.max(0, 1 - st.wipe));
|
|
1512
|
+
return { overlay: st.live ? 'live' : 'frozen', w: st.alpha * left };
|
|
1513
|
+
}
|
|
1514
|
+
|
|
1515
|
+
/** Pre-sort the live outgoing camera on `entity` before a swap (see LiveOutgoing.warm). */
|
|
1516
|
+
warmLiveOutgoing(entity) {
|
|
1517
|
+
this._live ||= new LiveOutgoing(this);
|
|
1518
|
+
return this._live.warm(entity);
|
|
1519
|
+
}
|
|
1520
|
+
|
|
1521
|
+
/**
|
|
1522
|
+
* Resolves true once the pre-sorted live manager has drawn a sorted frame, false after
|
|
1523
|
+
* `timeoutMs` (a hidden tab, a stalled sorter) or once the pre-sort was dropped.
|
|
1524
|
+
*/
|
|
1525
|
+
liveWarmed(timeoutMs = 1500) {
|
|
1526
|
+
const live = this._live;
|
|
1527
|
+
if (!live?.warming) return Promise.resolve(false);
|
|
1528
|
+
if (live.warmReady) return Promise.resolve(true);
|
|
1529
|
+
return new Promise((resolve) => {
|
|
1530
|
+
let done = false;
|
|
1531
|
+
const end = (v) => {
|
|
1532
|
+
if (done) return;
|
|
1533
|
+
done = true;
|
|
1534
|
+
clearTimeout(timer);
|
|
1535
|
+
resolve(v);
|
|
1536
|
+
};
|
|
1537
|
+
const timer = setTimeout(() => end(false), timeoutMs);
|
|
1538
|
+
this._hooks.push(() => {
|
|
1539
|
+
if (done) return false;
|
|
1540
|
+
if (this._disposed || !live.warming) return end(false), false;
|
|
1541
|
+
if (live.warmReady) return end(true), false;
|
|
1542
|
+
return true;
|
|
1543
|
+
});
|
|
1544
|
+
});
|
|
1545
|
+
}
|
|
1546
|
+
|
|
1547
|
+
/** Drop a pre-sort that is not going to become a live window. */
|
|
1548
|
+
cancelLiveWarm() {
|
|
1549
|
+
this._live?.cancelWarm();
|
|
1550
|
+
}
|
|
1551
|
+
|
|
1492
1552
|
/** End the live window: overlay back on the frozen capture, camera off, target freed. */
|
|
1493
1553
|
stopLiveOutgoing() {
|
|
1494
1554
|
if (!this._live?.active) return;
|
|
@@ -1673,7 +1733,7 @@ export class PlayCanvasSplatViewer {
|
|
|
1673
1733
|
// compose the node's PARENT with their own pose). Park it on the first eye.
|
|
1674
1734
|
placeNode(this.eye, entries[0].pose);
|
|
1675
1735
|
// setSource's live outgoing: the same views on its own camera, into its own target.
|
|
1676
|
-
if (this._live?.active) this._live.sync(entries, rect, f);
|
|
1736
|
+
if (this._live?.active || this._live?.warming) this._live.sync(entries, rect, f);
|
|
1677
1737
|
// setSource's wavefront: this frame's eye views, as the engine is about to compose them.
|
|
1678
1738
|
this.onBeforeRender?.(entries, rect);
|
|
1679
1739
|
} else {
|
|
@@ -2430,8 +2490,56 @@ export const SOURCE_TRANSITIONS = Object.freeze({
|
|
|
2430
2490
|
wavefront: { durationMs: 2000, easing: 'easeInOutSine', band: 0.18, ridge: 0.03, ridgeMaxDisparity: 0.004 },
|
|
2431
2491
|
// the particle transitions (./inline3d-splat-effects.js PARTICLE_TRANSITIONS)
|
|
2432
2492
|
...Object.fromEntries(Object.entries(PARTICLE_TRANSITIONS).map(([k, v]) => [k, { durationMs: v.durationMs, easing: v.easing, particle: true }])),
|
|
2493
|
+
// the named sequences (./inline3d-splat-effects.js SEQUENCE_TRANSITIONS); the general form is
|
|
2494
|
+
// `transition: { type: 'sequence', out, in }`
|
|
2495
|
+
...Object.fromEntries(Object.entries(SEQUENCE_TRANSITIONS).map(([k, v]) => [k, { durationMs: v.durationMs, easing: v.easing, sequence: true }])),
|
|
2433
2496
|
});
|
|
2434
2497
|
|
|
2498
|
+
/** A sequence's own defaults (the general form, `{ type: 'sequence', out, in }`). */
|
|
2499
|
+
const SEQUENCE_DEFAULTS = Object.freeze({ durationMs: 3000, easing: 'linear', beat: 0.1 });
|
|
2500
|
+
|
|
2501
|
+
/**
|
|
2502
|
+
* A sequence transition's plan: { name, out: { effect, opts }, in: { effect, opts }, beat } —
|
|
2503
|
+
* each side's defaults (SEQUENCE_REVEALS), then the page's shared particle options (particle
|
|
2504
|
+
* sides only), then `outgoingFx` / `incomingFx`. Every side is validated as its effect, and must
|
|
2505
|
+
* draw nothing at amount 0 (the swap happens there). `spec` = the named sequence or the object.
|
|
2506
|
+
*/
|
|
2507
|
+
export function resolveSequence(name, spec, o = {}) {
|
|
2508
|
+
const want = (k) => {
|
|
2509
|
+
const e = spec[k];
|
|
2510
|
+
if (typeof e !== 'string' || !(e in SEQUENCE_REVEALS)) {
|
|
2511
|
+
throw new Error(
|
|
2512
|
+
`@displayxr/inline3d/splat: setSource sequence ${k} '${e}' — expected one of ${Object.keys(SEQUENCE_REVEALS).join(', ')} ` +
|
|
2513
|
+
"(a reveal that draws nothing at its start; inflate's start is a flat photo — use transition 'flip').",
|
|
2514
|
+
);
|
|
2515
|
+
}
|
|
2516
|
+
return e;
|
|
2517
|
+
};
|
|
2518
|
+
const outEffect = want('out');
|
|
2519
|
+
const inEffect = want('in');
|
|
2520
|
+
for (const k of ['outgoingFx', 'incomingFx']) {
|
|
2521
|
+
if (o[k] !== undefined && (o[k] === null || typeof o[k] !== 'object')) throw new TypeError(`@displayxr/inline3d/splat: setSource ${k} must be an object of effect options.`);
|
|
2522
|
+
}
|
|
2523
|
+
const beat = o.beat ?? spec.beat ?? SEQUENCE_DEFAULTS.beat;
|
|
2524
|
+
if (!(typeof beat === 'number' && beat >= 0 && beat <= 0.9)) throw new RangeError(`@displayxr/inline3d/splat: setSource beat must be a number in [0, 0.9], got ${o.beat ?? spec.beat}.`);
|
|
2525
|
+
const side = (which, effect, extra) => {
|
|
2526
|
+
const shared = {};
|
|
2527
|
+
// sweep / fade take only the origin of the shared options
|
|
2528
|
+
const keys = EFFECTS[effect].particle ? PARTICLE_TRANSITION_OPTIONS : ['origin'];
|
|
2529
|
+
for (const k of keys) if (o[k] !== undefined) shared[k] = o[k];
|
|
2530
|
+
const opts = { ...SEQUENCE_REVEALS[effect][which], ...shared, ...(extra || {}) };
|
|
2531
|
+
if (opts.order === 'layers') {
|
|
2532
|
+
throw new Error(`@displayxr/inline3d/splat: setSource sequence: order 'layers' is a reveal-only order (it needs the file index, which a render-time body does not have).`);
|
|
2533
|
+
}
|
|
2534
|
+
const r = resolveEffectOptions(effect, { ...opts, scope: 'tile' }, 'set', { internal: true }); // throws on a bad one
|
|
2535
|
+
if (!emptyAtZero(effect, r)) {
|
|
2536
|
+
throw new Error(`@displayxr/inline3d/splat: setSource sequence ${which}: '${effect}' with these options still draws at its start (vanish 0?) — the swap would pop. Give it vanish > 0.`);
|
|
2537
|
+
}
|
|
2538
|
+
return { effect, opts };
|
|
2539
|
+
};
|
|
2540
|
+
return { name, out: side('out', outEffect, o.outgoingFx), in: side('in', inEffect, o.incomingFx), beat };
|
|
2541
|
+
}
|
|
2542
|
+
|
|
2435
2543
|
/**
|
|
2436
2544
|
* A particle transition's two sides, resolved: { out: { effect, opts }, in: { effect, opts },
|
|
2437
2545
|
* overlap } — the table's defaults, then the page's shared particle options, then its
|
|
@@ -2462,11 +2570,34 @@ export function resolveParticleTransition(name, o = {}) {
|
|
|
2462
2570
|
return { out, in: inc, overlap };
|
|
2463
2571
|
}
|
|
2464
2572
|
|
|
2573
|
+
/** resolveSwap for a sequence: the plan carries `sequence` (resolveSequence) and no outgoing image. */
|
|
2574
|
+
function resolveSequenceSwap(name, spec, o) {
|
|
2575
|
+
if (resolveRevealOption(o.reveal)) throw new Error(`@displayxr/inline3d/splat: setSource reveal plays with transition 'cut' or 'crossfade'; '${name}' is its own reveal.`);
|
|
2576
|
+
if (o.outgoing !== undefined && o.outgoing !== null) {
|
|
2577
|
+
resolveOutgoingOption(o.outgoing); // a bad value is still a bad value
|
|
2578
|
+
throw new Error(`@displayxr/inline3d/splat: setSource outgoing does not apply to '${name}' — one photo at a time, no outgoing image.`);
|
|
2579
|
+
}
|
|
2580
|
+
const durationMs = o.durationMs ?? spec.durationMs ?? SEQUENCE_DEFAULTS.durationMs;
|
|
2581
|
+
if (!Number.isFinite(durationMs) || durationMs < 0) throw new RangeError(`@displayxr/inline3d/splat: setSource durationMs must be ≥ 0, got ${o.durationMs}.`);
|
|
2582
|
+
const easing = o.easing ?? spec.easing ?? SEQUENCE_DEFAULTS.easing;
|
|
2583
|
+
if (typeof easing !== 'function' && !EASINGS[easing]) throw new Error(`@displayxr/inline3d/splat: unknown easing '${easing}'.`);
|
|
2584
|
+
const sequence = resolveSequence(name, spec, o);
|
|
2585
|
+
return { transition: 'sequence', durationMs, easing, reveal: null, outgoing: null, particles: null, sequence };
|
|
2586
|
+
}
|
|
2587
|
+
|
|
2465
2588
|
/** Validate setSource's options into a plan (throws on a page bug, before anything loads). */
|
|
2466
2589
|
export function resolveSwap(o = {}) {
|
|
2467
2590
|
if (o === null || typeof o !== 'object') throw new TypeError('@displayxr/inline3d/splat: setSource options must be an object.');
|
|
2591
|
+
// The general sequence form: transition: { type: 'sequence', out, in, durationMs?, beat?, easing? }
|
|
2592
|
+
// — its own keys read like setSource's (setSource's win when both are given).
|
|
2593
|
+
if (o.transition !== null && typeof o.transition === 'object') {
|
|
2594
|
+
const { type, out: outName, in: inName, ...rest } = o.transition;
|
|
2595
|
+
if (type !== 'sequence') throw new Error(`@displayxr/inline3d/splat: setSource transition object — expected { type: 'sequence', out, in }, got type '${type}'.`);
|
|
2596
|
+
return resolveSequenceSwap('sequence', { out: outName, in: inName, ...SEQUENCE_DEFAULTS }, { ...rest, ...o, transition: 'sequence' });
|
|
2597
|
+
}
|
|
2468
2598
|
const fadeMs = Number.isFinite(o.fadeMs) && o.fadeMs > 0 ? o.fadeMs : 0;
|
|
2469
2599
|
const transition = o.transition ?? (fadeMs > 0 ? 'crossfade' : 'cut');
|
|
2600
|
+
if (SOURCE_TRANSITIONS[transition]?.sequence) return resolveSequenceSwap(transition, SEQUENCE_TRANSITIONS[transition], o);
|
|
2470
2601
|
if (!(transition in SOURCE_TRANSITIONS)) {
|
|
2471
2602
|
throw new Error(`@displayxr/inline3d/splat: setSource transition '${transition}' — expected one of ${Object.keys(SOURCE_TRANSITIONS).join(', ')}.`);
|
|
2472
2603
|
}
|
|
@@ -2561,6 +2692,18 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
|
|
|
2561
2692
|
|
|
2562
2693
|
let handle = null;
|
|
2563
2694
|
let unbindFocusInput = null;
|
|
2695
|
+
|
|
2696
|
+
// ── diagnostics (diag / ?dxrdiag — ./inline3d-splat-diag.js) ──
|
|
2697
|
+
const diagCfg = resolveDiag(opts.diag);
|
|
2698
|
+
if (diagCfg.unknown.length) console.warn(`[inline3d/splat] diag: unknown switch(es) ${diagCfg.unknown.join(', ')} — known: norig, frozen, nowarm, cold, nooverlay.`);
|
|
2699
|
+
const diag = diagCfg.on ? new DiagRecorder({ switches: diagCfg.switches }) : null;
|
|
2700
|
+
/** norig: armed by the first setSource — from then on the declared rig is kept. */
|
|
2701
|
+
let rigLockArmed = false;
|
|
2702
|
+
if (diag) {
|
|
2703
|
+
diag.imageState = () => viewer.diagImageState();
|
|
2704
|
+
diag.observeLongTasks();
|
|
2705
|
+
diag.log(`on — switches [${[...diag.switches].join(', ') || 'none'}]; dump: copy(__dxrDiag.dump())`);
|
|
2706
|
+
}
|
|
2564
2707
|
let removed = false;
|
|
2565
2708
|
/** prepareSource: prepared handle → { loaded, state: 'ready' | 'used' | 'disposed', dispose }. */
|
|
2566
2709
|
const preparedAssets = new WeakMap();
|
|
@@ -2675,6 +2818,8 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
|
|
|
2675
2818
|
fx = null;
|
|
2676
2819
|
unbindFocusInput?.();
|
|
2677
2820
|
viewer.onFocusChange = null;
|
|
2821
|
+
diagLoop?.remove();
|
|
2822
|
+
diag?.dispose();
|
|
2678
2823
|
handle?.remove();
|
|
2679
2824
|
viewer.dispose(); // app.destroy(): every entity — ours and any a page added — goes with it
|
|
2680
2825
|
},
|
|
@@ -2749,7 +2894,14 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
|
|
|
2749
2894
|
const pageFocus = { d: Number.NaN, target: Number.NaN, fired: Number.NaN, source: null };
|
|
2750
2895
|
|
|
2751
2896
|
if (wall && wall.supported) {
|
|
2752
|
-
|
|
2897
|
+
const onFrame = diag
|
|
2898
|
+
? (views, layer) => {
|
|
2899
|
+
const t = performance.now();
|
|
2900
|
+
viewer.onFrame(views, layer);
|
|
2901
|
+
diag.frame(views, t); // after the draw: the overlay state is what this frame showed
|
|
2902
|
+
}
|
|
2903
|
+
: viewer.onFrame;
|
|
2904
|
+
handle = wall.addScene(canvas, onFrame, {
|
|
2753
2905
|
// controls:'page' starts on a camera rig (attach, provisional FOV/convergence until the page
|
|
2754
2906
|
// and the waterfall say otherwise); the display rig's height means nothing there.
|
|
2755
2907
|
...(pageMode
|
|
@@ -2762,6 +2914,26 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
|
|
|
2762
2914
|
} else {
|
|
2763
2915
|
viewer.startMono();
|
|
2764
2916
|
}
|
|
2917
|
+
let diagLoop = null;
|
|
2918
|
+
if (diag) {
|
|
2919
|
+
// Every rig push, logged with its values; under `norig`, every push after the first
|
|
2920
|
+
// setSource is DROPPED (the rig declared before it stays in force).
|
|
2921
|
+
if (handle && typeof handle.setViewRig === 'function') {
|
|
2922
|
+
const push = handle.setViewRig;
|
|
2923
|
+
handle.setViewRig = (rig) => {
|
|
2924
|
+
if (diag.has('norig') && rigLockArmed) {
|
|
2925
|
+
diag.rig(rig, { dropped: true });
|
|
2926
|
+
return false;
|
|
2927
|
+
}
|
|
2928
|
+
diag.rig(rig);
|
|
2929
|
+
return push(rig);
|
|
2930
|
+
};
|
|
2931
|
+
}
|
|
2932
|
+
diagLoop = startDiagLoop(diag, { overlay: !diag.has('nooverlay') && !!handle });
|
|
2933
|
+
// 2D DOM over the woven canvas: out of the weave (a no-op where occlusion is automatic).
|
|
2934
|
+
if (diagLoop?.el) handle?.exclude?.(diagLoop.el);
|
|
2935
|
+
registerDiag(diag);
|
|
2936
|
+
}
|
|
2765
2937
|
// Settle the stub's `firstWoven` (addSplatDeferred) with the core handle's own.
|
|
2766
2938
|
if (typeof out._resolveFirstWoven === 'function') {
|
|
2767
2939
|
out._resolveFirstWoven(handle ? handle.firstWoven : Promise.resolve(Object.freeze({ woven: false, confirmed: false, reason: 'unsupported', ms: 0 })));
|
|
@@ -3709,15 +3881,35 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
|
|
|
3709
3881
|
await first.catch(() => null);
|
|
3710
3882
|
if (!app || removed) throw new Error('@displayxr/inline3d/splat: prepareSource on a removed tile.');
|
|
3711
3883
|
const t0 = performance.now();
|
|
3712
|
-
|
|
3884
|
+
if (diag && diag.phase === 'idle') diag.setPhase('prepare');
|
|
3885
|
+
// A SEQUENCE keeps one splat resident: its prepare only FETCHES the bytes (nothing goes to the
|
|
3886
|
+
// engine — no decode, no GPU upload) and compiles its shaders; the engine load runs at the swap,
|
|
3887
|
+
// once the current asset is gone, in the empty beat. `resident: true` opts back into the full
|
|
3888
|
+
// prepare (decoded + uploaded now, not in the scene): no load at the swap, two assets resident
|
|
3889
|
+
// during the dwell.
|
|
3890
|
+
if (po.resident !== undefined && typeof po.resident !== 'boolean') throw new TypeError('@displayxr/inline3d/splat: prepareSource resident must be a boolean.');
|
|
3891
|
+
const fetchOnly = !!warm?.sequence && po.resident !== true;
|
|
3892
|
+
let loaded = null;
|
|
3893
|
+
let source = null;
|
|
3894
|
+
try {
|
|
3895
|
+
if (fetchOnly) {
|
|
3896
|
+
const got = await fetchSourceBytes(src);
|
|
3897
|
+
if (!got.ok) throw new Error(`@displayxr/inline3d/splat: prepareSource could not fetch ${src} (HTTP ${got.status}).`);
|
|
3898
|
+
source = got.source;
|
|
3899
|
+
}
|
|
3900
|
+
else loaded = await loadOne(pcModule, app, src, { background: true });
|
|
3901
|
+
} finally {
|
|
3902
|
+
if (diag?.phase === 'prepare') diag.setPhase('idle');
|
|
3903
|
+
}
|
|
3713
3904
|
perfSpan('prepareSource', t0);
|
|
3905
|
+
diag?.mark('prepared', { ms: Math.round(performance.now() - t0), fetchOnly });
|
|
3714
3906
|
// The transition the page declared: compile its shader now, in the dwell, not on its first frame.
|
|
3715
3907
|
await prewarmTransition(warm); // null (no transition declared): the overlay only
|
|
3716
|
-
const entry = { loaded, state: 'ready', dispose: null };
|
|
3908
|
+
const entry = { loaded, source, state: 'ready', dispose: null };
|
|
3717
3909
|
const prepared = {
|
|
3718
3910
|
[PREPARED_TAG]: true,
|
|
3719
|
-
/** The asset's own count (every splat of a flat source). */
|
|
3720
|
-
numSplats: loaded.desc.numSplats || loaded.cloud?.sourceTotal || 0,
|
|
3911
|
+
/** The asset's own count (every splat of a flat source); null for a fetch-only (sequence) prepare. */
|
|
3912
|
+
numSplats: loaded ? loaded.desc.numSplats || loaded.cloud?.sourceTotal || 0 : null,
|
|
3721
3913
|
/** 'ready' until setSource uses it ('used') or dispose() drops it ('disposed'). */
|
|
3722
3914
|
get state() {
|
|
3723
3915
|
return entry.state;
|
|
@@ -3726,8 +3918,11 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
|
|
|
3726
3918
|
if (entry.state !== 'ready') return;
|
|
3727
3919
|
entry.state = 'disposed';
|
|
3728
3920
|
livePrepared.delete(entry);
|
|
3729
|
-
|
|
3730
|
-
loaded
|
|
3921
|
+
entry.source = null;
|
|
3922
|
+
if (loaded) {
|
|
3923
|
+
app.assets.remove(loaded.asset);
|
|
3924
|
+
loaded.asset.unload?.();
|
|
3925
|
+
}
|
|
3731
3926
|
},
|
|
3732
3927
|
};
|
|
3733
3928
|
entry.dispose = prepared.dispose;
|
|
@@ -3753,30 +3948,32 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
|
|
|
3753
3948
|
* engine whose internals differ just compiles on the transition's first frame, as before.
|
|
3754
3949
|
*/
|
|
3755
3950
|
async function prewarmTransition(plan) {
|
|
3951
|
+
if (diag?.has('nowarm')) return;
|
|
3756
3952
|
// The chunk variant the transition installs (particles, wavefront), and the overlay's two quads
|
|
3757
3953
|
// (crossfade, wavefront and the particles all composite through them; created on the first
|
|
3758
3954
|
// capture otherwise, and compiled on its draw: 30–180 ms on the first frames of the window).
|
|
3759
|
-
|
|
3760
|
-
|
|
3955
|
+
// [key, code()] per variant: a particle transition's one body, the wavefront's ridge + cull, a
|
|
3956
|
+
// sequence's two (its out body, then its in body — one program each, or one when they match).
|
|
3957
|
+
const orderOf = (side) => (EFFECTS[side.effect].particle ? { order: side.opts.order } : {});
|
|
3958
|
+
const variants = plan?.particles
|
|
3959
|
+
? [[`${plan.particles.in.effect}|${plan.particles.in.opts.order ?? ''}`, () => fx.sharedChunkCode('transition', plan.particles.in.effect, { order: plan.particles.in.opts.order })]]
|
|
3761
3960
|
: plan?.transition === 'wavefront'
|
|
3762
|
-
? 'wavefront'
|
|
3763
|
-
:
|
|
3764
|
-
|
|
3765
|
-
|
|
3766
|
-
|
|
3961
|
+
? [['wavefront', () => fx.sharedChunkCodeFor([['transition', 'wavefront', {}], ['transition-cull', 'wipecull', {}]])]]
|
|
3962
|
+
: plan?.sequence
|
|
3963
|
+
? [plan.sequence.out, plan.sequence.in].map((side) => [`${side.effect}|${side.opts.order ?? ''}`, () => fx.sharedChunkCode('transition', side.effect, orderOf(side))])
|
|
3964
|
+
: [];
|
|
3965
|
+
const wantChunks = fx ? variants.filter(([k], i) => !prewarmed.has(k) && variants.findIndex((v) => v[0] === k) === i) : [];
|
|
3966
|
+
// A sequence draws through no overlay: nothing to warm there.
|
|
3967
|
+
const wantOverlay = !prewarmed.has('overlay') && !plan?.sequence;
|
|
3968
|
+
if (!wantChunks.length && !wantOverlay) return;
|
|
3767
3969
|
await yieldIdle();
|
|
3768
3970
|
if (removed) return;
|
|
3769
3971
|
try {
|
|
3770
3972
|
const pc = pcModule;
|
|
3771
3973
|
const made = [];
|
|
3772
|
-
|
|
3974
|
+
for (const [chunkKey, codeOf] of fx ? wantChunks : []) {
|
|
3773
3975
|
const cams = [viewer.eye, viewer._live?.cam].filter(Boolean);
|
|
3774
|
-
const code =
|
|
3775
|
-
? fx.sharedChunkCode('transition', plan.particles.in.effect, { order: plan.particles.in.opts.order })
|
|
3776
|
-
: fx.sharedChunkCodeFor([
|
|
3777
|
-
['transition', 'wavefront', {}],
|
|
3778
|
-
['transition-cull', 'wipecull', {}],
|
|
3779
|
-
]);
|
|
3976
|
+
const code = codeOf();
|
|
3780
3977
|
let issued = 0;
|
|
3781
3978
|
for (const cam of cams) {
|
|
3782
3979
|
const mi = managerMi(cam, null);
|
|
@@ -3877,8 +4074,17 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
|
|
|
3877
4074
|
}
|
|
3878
4075
|
async function setSourceNow(next, o = {}) {
|
|
3879
4076
|
const plan = resolveSwap(o);
|
|
4077
|
+
if (diag?.has('frozen')) plan.outgoing = 'frozen'; // kill switch: the 1.12.1 frozen outgoing
|
|
3880
4078
|
const { resetPose = false } = o;
|
|
3881
4079
|
const gen = ++sourceGen;
|
|
4080
|
+
if (diag) {
|
|
4081
|
+
rigLockArmed = true;
|
|
4082
|
+
diag.setPhase('swap', {
|
|
4083
|
+
transition: plan.sequence ? `${plan.sequence.name}(${plan.sequence.out.effect}>${plan.sequence.in.effect})` : plan.transition,
|
|
4084
|
+
outgoing: plan.sequence ? 'none (one splat)' : plan.outgoing || 'default',
|
|
4085
|
+
prepared: preparedAssets.has(next),
|
|
4086
|
+
});
|
|
4087
|
+
}
|
|
3882
4088
|
// A prepareSource() result: already fetched, decoded and uploaded — no load on this path.
|
|
3883
4089
|
const prep = preparedAssets.get(next) || null;
|
|
3884
4090
|
if (prep) {
|
|
@@ -3894,10 +4100,14 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
|
|
|
3894
4100
|
await first.catch(() => null); // a failed first asset may be replaced
|
|
3895
4101
|
if (!app || removed) return out;
|
|
3896
4102
|
const pc = pcModule;
|
|
4103
|
+
// A sequence: one splat at a time (playSequence). Without the effects runner (no asset ever
|
|
4104
|
+
// loaded) there is nothing to play out or in: a cut.
|
|
4105
|
+
if (plan.sequence && fx) return playSequence({ next, plan, gen, prep, resetPose, app, pc });
|
|
3897
4106
|
// Not prepared: compile what the transition draws with (its chunk, the overlay) while the asset
|
|
3898
4107
|
// loads, rather than on the window's first frames. Best effort, not awaited.
|
|
3899
|
-
if (!prep && current && plan.transition !== 'cut') prewarmTransition(plan);
|
|
3900
|
-
|
|
4108
|
+
if (!prep && current && plan.transition !== 'cut' && !plan.sequence) prewarmTransition(plan);
|
|
4109
|
+
// (a fetch-only prepare — prepareSource for a sequence — loads here, from its bytes)
|
|
4110
|
+
const loaded = prep ? prep.loaded || (await loadOne(pc, app, prep.source)) : await loadOne(pc, app, next);
|
|
3901
4111
|
if (removed || gen !== sourceGen) {
|
|
3902
4112
|
app.assets.remove(loaded.asset);
|
|
3903
4113
|
loaded.asset.unload?.();
|
|
@@ -3905,11 +4115,28 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
|
|
|
3905
4115
|
}
|
|
3906
4116
|
const prev = current;
|
|
3907
4117
|
pendingSwap?.finish();
|
|
3908
|
-
const transition = prev ? plan.transition : 'cut';
|
|
4118
|
+
const transition = prev && !plan.sequence ? plan.transition : 'cut';
|
|
3909
4119
|
const particle = plan.particles && transition in PARTICLE_TRANSITIONS ? plan.particles : null;
|
|
3910
4120
|
// FRAME_SNAPSHOT: freeze the outgoing frame BEFORE anything of the new asset (or its rig) is
|
|
3911
4121
|
// drawn. The overlay goes up in the same task, so no frame shows neither photo.
|
|
3912
4122
|
const wantsSnapshot = transition === 'crossfade' || transition === 'wavefront' || !!particle;
|
|
4123
|
+
const outgoingMode = plan.outgoing || (particle ? 'live' : defaultOutgoing(viewer.is3D));
|
|
4124
|
+
// PRE-SORT the live outgoing camera on the current asset BEFORE the swap (LiveOutgoing.warm):
|
|
4125
|
+
// its first sort runs while the photo is still on screen and live, so the frozen capture has
|
|
4126
|
+
// nothing to bridge. The transition starts once it has sorted (bounded; a hidden tab or a
|
|
4127
|
+
// stalled sorter falls back to the bridge). `?dxrdiag=cold` skips it (the 1.19.2 path).
|
|
4128
|
+
if (wantsSnapshot && outgoingMode === 'live' && viewer.canLiveOutgoing && !diag?.has('cold') && viewer.warmLiveOutgoing(prev.entity)) {
|
|
4129
|
+
const tw = performance.now();
|
|
4130
|
+
const ok = await viewer.liveWarmed();
|
|
4131
|
+
perfSpan('setSource:presort(async)', tw);
|
|
4132
|
+
diag?.mark('presorted', { ok, ms: Math.round(performance.now() - tw) });
|
|
4133
|
+
if (removed || gen !== sourceGen) {
|
|
4134
|
+
// A newer setSource owns the pre-sort now (it warms the same asset, or drops it).
|
|
4135
|
+
app.assets.remove(loaded.asset);
|
|
4136
|
+
loaded.asset.unload?.();
|
|
4137
|
+
return out;
|
|
4138
|
+
}
|
|
4139
|
+
}
|
|
3913
4140
|
const snapped = wantsSnapshot ? await viewer.captureFrame() : false;
|
|
3914
4141
|
if (removed || gen !== sourceGen) {
|
|
3915
4142
|
app.assets.remove(loaded.asset);
|
|
@@ -3922,16 +4149,20 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
|
|
|
3922
4149
|
// its camera has drawn a sorted frame. Its lens frame is read BEFORE adopt() switches the rig.
|
|
3923
4150
|
// A particle transition's outgoing photo MOVES, so it is live in 2D too (where the engine has
|
|
3924
4151
|
// the RenderView path); 'frozen' there = its snapshot fades out while the new one plays in.
|
|
3925
|
-
const outgoingMode = plan.outgoing || (particle ? 'live' : defaultOutgoing(viewer.is3D));
|
|
3926
4152
|
const live =
|
|
3927
4153
|
snapped && outgoingMode === 'live' && viewer.canLiveOutgoing ? viewer.startLiveOutgoing(prev.entity, viewer.lensFrame()) : null;
|
|
4154
|
+
if (!live) viewer.cancelLiveWarm(); // a pre-sort that is not becoming a live window
|
|
4155
|
+
diag?.mark('outgoing', { mode: live ? (live.warmed ? 'live (pre-sorted)' : 'live (cold: frozen bridge until sorted)') : snapped ? 'frozen' : 'none' });
|
|
3928
4156
|
/** Each frame of the window: once the live camera is ready, the overlay samples it. */
|
|
3929
4157
|
let liveShown = false;
|
|
3930
4158
|
const pumpLive = () => {
|
|
3931
4159
|
if (!live || liveShown || !live.active || !live.ready) return;
|
|
3932
4160
|
viewer.setSnapshotSource(live.texture);
|
|
3933
4161
|
liveShown = true;
|
|
4162
|
+
diag?.mark('live-shown');
|
|
3934
4163
|
};
|
|
4164
|
+
// Pre-sorted: the live target is already sorted, so the overlay samples it from the swap on.
|
|
4165
|
+
if (live?.warmed) pumpLive();
|
|
3935
4166
|
|
|
3936
4167
|
const release = (p) => {
|
|
3937
4168
|
if (!p) return;
|
|
@@ -3960,6 +4191,7 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
|
|
|
3960
4191
|
const kept = applyLoaded(loaded);
|
|
3961
4192
|
current = { asset: loaded.asset, entity, res: loaded.res, kind: loaded.desc.kind, ...kept };
|
|
3962
4193
|
if (resetPose && !pageMode) viewer.resetPose(); // the page owns the pose on controls:'page'
|
|
4194
|
+
diag?.setPhase('window');
|
|
3963
4195
|
};
|
|
3964
4196
|
|
|
3965
4197
|
const entity = viewer.addSplatAsset(loaded.asset);
|
|
@@ -3971,6 +4203,7 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
|
|
|
3971
4203
|
if (finished) return;
|
|
3972
4204
|
finished = true;
|
|
3973
4205
|
for (const f of finishers) f();
|
|
4206
|
+
if (gen === sourceGen) diag?.setPhase('settle');
|
|
3974
4207
|
if (pendingSwap?.finish === finish) pendingSwap = null;
|
|
3975
4208
|
settle();
|
|
3976
4209
|
};
|
|
@@ -4125,6 +4358,240 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
|
|
|
4125
4358
|
return out;
|
|
4126
4359
|
}
|
|
4127
4360
|
|
|
4361
|
+
/**
|
|
4362
|
+
* The BYTES of a source, fetched without touching the engine (a sequence's preload: CPU memory
|
|
4363
|
+
* only — no decode, no GPU upload). Bytes / a Blob are returned as they are. A URL the engine
|
|
4364
|
+
* must resolve itself (a Streamed SOG, an unbundled SOG's meta.json) is returned as the URL, and
|
|
4365
|
+
* so is any URL whose fetch THROWS here (CORS, a scheme fetch does not take…): the engine's own
|
|
4366
|
+
* load then runs at the swap. Resolves { source, ok }: ok = false only when the server answered
|
|
4367
|
+
* and it was not the file (an HTTP error) — the one case known before the current photo goes.
|
|
4368
|
+
*/
|
|
4369
|
+
async function fetchSourceBytes(src, signal) {
|
|
4370
|
+
if (typeof src !== 'string') return { source: src, ok: true };
|
|
4371
|
+
const fmt = engineFormatFor(src, null, opts.fileName, opts.fileType);
|
|
4372
|
+
if (!fmt || fmt.streamed || fmt.ext === 'json' || typeof fetch !== 'function') return { source: src, ok: true };
|
|
4373
|
+
try {
|
|
4374
|
+
const r = await fetch(src, signal ? { signal } : undefined);
|
|
4375
|
+
if (!r.ok) return { source: src, ok: false, status: r.status };
|
|
4376
|
+
return { source: new Uint8Array(await r.arrayBuffer()), ok: true };
|
|
4377
|
+
} catch (err) {
|
|
4378
|
+
if (signal?.aborted) return { source: null, ok: false, aborted: true };
|
|
4379
|
+
return { source: src, ok: true, fetchError: err };
|
|
4380
|
+
}
|
|
4381
|
+
}
|
|
4382
|
+
|
|
4383
|
+
/** The sequence on screen: the asset its effect is on and how much of it shows (a newer sequence starts from there). */
|
|
4384
|
+
let seqShown = null;
|
|
4385
|
+
|
|
4386
|
+
/**
|
|
4387
|
+
* setSource's SEQUENCE transitions ('reassemble', { type: 'sequence', out, in }): ONE splat at
|
|
4388
|
+
* a time.
|
|
4389
|
+
* out — the current photo plays `out` backwards on the eye camera (a tile-scope body at
|
|
4390
|
+
* render time) until nothing of it is drawn. Meanwhile the next file's BYTES are
|
|
4391
|
+
* fetched (a URL; a prepareSource() result is already in hand).
|
|
4392
|
+
* swap — the current asset is released and DESTROYED (entity + GPU resource) before the next
|
|
4393
|
+
* one is given to the engine; then it is decoded, uploaded, placed and adopted (rig
|
|
4394
|
+
* waterfall, rig declared) in one task with its effect at amount 0, so its first frame
|
|
4395
|
+
* draws nothing.
|
|
4396
|
+
* in — after the empty beat (and at least 3 frames, for its work buffer and sort and the
|
|
4397
|
+
* rig to land), its effect's frame is re-taken under the new rig and it plays `in`
|
|
4398
|
+
* forwards. The end removes the body: the exact baseline.
|
|
4399
|
+
* No second camera, layer, target, overlay or capture. Latest wins: a newer sequence takes over
|
|
4400
|
+
* from where this one stands (its out phase starts at the amount shown); a newer other call
|
|
4401
|
+
* ends this one when its asset has loaded (or at once, once this one's photo is gone).
|
|
4402
|
+
*/
|
|
4403
|
+
async function playSequence({ next, plan, gen, prep, resetPose, app, pc }) {
|
|
4404
|
+
const seq = plan.sequence;
|
|
4405
|
+
const ease = typeof plan.easing === 'function' ? plan.easing : EASINGS[plan.easing] || EASINGS.linear;
|
|
4406
|
+
const spans = sequenceSpans(seq.beat);
|
|
4407
|
+
const outMs = plan.durationMs * spans.out;
|
|
4408
|
+
const beatMs = plan.durationMs * spans.beat;
|
|
4409
|
+
const inMs = plan.durationMs * spans.in;
|
|
4410
|
+
let finished = false;
|
|
4411
|
+
const finishers = [];
|
|
4412
|
+
/** Resolvers of the waits in flight: ending this call resolves them at once, not on a later frame. */
|
|
4413
|
+
const wakers = new Set();
|
|
4414
|
+
const finish = () => {
|
|
4415
|
+
if (finished) return;
|
|
4416
|
+
finished = true;
|
|
4417
|
+
for (const f of finishers) f();
|
|
4418
|
+
for (const w of [...wakers]) w();
|
|
4419
|
+
wakers.clear();
|
|
4420
|
+
viewer._transitionState = null;
|
|
4421
|
+
if (gen === sourceGen) diag?.setPhase('settle');
|
|
4422
|
+
if (pendingSwap?.finish === finish) pendingSwap = null;
|
|
4423
|
+
};
|
|
4424
|
+
const ac = typeof AbortController === 'function' ? new AbortController() : null;
|
|
4425
|
+
finishers.push(() => ac?.abort());
|
|
4426
|
+
let shown = null; // this call's seqShown
|
|
4427
|
+
finishers.push(() => {
|
|
4428
|
+
if (seqShown && seqShown === shown) seqShown = null;
|
|
4429
|
+
});
|
|
4430
|
+
const dropLoaded = (l) => {
|
|
4431
|
+
if (!l) return;
|
|
4432
|
+
app.assets.remove(l.asset);
|
|
4433
|
+
l.asset.unload?.();
|
|
4434
|
+
};
|
|
4435
|
+
/** Superseded (a newer setSource) or removed: this call stops at its next step. */
|
|
4436
|
+
const stale = () => finished || removed || gen !== sourceGen;
|
|
4437
|
+
/** Run `step(k, s)` over `ms` of viewer ticks (k 0 → 1, s = seconds); false if ended early. */
|
|
4438
|
+
const span = (ms, step) =>
|
|
4439
|
+
new Promise((resolve) => {
|
|
4440
|
+
let t0 = null;
|
|
4441
|
+
const wake = () => resolve(false);
|
|
4442
|
+
wakers.add(wake);
|
|
4443
|
+
viewer._hooks.push((t) => {
|
|
4444
|
+
if (finished || removed) return (resolve(false), false);
|
|
4445
|
+
if (!wakers.has(wake)) return false;
|
|
4446
|
+
if (t0 === null) t0 = t;
|
|
4447
|
+
const k = ms > 0 ? Math.min(1, Math.max(0, (t - t0) / ms)) : 1;
|
|
4448
|
+
step(k, (t - t0) / 1000);
|
|
4449
|
+
if (k >= 1) return (wakers.delete(wake), resolve(true), false);
|
|
4450
|
+
return true;
|
|
4451
|
+
});
|
|
4452
|
+
});
|
|
4453
|
+
// The next asset's bytes, fetched during the out phase. Nothing of it goes to the engine yet.
|
|
4454
|
+
const sourceP = prep ? Promise.resolve({ source: prep.loaded ? null : prep.source, ok: true }) : fetchSourceBytes(next, ac?.signal);
|
|
4455
|
+
// Both bodies compiled before the clock starts. The photo stays on screen meanwhile, live — and
|
|
4456
|
+
// a sequence already playing on it keeps playing: it is taken over only once this one can run.
|
|
4457
|
+
await prewarmTransition(plan);
|
|
4458
|
+
if (stale()) {
|
|
4459
|
+
if (prep) dropLoaded(prep.loaded);
|
|
4460
|
+
finish();
|
|
4461
|
+
return out;
|
|
4462
|
+
}
|
|
4463
|
+
// a sequence already on this photo: take over from how much of it shows, in this same task
|
|
4464
|
+
const startAmount = seqShown && current && seqShown.entity === current.entity ? seqShown.amount : 1;
|
|
4465
|
+
pendingSwap?.finish(); // latest wins: whatever was in flight ends now (at its end state)
|
|
4466
|
+
pendingSwap = { finish };
|
|
4467
|
+
viewer._transitionPath = 'sequence'; // diagnostics
|
|
4468
|
+
diag?.mark('sequence', { name: seq.name, out: seq.out.effect, in: seq.in.effect, beat: seq.beat, from: +startAmount.toFixed(3), prepared: prep ? (prep.loaded ? 'resident' : 'fetched') : 'no' });
|
|
4469
|
+
|
|
4470
|
+
// ── out ──
|
|
4471
|
+
const prev = current;
|
|
4472
|
+
diag?.setPhase('window');
|
|
4473
|
+
if (prev) {
|
|
4474
|
+
const fxOut = fx.driveTile('transition', seq.out.effect, seq.out.opts); // its frame: the current rig's
|
|
4475
|
+
finishers.push(() => fxOut.remove());
|
|
4476
|
+
fxOut.set(startAmount, 0);
|
|
4477
|
+
shown = seqShown = { entity: prev.entity, amount: startAmount };
|
|
4478
|
+
const full = await span(outMs * startAmount, (k, s) => {
|
|
4479
|
+
const a = startAmount * (1 - ease(k));
|
|
4480
|
+
fxOut.set(a, s);
|
|
4481
|
+
shown.amount = a;
|
|
4482
|
+
viewer._transitionState = { phase: 'out', raw: k }; // diagnostics
|
|
4483
|
+
});
|
|
4484
|
+
if (!full) {
|
|
4485
|
+
// taken over (a newer call finished this one) or removed
|
|
4486
|
+
if (prep) dropLoaded(prep.loaded);
|
|
4487
|
+
return out;
|
|
4488
|
+
}
|
|
4489
|
+
diag?.mark('out-done');
|
|
4490
|
+
// Nothing of the current photo is drawn now. Its file must have arrived before it is let go:
|
|
4491
|
+
// if the next one cannot be fetched, the current one comes back instead.
|
|
4492
|
+
const got = await sourceP;
|
|
4493
|
+
if (!got.ok && !got.aborted && !stale()) {
|
|
4494
|
+
diag?.mark('fetch-failed', { status: got.status ?? null });
|
|
4495
|
+
viewer._transitionState = { phase: 'back', raw: 0 };
|
|
4496
|
+
await span(inMs, (k, s) => {
|
|
4497
|
+
const a = ease(k);
|
|
4498
|
+
fxOut.set(a, s);
|
|
4499
|
+
shown.amount = a;
|
|
4500
|
+
});
|
|
4501
|
+
finish();
|
|
4502
|
+
throw new Error(`@displayxr/inline3d/splat: setSource could not fetch ${typeof next === 'string' ? next : 'the source'} (HTTP ${got.status}); the current photo stays.`);
|
|
4503
|
+
}
|
|
4504
|
+
// COMMITTED: release it, and wait until it is gone (entity destroyed, resource unloaded)
|
|
4505
|
+
// before the next one is made resident. A newer call from here on finds nothing on screen.
|
|
4506
|
+
current = null;
|
|
4507
|
+
out.mesh = null;
|
|
4508
|
+
shown = null;
|
|
4509
|
+
seqShown = null;
|
|
4510
|
+
await new Promise((resolve) => {
|
|
4511
|
+
prev.entity.enabled = false;
|
|
4512
|
+
fx?.dropEntity(prev.entity);
|
|
4513
|
+
let frames = 0;
|
|
4514
|
+
// the engine's gsplat world keeps the placement until its next rebuild: destroy a few frames later
|
|
4515
|
+
viewer._hooks.push(() => {
|
|
4516
|
+
if (++frames < 4 && !removed) return true;
|
|
4517
|
+
prev.entity.destroy?.();
|
|
4518
|
+
app.assets.remove(prev.asset);
|
|
4519
|
+
prev.asset.unload?.();
|
|
4520
|
+
resolve();
|
|
4521
|
+
return false;
|
|
4522
|
+
});
|
|
4523
|
+
});
|
|
4524
|
+
diag?.mark('released');
|
|
4525
|
+
}
|
|
4526
|
+
const outEndAt = now();
|
|
4527
|
+
if (stale()) {
|
|
4528
|
+
if (prep) dropLoaded(prep.loaded);
|
|
4529
|
+
finish();
|
|
4530
|
+
return out;
|
|
4531
|
+
}
|
|
4532
|
+
|
|
4533
|
+
// ── swap ──
|
|
4534
|
+
let loaded;
|
|
4535
|
+
try {
|
|
4536
|
+
const got = await sourceP;
|
|
4537
|
+
viewer._transitionState = { phase: 'load', raw: 0 };
|
|
4538
|
+
const loadP = prep?.loaded ? Promise.resolve(prep.loaded) : loadOne(pc, app, got.source ?? next);
|
|
4539
|
+
// a newer call ends this one at once; the asset, when it lands, is dropped unseen
|
|
4540
|
+
const r = await Promise.race([loadP.then((l) => ({ l })), new Promise((resolve) => wakers.add(() => resolve(null)))]);
|
|
4541
|
+
if (!r) {
|
|
4542
|
+
loadP.then(dropLoaded, () => {});
|
|
4543
|
+
return out;
|
|
4544
|
+
}
|
|
4545
|
+
loaded = r.l;
|
|
4546
|
+
} catch (err) {
|
|
4547
|
+
finish();
|
|
4548
|
+
if (stale()) return out;
|
|
4549
|
+
throw err; // the tile is empty: the current photo was already gone
|
|
4550
|
+
}
|
|
4551
|
+
if (stale()) {
|
|
4552
|
+
dropLoaded(loaded);
|
|
4553
|
+
finish();
|
|
4554
|
+
return out;
|
|
4555
|
+
}
|
|
4556
|
+
diag?.mark('loaded');
|
|
4557
|
+
const entity = viewer.addSplatAsset(loaded.asset);
|
|
4558
|
+
out.mesh = { numSplats: loaded.desc.numSplats || loaded.cloud?.sourceTotal || 0, entity, asset: loaded.asset, resource: loaded.res };
|
|
4559
|
+
const kept = applyLoaded(loaded); // the rig waterfall; the rig is declared here, before any frame of it
|
|
4560
|
+
current = { asset: loaded.asset, entity, res: loaded.res, kind: loaded.desc.kind, ...kept };
|
|
4561
|
+
if (resetPose && !pageMode) viewer.resetPose();
|
|
4562
|
+
// same task: the in body at amount 0 (tile material values), so its first frame draws nothing
|
|
4563
|
+
const fxIn = fx.driveTile('transition', seq.in.effect, seq.in.opts);
|
|
4564
|
+
finishers.push(() => fxIn.remove());
|
|
4565
|
+
fxIn.set(0, 0);
|
|
4566
|
+
shown = seqShown = { entity, amount: 0 };
|
|
4567
|
+
diag?.mark('adopted');
|
|
4568
|
+
|
|
4569
|
+
// ── the empty beat: at least `beat` since the old photo went, and 3 frames of the new one ──
|
|
4570
|
+
await new Promise((resolve) => {
|
|
4571
|
+
let ticks = 0;
|
|
4572
|
+
wakers.add(resolve);
|
|
4573
|
+
viewer._hooks.push((t) => {
|
|
4574
|
+
if (finished || removed) return (resolve(), false);
|
|
4575
|
+
viewer._transitionState = { phase: 'beat', raw: 0 };
|
|
4576
|
+
if (++ticks >= 3 && t - outEndAt >= beatMs) return (resolve(), false);
|
|
4577
|
+
return true;
|
|
4578
|
+
});
|
|
4579
|
+
});
|
|
4580
|
+
if (finished || removed) return out;
|
|
4581
|
+
|
|
4582
|
+
// ── in ──
|
|
4583
|
+
fxIn.restart(); // its frame: the new photo under its own rig, now declared and drawn
|
|
4584
|
+
diag?.mark('in-start');
|
|
4585
|
+
const full = await span(inMs, (k, s) => {
|
|
4586
|
+
const a = ease(k);
|
|
4587
|
+
fxIn.set(a, s);
|
|
4588
|
+
shown.amount = a;
|
|
4589
|
+
viewer._transitionState = { phase: 'in', raw: k }; // diagnostics
|
|
4590
|
+
});
|
|
4591
|
+
if (full) finish(); // removes the body: the plain render, exactly
|
|
4592
|
+
return out;
|
|
4593
|
+
}
|
|
4594
|
+
|
|
4128
4595
|
/** The mesh instance of the gsplat manager that `cam` draws `layer` with (any layer: null). */
|
|
4129
4596
|
const managerMi = (cam, layer) => {
|
|
4130
4597
|
const cd = viewer.app?.renderer?.gsplatDirector?.camerasMap?.get?.(cam?.camera?.camera);
|