@displayxr/inline3d 1.14.0 → 1.15.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 +37 -0
- package/js/inline3d-splat-effects.js +213 -5
- package/js/inline3d-splat-playcanvas.js +268 -10
- package/package.json +1 -1
- package/splat.d.ts +40 -3
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,43 @@ 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
|
+
## 1.15.0 — 2026-09-24
|
|
9
|
+
|
|
10
|
+
Touches the **preview tier** (`./splat`, `engine: 'playcanvas'` only). Additive; the existing transitions
|
|
11
|
+
and reveals render the same pixels.
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
|
|
15
|
+
- **Particle transitions for `setSource`** (#36): `transition: 'swarm' | 'burst' | 'shimmer-cross' | 'dust'`.
|
|
16
|
+
They play the particle reveals across a slide change, with both photos live, in 3D and in 2D.
|
|
17
|
+
The old photo plays its effect backwards while the new one plays it forwards, on overlapping
|
|
18
|
+
spans of one clock (`overlap`).
|
|
19
|
+
- `swarm`: the old photo disperses into a curl-noise swarm and the new one assembles out of one.
|
|
20
|
+
- `burst`: the old photo collapses into its focus and the new one bursts out of its own.
|
|
21
|
+
- `shimmer-cross`: twinkling points out and twinkling points in; nothing moves.
|
|
22
|
+
- `dust`: drifting dust out, gathering dust in.
|
|
23
|
+
|
|
24
|
+
Defaults are 2.6–2.8 s on a linear shared clock; each particle eases on its own path. The end
|
|
25
|
+
state is a `cut`, MAE 0.000 in colour and alpha in both eyes, and VRAM goes back to baseline.
|
|
26
|
+
A hidden tab gets the crossfade.
|
|
27
|
+
See [`docs/splat-effects.md` § Particle transitions](docs/splat-effects.md#particle-transitions).
|
|
28
|
+
- They render at RENDER TIME. One tile-scope chunk serves both photos, and each photo's values
|
|
29
|
+
sit on the mesh instance of the gsplat manager that draws it. A first cut used entity-scope
|
|
30
|
+
work-buffer modifiers, which rewrote both 1.18M work buffers and re-sorted both every frame.
|
|
31
|
+
Measured in a visible Chrome, that cost 2D pacing its 120 Hz: p95 went from 16.7 ms to 9.8–10.1.
|
|
32
|
+
A photo with nothing to draw skips its draw.
|
|
33
|
+
- **`prepareSource(src, { transition, … })`**: with a particle transition, this also compiles and
|
|
34
|
+
links its shaders in the dwell. Otherwise the first transition of each kind blocks its first
|
|
35
|
+
frame on the link: 35–50 ms, and 190 + 260 ms on a variant the machine has never compiled.
|
|
36
|
+
- Particle options `vanish` (fade over the first part of the flight) and `density` (the share
|
|
37
|
+
drawn in flight). Both are off for the reveals.
|
|
38
|
+
|
|
39
|
+
### Not changed
|
|
40
|
+
|
|
41
|
+
- The live `crossfade` / `wavefront` still run at about 40 Hz in stereo on an M1 (median 25.6 ms).
|
|
42
|
+
They draw both photos in full for the whole window. The same skip-what-is-not-visible idea may
|
|
43
|
+
apply there; that is a follow-up.
|
|
44
|
+
|
|
8
45
|
## 1.14.0 — 2026-09-24
|
|
9
46
|
|
|
10
47
|
Touches the **preview tier** (`./splat`, `engine: 'playcanvas'` only). New particle reveals are additive. One default changes pixels
|
|
@@ -626,6 +626,8 @@ uniform float ${P}grow;
|
|
|
626
626
|
uniform float ${P}tx;
|
|
627
627
|
uniform vec3 ${P}glow;
|
|
628
628
|
uniform float ${P}falpha;
|
|
629
|
+
uniform float ${P}van;
|
|
630
|
+
uniform float ${P}dens;
|
|
629
631
|
float ${P}lp = -1.0;
|
|
630
632
|
float ${P}h(vec3 c, float s) { return dxrFxHash(c + vec3(s * 17.13, s * 31.71, s * 7.31)); }
|
|
631
633
|
// where a point sits in the PICTURE (image x, y in half-view-widths) and its log depth: noise is
|
|
@@ -683,6 +685,12 @@ void ${P}color(vec3 c, inout vec4 col) {
|
|
|
683
685
|
float g = smoothstep(${P}grow, 1.0, lp);
|
|
684
686
|
col.rgb += ${P}glow * (1.0 - g);
|
|
685
687
|
col.a *= mix(${P}falpha, 1.0, g);
|
|
688
|
+
// setSource's particle transitions: a particle fades over the first "vanish" of its flight, so a
|
|
689
|
+
// swarm gathers out of nothing and a leaving photo's swarm thins out to nothing (0 = off)
|
|
690
|
+
if (${P}van > 0.0) col.a *= smoothstep(0.0, ${P}van, lp);
|
|
691
|
+
// "density" < 1: only that share of the gaussians is drawn in flight (the rest appear as they
|
|
692
|
+
// grow home), so a million-point swarm reads as a swarm, not as snow
|
|
693
|
+
if (${P}dens < 1.0 && ${P}h(c, 9.0) > ${P}dens) col.a *= g;
|
|
686
694
|
${extra}
|
|
687
695
|
}
|
|
688
696
|
`;
|
|
@@ -705,6 +713,8 @@ const PARTICLE_VALIDATE = (what, o) => {
|
|
|
705
713
|
if (o.color !== undefined) vec3Of(o.color, `${what} color`);
|
|
706
714
|
if (o.noiseScale !== undefined) num(o.noiseScale, `${what} noiseScale`, 1e-3, 1e3);
|
|
707
715
|
if (o.maxDisparity !== undefined) num(o.maxDisparity, `${what} maxDisparity`, 0, 0.05);
|
|
716
|
+
if (o.vanish !== undefined) num(o.vanish, `${what} vanish`, 0, 1);
|
|
717
|
+
if (o.density !== undefined) num(o.density, `${what} density`, 0, 1);
|
|
708
718
|
if (o.layerSize !== undefined && !(Number.isInteger(o.layerSize) && o.layerSize > 0)) {
|
|
709
719
|
throw new RangeError(`@displayxr/inline3d/splat: ${what} layerSize must be a positive integer.`);
|
|
710
720
|
}
|
|
@@ -773,6 +783,8 @@ const particleUniforms = (ctx, inst, amount, tMs) => {
|
|
|
773
783
|
tx: s.tanX,
|
|
774
784
|
glow: o.color.map((x) => x * o.glow),
|
|
775
785
|
falpha: o.flightAlpha,
|
|
786
|
+
van: o.vanish,
|
|
787
|
+
dens: o.density,
|
|
776
788
|
};
|
|
777
789
|
};
|
|
778
790
|
|
|
@@ -785,11 +797,18 @@ const PARTICLE_DEFAULTS = {
|
|
|
785
797
|
noiseScale: 2,
|
|
786
798
|
dotSize: 0.0007,
|
|
787
799
|
grow: 0.6,
|
|
800
|
+
vanish: 0,
|
|
801
|
+
density: 1,
|
|
788
802
|
};
|
|
789
803
|
|
|
790
|
-
/**
|
|
791
|
-
|
|
804
|
+
/**
|
|
805
|
+
* A particle reveal's registry entry: `body(P)` adds the centre/rs stages (and extra uniforms).
|
|
806
|
+
* `hiddenAtZero(opts)`: nothing is drawn at amount 0 (converge / shimmer: not yet launched; the
|
|
807
|
+
* others with `vanish` > 0) — a setSource transition then skips that photo's draw.
|
|
808
|
+
*/
|
|
809
|
+
function particleEffect(name, defaults, { body, color = particleColor, uniforms: extra = () => ({}), validate, hiddenAtZero = (o) => o.vanish > 0 }) {
|
|
792
810
|
return {
|
|
811
|
+
hiddenAtZero,
|
|
793
812
|
stage: 'reveal',
|
|
794
813
|
kind: 'transition',
|
|
795
814
|
particle: true,
|
|
@@ -967,6 +986,7 @@ void ${P}rs(vec3 oc, vec3 mc, inout vec4 r, inout vec3 sc) {
|
|
|
967
986
|
}
|
|
968
987
|
`,
|
|
969
988
|
color: (P) => particleColor(P, 'col.a *= smoothstep(0.0, 0.15, lp);'),
|
|
989
|
+
hiddenAtZero: () => true,
|
|
970
990
|
},
|
|
971
991
|
),
|
|
972
992
|
|
|
@@ -1009,10 +1029,68 @@ void ${P}rs(vec3 oc, vec3 mc, inout vec4 r, inout vec3 sc) {
|
|
|
1009
1029
|
col.rgb = mix(col.rgb, ${P}sc, min(1.0, tw * ${P}sp) * (1.0 - g));
|
|
1010
1030
|
col.a *= smoothstep(0.0, 0.12, lp) * mix(0.55 + 0.45 * tw, 1.0, g);`,
|
|
1011
1031
|
),
|
|
1032
|
+
hiddenAtZero: () => true,
|
|
1012
1033
|
},
|
|
1013
1034
|
),
|
|
1014
1035
|
});
|
|
1015
1036
|
|
|
1037
|
+
// ── setSource's particle transitions ─────────────────────────────────────────────────────────
|
|
1038
|
+
//
|
|
1039
|
+
// swarm / burst / shimmer-cross / dust: the OUTGOING photo plays a particle reveal backwards (its
|
|
1040
|
+
// gaussians leave home as dots and thin out to nothing, `vanish`) while the INCOMING one plays it
|
|
1041
|
+
// forwards, on overlapping spans of one clock. Both run as entity-scope effects driven by the
|
|
1042
|
+
// adapter (SplatEffects.drive); the outgoing one is drawn by the LIVE outgoing camera, and the
|
|
1043
|
+
// adapter composites the two live images per eye, the old one OVER the new one.
|
|
1044
|
+
|
|
1045
|
+
/**
|
|
1046
|
+
* The transitions and their defaults. `out` / `in` = the particle effect each side plays (out: in
|
|
1047
|
+
* reverse) with its option overrides; `overlap` = how much of the clock the two spans share (0 =
|
|
1048
|
+
* one after the other, 1 = both over the whole clock): out over [0, (1 + overlap)/2], in over
|
|
1049
|
+
* [(1 − overlap)/2, 1] of the eased clock.
|
|
1050
|
+
*/
|
|
1051
|
+
export const PARTICLE_TRANSITIONS = Object.freeze({
|
|
1052
|
+
swarm: {
|
|
1053
|
+
durationMs: 2800,
|
|
1054
|
+
easing: 'linear',
|
|
1055
|
+
overlap: 0.45,
|
|
1056
|
+
out: { effect: 'assemble', opts: { stagger: 0.65, jitter: 0.35, spread: 0.3, swirl: 1.8, density: 0.2, dotSize: 0.0009, vanish: 0.45, glow: 0.1 } },
|
|
1057
|
+
in: { effect: 'assemble', opts: { stagger: 0.65, spread: 0.3, swirl: 1.8, density: 0.2, dotSize: 0.0009, vanish: 0.35, glow: 0.1 } },
|
|
1058
|
+
},
|
|
1059
|
+
burst: {
|
|
1060
|
+
durationMs: 2600,
|
|
1061
|
+
easing: 'linear',
|
|
1062
|
+
overlap: 0.3,
|
|
1063
|
+
out: { effect: 'converge', opts: { stagger: 0.5, density: 0.5, glow: 0.25 } },
|
|
1064
|
+
in: { effect: 'converge', opts: { stagger: 0.55, density: 0.5 } },
|
|
1065
|
+
},
|
|
1066
|
+
'shimmer-cross': {
|
|
1067
|
+
durationMs: 2600,
|
|
1068
|
+
easing: 'linear',
|
|
1069
|
+
overlap: 0.45,
|
|
1070
|
+
out: { effect: 'shimmer', opts: { stagger: 0.75 } },
|
|
1071
|
+
in: { effect: 'shimmer', opts: { stagger: 0.75 } },
|
|
1072
|
+
},
|
|
1073
|
+
dust: {
|
|
1074
|
+
durationMs: 2800,
|
|
1075
|
+
easing: 'linear',
|
|
1076
|
+
overlap: 0.4,
|
|
1077
|
+
out: { effect: 'dissolve-in', opts: { density: 0.4, vanish: 0.4, drift: 0.45 } },
|
|
1078
|
+
in: { effect: 'dissolve-in', opts: { density: 0.4, vanish: 0.3 } },
|
|
1079
|
+
},
|
|
1080
|
+
});
|
|
1081
|
+
|
|
1082
|
+
/** Where a side stands at eased clock t: 0..1 over its span. */
|
|
1083
|
+
export function particleSpan(t, overlap, side) {
|
|
1084
|
+
const v = Math.min(1, Math.max(0, overlap));
|
|
1085
|
+
const a = side === 'out' ? 0 : (1 - v) / 2;
|
|
1086
|
+
const b = side === 'out' ? (1 + v) / 2 : 1;
|
|
1087
|
+
return Math.min(1, Math.max(0, (t - a) / Math.max(b - a, 1e-6)));
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
/** Options a page may pass through setSource to both sides of a particle transition. */
|
|
1091
|
+
export const PARTICLE_TRANSITION_OPTIONS = Object.freeze(['order', 'stagger', 'jitter', 'maxDisparity', 'dotSize', 'noiseScale', 'layerSize', 'origin']);
|
|
1092
|
+
|
|
1093
|
+
|
|
1016
1094
|
/**
|
|
1017
1095
|
* The wavefront's per-column commit, lt ∈ [0, 1], for eased progress `t` at normalised u — the
|
|
1018
1096
|
* prototype's formula: column u starts at t = u·(1 − band) and has committed band later, so
|
|
@@ -1243,7 +1321,8 @@ export class SplatEffects {
|
|
|
1243
1321
|
resolve: null,
|
|
1244
1322
|
promise: null,
|
|
1245
1323
|
originPoint: (ctx, eyes) => this._origin(inst, ctx, eyes),
|
|
1246
|
-
|
|
1324
|
+
// a driven instance (drive()) is handed its time by the adapter's clock
|
|
1325
|
+
elapsedS: (tMs) => (inst.timeS !== undefined ? inst.timeS : inst.startedAt === null ? 0 : Math.max(0, (tMs - inst.startedAt) / 1000)),
|
|
1247
1326
|
};
|
|
1248
1327
|
inst.promise = new Promise((r) => (inst.resolve = r));
|
|
1249
1328
|
return inst;
|
|
@@ -1326,6 +1405,134 @@ export class SplatEffects {
|
|
|
1326
1405
|
this._install(entity);
|
|
1327
1406
|
}
|
|
1328
1407
|
|
|
1408
|
+
/**
|
|
1409
|
+
* An effect on one entity driven by the ADAPTER's clock, not the runner's (setSource's particle
|
|
1410
|
+
* transitions: two effects on two assets, on overlapping spans of one transition clock). Hidden
|
|
1411
|
+
* from effects() and from a page's stopEffect(). Returns { set(amount, timeS), restart(),
|
|
1412
|
+
* remove() }: `amount` as the GLSL reads it (1 = the untouched asset), `restart()` re-takes the
|
|
1413
|
+
* effect's frame (start()) — the incoming asset's, once its rig and first frames are in.
|
|
1414
|
+
*/
|
|
1415
|
+
drive(entity, name, opts) {
|
|
1416
|
+
const o = resolveEffectOptions(name, { ...opts, scope: 'entity', direction: 'in', progress: 0 }, 'set', { internal: true });
|
|
1417
|
+
o.entity = entity;
|
|
1418
|
+
const inst = this._makeInstance(name, o, 'set');
|
|
1419
|
+
inst.hidden = true;
|
|
1420
|
+
inst.timeS = 0;
|
|
1421
|
+
this._replace(entity, name, inst);
|
|
1422
|
+
this._setupInstance(inst);
|
|
1423
|
+
this._apply(entity, inst, this.ctx.now());
|
|
1424
|
+
this._install(entity);
|
|
1425
|
+
const alive = () => !this._disposed && this.scopes.get(entity)?.get(name) === inst;
|
|
1426
|
+
return {
|
|
1427
|
+
set: (amount, timeS = inst.timeS) => {
|
|
1428
|
+
if (!alive()) return;
|
|
1429
|
+
inst.opts.progress = Math.min(1, Math.max(0, amount));
|
|
1430
|
+
inst.timeS = timeS;
|
|
1431
|
+
this._apply(entity, inst, this.ctx.now());
|
|
1432
|
+
},
|
|
1433
|
+
restart: () => {
|
|
1434
|
+
if (!alive()) return;
|
|
1435
|
+
inst.state = {};
|
|
1436
|
+
this._setupInstance(inst);
|
|
1437
|
+
this._apply(entity, inst, this.ctx.now());
|
|
1438
|
+
},
|
|
1439
|
+
remove: () => {
|
|
1440
|
+
if (!alive()) return;
|
|
1441
|
+
this.scopes.get(entity).delete(name);
|
|
1442
|
+
inst.resolve({ finished: true });
|
|
1443
|
+
this._install(entity);
|
|
1444
|
+
},
|
|
1445
|
+
get alive() {
|
|
1446
|
+
return alive();
|
|
1447
|
+
},
|
|
1448
|
+
};
|
|
1449
|
+
}
|
|
1450
|
+
|
|
1451
|
+
/**
|
|
1452
|
+
* ONE tile-scope body for the two photos of a setSource particle transition, with each photo's
|
|
1453
|
+
* values on its OWN mesh instance (the engine draws each photo through its own gsplat manager and
|
|
1454
|
+
* renderer: the eye camera's for the incoming photo, the live camera's for the outgoing one).
|
|
1455
|
+
* Render-time only: no work-buffer rewrite, no per-frame re-sort (an entity-scope modifier forces
|
|
1456
|
+
* both, every frame, for each 1.18M asset — measured as the transitions' hitches). The chunk's
|
|
1457
|
+
* material values are the untouched asset's (amount 1). Returns { side(getMeshInstance, opts),
|
|
1458
|
+
* remove() }; a side is { set(amount, timeS), restart() }. `opts` of the two sides share what
|
|
1459
|
+
* the GLSL is compiled from (the `order`).
|
|
1460
|
+
*/
|
|
1461
|
+
driveShared(name, effect, opts) {
|
|
1462
|
+
const def = EFFECTS[effect];
|
|
1463
|
+
const o = resolveEffectOptions(effect, { ...opts, scope: 'tile', direction: 'in', progress: 1 }, 'set', { internal: true });
|
|
1464
|
+
const inst = this._makeInstance(name, o, 'set');
|
|
1465
|
+
inst.def = def;
|
|
1466
|
+
inst.hidden = true;
|
|
1467
|
+
inst.fixed = true; // its material values never change: no per-frame upload
|
|
1468
|
+
this._replace('tile', name, inst);
|
|
1469
|
+
this._setupInstance(inst);
|
|
1470
|
+
this._apply('tile', inst, this.ctx.now());
|
|
1471
|
+
this._install('tile');
|
|
1472
|
+
const P = prefixOf(name);
|
|
1473
|
+
const alive = () => !this._disposed && this.scopes.get('tile')?.get(name) === inst;
|
|
1474
|
+
const touched = new Set();
|
|
1475
|
+
const hidden = new Set(); // mesh instances this transition turned off (a photo with nothing to draw)
|
|
1476
|
+
return {
|
|
1477
|
+
side: (getMeshInstance, sideOpts) => {
|
|
1478
|
+
const s = { opts: resolveEffectOptions(effect, { ...sideOpts, order: o.order, scope: 'tile', direction: 'in' }, 'set', { internal: true }), state: {}, timeS: 0 };
|
|
1479
|
+
s.originPoint = (ctx, eyes) => this._origin(s, ctx, eyes);
|
|
1480
|
+
s.elapsedS = () => s.timeS;
|
|
1481
|
+
def.start?.(this.ctx, s);
|
|
1482
|
+
return {
|
|
1483
|
+
/** Upload this photo's values; false when its mesh instance is not there (yet). */
|
|
1484
|
+
set: (amount, timeS = s.timeS) => {
|
|
1485
|
+
if (!alive()) return false;
|
|
1486
|
+
const mi = getMeshInstance();
|
|
1487
|
+
if (!mi?.setParameter) return false;
|
|
1488
|
+
s.timeS = timeS;
|
|
1489
|
+
touched.add(mi);
|
|
1490
|
+
// the values first, always: should the engine show the mesh again on its own (a
|
|
1491
|
+
// rebuild sets its visibility), it draws this state, never the untouched photo
|
|
1492
|
+
const values = def.uniforms(this.ctx, s, Math.min(1, Math.max(0, amount)), this.ctx.now());
|
|
1493
|
+
for (const [k, v] of Object.entries(values)) mi.setParameter(P + k, v);
|
|
1494
|
+
// Nothing of this photo is drawn at amount 0 (every particle hidden before its flight):
|
|
1495
|
+
// skip its draw call rather than run the vertex stage for 1.18M invisible gaussians —
|
|
1496
|
+
// the two photos then share the GPU only while their spans overlap.
|
|
1497
|
+
if (amount <= 0 && def.hiddenAtZero?.(s.opts)) {
|
|
1498
|
+
if (mi.visible !== false) {
|
|
1499
|
+
mi.visible = false;
|
|
1500
|
+
hidden.add(mi);
|
|
1501
|
+
}
|
|
1502
|
+
} else if (hidden.delete(mi)) mi.visible = true;
|
|
1503
|
+
return true;
|
|
1504
|
+
},
|
|
1505
|
+
restart: () => {
|
|
1506
|
+
s.state = {};
|
|
1507
|
+
def.start?.(this.ctx, s);
|
|
1508
|
+
},
|
|
1509
|
+
};
|
|
1510
|
+
},
|
|
1511
|
+
remove: () => {
|
|
1512
|
+
for (const mi of hidden) mi.visible = true;
|
|
1513
|
+
hidden.clear();
|
|
1514
|
+
for (const mi of touched) {
|
|
1515
|
+
for (const k of Object.keys(mi.parameters || {})) if (k.startsWith(P)) mi.deleteParameter?.(k);
|
|
1516
|
+
}
|
|
1517
|
+
touched.clear();
|
|
1518
|
+
if (!alive()) return;
|
|
1519
|
+
this.scopes.get('tile').delete(name);
|
|
1520
|
+
inst.resolve({ finished: true });
|
|
1521
|
+
this._install('tile');
|
|
1522
|
+
},
|
|
1523
|
+
};
|
|
1524
|
+
}
|
|
1525
|
+
|
|
1526
|
+
/**
|
|
1527
|
+
* The tile chunk driveShared(name, effect, opts) WOULD install, without installing it — what
|
|
1528
|
+
* setSource's shader pre-warm compiles ahead of the transition.
|
|
1529
|
+
*/
|
|
1530
|
+
sharedChunkCode(name, effect, opts) {
|
|
1531
|
+
const o = resolveEffectOptions(effect, { ...opts, scope: 'tile', direction: 'in', progress: 1 }, 'set', { internal: true });
|
|
1532
|
+
const insts = [...(this.scopes.get('tile')?.values() ?? [])].filter((i) => i.name !== name);
|
|
1533
|
+
return composeModifier([...insts, { name, def: EFFECTS[effect], opts: o }]).code;
|
|
1534
|
+
}
|
|
1535
|
+
|
|
1329
1536
|
_setupInstance(inst) {
|
|
1330
1537
|
// Also for a gated one (its held START state needs the geometry); re-run when the gate opens,
|
|
1331
1538
|
// since the rig/framing may have changed meanwhile (setSource's flip adopts a new asset).
|
|
@@ -1346,7 +1553,7 @@ export class SplatEffects {
|
|
|
1346
1553
|
*/
|
|
1347
1554
|
stop(name, { finish = false, entity = null } = {}) {
|
|
1348
1555
|
for (const [key, n, inst] of [...this._all()]) {
|
|
1349
|
-
if (name ? n !== name : inst.def.internal) continue;
|
|
1556
|
+
if (inst.hidden || (name ? n !== name : inst.def.internal)) continue;
|
|
1350
1557
|
if (entity && key !== entity) continue;
|
|
1351
1558
|
const m = this.scopes.get(key);
|
|
1352
1559
|
if (finish && inst.mode === 'play' && inst.opts.direction === 'out') {
|
|
@@ -1366,7 +1573,7 @@ export class SplatEffects {
|
|
|
1366
1573
|
list() {
|
|
1367
1574
|
const out = [];
|
|
1368
1575
|
for (const [key, name, inst] of this._all()) {
|
|
1369
|
-
if (inst.def.internal) continue;
|
|
1576
|
+
if (inst.def.internal || inst.hidden) continue;
|
|
1370
1577
|
out.push({
|
|
1371
1578
|
name,
|
|
1372
1579
|
scope: key === 'tile' ? 'tile' : 'entity',
|
|
@@ -1398,6 +1605,7 @@ export class SplatEffects {
|
|
|
1398
1605
|
tick(tMs) {
|
|
1399
1606
|
if (this._disposed) return;
|
|
1400
1607
|
for (const [key, name, inst] of [...this._all()]) {
|
|
1608
|
+
if (inst.fixed) continue;
|
|
1401
1609
|
if (inst.mode === 'play') {
|
|
1402
1610
|
if (inst.startGate) {
|
|
1403
1611
|
this._apply(key, inst, tMs); // hold the start state
|
|
@@ -56,7 +56,11 @@ import {
|
|
|
56
56
|
EFFECTS,
|
|
57
57
|
EASINGS,
|
|
58
58
|
resolveRevealOption,
|
|
59
|
+
resolveEffectOptions,
|
|
59
60
|
validateEffectCall,
|
|
61
|
+
PARTICLE_TRANSITIONS,
|
|
62
|
+
PARTICLE_TRANSITION_OPTIONS,
|
|
63
|
+
particleSpan,
|
|
60
64
|
} from './inline3d-splat-effects.js';
|
|
61
65
|
import { LiveOutgoing, resolveOutgoingOption, defaultOutgoing, yieldIdle } from './inline3d-splat-live.js';
|
|
62
66
|
import {
|
|
@@ -1269,8 +1273,9 @@ export class PlayCanvasSplatViewer {
|
|
|
1269
1273
|
* so a region only one of the two photos covers fades against the page, not against black.
|
|
1270
1274
|
*/
|
|
1271
1275
|
_ensureSnapshotOverlay() {
|
|
1272
|
-
const s = this._snap;
|
|
1276
|
+
const s = (this._snap ||= { w: 0, h: 0, tex: null, rt: null, parts: null }); // prewarm: no capture yet
|
|
1273
1277
|
if (s.parts) {
|
|
1278
|
+
if (!s.tex) return;
|
|
1274
1279
|
for (const p of s.parts) {
|
|
1275
1280
|
p.mat.setParameter('dxrSnap', s.tex);
|
|
1276
1281
|
p.mat.setParameter('dxrSnapInvSize', [1 / s.w, 1 / s.h]);
|
|
@@ -1295,6 +1300,7 @@ export class PlayCanvasSplatViewer {
|
|
|
1295
1300
|
uniform vec2 dxrSnapInvSize;
|
|
1296
1301
|
uniform float dxrSnapAlpha;
|
|
1297
1302
|
uniform vec3 dxrSnapWipe; // t, band, views across (t < -1: no wipe)
|
|
1303
|
+
uniform float dxrSnapOver; // 0 = lerp, 1 = A OVER the scene (the particle transitions)
|
|
1298
1304
|
float dxrSnapWeight() {
|
|
1299
1305
|
if (dxrSnapWipe.x < -1.0) return dxrSnapAlpha;
|
|
1300
1306
|
float vw = 1.0 / (dxrSnapInvSize.x * dxrSnapWipe.z);
|
|
@@ -1309,10 +1315,13 @@ export class PlayCanvasSplatViewer {
|
|
|
1309
1315
|
mat.depthTest = false;
|
|
1310
1316
|
mat.depthWrite = false;
|
|
1311
1317
|
mat.cull = pc.CULLFACE_NONE;
|
|
1312
|
-
|
|
1313
|
-
|
|
1318
|
+
if (s.tex) {
|
|
1319
|
+
mat.setParameter('dxrSnap', s.tex);
|
|
1320
|
+
mat.setParameter('dxrSnapInvSize', [1 / s.w, 1 / s.h]);
|
|
1321
|
+
}
|
|
1314
1322
|
mat.setParameter('dxrSnapAlpha', 0);
|
|
1315
1323
|
mat.setParameter('dxrSnapWipe', [-2, 0.1, 1]);
|
|
1324
|
+
mat.setParameter('dxrSnapOver', 0);
|
|
1316
1325
|
mat.update();
|
|
1317
1326
|
const mi = new pc.MeshInstance(mesh, mat, new pc.GraphNode(name));
|
|
1318
1327
|
mi.cull = false;
|
|
@@ -1321,7 +1330,14 @@ export class PlayCanvasSplatViewer {
|
|
|
1321
1330
|
return { mat, mi };
|
|
1322
1331
|
};
|
|
1323
1332
|
s.parts = [
|
|
1324
|
-
|
|
1333
|
+
// lerp: dst·(1 − w); over: dst·(1 − w·A.a) — premultiplied A over the scene
|
|
1334
|
+
part(
|
|
1335
|
+
'inline3dSnapshotScale',
|
|
1336
|
+
'float w = dxrSnapWeight(); float a = texture2D(dxrSnap, gl_FragCoord.xy * dxrSnapInvSize).a; gl_FragColor = vec4(0.0, 0.0, 0.0, mix(1.0 - w, 1.0 - w * a, dxrSnapOver));',
|
|
1337
|
+
pc.BLENDMODE_ZERO,
|
|
1338
|
+
pc.BLENDMODE_SRC_ALPHA,
|
|
1339
|
+
-2,
|
|
1340
|
+
),
|
|
1325
1341
|
part('inline3dSnapshotAdd', 'gl_FragColor = texture2D(dxrSnap, gl_FragCoord.xy * dxrSnapInvSize) * dxrSnapWeight();', pc.BLENDMODE_ONE, pc.BLENDMODE_ONE, -1),
|
|
1326
1342
|
];
|
|
1327
1343
|
this.app.scene.layers.getLayerById(pc.LAYERID_UI).addMeshInstances(s.parts.map((p) => p.mi));
|
|
@@ -1331,7 +1347,7 @@ export class PlayCanvasSplatViewer {
|
|
|
1331
1347
|
* Lerp the snapshot over the scene at `alpha` (0 hides it). Returns false when the canvas buffer
|
|
1332
1348
|
* no longer matches the capture (resize, 2D/3D switch): the caller ends its fade.
|
|
1333
1349
|
*/
|
|
1334
|
-
setSnapshotAlpha(alpha, wipe = null) {
|
|
1350
|
+
setSnapshotAlpha(alpha, wipe = null, mix = null) {
|
|
1335
1351
|
const s = this._snap;
|
|
1336
1352
|
if (!s?.parts) return false;
|
|
1337
1353
|
const fits = !!s.tex && s.w === this.canvas.width && s.h === this.canvas.height;
|
|
@@ -1344,6 +1360,8 @@ export class PlayCanvasSplatViewer {
|
|
|
1344
1360
|
// SAME viewport-relative x in every eye — a front at the zero-disparity plane, so both
|
|
1345
1361
|
// eyes agree. `views` = eye viewports side by side across the buffer.
|
|
1346
1362
|
p.mat.setParameter('dxrSnapWipe', wipe ? [wipe.t, wipe.band, wipe.views] : [-2, 0.1, 1]);
|
|
1363
|
+
// setSource's particle transitions: the live outgoing image OVER the incoming one
|
|
1364
|
+
p.mat.setParameter('dxrSnapOver', mix?.over ? 1 : 0);
|
|
1347
1365
|
p.mat.update();
|
|
1348
1366
|
}
|
|
1349
1367
|
}
|
|
@@ -2139,8 +2157,40 @@ export const SOURCE_TRANSITIONS = Object.freeze({
|
|
|
2139
2157
|
crossfade: {},
|
|
2140
2158
|
flip: { durationMs: 2200, easing: 'easeInOutSine' },
|
|
2141
2159
|
wavefront: { durationMs: 2000, easing: 'easeInOutSine', band: 0.18, ridge: 0.03, ridgeMaxDisparity: 0.004 },
|
|
2160
|
+
// the particle transitions (./inline3d-splat-effects.js PARTICLE_TRANSITIONS)
|
|
2161
|
+
...Object.fromEntries(Object.entries(PARTICLE_TRANSITIONS).map(([k, v]) => [k, { durationMs: v.durationMs, easing: v.easing, particle: true }])),
|
|
2142
2162
|
});
|
|
2143
2163
|
|
|
2164
|
+
/**
|
|
2165
|
+
* A particle transition's two sides, resolved: { out: { effect, opts }, in: { effect, opts },
|
|
2166
|
+
* overlap } — the table's defaults, then the page's shared particle options, then its
|
|
2167
|
+
* per-side `outgoingFx` / `incomingFx` overrides. Every side is validated as its effect (throws).
|
|
2168
|
+
*/
|
|
2169
|
+
export function resolveParticleTransition(name, o = {}) {
|
|
2170
|
+
const spec = PARTICLE_TRANSITIONS[name];
|
|
2171
|
+
const shared = {};
|
|
2172
|
+
for (const k of PARTICLE_TRANSITION_OPTIONS) if (o[k] !== undefined) shared[k] = o[k];
|
|
2173
|
+
for (const k of ['outgoingFx', 'incomingFx']) {
|
|
2174
|
+
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.`);
|
|
2175
|
+
}
|
|
2176
|
+
const overlap = o.overlap ?? spec.overlap ?? 0;
|
|
2177
|
+
if (!(typeof overlap === 'number' && overlap >= 0 && overlap <= 1)) throw new RangeError(`@displayxr/inline3d/splat: setSource overlap must be a number in [0, 1], got ${o.overlap}.`);
|
|
2178
|
+
const side = (which, dflt, extra) => {
|
|
2179
|
+
const effect = dflt.effect;
|
|
2180
|
+
const opts = { ...dflt.opts, ...shared, ...(extra || {}) };
|
|
2181
|
+
resolveEffectOptions(effect, { ...opts, scope: 'entity' }, 'set', { internal: true }); // throws on a bad one
|
|
2182
|
+
return { effect, opts };
|
|
2183
|
+
};
|
|
2184
|
+
const out = side('out', spec.out, o.outgoingFx);
|
|
2185
|
+
const inc = side('in', spec.in, o.incomingFx);
|
|
2186
|
+
// Both photos run ONE render-time body, compiled for one `order`; `layers` reads splat.index,
|
|
2187
|
+
// which is a file index only in a work-buffer modifier (not at render time).
|
|
2188
|
+
const order = (x) => x.opts.order ?? EFFECTS[x.effect].defaults.order;
|
|
2189
|
+
if (order(out) !== order(inc)) throw new Error(`@displayxr/inline3d/splat: setSource ${name}: both photos take the same order (got '${order(out)}' and '${order(inc)}').`);
|
|
2190
|
+
if (order(inc) === 'layers') throw new Error(`@displayxr/inline3d/splat: setSource ${name}: order 'layers' is a reveal-only order (it needs the file index, which a transition's render-time body does not have).`);
|
|
2191
|
+
return { out, in: inc, overlap };
|
|
2192
|
+
}
|
|
2193
|
+
|
|
2144
2194
|
/** Validate setSource's options into a plan (throws on a page bug, before anything loads). */
|
|
2145
2195
|
export function resolveSwap(o = {}) {
|
|
2146
2196
|
if (o === null || typeof o !== 'object') throw new TypeError('@displayxr/inline3d/splat: setSource options must be an object.');
|
|
@@ -2155,9 +2205,10 @@ export function resolveSwap(o = {}) {
|
|
|
2155
2205
|
const easing = o.easing ?? d.easing ?? 'linear';
|
|
2156
2206
|
if (typeof easing !== 'function' && !EASINGS[easing]) throw new Error(`@displayxr/inline3d/splat: unknown easing '${easing}'.`);
|
|
2157
2207
|
const reveal = resolveRevealOption(o.reveal);
|
|
2158
|
-
if (reveal && (transition === 'flip' || transition === 'wavefront')) {
|
|
2208
|
+
if (reveal && (transition === 'flip' || transition === 'wavefront' || d.particle)) {
|
|
2159
2209
|
throw new Error(`@displayxr/inline3d/splat: setSource reveal plays with transition 'cut' or 'crossfade'; '${transition}' is its own reveal.`);
|
|
2160
2210
|
}
|
|
2211
|
+
const particles = d.particle ? resolveParticleTransition(transition, o) : null;
|
|
2161
2212
|
const band = o.band ?? d.band ?? 0.18;
|
|
2162
2213
|
const ridge = o.ridge ?? d.ridge ?? 0.03;
|
|
2163
2214
|
const ridgeMaxDisparity = o.ridgeMaxDisparity ?? d.ridgeMaxDisparity ?? 0.004;
|
|
@@ -2165,7 +2216,7 @@ export function resolveSwap(o = {}) {
|
|
|
2165
2216
|
EFFECTS.wavefront.validate({ band, ridge, ridgeMaxDisparity });
|
|
2166
2217
|
}
|
|
2167
2218
|
const outgoing = resolveOutgoingOption(o.outgoing);
|
|
2168
|
-
return { transition, durationMs: transition === 'crossfade' && durationMs === 0 ? 800 : durationMs, easing, reveal, band, ridge, ridgeMaxDisparity, outgoing };
|
|
2219
|
+
return { transition, durationMs: transition === 'crossfade' && durationMs === 0 ? 800 : durationMs, easing, reveal, band, ridge, ridgeMaxDisparity, outgoing, particles };
|
|
2169
2220
|
}
|
|
2170
2221
|
|
|
2171
2222
|
/**
|
|
@@ -2892,13 +2943,17 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
|
|
|
2892
2943
|
* Memory: the prepared asset is fully resident (a 1.18M-gaussian SOG: its GPU textures plus the
|
|
2893
2944
|
* engine's centre array) alongside the current one until it is used or disposed.
|
|
2894
2945
|
*/
|
|
2895
|
-
async function prepareSource(src) {
|
|
2946
|
+
async function prepareSource(src, po = {}) {
|
|
2947
|
+
if (po === null || typeof po !== 'object') throw new TypeError('@displayxr/inline3d/splat: prepareSource options must be an object.');
|
|
2948
|
+
const warm = po.transition !== undefined ? resolveSwap(po) : null; // the setSource options it will get; throws on a bad one
|
|
2896
2949
|
const app = await booted;
|
|
2897
2950
|
await first.catch(() => null);
|
|
2898
2951
|
if (!app || removed) throw new Error('@displayxr/inline3d/splat: prepareSource on a removed tile.');
|
|
2899
2952
|
const t0 = performance.now();
|
|
2900
2953
|
const loaded = await loadOne(pcModule, app, src, { background: true });
|
|
2901
2954
|
perfSpan('prepareSource', t0);
|
|
2955
|
+
// The transition the page declared: compile its shader now, in the dwell, not on its first frame.
|
|
2956
|
+
if (warm?.particles) await prewarmTransition(warm);
|
|
2902
2957
|
const entry = { loaded, state: 'ready', dispose: null };
|
|
2903
2958
|
const prepared = {
|
|
2904
2959
|
[PREPARED_TAG]: true,
|
|
@@ -2926,6 +2981,98 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
|
|
|
2926
2981
|
return prepared;
|
|
2927
2982
|
}
|
|
2928
2983
|
|
|
2984
|
+
// ── shader pre-warm for the particle transitions ──
|
|
2985
|
+
const prewarmed = new Set();
|
|
2986
|
+
/**
|
|
2987
|
+
* Compile + link the render-time variant a particle transition will install, ahead of it. The
|
|
2988
|
+
* first frame of a transition otherwise blocks on the link (GetProgramiv: 35–45 ms on an M1,
|
|
2989
|
+
* measured with a trace) — on the very transition a user sees first. A throwaway material with
|
|
2990
|
+
* the eye renderer's own description, defines and chunks, plus the transition's chunk, asks the
|
|
2991
|
+
* engine's program library for the same variant the renderer will ask for (the library keys on
|
|
2992
|
+
* the generated source + processing options, not on the material), so the compile is issued
|
|
2993
|
+
* now and, with KHR_parallel_shader_compile, finishes off the main thread. Best effort: an
|
|
2994
|
+
* engine whose internals differ just compiles on the transition's first frame, as before.
|
|
2995
|
+
*/
|
|
2996
|
+
async function prewarmTransition(plan) {
|
|
2997
|
+
const key = `${plan.particles.in.effect}|${plan.particles.in.opts.order ?? ''}`;
|
|
2998
|
+
if (!fx || prewarmed.has(key)) return;
|
|
2999
|
+
await yieldIdle();
|
|
3000
|
+
if (removed || !fx) return;
|
|
3001
|
+
try {
|
|
3002
|
+
const pc = pcModule;
|
|
3003
|
+
const cams = [viewer.eye, viewer._live?.cam].filter(Boolean);
|
|
3004
|
+
const code = fx.sharedChunkCode('transition', plan.particles.in.effect, { order: plan.particles.in.opts.order });
|
|
3005
|
+
const made = [];
|
|
3006
|
+
let issued = 0;
|
|
3007
|
+
for (const cam of cams) {
|
|
3008
|
+
const cd = viewer.app?.renderer?.gsplatDirector?.camerasMap?.get?.(cam.camera?.camera);
|
|
3009
|
+
let mi = null;
|
|
3010
|
+
if (cd?.layersMap) for (const ld of cd.layersMap.values()) mi ||= ld?.gsplatManager?.renderer?.meshInstance ?? null;
|
|
3011
|
+
const src = mi?.material;
|
|
3012
|
+
const camera = cam.camera?.camera;
|
|
3013
|
+
if (!src?.shaderDesc || !camera?.shaderParams || typeof src.getShaderVariant !== 'function') continue;
|
|
3014
|
+
const m = new pc.ShaderMaterial(src.shaderDesc);
|
|
3015
|
+
src.defines.forEach((v, k) => m.setDefine(k, v));
|
|
3016
|
+
m.shaderChunks.copy(src.shaderChunks);
|
|
3017
|
+
m.getShaderChunks(pc.SHADERLANGUAGE_GLSL).set('gsplatModifyVS', code);
|
|
3018
|
+
m.blendState = src.blendState;
|
|
3019
|
+
made.push(m.getShaderVariant({
|
|
3020
|
+
device: viewer.app.graphicsDevice,
|
|
3021
|
+
scene: viewer.app.scene,
|
|
3022
|
+
objDefs: mi._shaderDefs,
|
|
3023
|
+
cameraShaderParams: camera.shaderParams,
|
|
3024
|
+
pass: 0, // SHADER_FORWARD
|
|
3025
|
+
sortedLights: [],
|
|
3026
|
+
viewUniformFormat: viewer.app.renderer?.viewUniformFormat ?? null,
|
|
3027
|
+
vertexFormat: mi.mesh?.vertexBuffer?.format,
|
|
3028
|
+
}));
|
|
3029
|
+
issued++;
|
|
3030
|
+
}
|
|
3031
|
+
// the overlay's two quads (created on the first capture otherwise, and compiled on its draw)
|
|
3032
|
+
const cam = viewer.eye?.camera?.camera;
|
|
3033
|
+
if (cam?.shaderParams) {
|
|
3034
|
+
viewer._ensureSnapshotOverlay();
|
|
3035
|
+
for (const p of viewer._snap?.parts || []) {
|
|
3036
|
+
made.push(p.mat.getShaderVariant?.({
|
|
3037
|
+
device: viewer.app.graphicsDevice,
|
|
3038
|
+
scene: viewer.app.scene,
|
|
3039
|
+
objDefs: p.mi._shaderDefs,
|
|
3040
|
+
cameraShaderParams: cam.shaderParams,
|
|
3041
|
+
pass: 0,
|
|
3042
|
+
sortedLights: [],
|
|
3043
|
+
viewUniformFormat: viewer.app.renderer?.viewUniformFormat ?? null,
|
|
3044
|
+
vertexFormat: p.mi.mesh?.vertexBuffer?.format,
|
|
3045
|
+
}));
|
|
3046
|
+
}
|
|
3047
|
+
}
|
|
3048
|
+
if (issued) prewarmed.add(key);
|
|
3049
|
+
await finalizeWhenLinked(made.filter(Boolean));
|
|
3050
|
+
} catch (err) {
|
|
3051
|
+
console.info('[inline3d/splat] transition shader pre-warm skipped', err);
|
|
3052
|
+
}
|
|
3053
|
+
}
|
|
3054
|
+
|
|
3055
|
+
/**
|
|
3056
|
+
* Creating a program only ISSUES its compile + link; the browser resolves the link when the
|
|
3057
|
+
* program is first queried — the engine's first draw, blocking (the 35–50 ms GetProgramiv the
|
|
3058
|
+
* trace shows, even for a program created seconds earlier). So finish the engine's own
|
|
3059
|
+
* finalize here, in the dwell: wait (in idle periods, not blocking) for the link to complete
|
|
3060
|
+
* where the browser reports it (KHR_parallel_shader_compile), then finalize.
|
|
3061
|
+
*/
|
|
3062
|
+
async function finalizeWhenLinked(shaders) {
|
|
3063
|
+
const dev = viewer.app?.graphicsDevice;
|
|
3064
|
+
if (!dev) return;
|
|
3065
|
+
const pending = () => shaders.filter((x) => x && !x.ready && !x.failed && x.impl?.finalize);
|
|
3066
|
+
for (let i = 0; i < 40 && pending().length; i++) {
|
|
3067
|
+
for (const x of pending()) if (x.impl.isLinked?.(dev)) x.impl.finalize(dev, x) || (x.failed = true);
|
|
3068
|
+
if (pending().length) await yieldIdle(50);
|
|
3069
|
+
if (removed) return;
|
|
3070
|
+
}
|
|
3071
|
+
// still linking (or no completion query): finalize now — a block in the dwell, not on the
|
|
3072
|
+
// transition's first frame
|
|
3073
|
+
for (const x of pending()) x.impl.finalize(dev, x) || (x.failed = true);
|
|
3074
|
+
}
|
|
3075
|
+
|
|
2929
3076
|
// ── setSource: swap the asset — a cut, a crossfade, or a transition ──
|
|
2930
3077
|
let sourceGen = 0;
|
|
2931
3078
|
let pendingSwap = null;
|
|
@@ -2972,9 +3119,10 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
|
|
|
2972
3119
|
const prev = current;
|
|
2973
3120
|
pendingSwap?.finish();
|
|
2974
3121
|
const transition = prev ? plan.transition : 'cut';
|
|
3122
|
+
const particle = plan.particles && transition in PARTICLE_TRANSITIONS ? plan.particles : null;
|
|
2975
3123
|
// FRAME_SNAPSHOT: freeze the outgoing frame BEFORE anything of the new asset (or its rig) is
|
|
2976
3124
|
// drawn. The overlay goes up in the same task, so no frame shows neither photo.
|
|
2977
|
-
const wantsSnapshot = transition === 'crossfade' || transition === 'wavefront';
|
|
3125
|
+
const wantsSnapshot = transition === 'crossfade' || transition === 'wavefront' || !!particle;
|
|
2978
3126
|
const snapped = wantsSnapshot ? await viewer.captureFrame() : false;
|
|
2979
3127
|
if (removed || gen !== sourceGen) {
|
|
2980
3128
|
app.assets.remove(loaded.asset);
|
|
@@ -2985,7 +3133,9 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
|
|
|
2985
3133
|
// LIVE outgoing (default in 3D): the old asset stays resident and keeps rendering, through the
|
|
2986
3134
|
// same eye views, into its own target; the frozen capture above bridges the first frames until
|
|
2987
3135
|
// its camera has drawn a sorted frame. Its lens frame is read BEFORE adopt() switches the rig.
|
|
2988
|
-
|
|
3136
|
+
// A particle transition's outgoing photo MOVES, so it is live in 2D too (where the engine has
|
|
3137
|
+
// the RenderView path); 'frozen' there = its snapshot fades out while the new one plays in.
|
|
3138
|
+
const outgoingMode = plan.outgoing || (particle ? 'live' : defaultOutgoing(viewer.is3D));
|
|
2989
3139
|
const live =
|
|
2990
3140
|
snapped && outgoingMode === 'live' && viewer.canLiveOutgoing ? viewer.startLiveOutgoing(prev.entity, viewer.lensFrame()) : null;
|
|
2991
3141
|
/** Each frame of the window: once the live camera is ready, the overlay samples it. */
|
|
@@ -3070,6 +3220,12 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
|
|
|
3070
3220
|
return out;
|
|
3071
3221
|
}
|
|
3072
3222
|
|
|
3223
|
+
if (particle && snapped) {
|
|
3224
|
+
playParticleTransition({ particle, plan, prev, entity, live, adopt, release, finishers, finish, isFinished: () => finished, pumpLive, isLiveShown: () => liveShown });
|
|
3225
|
+
await done;
|
|
3226
|
+
return out;
|
|
3227
|
+
}
|
|
3228
|
+
|
|
3073
3229
|
adopt(entity);
|
|
3074
3230
|
if (transition === 'cut') {
|
|
3075
3231
|
release(prev);
|
|
@@ -3173,6 +3329,108 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
|
|
|
3173
3329
|
return out;
|
|
3174
3330
|
}
|
|
3175
3331
|
|
|
3332
|
+
/**
|
|
3333
|
+
* setSource's particle transitions (swarm, burst, shimmer-cross, dust), once the frame snapshot
|
|
3334
|
+
* is up. The OUTGOING asset plays its effect in reverse, on the live camera, keyed on its own
|
|
3335
|
+
* frame (taken here, before the rig switches); the INCOMING one plays forwards on the eye camera,
|
|
3336
|
+
* its frame re-taken when the clock starts. One clock drives both, on overlapping spans
|
|
3337
|
+
* (particleSpan). The overlay puts the live outgoing image OVER the incoming one, per eye. With
|
|
3338
|
+
* no live camera (outgoing:'frozen', or an engine without the RenderView path) the frozen frame
|
|
3339
|
+
* fades out over the outgoing span while the incoming asset plays its side. The end: effects
|
|
3340
|
+
* removed (modifiers deleted), overlay hidden, live camera off, old asset released — the frame
|
|
3341
|
+
* is a plain cut's.
|
|
3342
|
+
*/
|
|
3343
|
+
function playParticleTransition({ particle, plan, prev, entity, live, adopt, release, finishers, finish, isFinished, pumpLive, isLiveShown }) {
|
|
3344
|
+
const ease = typeof plan.easing === 'function' ? plan.easing : EASINGS[plan.easing] || EASINGS.linear;
|
|
3345
|
+
const overlap = particle.overlap;
|
|
3346
|
+
// RENDER-TIME (the default): one tile-scope body, each photo's values on the mesh instance of
|
|
3347
|
+
// the gsplat manager that draws it — the eye camera's (incoming) and the live camera's
|
|
3348
|
+
// (outgoing). No work-buffer rewrite and no re-sort per frame. An engine that does not expose
|
|
3349
|
+
// the managers' mesh instances gets the entity-scope modifiers instead (a full work-buffer
|
|
3350
|
+
// rewrite + a re-sort per asset per frame: correct, but it hitches on a 1.18M photo).
|
|
3351
|
+
const managerMi = (cam, layer) => {
|
|
3352
|
+
const cd = viewer.app?.renderer?.gsplatDirector?.camerasMap?.get?.(cam?.camera?.camera);
|
|
3353
|
+
if (!cd?.layersMap) return null;
|
|
3354
|
+
if (layer) return cd.layersMap.get(layer)?.gsplatManager?.renderer?.meshInstance ?? null;
|
|
3355
|
+
for (const ld of cd.layersMap.values()) if (ld?.gsplatManager?.renderer?.meshInstance) return ld.gsplatManager.renderer.meshInstance;
|
|
3356
|
+
return null;
|
|
3357
|
+
};
|
|
3358
|
+
const eyeMi = () => managerMi(viewer.eye, null);
|
|
3359
|
+
const liveMi = () => (live?.active ? managerMi(live.cam, live.layer) : null);
|
|
3360
|
+
const shared = particle.out.effect === particle.in.effect && eyeMi() ? fx.driveShared('transition', particle.in.effect, { order: particle.in.opts.order }) : null;
|
|
3361
|
+
// the outgoing side, BEFORE adopt(): its frame (eyes, focus, framing) is the old asset's
|
|
3362
|
+
let outFx = null;
|
|
3363
|
+
if (live) {
|
|
3364
|
+
outFx = shared ? shared.side(liveMi, particle.out.opts) : fx.drive(prev.entity, particle.out.effect, particle.out.opts);
|
|
3365
|
+
outFx.set(1, 0);
|
|
3366
|
+
} else {
|
|
3367
|
+
release(prev); // the frozen frame shows it from here on
|
|
3368
|
+
}
|
|
3369
|
+
adopt(entity);
|
|
3370
|
+
// the incoming side, hidden (amount 0) until the clock starts
|
|
3371
|
+
const inFx = shared ? shared.side(eyeMi, particle.in.opts) : fx.drive(entity, particle.in.effect, particle.in.opts);
|
|
3372
|
+
inFx.set(0, 0);
|
|
3373
|
+
viewer._transitionPath = shared ? 'render' : 'entity'; // diagnostics (docs §Gates)
|
|
3374
|
+
finishers.push(() => {
|
|
3375
|
+
viewer._transitionState = null;
|
|
3376
|
+
if (shared) shared.remove();
|
|
3377
|
+
else {
|
|
3378
|
+
outFx?.remove();
|
|
3379
|
+
inFx.remove();
|
|
3380
|
+
}
|
|
3381
|
+
viewer.setSnapshotAlpha(0);
|
|
3382
|
+
viewer.releaseSnapshot();
|
|
3383
|
+
if (live) {
|
|
3384
|
+
viewer.stopLiveOutgoing();
|
|
3385
|
+
release(prev);
|
|
3386
|
+
}
|
|
3387
|
+
});
|
|
3388
|
+
const dur = plan.durationMs;
|
|
3389
|
+
let t0 = null;
|
|
3390
|
+
let ticks = 0;
|
|
3391
|
+
viewer._hooks.push((t) => {
|
|
3392
|
+
if (removed) return (finish(), false);
|
|
3393
|
+
if (isFinished()) return false;
|
|
3394
|
+
if (live && !live.fits) {
|
|
3395
|
+
finish(); // a resize or a 2D/3D switch mid-window: end now
|
|
3396
|
+
return false;
|
|
3397
|
+
}
|
|
3398
|
+
pumpLive();
|
|
3399
|
+
// The clock starts on the SECOND tick (the incoming asset's first frame builds its work
|
|
3400
|
+
// buffer) and, live, once the live camera has drawn a sorted frame (until then the frozen
|
|
3401
|
+
// capture stands in for the outgoing photo, untouched).
|
|
3402
|
+
if (++ticks < 2 || (live && !isLiveShown())) {
|
|
3403
|
+
if (shared) {
|
|
3404
|
+
// the managers (and their mesh instances) may be new: keep both photos' values on them
|
|
3405
|
+
outFx?.set(1, 0);
|
|
3406
|
+
inFx.set(0, 0);
|
|
3407
|
+
}
|
|
3408
|
+
if (!viewer.setSnapshotAlpha(1)) {
|
|
3409
|
+
finish();
|
|
3410
|
+
return false;
|
|
3411
|
+
}
|
|
3412
|
+
return true;
|
|
3413
|
+
}
|
|
3414
|
+
if (t0 === null) {
|
|
3415
|
+
t0 = t;
|
|
3416
|
+
inFx.restart(); // the incoming asset's frame, now its rig and first frames are in
|
|
3417
|
+
}
|
|
3418
|
+
const raw = dur > 0 ? Math.min(1, Math.max(0, (t - t0) / dur)) : 1;
|
|
3419
|
+
const te = ease(raw);
|
|
3420
|
+
const timeS = (t - t0) / 1000;
|
|
3421
|
+
viewer._transitionState = { raw, live: !!live }; // diagnostics (docs §Gates)
|
|
3422
|
+
const outSpan = particleSpan(te, overlap, 'out');
|
|
3423
|
+
outFx?.set(1 - outSpan, timeS);
|
|
3424
|
+
inFx.set(particleSpan(te, overlap, 'in'), timeS);
|
|
3425
|
+
const ok = live ? viewer.setSnapshotAlpha(1, null, { over: true }) : viewer.setSnapshotAlpha(1 - outSpan);
|
|
3426
|
+
if (!ok || raw >= 1) {
|
|
3427
|
+
finish();
|
|
3428
|
+
return false;
|
|
3429
|
+
}
|
|
3430
|
+
return true;
|
|
3431
|
+
});
|
|
3432
|
+
}
|
|
3433
|
+
|
|
3176
3434
|
// The load promise is RETURNED, never written to `out.ready`: ./splat's addSplat owns that
|
|
3177
3435
|
// field (one promise, one owner).
|
|
3178
3436
|
return first
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@displayxr/inline3d",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.15.0",
|
|
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
|
@@ -374,6 +374,10 @@ export interface SplatParticleOptions {
|
|
|
374
374
|
* home; max 0.05). In 2D a nominal 64 mm-at-1.7 m separation is assumed.
|
|
375
375
|
*/
|
|
376
376
|
maxDisparity?: number;
|
|
377
|
+
/** Fade a particle over the first `vanish` of its flight (0 = off, the reveals' default; the particle transitions use ~0.3–0.45). */
|
|
378
|
+
vanish?: number;
|
|
379
|
+
/** Share of the gaussians drawn while in flight, 0..1 (1 = all, the reveals' default; the swarm uses 0.2). */
|
|
380
|
+
density?: number;
|
|
377
381
|
/** `order: 'layers'`: gaussians per layer (default 768² = 589 824, SHARP). */
|
|
378
382
|
layerSize?: number;
|
|
379
383
|
/** `assemble`: how far out the swarm starts (half-view-widths), its spiral (rad), its curl turbulence, how coherent its start field is (0..1), and how much of it reaches back in depth (0..1). */
|
|
@@ -497,8 +501,15 @@ export interface SplatSourceOptions {
|
|
|
497
501
|
* inflates out of its own; default 2200 ms), `'wavefront'` (a soft front crosses left → right
|
|
498
502
|
* with a thin depth ridge riding it; default 2000 ms, ease-in-out; falls back to the crossfade
|
|
499
503
|
* in a hidden tab). For photo slideshows: `crossfade` or `wavefront`.
|
|
500
|
-
|
|
501
|
-
|
|
504
|
+
*
|
|
505
|
+
* The particle transitions (docs/splat-effects.md §Particle transitions): `'swarm'` (the old
|
|
506
|
+
* photo disperses into a curl-noise swarm while the new one assembles out of one), `'burst'`
|
|
507
|
+
* (the old one collapses into its focus, the new one bursts out of its own), `'shimmer-cross'`
|
|
508
|
+
* (nothing moves: twinkling points out, twinkling points in) and `'dust'` (drifting dust out,
|
|
509
|
+
* gathering dust in). 2.6–2.8 s, linear shared clock (each particle eases itself). The old
|
|
510
|
+
* photo is live, in 2D too; a hidden tab gets the crossfade.
|
|
511
|
+
*/
|
|
512
|
+
transition?: 'cut' | 'crossfade' | 'flip' | 'wavefront' | SplatParticleTransitionName;
|
|
502
513
|
durationMs?: number;
|
|
503
514
|
easing?: SplatEasing;
|
|
504
515
|
/** `cut`/`crossfade` only: reveal the INCOMING asset (entity scope) while the old one fades. */
|
|
@@ -519,8 +530,34 @@ export interface SplatSourceOptions {
|
|
|
519
530
|
* the single-camera RenderView path always use `'frozen'`.
|
|
520
531
|
*/
|
|
521
532
|
outgoing?: 'live' | 'frozen';
|
|
533
|
+
/** Particle transitions: particle options for BOTH photos (the shared ones of the particle reveals). */
|
|
534
|
+
order?: SplatParticleOptions['order'];
|
|
535
|
+
stagger?: number;
|
|
536
|
+
jitter?: number;
|
|
537
|
+
maxDisparity?: number;
|
|
538
|
+
dotSize?: number;
|
|
539
|
+
noiseScale?: number;
|
|
540
|
+
layerSize?: number;
|
|
541
|
+
origin?: SplatEffectOrigin;
|
|
542
|
+
/** Particle transitions: how much of the clock the two photos' spans share, 0..1 (0 = one after the other). */
|
|
543
|
+
overlap?: number;
|
|
544
|
+
/** Particle transitions: option overrides for the OUTGOING photo's effect. */
|
|
545
|
+
outgoingFx?: SplatParticleOptions & Record<string, unknown>;
|
|
546
|
+
/** Particle transitions: option overrides for the INCOMING photo's effect. */
|
|
547
|
+
incomingFx?: SplatParticleOptions & Record<string, unknown>;
|
|
522
548
|
}
|
|
523
549
|
|
|
550
|
+
/**
|
|
551
|
+
* `prepareSource`'s options: the `setSource` options the prepared asset will be swapped in with.
|
|
552
|
+
* For a particle transition, its shaders are compiled and linked NOW, in the dwell — otherwise the
|
|
553
|
+
* first transition of each kind in a page blocks its first frame on the link (tens of ms, hundreds
|
|
554
|
+
* the first time a machine sees the variant). Validated like setSource's.
|
|
555
|
+
*/
|
|
556
|
+
export type SplatPrepareOptions = SplatSourceOptions;
|
|
557
|
+
|
|
558
|
+
/** setSource's particle transitions (docs/splat-effects.md §Particle transitions). */
|
|
559
|
+
export type SplatParticleTransitionName = 'swarm' | 'burst' | 'shimmer-cross' | 'dust';
|
|
560
|
+
|
|
524
561
|
/**
|
|
525
562
|
* What `prepareSource()` resolves to: an opaque, single-use handle for `setSource`. The asset is
|
|
526
563
|
* fully resident (GPU textures + the engine's centre array) until used or disposed.
|
|
@@ -689,7 +726,7 @@ export interface SplatHandle {
|
|
|
689
726
|
* Memory: two full assets resident until the swap (≈ +70 MB of GPU textures per 1.18M-gaussian
|
|
690
727
|
* SOG). `remove()` disposes any still unused.
|
|
691
728
|
*/
|
|
692
|
-
prepareSource(src: string | Blob | ArrayBuffer | Uint8Array): Promise<SplatPreparedSource>;
|
|
729
|
+
prepareSource(src: string | Blob | ArrayBuffer | Uint8Array, opts?: SplatPrepareOptions): Promise<SplatPreparedSource>;
|
|
693
730
|
/**
|
|
694
731
|
* `engine: 'playcanvas'` only (throws on Spark). Play a transition/pulse/custom effect;
|
|
695
732
|
* validated at the call, run once the first asset is on screen. Resolves `{ finished }` —
|