@displayxr/inline3d 1.20.0 → 1.21.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +36 -1
- package/js/inline3d-splat-effects.js +101 -0
- package/js/inline3d-splat-playcanvas.js +375 -26
- package/package.json +1 -1
- package/splat.d.ts +44 -8
package/CHANGELOG.md
CHANGED
|
@@ -5,7 +5,42 @@ entry points (`.`, `./three`) are frozen for 1.x, while the **scene subpaths** (
|
|
|
5
5
|
`./splat`, `./model`) are a preview tier whose options may change in any release. Entries below say
|
|
6
6
|
which tier they touch, because that is what tells you whether an upgrade can move your pixels.
|
|
7
7
|
|
|
8
|
-
##
|
|
8
|
+
## 1.21.1 — 2026-09-24
|
|
9
|
+
|
|
10
|
+
Touches the **preview tier** (`./splat`, `engine: 'playcanvas'` only). No API change; nothing changes at rest.
|
|
11
|
+
|
|
12
|
+
- **A gated `reveal` no longer shows its start state frozen.** The splat is hidden until the reveal's clock
|
|
13
|
+
starts, so its first visible frame is the first frame of motion; previously the start state (e.g.
|
|
14
|
+
`assemble`'s scatter) sat still for up to the rest of the `firstWoven` hold, then jumped. On a cached
|
|
15
|
+
asset the tile is empty for the rest of the hold instead.
|
|
16
|
+
|
|
17
|
+
## 1.21.0 — 2026-09-24
|
|
18
|
+
|
|
19
|
+
Touches the **preview tier** (`./splat`, `engine: 'playcanvas'` only). Minor: a new `setSource`
|
|
20
|
+
option. No pixel change for any existing transition, and the default (`cut`) is unchanged.
|
|
21
|
+
|
|
22
|
+
### Added
|
|
23
|
+
|
|
24
|
+
- **Sequence transitions: one splat at a time** (#36). `setSource(next, { transition: 'reassemble' })`
|
|
25
|
+
disperses the current photo (`assemble` played backwards), releases it, loads the next one and
|
|
26
|
+
assembles it: 3000 ms, 45 % out, a 10 % empty beat, 45 % in. The general form
|
|
27
|
+
`transition: { type: 'sequence', out, in, durationMs?, beat?, easing? }` runs any two of
|
|
28
|
+
`assemble`, `dissolve-in`, `converge`, `shimmer`, `sweep`, `fade` (each draws nothing at its start,
|
|
29
|
+
which is what makes the swap invisible; `inflate` and `dissolve` are refused).
|
|
30
|
+
- No second camera, live-outgoing layer, overlay target or frozen capture: the eye camera renders
|
|
31
|
+
every frame with one asset in the scene, so the transition machinery cannot hold an image still
|
|
32
|
+
under a moving head. The fallback if a panel still pauses on the other transitions.
|
|
33
|
+
- One splat resident: the next file is only FETCHED during the out phase, and reaches the engine
|
|
34
|
+
after the old asset is destroyed and unloaded. `prepareSource(src, { transition: 'reassemble' })`
|
|
35
|
+
therefore only fetches and compiles (`numSplats` is `null`); `resident: true` opts back into the
|
|
36
|
+
full prepare. Measured peak: 1 asset, 1,179,648 gaussians, 58.8 MB of GPU textures, the still
|
|
37
|
+
photo's (a live `crossfade`: 2 assets, 131.6 MB).
|
|
38
|
+
- Latest wins: a newer sequence takes over from the amount on screen; any other call ends it as it
|
|
39
|
+
ends any transition. An HTTP error on the next file brings the current photo back and rejects.
|
|
40
|
+
- Both bodies are pre-warmed (by `prepareSource`, or before the out clock starts); `?dxrdiag`
|
|
41
|
+
records the sequence with its steps. See `docs/splat-effects.md` § Sequence transitions.
|
|
42
|
+
|
|
43
|
+
## 1.20.0 — 2026-09-24
|
|
9
44
|
|
|
10
45
|
Touches the **preview tier** (`./splat`, `engine: 'playcanvas'` only). No pixel change at rest; the
|
|
11
46
|
first frames of a live `crossfade` / `wavefront` / particle transition change (see Fixed).
|
|
@@ -1164,6 +1164,58 @@ export function particleSpan(t, overlap, side) {
|
|
|
1164
1164
|
/** Options a page may pass through setSource to both sides of a particle transition. */
|
|
1165
1165
|
export const PARTICLE_TRANSITION_OPTIONS = Object.freeze(['order', 'stagger', 'jitter', 'maxDisparity', 'dotSize', 'noiseScale', 'layerSize', 'origin']);
|
|
1166
1166
|
|
|
1167
|
+
// ── setSource's SEQUENCE transitions ('reassemble', { type: 'sequence', out, in }) ────────────
|
|
1168
|
+
//
|
|
1169
|
+
// One photo at a time: the current photo plays a reveal BACKWARDS until nothing of it is drawn,
|
|
1170
|
+
// it is released, the next one is loaded and placed hidden, and it plays a reveal FORWARDS. No
|
|
1171
|
+
// second camera, no live-outgoing layer, no overlay target, no frozen capture: the eye camera
|
|
1172
|
+
// renders every frame, with one asset in the scene.
|
|
1173
|
+
//
|
|
1174
|
+
// A reveal can take part only if it draws NOTHING at amount 0 — otherwise the swap would pop from
|
|
1175
|
+
// a visible end state to an empty start (inflate's end state is a flat photo: that swap is
|
|
1176
|
+
// 'flip'). `dissolve` is left out for the reason it is not a setSource transition: its sway has a
|
|
1177
|
+
// depth component (docs/splat-effects.md §Comfort).
|
|
1178
|
+
|
|
1179
|
+
/**
|
|
1180
|
+
* The reveals a sequence can run, with the option overrides each side gets by default. The
|
|
1181
|
+
* particle ones reuse the tuned sides of the particle transitions (a sparse `density`, and a
|
|
1182
|
+
* `vanish` so the swarm thins out to nothing and gathers out of nothing).
|
|
1183
|
+
*/
|
|
1184
|
+
export const SEQUENCE_REVEALS = Object.freeze({
|
|
1185
|
+
assemble: { out: PARTICLE_TRANSITIONS.swarm.out.opts, in: PARTICLE_TRANSITIONS.swarm.in.opts },
|
|
1186
|
+
'dissolve-in': { out: PARTICLE_TRANSITIONS.dust.out.opts, in: PARTICLE_TRANSITIONS.dust.in.opts },
|
|
1187
|
+
converge: { out: PARTICLE_TRANSITIONS.burst.out.opts, in: PARTICLE_TRANSITIONS.burst.in.opts },
|
|
1188
|
+
shimmer: { out: PARTICLE_TRANSITIONS['shimmer-cross'].out.opts, in: PARTICLE_TRANSITIONS['shimmer-cross'].in.opts },
|
|
1189
|
+
sweep: { out: {}, in: {} },
|
|
1190
|
+
fade: { out: {}, in: {} },
|
|
1191
|
+
});
|
|
1192
|
+
|
|
1193
|
+
/** Named sequences: `transition: 'reassemble'` = assemble backwards, then forwards. */
|
|
1194
|
+
export const SEQUENCE_TRANSITIONS = Object.freeze({
|
|
1195
|
+
reassemble: { out: 'assemble', in: 'assemble', durationMs: 3000, easing: 'linear', beat: 0.1 },
|
|
1196
|
+
});
|
|
1197
|
+
|
|
1198
|
+
/**
|
|
1199
|
+
* Does `effect` with resolved options `o` draw nothing at amount 0? sweep's front starts one band
|
|
1200
|
+
* before its origin and fade's coverage is 0; a particle reveal needs `hiddenAtZero` (converge,
|
|
1201
|
+
* shimmer: not yet launched; assemble, dissolve-in: `vanish` > 0).
|
|
1202
|
+
*/
|
|
1203
|
+
export function emptyAtZero(effect, o) {
|
|
1204
|
+
if (effect === 'sweep' || effect === 'fade') return true;
|
|
1205
|
+
const def = EFFECTS[effect];
|
|
1206
|
+
return !!(def?.particle && def.hiddenAtZero?.(o));
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
/**
|
|
1210
|
+
* The three spans of a sequence over its clock, as fractions: out over [0, a], the empty beat
|
|
1211
|
+
* over [a, 1 − a], in over [1 − a, 1], with a = (1 − beat) / 2.
|
|
1212
|
+
*/
|
|
1213
|
+
export function sequenceSpans(beat) {
|
|
1214
|
+
const b = Math.min(0.9, Math.max(0, beat));
|
|
1215
|
+
const a = (1 - b) / 2;
|
|
1216
|
+
return { out: a, beat: b, in: a };
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1167
1219
|
|
|
1168
1220
|
/**
|
|
1169
1221
|
* The wavefront's per-column commit, lt ∈ [0, 1], for eased progress `t` at normalised u — the
|
|
@@ -1522,6 +1574,55 @@ export class SplatEffects {
|
|
|
1522
1574
|
};
|
|
1523
1575
|
}
|
|
1524
1576
|
|
|
1577
|
+
/**
|
|
1578
|
+
* A TILE-scope effect driven by the adapter's clock (setSource's sequence transitions: one photo
|
|
1579
|
+
* in the scene, so the tile material carries its values and every manager of the tile reads
|
|
1580
|
+
* them — a fresh one included, so a just-placed photo draws its hidden start state on its very
|
|
1581
|
+
* first frame). Render time: no work-buffer rewrite, no re-sort. Named `name` (the chunk's
|
|
1582
|
+
* prefix), so a sequence installs the same program as a particle transition of the same effect
|
|
1583
|
+
* and order. Hidden from effects() and from a page's stopEffect(). Returns
|
|
1584
|
+
* { set(amount, timeS), restart(), remove(), amount }.
|
|
1585
|
+
*/
|
|
1586
|
+
driveTile(name, effect, opts) {
|
|
1587
|
+
const o = resolveEffectOptions(effect, { ...opts, scope: 'tile', direction: 'in', progress: 0 }, 'set', { internal: true });
|
|
1588
|
+
const inst = this._makeInstance(name, o, 'set');
|
|
1589
|
+
inst.def = EFFECTS[effect];
|
|
1590
|
+
inst.hidden = true;
|
|
1591
|
+
inst.timeS = 0;
|
|
1592
|
+
this._replace('tile', name, inst);
|
|
1593
|
+
this._setupInstance(inst);
|
|
1594
|
+
this._apply('tile', inst, this.ctx.now());
|
|
1595
|
+
this._install('tile');
|
|
1596
|
+
const alive = () => !this._disposed && this.scopes.get('tile')?.get(name) === inst;
|
|
1597
|
+
return {
|
|
1598
|
+
set: (amount, timeS = inst.timeS) => {
|
|
1599
|
+
if (!alive()) return false;
|
|
1600
|
+
inst.opts.progress = Math.min(1, Math.max(0, amount));
|
|
1601
|
+
inst.timeS = timeS;
|
|
1602
|
+
this._apply('tile', inst, this.ctx.now());
|
|
1603
|
+
return true;
|
|
1604
|
+
},
|
|
1605
|
+
restart: () => {
|
|
1606
|
+
if (!alive()) return;
|
|
1607
|
+
inst.state = {};
|
|
1608
|
+
this._setupInstance(inst);
|
|
1609
|
+
this._apply('tile', inst, this.ctx.now());
|
|
1610
|
+
},
|
|
1611
|
+
remove: () => {
|
|
1612
|
+
if (!alive()) return;
|
|
1613
|
+
this.scopes.get('tile').delete(name);
|
|
1614
|
+
inst.resolve({ finished: true });
|
|
1615
|
+
this._install('tile');
|
|
1616
|
+
},
|
|
1617
|
+
get amount() {
|
|
1618
|
+
return inst.opts.progress;
|
|
1619
|
+
},
|
|
1620
|
+
get alive() {
|
|
1621
|
+
return alive();
|
|
1622
|
+
},
|
|
1623
|
+
};
|
|
1624
|
+
}
|
|
1625
|
+
|
|
1525
1626
|
/**
|
|
1526
1627
|
* ONE tile-scope body for the two photos of a setSource particle transition, with each photo's
|
|
1527
1628
|
* values on its OWN mesh instance (the engine draws each photo through its own gsplat manager and
|
|
@@ -61,6 +61,10 @@ 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';
|
|
@@ -2486,8 +2490,56 @@ export const SOURCE_TRANSITIONS = Object.freeze({
|
|
|
2486
2490
|
wavefront: { durationMs: 2000, easing: 'easeInOutSine', band: 0.18, ridge: 0.03, ridgeMaxDisparity: 0.004 },
|
|
2487
2491
|
// the particle transitions (./inline3d-splat-effects.js PARTICLE_TRANSITIONS)
|
|
2488
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 }])),
|
|
2489
2496
|
});
|
|
2490
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
|
+
|
|
2491
2543
|
/**
|
|
2492
2544
|
* A particle transition's two sides, resolved: { out: { effect, opts }, in: { effect, opts },
|
|
2493
2545
|
* overlap } — the table's defaults, then the page's shared particle options, then its
|
|
@@ -2518,11 +2570,34 @@ export function resolveParticleTransition(name, o = {}) {
|
|
|
2518
2570
|
return { out, in: inc, overlap };
|
|
2519
2571
|
}
|
|
2520
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
|
+
|
|
2521
2588
|
/** Validate setSource's options into a plan (throws on a page bug, before anything loads). */
|
|
2522
2589
|
export function resolveSwap(o = {}) {
|
|
2523
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
|
+
}
|
|
2524
2598
|
const fadeMs = Number.isFinite(o.fadeMs) && o.fadeMs > 0 ? o.fadeMs : 0;
|
|
2525
2599
|
const transition = o.transition ?? (fadeMs > 0 ? 'crossfade' : 'cut');
|
|
2600
|
+
if (SOURCE_TRANSITIONS[transition]?.sequence) return resolveSequenceSwap(transition, SEQUENCE_TRANSITIONS[transition], o);
|
|
2526
2601
|
if (!(transition in SOURCE_TRANSITIONS)) {
|
|
2527
2602
|
throw new Error(`@displayxr/inline3d/splat: setSource transition '${transition}' — expected one of ${Object.keys(SOURCE_TRANSITIONS).join(', ')}.`);
|
|
2528
2603
|
}
|
|
@@ -3555,7 +3630,22 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
|
|
|
3555
3630
|
// tile is woven (handle.firstWoven — which is immediate in 2D) and the first frames are built.
|
|
3556
3631
|
if (revealSpec) {
|
|
3557
3632
|
const gate = Promise.resolve(out.firstWoven).then(() => afterTicks(2));
|
|
3633
|
+
// The splat stays HIDDEN until the gate opens, so the first frame anyone sees is the first
|
|
3634
|
+
// frame of motion. The gate is `firstWoven`, a worst-case hold of up to 1.2 s past the
|
|
3635
|
+
// layer's birth (docs/woven-canvas-rules.md §5), and the browser usually joins the canvas
|
|
3636
|
+
// well before it ends. Drawn meanwhile, the start state (a particle reveal's scatter) sat
|
|
3637
|
+
// frozen on the woven panel for the rest of the hold and then jumped into motion. Hidden,
|
|
3638
|
+
// the tile is empty for that span, which also keeps stereo pixels off a canvas that may
|
|
3639
|
+
// not be joined yet (rule 5). Registered after fx.play's own `.then`, so it lands in the
|
|
3640
|
+
// same microtask that releases the effect: the clock starts on the frame it first shows.
|
|
3641
|
+
entity.enabled = false;
|
|
3558
3642
|
fx.play(revealSpec.type, revealSpec.raw, { gate });
|
|
3643
|
+
gate.then(() => {
|
|
3644
|
+
if (removed || current?.entity !== entity) return;
|
|
3645
|
+
// setVideo took the splat off screen meanwhile: its exit restores what it saved.
|
|
3646
|
+
if (vid?.saved?.entity === entity) vid.saved.splatEnabled = true;
|
|
3647
|
+
else entity.enabled = true;
|
|
3648
|
+
});
|
|
3559
3649
|
}
|
|
3560
3650
|
for (const [what, name, arg] of fxPending.splice(0)) {
|
|
3561
3651
|
if (what === 'set') fx.set(name, arg);
|
|
@@ -3807,21 +3897,34 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
|
|
|
3807
3897
|
if (!app || removed) throw new Error('@displayxr/inline3d/splat: prepareSource on a removed tile.');
|
|
3808
3898
|
const t0 = performance.now();
|
|
3809
3899
|
if (diag && diag.phase === 'idle') diag.setPhase('prepare');
|
|
3810
|
-
|
|
3900
|
+
// A SEQUENCE keeps one splat resident: its prepare only FETCHES the bytes (nothing goes to the
|
|
3901
|
+
// engine — no decode, no GPU upload) and compiles its shaders; the engine load runs at the swap,
|
|
3902
|
+
// once the current asset is gone, in the empty beat. `resident: true` opts back into the full
|
|
3903
|
+
// prepare (decoded + uploaded now, not in the scene): no load at the swap, two assets resident
|
|
3904
|
+
// during the dwell.
|
|
3905
|
+
if (po.resident !== undefined && typeof po.resident !== 'boolean') throw new TypeError('@displayxr/inline3d/splat: prepareSource resident must be a boolean.');
|
|
3906
|
+
const fetchOnly = !!warm?.sequence && po.resident !== true;
|
|
3907
|
+
let loaded = null;
|
|
3908
|
+
let source = null;
|
|
3811
3909
|
try {
|
|
3812
|
-
|
|
3910
|
+
if (fetchOnly) {
|
|
3911
|
+
const got = await fetchSourceBytes(src);
|
|
3912
|
+
if (!got.ok) throw new Error(`@displayxr/inline3d/splat: prepareSource could not fetch ${src} (HTTP ${got.status}).`);
|
|
3913
|
+
source = got.source;
|
|
3914
|
+
}
|
|
3915
|
+
else loaded = await loadOne(pcModule, app, src, { background: true });
|
|
3813
3916
|
} finally {
|
|
3814
3917
|
if (diag?.phase === 'prepare') diag.setPhase('idle');
|
|
3815
3918
|
}
|
|
3816
3919
|
perfSpan('prepareSource', t0);
|
|
3817
|
-
diag?.mark('prepared', { ms: Math.round(performance.now() - t0) });
|
|
3920
|
+
diag?.mark('prepared', { ms: Math.round(performance.now() - t0), fetchOnly });
|
|
3818
3921
|
// The transition the page declared: compile its shader now, in the dwell, not on its first frame.
|
|
3819
3922
|
await prewarmTransition(warm); // null (no transition declared): the overlay only
|
|
3820
|
-
const entry = { loaded, state: 'ready', dispose: null };
|
|
3923
|
+
const entry = { loaded, source, state: 'ready', dispose: null };
|
|
3821
3924
|
const prepared = {
|
|
3822
3925
|
[PREPARED_TAG]: true,
|
|
3823
|
-
/** The asset's own count (every splat of a flat source). */
|
|
3824
|
-
numSplats: loaded.desc.numSplats || loaded.cloud?.sourceTotal || 0,
|
|
3926
|
+
/** The asset's own count (every splat of a flat source); null for a fetch-only (sequence) prepare. */
|
|
3927
|
+
numSplats: loaded ? loaded.desc.numSplats || loaded.cloud?.sourceTotal || 0 : null,
|
|
3825
3928
|
/** 'ready' until setSource uses it ('used') or dispose() drops it ('disposed'). */
|
|
3826
3929
|
get state() {
|
|
3827
3930
|
return entry.state;
|
|
@@ -3830,8 +3933,11 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
|
|
|
3830
3933
|
if (entry.state !== 'ready') return;
|
|
3831
3934
|
entry.state = 'disposed';
|
|
3832
3935
|
livePrepared.delete(entry);
|
|
3833
|
-
|
|
3834
|
-
loaded
|
|
3936
|
+
entry.source = null;
|
|
3937
|
+
if (loaded) {
|
|
3938
|
+
app.assets.remove(loaded.asset);
|
|
3939
|
+
loaded.asset.unload?.();
|
|
3940
|
+
}
|
|
3835
3941
|
},
|
|
3836
3942
|
};
|
|
3837
3943
|
entry.dispose = prepared.dispose;
|
|
@@ -3861,27 +3967,28 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
|
|
|
3861
3967
|
// The chunk variant the transition installs (particles, wavefront), and the overlay's two quads
|
|
3862
3968
|
// (crossfade, wavefront and the particles all composite through them; created on the first
|
|
3863
3969
|
// capture otherwise, and compiled on its draw: 30–180 ms on the first frames of the window).
|
|
3864
|
-
|
|
3865
|
-
|
|
3970
|
+
// [key, code()] per variant: a particle transition's one body, the wavefront's ridge + cull, a
|
|
3971
|
+
// sequence's two (its out body, then its in body — one program each, or one when they match).
|
|
3972
|
+
const orderOf = (side) => (EFFECTS[side.effect].particle ? { order: side.opts.order } : {});
|
|
3973
|
+
const variants = plan?.particles
|
|
3974
|
+
? [[`${plan.particles.in.effect}|${plan.particles.in.opts.order ?? ''}`, () => fx.sharedChunkCode('transition', plan.particles.in.effect, { order: plan.particles.in.opts.order })]]
|
|
3866
3975
|
: plan?.transition === 'wavefront'
|
|
3867
|
-
? 'wavefront'
|
|
3868
|
-
:
|
|
3869
|
-
|
|
3870
|
-
|
|
3871
|
-
|
|
3976
|
+
? [['wavefront', () => fx.sharedChunkCodeFor([['transition', 'wavefront', {}], ['transition-cull', 'wipecull', {}]])]]
|
|
3977
|
+
: plan?.sequence
|
|
3978
|
+
? [plan.sequence.out, plan.sequence.in].map((side) => [`${side.effect}|${side.opts.order ?? ''}`, () => fx.sharedChunkCode('transition', side.effect, orderOf(side))])
|
|
3979
|
+
: [];
|
|
3980
|
+
const wantChunks = fx ? variants.filter(([k], i) => !prewarmed.has(k) && variants.findIndex((v) => v[0] === k) === i) : [];
|
|
3981
|
+
// A sequence draws through no overlay: nothing to warm there.
|
|
3982
|
+
const wantOverlay = !prewarmed.has('overlay') && !plan?.sequence;
|
|
3983
|
+
if (!wantChunks.length && !wantOverlay) return;
|
|
3872
3984
|
await yieldIdle();
|
|
3873
3985
|
if (removed) return;
|
|
3874
3986
|
try {
|
|
3875
3987
|
const pc = pcModule;
|
|
3876
3988
|
const made = [];
|
|
3877
|
-
|
|
3989
|
+
for (const [chunkKey, codeOf] of fx ? wantChunks : []) {
|
|
3878
3990
|
const cams = [viewer.eye, viewer._live?.cam].filter(Boolean);
|
|
3879
|
-
const code =
|
|
3880
|
-
? fx.sharedChunkCode('transition', plan.particles.in.effect, { order: plan.particles.in.opts.order })
|
|
3881
|
-
: fx.sharedChunkCodeFor([
|
|
3882
|
-
['transition', 'wavefront', {}],
|
|
3883
|
-
['transition-cull', 'wipecull', {}],
|
|
3884
|
-
]);
|
|
3991
|
+
const code = codeOf();
|
|
3885
3992
|
let issued = 0;
|
|
3886
3993
|
for (const cam of cams) {
|
|
3887
3994
|
const mi = managerMi(cam, null);
|
|
@@ -3987,7 +4094,11 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
|
|
|
3987
4094
|
const gen = ++sourceGen;
|
|
3988
4095
|
if (diag) {
|
|
3989
4096
|
rigLockArmed = true;
|
|
3990
|
-
diag.setPhase('swap', {
|
|
4097
|
+
diag.setPhase('swap', {
|
|
4098
|
+
transition: plan.sequence ? `${plan.sequence.name}(${plan.sequence.out.effect}>${plan.sequence.in.effect})` : plan.transition,
|
|
4099
|
+
outgoing: plan.sequence ? 'none (one splat)' : plan.outgoing || 'default',
|
|
4100
|
+
prepared: preparedAssets.has(next),
|
|
4101
|
+
});
|
|
3991
4102
|
}
|
|
3992
4103
|
// A prepareSource() result: already fetched, decoded and uploaded — no load on this path.
|
|
3993
4104
|
const prep = preparedAssets.get(next) || null;
|
|
@@ -4004,10 +4115,14 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
|
|
|
4004
4115
|
await first.catch(() => null); // a failed first asset may be replaced
|
|
4005
4116
|
if (!app || removed) return out;
|
|
4006
4117
|
const pc = pcModule;
|
|
4118
|
+
// A sequence: one splat at a time (playSequence). Without the effects runner (no asset ever
|
|
4119
|
+
// loaded) there is nothing to play out or in: a cut.
|
|
4120
|
+
if (plan.sequence && fx) return playSequence({ next, plan, gen, prep, resetPose, app, pc });
|
|
4007
4121
|
// Not prepared: compile what the transition draws with (its chunk, the overlay) while the asset
|
|
4008
4122
|
// loads, rather than on the window's first frames. Best effort, not awaited.
|
|
4009
|
-
if (!prep && current && plan.transition !== 'cut') prewarmTransition(plan);
|
|
4010
|
-
|
|
4123
|
+
if (!prep && current && plan.transition !== 'cut' && !plan.sequence) prewarmTransition(plan);
|
|
4124
|
+
// (a fetch-only prepare — prepareSource for a sequence — loads here, from its bytes)
|
|
4125
|
+
const loaded = prep ? prep.loaded || (await loadOne(pc, app, prep.source)) : await loadOne(pc, app, next);
|
|
4011
4126
|
if (removed || gen !== sourceGen) {
|
|
4012
4127
|
app.assets.remove(loaded.asset);
|
|
4013
4128
|
loaded.asset.unload?.();
|
|
@@ -4015,7 +4130,7 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
|
|
|
4015
4130
|
}
|
|
4016
4131
|
const prev = current;
|
|
4017
4132
|
pendingSwap?.finish();
|
|
4018
|
-
const transition = prev ? plan.transition : 'cut';
|
|
4133
|
+
const transition = prev && !plan.sequence ? plan.transition : 'cut';
|
|
4019
4134
|
const particle = plan.particles && transition in PARTICLE_TRANSITIONS ? plan.particles : null;
|
|
4020
4135
|
// FRAME_SNAPSHOT: freeze the outgoing frame BEFORE anything of the new asset (or its rig) is
|
|
4021
4136
|
// drawn. The overlay goes up in the same task, so no frame shows neither photo.
|
|
@@ -4258,6 +4373,240 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
|
|
|
4258
4373
|
return out;
|
|
4259
4374
|
}
|
|
4260
4375
|
|
|
4376
|
+
/**
|
|
4377
|
+
* The BYTES of a source, fetched without touching the engine (a sequence's preload: CPU memory
|
|
4378
|
+
* only — no decode, no GPU upload). Bytes / a Blob are returned as they are. A URL the engine
|
|
4379
|
+
* must resolve itself (a Streamed SOG, an unbundled SOG's meta.json) is returned as the URL, and
|
|
4380
|
+
* so is any URL whose fetch THROWS here (CORS, a scheme fetch does not take…): the engine's own
|
|
4381
|
+
* load then runs at the swap. Resolves { source, ok }: ok = false only when the server answered
|
|
4382
|
+
* and it was not the file (an HTTP error) — the one case known before the current photo goes.
|
|
4383
|
+
*/
|
|
4384
|
+
async function fetchSourceBytes(src, signal) {
|
|
4385
|
+
if (typeof src !== 'string') return { source: src, ok: true };
|
|
4386
|
+
const fmt = engineFormatFor(src, null, opts.fileName, opts.fileType);
|
|
4387
|
+
if (!fmt || fmt.streamed || fmt.ext === 'json' || typeof fetch !== 'function') return { source: src, ok: true };
|
|
4388
|
+
try {
|
|
4389
|
+
const r = await fetch(src, signal ? { signal } : undefined);
|
|
4390
|
+
if (!r.ok) return { source: src, ok: false, status: r.status };
|
|
4391
|
+
return { source: new Uint8Array(await r.arrayBuffer()), ok: true };
|
|
4392
|
+
} catch (err) {
|
|
4393
|
+
if (signal?.aborted) return { source: null, ok: false, aborted: true };
|
|
4394
|
+
return { source: src, ok: true, fetchError: err };
|
|
4395
|
+
}
|
|
4396
|
+
}
|
|
4397
|
+
|
|
4398
|
+
/** The sequence on screen: the asset its effect is on and how much of it shows (a newer sequence starts from there). */
|
|
4399
|
+
let seqShown = null;
|
|
4400
|
+
|
|
4401
|
+
/**
|
|
4402
|
+
* setSource's SEQUENCE transitions ('reassemble', { type: 'sequence', out, in }): ONE splat at
|
|
4403
|
+
* a time.
|
|
4404
|
+
* out — the current photo plays `out` backwards on the eye camera (a tile-scope body at
|
|
4405
|
+
* render time) until nothing of it is drawn. Meanwhile the next file's BYTES are
|
|
4406
|
+
* fetched (a URL; a prepareSource() result is already in hand).
|
|
4407
|
+
* swap — the current asset is released and DESTROYED (entity + GPU resource) before the next
|
|
4408
|
+
* one is given to the engine; then it is decoded, uploaded, placed and adopted (rig
|
|
4409
|
+
* waterfall, rig declared) in one task with its effect at amount 0, so its first frame
|
|
4410
|
+
* draws nothing.
|
|
4411
|
+
* in — after the empty beat (and at least 3 frames, for its work buffer and sort and the
|
|
4412
|
+
* rig to land), its effect's frame is re-taken under the new rig and it plays `in`
|
|
4413
|
+
* forwards. The end removes the body: the exact baseline.
|
|
4414
|
+
* No second camera, layer, target, overlay or capture. Latest wins: a newer sequence takes over
|
|
4415
|
+
* from where this one stands (its out phase starts at the amount shown); a newer other call
|
|
4416
|
+
* ends this one when its asset has loaded (or at once, once this one's photo is gone).
|
|
4417
|
+
*/
|
|
4418
|
+
async function playSequence({ next, plan, gen, prep, resetPose, app, pc }) {
|
|
4419
|
+
const seq = plan.sequence;
|
|
4420
|
+
const ease = typeof plan.easing === 'function' ? plan.easing : EASINGS[plan.easing] || EASINGS.linear;
|
|
4421
|
+
const spans = sequenceSpans(seq.beat);
|
|
4422
|
+
const outMs = plan.durationMs * spans.out;
|
|
4423
|
+
const beatMs = plan.durationMs * spans.beat;
|
|
4424
|
+
const inMs = plan.durationMs * spans.in;
|
|
4425
|
+
let finished = false;
|
|
4426
|
+
const finishers = [];
|
|
4427
|
+
/** Resolvers of the waits in flight: ending this call resolves them at once, not on a later frame. */
|
|
4428
|
+
const wakers = new Set();
|
|
4429
|
+
const finish = () => {
|
|
4430
|
+
if (finished) return;
|
|
4431
|
+
finished = true;
|
|
4432
|
+
for (const f of finishers) f();
|
|
4433
|
+
for (const w of [...wakers]) w();
|
|
4434
|
+
wakers.clear();
|
|
4435
|
+
viewer._transitionState = null;
|
|
4436
|
+
if (gen === sourceGen) diag?.setPhase('settle');
|
|
4437
|
+
if (pendingSwap?.finish === finish) pendingSwap = null;
|
|
4438
|
+
};
|
|
4439
|
+
const ac = typeof AbortController === 'function' ? new AbortController() : null;
|
|
4440
|
+
finishers.push(() => ac?.abort());
|
|
4441
|
+
let shown = null; // this call's seqShown
|
|
4442
|
+
finishers.push(() => {
|
|
4443
|
+
if (seqShown && seqShown === shown) seqShown = null;
|
|
4444
|
+
});
|
|
4445
|
+
const dropLoaded = (l) => {
|
|
4446
|
+
if (!l) return;
|
|
4447
|
+
app.assets.remove(l.asset);
|
|
4448
|
+
l.asset.unload?.();
|
|
4449
|
+
};
|
|
4450
|
+
/** Superseded (a newer setSource) or removed: this call stops at its next step. */
|
|
4451
|
+
const stale = () => finished || removed || gen !== sourceGen;
|
|
4452
|
+
/** Run `step(k, s)` over `ms` of viewer ticks (k 0 → 1, s = seconds); false if ended early. */
|
|
4453
|
+
const span = (ms, step) =>
|
|
4454
|
+
new Promise((resolve) => {
|
|
4455
|
+
let t0 = null;
|
|
4456
|
+
const wake = () => resolve(false);
|
|
4457
|
+
wakers.add(wake);
|
|
4458
|
+
viewer._hooks.push((t) => {
|
|
4459
|
+
if (finished || removed) return (resolve(false), false);
|
|
4460
|
+
if (!wakers.has(wake)) return false;
|
|
4461
|
+
if (t0 === null) t0 = t;
|
|
4462
|
+
const k = ms > 0 ? Math.min(1, Math.max(0, (t - t0) / ms)) : 1;
|
|
4463
|
+
step(k, (t - t0) / 1000);
|
|
4464
|
+
if (k >= 1) return (wakers.delete(wake), resolve(true), false);
|
|
4465
|
+
return true;
|
|
4466
|
+
});
|
|
4467
|
+
});
|
|
4468
|
+
// The next asset's bytes, fetched during the out phase. Nothing of it goes to the engine yet.
|
|
4469
|
+
const sourceP = prep ? Promise.resolve({ source: prep.loaded ? null : prep.source, ok: true }) : fetchSourceBytes(next, ac?.signal);
|
|
4470
|
+
// Both bodies compiled before the clock starts. The photo stays on screen meanwhile, live — and
|
|
4471
|
+
// a sequence already playing on it keeps playing: it is taken over only once this one can run.
|
|
4472
|
+
await prewarmTransition(plan);
|
|
4473
|
+
if (stale()) {
|
|
4474
|
+
if (prep) dropLoaded(prep.loaded);
|
|
4475
|
+
finish();
|
|
4476
|
+
return out;
|
|
4477
|
+
}
|
|
4478
|
+
// a sequence already on this photo: take over from how much of it shows, in this same task
|
|
4479
|
+
const startAmount = seqShown && current && seqShown.entity === current.entity ? seqShown.amount : 1;
|
|
4480
|
+
pendingSwap?.finish(); // latest wins: whatever was in flight ends now (at its end state)
|
|
4481
|
+
pendingSwap = { finish };
|
|
4482
|
+
viewer._transitionPath = 'sequence'; // diagnostics
|
|
4483
|
+
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' });
|
|
4484
|
+
|
|
4485
|
+
// ── out ──
|
|
4486
|
+
const prev = current;
|
|
4487
|
+
diag?.setPhase('window');
|
|
4488
|
+
if (prev) {
|
|
4489
|
+
const fxOut = fx.driveTile('transition', seq.out.effect, seq.out.opts); // its frame: the current rig's
|
|
4490
|
+
finishers.push(() => fxOut.remove());
|
|
4491
|
+
fxOut.set(startAmount, 0);
|
|
4492
|
+
shown = seqShown = { entity: prev.entity, amount: startAmount };
|
|
4493
|
+
const full = await span(outMs * startAmount, (k, s) => {
|
|
4494
|
+
const a = startAmount * (1 - ease(k));
|
|
4495
|
+
fxOut.set(a, s);
|
|
4496
|
+
shown.amount = a;
|
|
4497
|
+
viewer._transitionState = { phase: 'out', raw: k }; // diagnostics
|
|
4498
|
+
});
|
|
4499
|
+
if (!full) {
|
|
4500
|
+
// taken over (a newer call finished this one) or removed
|
|
4501
|
+
if (prep) dropLoaded(prep.loaded);
|
|
4502
|
+
return out;
|
|
4503
|
+
}
|
|
4504
|
+
diag?.mark('out-done');
|
|
4505
|
+
// Nothing of the current photo is drawn now. Its file must have arrived before it is let go:
|
|
4506
|
+
// if the next one cannot be fetched, the current one comes back instead.
|
|
4507
|
+
const got = await sourceP;
|
|
4508
|
+
if (!got.ok && !got.aborted && !stale()) {
|
|
4509
|
+
diag?.mark('fetch-failed', { status: got.status ?? null });
|
|
4510
|
+
viewer._transitionState = { phase: 'back', raw: 0 };
|
|
4511
|
+
await span(inMs, (k, s) => {
|
|
4512
|
+
const a = ease(k);
|
|
4513
|
+
fxOut.set(a, s);
|
|
4514
|
+
shown.amount = a;
|
|
4515
|
+
});
|
|
4516
|
+
finish();
|
|
4517
|
+
throw new Error(`@displayxr/inline3d/splat: setSource could not fetch ${typeof next === 'string' ? next : 'the source'} (HTTP ${got.status}); the current photo stays.`);
|
|
4518
|
+
}
|
|
4519
|
+
// COMMITTED: release it, and wait until it is gone (entity destroyed, resource unloaded)
|
|
4520
|
+
// before the next one is made resident. A newer call from here on finds nothing on screen.
|
|
4521
|
+
current = null;
|
|
4522
|
+
out.mesh = null;
|
|
4523
|
+
shown = null;
|
|
4524
|
+
seqShown = null;
|
|
4525
|
+
await new Promise((resolve) => {
|
|
4526
|
+
prev.entity.enabled = false;
|
|
4527
|
+
fx?.dropEntity(prev.entity);
|
|
4528
|
+
let frames = 0;
|
|
4529
|
+
// the engine's gsplat world keeps the placement until its next rebuild: destroy a few frames later
|
|
4530
|
+
viewer._hooks.push(() => {
|
|
4531
|
+
if (++frames < 4 && !removed) return true;
|
|
4532
|
+
prev.entity.destroy?.();
|
|
4533
|
+
app.assets.remove(prev.asset);
|
|
4534
|
+
prev.asset.unload?.();
|
|
4535
|
+
resolve();
|
|
4536
|
+
return false;
|
|
4537
|
+
});
|
|
4538
|
+
});
|
|
4539
|
+
diag?.mark('released');
|
|
4540
|
+
}
|
|
4541
|
+
const outEndAt = now();
|
|
4542
|
+
if (stale()) {
|
|
4543
|
+
if (prep) dropLoaded(prep.loaded);
|
|
4544
|
+
finish();
|
|
4545
|
+
return out;
|
|
4546
|
+
}
|
|
4547
|
+
|
|
4548
|
+
// ── swap ──
|
|
4549
|
+
let loaded;
|
|
4550
|
+
try {
|
|
4551
|
+
const got = await sourceP;
|
|
4552
|
+
viewer._transitionState = { phase: 'load', raw: 0 };
|
|
4553
|
+
const loadP = prep?.loaded ? Promise.resolve(prep.loaded) : loadOne(pc, app, got.source ?? next);
|
|
4554
|
+
// a newer call ends this one at once; the asset, when it lands, is dropped unseen
|
|
4555
|
+
const r = await Promise.race([loadP.then((l) => ({ l })), new Promise((resolve) => wakers.add(() => resolve(null)))]);
|
|
4556
|
+
if (!r) {
|
|
4557
|
+
loadP.then(dropLoaded, () => {});
|
|
4558
|
+
return out;
|
|
4559
|
+
}
|
|
4560
|
+
loaded = r.l;
|
|
4561
|
+
} catch (err) {
|
|
4562
|
+
finish();
|
|
4563
|
+
if (stale()) return out;
|
|
4564
|
+
throw err; // the tile is empty: the current photo was already gone
|
|
4565
|
+
}
|
|
4566
|
+
if (stale()) {
|
|
4567
|
+
dropLoaded(loaded);
|
|
4568
|
+
finish();
|
|
4569
|
+
return out;
|
|
4570
|
+
}
|
|
4571
|
+
diag?.mark('loaded');
|
|
4572
|
+
const entity = viewer.addSplatAsset(loaded.asset);
|
|
4573
|
+
out.mesh = { numSplats: loaded.desc.numSplats || loaded.cloud?.sourceTotal || 0, entity, asset: loaded.asset, resource: loaded.res };
|
|
4574
|
+
const kept = applyLoaded(loaded); // the rig waterfall; the rig is declared here, before any frame of it
|
|
4575
|
+
current = { asset: loaded.asset, entity, res: loaded.res, kind: loaded.desc.kind, ...kept };
|
|
4576
|
+
if (resetPose && !pageMode) viewer.resetPose();
|
|
4577
|
+
// same task: the in body at amount 0 (tile material values), so its first frame draws nothing
|
|
4578
|
+
const fxIn = fx.driveTile('transition', seq.in.effect, seq.in.opts);
|
|
4579
|
+
finishers.push(() => fxIn.remove());
|
|
4580
|
+
fxIn.set(0, 0);
|
|
4581
|
+
shown = seqShown = { entity, amount: 0 };
|
|
4582
|
+
diag?.mark('adopted');
|
|
4583
|
+
|
|
4584
|
+
// ── the empty beat: at least `beat` since the old photo went, and 3 frames of the new one ──
|
|
4585
|
+
await new Promise((resolve) => {
|
|
4586
|
+
let ticks = 0;
|
|
4587
|
+
wakers.add(resolve);
|
|
4588
|
+
viewer._hooks.push((t) => {
|
|
4589
|
+
if (finished || removed) return (resolve(), false);
|
|
4590
|
+
viewer._transitionState = { phase: 'beat', raw: 0 };
|
|
4591
|
+
if (++ticks >= 3 && t - outEndAt >= beatMs) return (resolve(), false);
|
|
4592
|
+
return true;
|
|
4593
|
+
});
|
|
4594
|
+
});
|
|
4595
|
+
if (finished || removed) return out;
|
|
4596
|
+
|
|
4597
|
+
// ── in ──
|
|
4598
|
+
fxIn.restart(); // its frame: the new photo under its own rig, now declared and drawn
|
|
4599
|
+
diag?.mark('in-start');
|
|
4600
|
+
const full = await span(inMs, (k, s) => {
|
|
4601
|
+
const a = ease(k);
|
|
4602
|
+
fxIn.set(a, s);
|
|
4603
|
+
shown.amount = a;
|
|
4604
|
+
viewer._transitionState = { phase: 'in', raw: k }; // diagnostics
|
|
4605
|
+
});
|
|
4606
|
+
if (full) finish(); // removes the body: the plain render, exactly
|
|
4607
|
+
return out;
|
|
4608
|
+
}
|
|
4609
|
+
|
|
4261
4610
|
/** The mesh instance of the gsplat manager that `cam` draws `layer` with (any layer: null). */
|
|
4262
4611
|
const managerMi = (cam, layer) => {
|
|
4263
4612
|
const cd = viewer.app?.renderer?.gsplatDirector?.camerasMap?.get?.(cam?.camera?.camera);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@displayxr/inline3d",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.21.1",
|
|
4
4
|
"description": "Turn any HTML <canvas> into a glasses-free-3D window on a DisplayXR display, inside an ordinary web page. Dependency-free; progressive enhancement (falls back to plain 2D everywhere else).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"types": "./index.d.ts",
|
package/splat.d.ts
CHANGED
|
@@ -623,8 +623,17 @@ export interface SplatSourceOptions {
|
|
|
623
623
|
* (nothing moves: twinkling points out, twinkling points in) and `'dust'` (drifting dust out,
|
|
624
624
|
* gathering dust in). 2.6–2.8 s, linear shared clock (each particle eases itself). The old
|
|
625
625
|
* photo is live, in 2D too; a hidden tab gets the crossfade.
|
|
626
|
-
|
|
627
|
-
|
|
626
|
+
*
|
|
627
|
+
* The SEQUENCE transitions (docs/splat-effects.md §Sequence transitions): ONE photo at a time.
|
|
628
|
+
* `'reassemble'` disperses the current photo (`assemble` backwards), releases it, loads the next
|
|
629
|
+
* one and assembles it (3000 ms: 45 % out, a 10 % empty beat, 45 % in). The general form
|
|
630
|
+
* `{ type: 'sequence', out, in }` runs any two of `assemble`, `dissolve-in`, `converge`,
|
|
631
|
+
* `shimmer`, `sweep`, `fade`. No second camera, overlay or capture: the eye camera renders every
|
|
632
|
+
* frame.
|
|
633
|
+
*/
|
|
634
|
+
transition?: 'cut' | 'crossfade' | 'flip' | 'wavefront' | SplatParticleTransitionName | 'reassemble' | SplatSequenceTransition;
|
|
635
|
+
/** Sequence transitions: the empty beat between out and in, a fraction of `durationMs`, 0..0.9 (default 0.1). */
|
|
636
|
+
beat?: number;
|
|
628
637
|
durationMs?: number;
|
|
629
638
|
easing?: SplatEasing;
|
|
630
639
|
/** `cut`/`crossfade` only: reveal the INCOMING asset (entity scope) while the old one fades. */
|
|
@@ -656,9 +665,9 @@ export interface SplatSourceOptions {
|
|
|
656
665
|
origin?: SplatEffectOrigin;
|
|
657
666
|
/** Particle transitions: how much of the clock the two photos' spans share, 0..1 (0 = one after the other). */
|
|
658
667
|
overlap?: number;
|
|
659
|
-
/** Particle transitions: option overrides for the OUTGOING photo's effect. */
|
|
668
|
+
/** Particle and sequence transitions: option overrides for the OUTGOING photo's effect. */
|
|
660
669
|
outgoingFx?: SplatParticleOptions & Record<string, unknown>;
|
|
661
|
-
/** Particle transitions: option overrides for the INCOMING photo's effect. */
|
|
670
|
+
/** Particle and sequence transitions: option overrides for the INCOMING photo's effect. */
|
|
662
671
|
incomingFx?: SplatParticleOptions & Record<string, unknown>;
|
|
663
672
|
}
|
|
664
673
|
|
|
@@ -668,18 +677,45 @@ export interface SplatSourceOptions {
|
|
|
668
677
|
* first transition of each kind in a page blocks its first frame on the link (tens of ms, hundreds
|
|
669
678
|
* the first time a machine sees the variant). Validated like setSource's.
|
|
670
679
|
*/
|
|
671
|
-
export type SplatPrepareOptions = SplatSourceOptions
|
|
680
|
+
export type SplatPrepareOptions = SplatSourceOptions & {
|
|
681
|
+
/**
|
|
682
|
+
* Sequence transitions only. Default false: the prepare only FETCHES the file's bytes, so one
|
|
683
|
+
* splat stays resident and the decode + upload run at the swap, in the empty beat. `true`: the
|
|
684
|
+
* full prepare (decoded and uploaded now, not in the scene) — no load at the swap, two assets
|
|
685
|
+
* resident during the dwell.
|
|
686
|
+
*/
|
|
687
|
+
resident?: boolean;
|
|
688
|
+
};
|
|
672
689
|
|
|
673
690
|
/** setSource's particle transitions (docs/splat-effects.md §Particle transitions). */
|
|
674
691
|
export type SplatParticleTransitionName = 'swarm' | 'burst' | 'shimmer-cross' | 'dust';
|
|
675
692
|
|
|
693
|
+
/** The reveals a sequence transition can run (each draws nothing at its start). */
|
|
694
|
+
export type SplatSequenceRevealName = 'assemble' | 'dissolve-in' | 'converge' | 'shimmer' | 'sweep' | 'fade';
|
|
695
|
+
|
|
696
|
+
/**
|
|
697
|
+
* setSource's general SEQUENCE transition: `out` backwards on the current photo, the swap, `in`
|
|
698
|
+
* forwards on the next. `durationMs` / `beat` / `easing` here or at the top level of the options
|
|
699
|
+
* (the top level wins).
|
|
700
|
+
*/
|
|
701
|
+
export interface SplatSequenceTransition {
|
|
702
|
+
type: 'sequence';
|
|
703
|
+
out: SplatSequenceRevealName;
|
|
704
|
+
in: SplatSequenceRevealName;
|
|
705
|
+
durationMs?: number;
|
|
706
|
+
beat?: number;
|
|
707
|
+
easing?: SplatEasing;
|
|
708
|
+
}
|
|
709
|
+
|
|
676
710
|
/**
|
|
677
711
|
* What `prepareSource()` resolves to: an opaque, single-use handle for `setSource`. The asset is
|
|
678
|
-
* fully resident (GPU textures + the engine's centre array) until used or disposed
|
|
712
|
+
* fully resident (GPU textures + the engine's centre array) until used or disposed — except for a
|
|
713
|
+
* sequence transition (`prepareSource(src, { transition: 'reassemble' })`), whose prepare only
|
|
714
|
+
* fetches the file's bytes: nothing reaches the engine until the swap.
|
|
679
715
|
*/
|
|
680
716
|
export interface SplatPreparedSource {
|
|
681
|
-
/** The asset's own count (every splat of a flat source). */
|
|
682
|
-
readonly numSplats: number;
|
|
717
|
+
/** The asset's own count (every splat of a flat source); null for a fetch-only (sequence) prepare. */
|
|
718
|
+
readonly numSplats: number | null;
|
|
683
719
|
/** `'ready'` until `setSource` uses it (`'used'`) or `dispose()` drops it (`'disposed'`). */
|
|
684
720
|
readonly state: 'ready' | 'used' | 'disposed';
|
|
685
721
|
/** Release the prepared asset (no-op once used or disposed). */
|