@displayxr/inline3d 1.13.1 → 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 +93 -1
- package/README.md +4 -3
- package/js/inline3d-playcanvas-engine.js +2 -0
- package/js/inline3d-splat-effects.js +675 -6
- package/js/inline3d-splat-live.js +343 -0
- package/js/inline3d-splat-playcanvas.js +469 -33
- package/js/inline3d-splat.js +5 -0
- package/js/inline3d-viewer.js +25 -1
- package/package.json +2 -1
- package/splat.d.ts +136 -9
|
@@ -49,15 +49,20 @@ import {
|
|
|
49
49
|
tileSplatBudget,
|
|
50
50
|
budgetPerManager,
|
|
51
51
|
} from './inline3d-splat-perf.js';
|
|
52
|
-
import { boundsFromPositions } from './inline3d-viewer.js';
|
|
52
|
+
import { boundsFromPositions, boundsFromPositionsAsync } from './inline3d-viewer.js';
|
|
53
53
|
import { cameraRigFromPose } from './inline3d-three.js';
|
|
54
54
|
import {
|
|
55
55
|
SplatEffects,
|
|
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';
|
|
65
|
+
import { LiveOutgoing, resolveOutgoingOption, defaultOutgoing, yieldIdle } from './inline3d-splat-live.js';
|
|
61
66
|
import {
|
|
62
67
|
clamp,
|
|
63
68
|
finite,
|
|
@@ -668,6 +673,8 @@ export class PlayCanvasSplatViewer {
|
|
|
668
673
|
this._captureWaiters = [];
|
|
669
674
|
/** The crossfade's frame snapshot: { w, h, tex, rt, mi, mat } once captured. */
|
|
670
675
|
this._snap = null;
|
|
676
|
+
/** setSource's live outgoing asset (./inline3d-splat-live.js), made on first use. */
|
|
677
|
+
this._live = null;
|
|
671
678
|
this.vH = virtualDisplayHeight;
|
|
672
679
|
this.fit = fit;
|
|
673
680
|
this.margin = margin;
|
|
@@ -987,6 +994,8 @@ export class PlayCanvasSplatViewer {
|
|
|
987
994
|
clearTimeout(w.timer);
|
|
988
995
|
w.resolve(false);
|
|
989
996
|
}
|
|
997
|
+
this._live?.destroy();
|
|
998
|
+
this._live = null;
|
|
990
999
|
try {
|
|
991
1000
|
this.app?.destroy();
|
|
992
1001
|
} catch (err) {
|
|
@@ -1223,8 +1232,12 @@ export class PlayCanvasSplatViewer {
|
|
|
1223
1232
|
const w = this.canvas.width;
|
|
1224
1233
|
const h = this.canvas.height;
|
|
1225
1234
|
let s = this._snap;
|
|
1226
|
-
if (!s || s.w !== w || s.h !== h) {
|
|
1227
|
-
|
|
1235
|
+
if (!s || !s.tex || s.w !== w || s.h !== h) {
|
|
1236
|
+
// A new size, or the texture was released after the last transition: a new texture.
|
|
1237
|
+
// The overlay quads (and their compiled materials) are kept.
|
|
1238
|
+
const parts = s?.parts || null;
|
|
1239
|
+
s?.rt?.destroy?.();
|
|
1240
|
+
s?.tex?.destroy?.();
|
|
1228
1241
|
const tex = new pc.Texture(device, {
|
|
1229
1242
|
name: 'inline3d-snapshot',
|
|
1230
1243
|
width: w,
|
|
@@ -1236,7 +1249,7 @@ export class PlayCanvasSplatViewer {
|
|
|
1236
1249
|
addressU: pc.ADDRESS_CLAMP_TO_EDGE,
|
|
1237
1250
|
addressV: pc.ADDRESS_CLAMP_TO_EDGE,
|
|
1238
1251
|
});
|
|
1239
|
-
s = this._snap = { w, h, tex, rt: new pc.RenderTarget({ colorBuffer: tex, depth: false }), parts
|
|
1252
|
+
s = this._snap = { w, h, tex, rt: new pc.RenderTarget({ colorBuffer: tex, depth: false }), parts };
|
|
1240
1253
|
}
|
|
1241
1254
|
// null source = the back buffer, still holding this frame (same task, before compositing).
|
|
1242
1255
|
ok = device.copyRenderTarget(null, s.rt, true, false) !== false;
|
|
@@ -1260,8 +1273,9 @@ export class PlayCanvasSplatViewer {
|
|
|
1260
1273
|
* so a region only one of the two photos covers fades against the page, not against black.
|
|
1261
1274
|
*/
|
|
1262
1275
|
_ensureSnapshotOverlay() {
|
|
1263
|
-
const s = this._snap;
|
|
1276
|
+
const s = (this._snap ||= { w: 0, h: 0, tex: null, rt: null, parts: null }); // prewarm: no capture yet
|
|
1264
1277
|
if (s.parts) {
|
|
1278
|
+
if (!s.tex) return;
|
|
1265
1279
|
for (const p of s.parts) {
|
|
1266
1280
|
p.mat.setParameter('dxrSnap', s.tex);
|
|
1267
1281
|
p.mat.setParameter('dxrSnapInvSize', [1 / s.w, 1 / s.h]);
|
|
@@ -1286,6 +1300,7 @@ export class PlayCanvasSplatViewer {
|
|
|
1286
1300
|
uniform vec2 dxrSnapInvSize;
|
|
1287
1301
|
uniform float dxrSnapAlpha;
|
|
1288
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)
|
|
1289
1304
|
float dxrSnapWeight() {
|
|
1290
1305
|
if (dxrSnapWipe.x < -1.0) return dxrSnapAlpha;
|
|
1291
1306
|
float vw = 1.0 / (dxrSnapInvSize.x * dxrSnapWipe.z);
|
|
@@ -1300,10 +1315,13 @@ export class PlayCanvasSplatViewer {
|
|
|
1300
1315
|
mat.depthTest = false;
|
|
1301
1316
|
mat.depthWrite = false;
|
|
1302
1317
|
mat.cull = pc.CULLFACE_NONE;
|
|
1303
|
-
|
|
1304
|
-
|
|
1318
|
+
if (s.tex) {
|
|
1319
|
+
mat.setParameter('dxrSnap', s.tex);
|
|
1320
|
+
mat.setParameter('dxrSnapInvSize', [1 / s.w, 1 / s.h]);
|
|
1321
|
+
}
|
|
1305
1322
|
mat.setParameter('dxrSnapAlpha', 0);
|
|
1306
1323
|
mat.setParameter('dxrSnapWipe', [-2, 0.1, 1]);
|
|
1324
|
+
mat.setParameter('dxrSnapOver', 0);
|
|
1307
1325
|
mat.update();
|
|
1308
1326
|
const mi = new pc.MeshInstance(mesh, mat, new pc.GraphNode(name));
|
|
1309
1327
|
mi.cull = false;
|
|
@@ -1312,7 +1330,14 @@ export class PlayCanvasSplatViewer {
|
|
|
1312
1330
|
return { mat, mi };
|
|
1313
1331
|
};
|
|
1314
1332
|
s.parts = [
|
|
1315
|
-
|
|
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
|
+
),
|
|
1316
1341
|
part('inline3dSnapshotAdd', 'gl_FragColor = texture2D(dxrSnap, gl_FragCoord.xy * dxrSnapInvSize) * dxrSnapWeight();', pc.BLENDMODE_ONE, pc.BLENDMODE_ONE, -1),
|
|
1317
1342
|
];
|
|
1318
1343
|
this.app.scene.layers.getLayerById(pc.LAYERID_UI).addMeshInstances(s.parts.map((p) => p.mi));
|
|
@@ -1322,10 +1347,10 @@ export class PlayCanvasSplatViewer {
|
|
|
1322
1347
|
* Lerp the snapshot over the scene at `alpha` (0 hides it). Returns false when the canvas buffer
|
|
1323
1348
|
* no longer matches the capture (resize, 2D/3D switch): the caller ends its fade.
|
|
1324
1349
|
*/
|
|
1325
|
-
setSnapshotAlpha(alpha, wipe = null) {
|
|
1350
|
+
setSnapshotAlpha(alpha, wipe = null, mix = null) {
|
|
1326
1351
|
const s = this._snap;
|
|
1327
1352
|
if (!s?.parts) return false;
|
|
1328
|
-
const fits = s.w === this.canvas.width && s.h === this.canvas.height;
|
|
1353
|
+
const fits = !!s.tex && s.w === this.canvas.width && s.h === this.canvas.height;
|
|
1329
1354
|
const show = alpha > 0 && fits;
|
|
1330
1355
|
for (const p of s.parts) {
|
|
1331
1356
|
p.mi.visible = show;
|
|
@@ -1335,12 +1360,75 @@ export class PlayCanvasSplatViewer {
|
|
|
1335
1360
|
// SAME viewport-relative x in every eye — a front at the zero-disparity plane, so both
|
|
1336
1361
|
// eyes agree. `views` = eye viewports side by side across the buffer.
|
|
1337
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);
|
|
1338
1365
|
p.mat.update();
|
|
1339
1366
|
}
|
|
1340
1367
|
}
|
|
1341
1368
|
return alpha <= 0 || fits;
|
|
1342
1369
|
}
|
|
1343
1370
|
|
|
1371
|
+
/**
|
|
1372
|
+
* The overlay's source: the live outgoing target, or null for the frozen capture. The same two
|
|
1373
|
+
* quads, the same lerp / wipe — only the texture changes.
|
|
1374
|
+
*/
|
|
1375
|
+
setSnapshotSource(tex) {
|
|
1376
|
+
const s = this._snap;
|
|
1377
|
+
if (!s?.parts) return;
|
|
1378
|
+
for (const p of s.parts) {
|
|
1379
|
+
p.mat.setParameter('dxrSnap', tex || s.tex);
|
|
1380
|
+
p.mat.update();
|
|
1381
|
+
}
|
|
1382
|
+
}
|
|
1383
|
+
|
|
1384
|
+
/**
|
|
1385
|
+
* The mono/capture camera as a lens frame, for the live outgoing's rig chain
|
|
1386
|
+
* (./inline3d-splat-live.js): the rig node's matrix, the camera pose in rig space, the
|
|
1387
|
+
* convergence distance c (along the view axis to the focus, which the pivot puts at the orbit
|
|
1388
|
+
* centre) and t = tan(vertical fov / 2).
|
|
1389
|
+
*/
|
|
1390
|
+
lensFrame() {
|
|
1391
|
+
const pose = Float64Array.from(this.mono.pose);
|
|
1392
|
+
const o = this._orbitCentre;
|
|
1393
|
+
const fz = o.z + this._depthOffset;
|
|
1394
|
+
let c = -((o.x - pose[12]) * pose[8] + (o.y - pose[13]) * pose[9] + (fz - pose[14]) * pose[10]);
|
|
1395
|
+
if (!(c > 1e-6)) c = 1;
|
|
1396
|
+
return { rig: this.rigMatrix(), pose, c, t: Math.tan((this.mono.fov * DEG) / 2) };
|
|
1397
|
+
}
|
|
1398
|
+
|
|
1399
|
+
/** Can this tile render a live outgoing asset (the single-camera RenderView path)? */
|
|
1400
|
+
get canLiveOutgoing() {
|
|
1401
|
+
return LiveOutgoing.supported(this);
|
|
1402
|
+
}
|
|
1403
|
+
|
|
1404
|
+
/** Start rendering `entity` live into its own target (see ./inline3d-splat-live.js). */
|
|
1405
|
+
startLiveOutgoing(entity, oldFrame) {
|
|
1406
|
+
this._live ||= new LiveOutgoing(this);
|
|
1407
|
+
return this._live.start(entity, oldFrame) ? this._live : null;
|
|
1408
|
+
}
|
|
1409
|
+
|
|
1410
|
+
/** End the live window: overlay back on the frozen capture, camera off, target freed. */
|
|
1411
|
+
stopLiveOutgoing() {
|
|
1412
|
+
if (!this._live?.active) return;
|
|
1413
|
+
this.setSnapshotSource(null);
|
|
1414
|
+
this._live.stop();
|
|
1415
|
+
}
|
|
1416
|
+
|
|
1417
|
+
/**
|
|
1418
|
+
* A transition has ended: free the frozen frame's texture (7 MB at 2560×720; it used to stay
|
|
1419
|
+
* allocated until the next swap). The overlay quads stay, hidden; the next capture makes a new
|
|
1420
|
+
* texture.
|
|
1421
|
+
*/
|
|
1422
|
+
releaseSnapshot() {
|
|
1423
|
+
const s = this._snap;
|
|
1424
|
+
if (!s?.tex) return;
|
|
1425
|
+
for (const p of s.parts || []) p.mi.visible = false;
|
|
1426
|
+
s.rt?.destroy?.();
|
|
1427
|
+
s.tex.destroy?.();
|
|
1428
|
+
s.rt = s.tex = null;
|
|
1429
|
+
s.w = s.h = 0;
|
|
1430
|
+
}
|
|
1431
|
+
|
|
1344
1432
|
/** How many eye viewports sit side by side in the buffer right now (1 in mono). */
|
|
1345
1433
|
get viewsAcross() {
|
|
1346
1434
|
return this._mode === '3d' && this._lastGood ? this._lastGood.entries.length : 1;
|
|
@@ -1500,6 +1588,8 @@ export class PlayCanvasSplatViewer {
|
|
|
1500
1588
|
// The camera NODE drives the sort direction and LOD distance; the views ignore it (they
|
|
1501
1589
|
// compose the node's PARENT with their own pose). Park it on the first eye.
|
|
1502
1590
|
placeNode(this.eye, entries[0].pose);
|
|
1591
|
+
// setSource's live outgoing: the same views on its own camera, into its own target.
|
|
1592
|
+
if (this._live?.active) this._live.sync(entries, rect, f);
|
|
1503
1593
|
} else {
|
|
1504
1594
|
// Fallback: one camera per view, `rect` + `calculateProjection`.
|
|
1505
1595
|
const cams = this._views;
|
|
@@ -1640,7 +1730,7 @@ export class PlayCanvasSplatViewer {
|
|
|
1640
1730
|
/**
|
|
1641
1731
|
* The eyes' frame in WORLD (content) space, for effects keyed on "where the viewer is":
|
|
1642
1732
|
* `origin` = the midpoint of the eyes drawn last (the mono camera in 2D), `axis`/`right`/`up` =
|
|
1643
|
-
* the first eye's forward/right/up, `tanHalfFovX` its lens half-
|
|
1733
|
+
* the first eye's forward/right/up, `tanHalfFovX` / `tanHalfFovY` its lens half-extents.
|
|
1644
1734
|
*/
|
|
1645
1735
|
eyeFrame() {
|
|
1646
1736
|
const R = this.rigMatrix();
|
|
@@ -1667,6 +1757,7 @@ export class PlayCanvasSplatViewer {
|
|
|
1667
1757
|
right: unit(M[0], M[1], M[2]),
|
|
1668
1758
|
up: unit(M[4], M[5], M[6]),
|
|
1669
1759
|
tanHalfFovX: p0 > 0 ? 1 / p0 : 0.5,
|
|
1760
|
+
tanHalfFovY: es[0].proj?.[5] > 0 ? 1 / es[0].proj[5] : 0,
|
|
1670
1761
|
};
|
|
1671
1762
|
}
|
|
1672
1763
|
|
|
@@ -1859,7 +1950,7 @@ function perfSpan(name, t0) {
|
|
|
1859
1950
|
}
|
|
1860
1951
|
}
|
|
1861
1952
|
|
|
1862
|
-
export async function readCloud(resource) {
|
|
1953
|
+
export async function readCloud(resource, yielder = yieldToMain) {
|
|
1863
1954
|
if (!resource) return null;
|
|
1864
1955
|
const centers = resource.centers;
|
|
1865
1956
|
const data = resource.gsplatData;
|
|
@@ -1869,7 +1960,7 @@ export async function readCloud(resource) {
|
|
|
1869
1960
|
const total = Math.ceil(sourceTotal / stride);
|
|
1870
1961
|
// Not in the task that delivered the asset: the engine's own end-of-load work (its centre
|
|
1871
1962
|
// readback unpack) runs there, and stacking ours on it made one long task.
|
|
1872
|
-
await
|
|
1963
|
+
await yielder();
|
|
1873
1964
|
let t0 = performance.now();
|
|
1874
1965
|
const xyz = new Float32Array(total * 3);
|
|
1875
1966
|
for (let j = 0, i = 0; j < total; j++, i += stride) {
|
|
@@ -1878,7 +1969,7 @@ export async function readCloud(resource) {
|
|
|
1878
1969
|
xyz[j * 3 + 2] = centers[i * 3 + 2];
|
|
1879
1970
|
}
|
|
1880
1971
|
perfSpan('readCloud:copy', t0);
|
|
1881
|
-
await
|
|
1972
|
+
await yielder();
|
|
1882
1973
|
let opacity = null;
|
|
1883
1974
|
// Full-resolution peak opacity, ONE BYTE per splat, kept for the exact pick (which walks the
|
|
1884
1975
|
// engine's own full centre set at pick time): 1.18 MB on the 1.18M bench asset.
|
|
@@ -1888,7 +1979,7 @@ export async function readCloud(resource) {
|
|
|
1888
1979
|
t0 = performance.now();
|
|
1889
1980
|
const px = await data.sh0.read(0, 0, data.sh0.width, data.sh0.height, { mipLevel: 0, face: 0, immediate: true });
|
|
1890
1981
|
perfSpan('readCloud:sh0-readback(async)', t0);
|
|
1891
|
-
await
|
|
1982
|
+
await yielder();
|
|
1892
1983
|
t0 = performance.now();
|
|
1893
1984
|
if (px && px.length >= sourceTotal * 4) {
|
|
1894
1985
|
const v2 = data.meta?.version === 2;
|
|
@@ -2066,8 +2157,40 @@ export const SOURCE_TRANSITIONS = Object.freeze({
|
|
|
2066
2157
|
crossfade: {},
|
|
2067
2158
|
flip: { durationMs: 2200, easing: 'easeInOutSine' },
|
|
2068
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 }])),
|
|
2069
2162
|
});
|
|
2070
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
|
+
|
|
2071
2194
|
/** Validate setSource's options into a plan (throws on a page bug, before anything loads). */
|
|
2072
2195
|
export function resolveSwap(o = {}) {
|
|
2073
2196
|
if (o === null || typeof o !== 'object') throw new TypeError('@displayxr/inline3d/splat: setSource options must be an object.');
|
|
@@ -2082,16 +2205,18 @@ export function resolveSwap(o = {}) {
|
|
|
2082
2205
|
const easing = o.easing ?? d.easing ?? 'linear';
|
|
2083
2206
|
if (typeof easing !== 'function' && !EASINGS[easing]) throw new Error(`@displayxr/inline3d/splat: unknown easing '${easing}'.`);
|
|
2084
2207
|
const reveal = resolveRevealOption(o.reveal);
|
|
2085
|
-
if (reveal && (transition === 'flip' || transition === 'wavefront')) {
|
|
2208
|
+
if (reveal && (transition === 'flip' || transition === 'wavefront' || d.particle)) {
|
|
2086
2209
|
throw new Error(`@displayxr/inline3d/splat: setSource reveal plays with transition 'cut' or 'crossfade'; '${transition}' is its own reveal.`);
|
|
2087
2210
|
}
|
|
2211
|
+
const particles = d.particle ? resolveParticleTransition(transition, o) : null;
|
|
2088
2212
|
const band = o.band ?? d.band ?? 0.18;
|
|
2089
2213
|
const ridge = o.ridge ?? d.ridge ?? 0.03;
|
|
2090
2214
|
const ridgeMaxDisparity = o.ridgeMaxDisparity ?? d.ridgeMaxDisparity ?? 0.004;
|
|
2091
2215
|
if (transition === 'wavefront') {
|
|
2092
2216
|
EFFECTS.wavefront.validate({ band, ridge, ridgeMaxDisparity });
|
|
2093
2217
|
}
|
|
2094
|
-
|
|
2218
|
+
const outgoing = resolveOutgoingOption(o.outgoing);
|
|
2219
|
+
return { transition, durationMs: transition === 'crossfade' && durationMs === 0 ? 800 : durationMs, easing, reveal, band, ridge, ridgeMaxDisparity, outgoing, particles };
|
|
2095
2220
|
}
|
|
2096
2221
|
|
|
2097
2222
|
/**
|
|
@@ -2164,6 +2289,9 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
|
|
|
2164
2289
|
let handle = null;
|
|
2165
2290
|
let unbindFocusInput = null;
|
|
2166
2291
|
let removed = false;
|
|
2292
|
+
/** prepareSource: prepared handle → { loaded, state: 'ready' | 'used' | 'disposed', dispose }. */
|
|
2293
|
+
const preparedAssets = new WeakMap();
|
|
2294
|
+
const livePrepared = new Set();
|
|
2167
2295
|
/** The asset on screen: its resource, kind, the pick data it keeps. */
|
|
2168
2296
|
let current = null; // { asset, entity, res, kind, streamedBounds, alpha8, pickCentres }
|
|
2169
2297
|
|
|
@@ -2232,6 +2360,7 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
|
|
|
2232
2360
|
return pickModel(clientX, clientY);
|
|
2233
2361
|
},
|
|
2234
2362
|
setSource,
|
|
2363
|
+
prepareSource,
|
|
2235
2364
|
/**
|
|
2236
2365
|
* Play a transition effect (inflate, deflate, sweep, dissolve, fade, pulse, custom) — see
|
|
2237
2366
|
* docs/splat-effects.md. Validated now; runs once the first asset is on screen. Resolves
|
|
@@ -2259,6 +2388,7 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
|
|
|
2259
2388
|
remove() {
|
|
2260
2389
|
removed = true;
|
|
2261
2390
|
current = null;
|
|
2391
|
+
for (const p of [...livePrepared]) p.dispose();
|
|
2262
2392
|
fx?.dispose();
|
|
2263
2393
|
fx = null;
|
|
2264
2394
|
unbindFocusInput?.();
|
|
@@ -2587,7 +2717,9 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
|
|
|
2587
2717
|
// ── load ──
|
|
2588
2718
|
let byteSeqLocal = 0;
|
|
2589
2719
|
/** Fetch/parse one source into an engine asset, and read what the waterfall needs from it. */
|
|
2590
|
-
async function loadOne(pc, app, source) {
|
|
2720
|
+
async function loadOne(pc, app, source, { background = false } = {}) {
|
|
2721
|
+
// prepareSource: our own passes run in idle periods; setSource: at the task's own priority.
|
|
2722
|
+
const y = background ? yieldIdle : yieldToMain;
|
|
2591
2723
|
let bytes = null;
|
|
2592
2724
|
if (typeof source !== 'string') {
|
|
2593
2725
|
const buf = source instanceof Blob ? await source.arrayBuffer() : source;
|
|
@@ -2634,7 +2766,7 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
|
|
|
2634
2766
|
// A URL `.sog` carries its meta in the resource (the engine keeps unknown keys); a Streamed
|
|
2635
2767
|
// SOG carries it at the top level of lod-meta.json. Both validated by the same reader.
|
|
2636
2768
|
if (!bytes && rigOpts.rig !== 'display') camera = sogCameraFromMeta(desc.meta);
|
|
2637
|
-
const cloud = desc.kind === 'flat' ? await readCloud(res) : null;
|
|
2769
|
+
const cloud = desc.kind === 'flat' ? await readCloud(res, y) : null;
|
|
2638
2770
|
// The cloud passes, each in its OWN task: framing, the rest-space sample and the pick set
|
|
2639
2771
|
// were one ~60 ms main-thread block on a 1.18M-gaussian swap — pointer input waited on it.
|
|
2640
2772
|
// Split with a yield between them (and a linear-time percentile in boundsFromPositions),
|
|
@@ -2642,20 +2774,26 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
|
|
|
2642
2774
|
const pre = { local: null, rest: null, pickCentres: null };
|
|
2643
2775
|
if (cloud) {
|
|
2644
2776
|
const walk = centresVisitor(cloud.xyz, cloud.opacity, cloud.total);
|
|
2645
|
-
await
|
|
2777
|
+
await y();
|
|
2646
2778
|
let t = performance.now();
|
|
2647
|
-
|
|
2779
|
+
const framed = sampleCloudCentres(cloud.total, walk) || [];
|
|
2780
|
+
perfSpan('cloud:frame-sample', t);
|
|
2781
|
+
await y();
|
|
2782
|
+
// The percentile core per axis + the window pass, each in its own task (bit-identical to
|
|
2783
|
+
// boundsFromPositions): one ~60-80 ms task per 1.18M gaussians at 4× CPU before.
|
|
2784
|
+
t = performance.now();
|
|
2785
|
+
pre.local = await boundsFromPositionsAsync(framed, undefined, y);
|
|
2648
2786
|
perfSpan('cloud:bounds', t);
|
|
2649
|
-
await
|
|
2787
|
+
await y();
|
|
2650
2788
|
t = performance.now();
|
|
2651
2789
|
if (rigNeedsCloud(camera)) pre.rest = sampleCloudRestSpace(cloud.total, walk, camera?.rest);
|
|
2652
2790
|
perfSpan('cloud:rest-sample', t);
|
|
2653
|
-
await
|
|
2791
|
+
await y();
|
|
2654
2792
|
t = performance.now();
|
|
2655
2793
|
const s = sampleCloudCentres(cloud.total, walk, { cap: RIG_SAMPLE_CAP });
|
|
2656
2794
|
pre.pickCentres = s ? s.slice() : null;
|
|
2657
2795
|
perfSpan('cloud:pick-set', t);
|
|
2658
|
-
await
|
|
2796
|
+
await y();
|
|
2659
2797
|
}
|
|
2660
2798
|
return { asset, res, desc, camera, cloud, pre };
|
|
2661
2799
|
}
|
|
@@ -2796,6 +2934,145 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
|
|
|
2796
2934
|
return out;
|
|
2797
2935
|
});
|
|
2798
2936
|
|
|
2937
|
+
// ── prepareSource: load the next asset in the background, for a setSource with no load ──
|
|
2938
|
+
/**
|
|
2939
|
+
* Fetch, decode and upload `src` now, without rendering it: the engine's load (its own unpack
|
|
2940
|
+
* runs when it must) and our cloud passes (framing, rig sample, pick set — in IDLE periods).
|
|
2941
|
+
* Resolves to an opaque handle for setSource(prepared, opts), which then starts on the next
|
|
2942
|
+
* frame with no load on the transition frame. `dispose()` if the page changes its mind.
|
|
2943
|
+
* Memory: the prepared asset is fully resident (a 1.18M-gaussian SOG: its GPU textures plus the
|
|
2944
|
+
* engine's centre array) alongside the current one until it is used or disposed.
|
|
2945
|
+
*/
|
|
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
|
|
2949
|
+
const app = await booted;
|
|
2950
|
+
await first.catch(() => null);
|
|
2951
|
+
if (!app || removed) throw new Error('@displayxr/inline3d/splat: prepareSource on a removed tile.');
|
|
2952
|
+
const t0 = performance.now();
|
|
2953
|
+
const loaded = await loadOne(pcModule, app, src, { background: true });
|
|
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);
|
|
2957
|
+
const entry = { loaded, state: 'ready', dispose: null };
|
|
2958
|
+
const prepared = {
|
|
2959
|
+
[PREPARED_TAG]: true,
|
|
2960
|
+
/** The asset's own count (every splat of a flat source). */
|
|
2961
|
+
numSplats: loaded.desc.numSplats || loaded.cloud?.sourceTotal || 0,
|
|
2962
|
+
/** 'ready' until setSource uses it ('used') or dispose() drops it ('disposed'). */
|
|
2963
|
+
get state() {
|
|
2964
|
+
return entry.state;
|
|
2965
|
+
},
|
|
2966
|
+
dispose() {
|
|
2967
|
+
if (entry.state !== 'ready') return;
|
|
2968
|
+
entry.state = 'disposed';
|
|
2969
|
+
livePrepared.delete(entry);
|
|
2970
|
+
app.assets.remove(loaded.asset);
|
|
2971
|
+
loaded.asset.unload?.();
|
|
2972
|
+
},
|
|
2973
|
+
};
|
|
2974
|
+
entry.dispose = prepared.dispose;
|
|
2975
|
+
preparedAssets.set(prepared, entry);
|
|
2976
|
+
if (removed) {
|
|
2977
|
+
prepared.dispose();
|
|
2978
|
+
throw new Error('@displayxr/inline3d/splat: prepareSource on a removed tile.');
|
|
2979
|
+
}
|
|
2980
|
+
livePrepared.add(entry);
|
|
2981
|
+
return prepared;
|
|
2982
|
+
}
|
|
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
|
+
|
|
2799
3076
|
// ── setSource: swap the asset — a cut, a crossfade, or a transition ──
|
|
2800
3077
|
let sourceGen = 0;
|
|
2801
3078
|
let pendingSwap = null;
|
|
@@ -2818,11 +3095,22 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
|
|
|
2818
3095
|
const plan = resolveSwap(o);
|
|
2819
3096
|
const { resetPose = false } = o;
|
|
2820
3097
|
const gen = ++sourceGen;
|
|
3098
|
+
// A prepareSource() result: already fetched, decoded and uploaded — no load on this path.
|
|
3099
|
+
const prep = preparedAssets.get(next) || null;
|
|
3100
|
+
if (prep) {
|
|
3101
|
+
if (prep.state !== 'ready') {
|
|
3102
|
+
throw new Error(`@displayxr/inline3d/splat: setSource got a prepared source that was already ${prep.state === 'used' ? 'used' : 'disposed'}.`);
|
|
3103
|
+
}
|
|
3104
|
+
prep.state = 'used';
|
|
3105
|
+
livePrepared.delete(prep);
|
|
3106
|
+
} else if (next && typeof next === 'object' && next[PREPARED_TAG]) {
|
|
3107
|
+
throw new Error('@displayxr/inline3d/splat: setSource got a prepared source from another handle.');
|
|
3108
|
+
}
|
|
2821
3109
|
const app = await booted;
|
|
2822
3110
|
await first.catch(() => null); // a failed first asset may be replaced
|
|
2823
3111
|
if (!app || removed) return out;
|
|
2824
3112
|
const pc = pcModule;
|
|
2825
|
-
const loaded = await loadOne(pc, app, next);
|
|
3113
|
+
const loaded = prep ? prep.loaded : await loadOne(pc, app, next);
|
|
2826
3114
|
if (removed || gen !== sourceGen) {
|
|
2827
3115
|
app.assets.remove(loaded.asset);
|
|
2828
3116
|
loaded.asset.unload?.();
|
|
@@ -2831,9 +3119,10 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
|
|
|
2831
3119
|
const prev = current;
|
|
2832
3120
|
pendingSwap?.finish();
|
|
2833
3121
|
const transition = prev ? plan.transition : 'cut';
|
|
3122
|
+
const particle = plan.particles && transition in PARTICLE_TRANSITIONS ? plan.particles : null;
|
|
2834
3123
|
// FRAME_SNAPSHOT: freeze the outgoing frame BEFORE anything of the new asset (or its rig) is
|
|
2835
3124
|
// drawn. The overlay goes up in the same task, so no frame shows neither photo.
|
|
2836
|
-
const wantsSnapshot = transition === 'crossfade' || transition === 'wavefront';
|
|
3125
|
+
const wantsSnapshot = transition === 'crossfade' || transition === 'wavefront' || !!particle;
|
|
2837
3126
|
const snapped = wantsSnapshot ? await viewer.captureFrame() : false;
|
|
2838
3127
|
if (removed || gen !== sourceGen) {
|
|
2839
3128
|
app.assets.remove(loaded.asset);
|
|
@@ -2841,6 +3130,21 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
|
|
|
2841
3130
|
return out;
|
|
2842
3131
|
}
|
|
2843
3132
|
if (snapped) viewer.setSnapshotAlpha(1, transition === 'wavefront' ? { t: 0, band: plan.band, views: viewer.viewsAcross } : null);
|
|
3133
|
+
// LIVE outgoing (default in 3D): the old asset stays resident and keeps rendering, through the
|
|
3134
|
+
// same eye views, into its own target; the frozen capture above bridges the first frames until
|
|
3135
|
+
// its camera has drawn a sorted frame. Its lens frame is read BEFORE adopt() switches the rig.
|
|
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));
|
|
3139
|
+
const live =
|
|
3140
|
+
snapped && outgoingMode === 'live' && viewer.canLiveOutgoing ? viewer.startLiveOutgoing(prev.entity, viewer.lensFrame()) : null;
|
|
3141
|
+
/** Each frame of the window: once the live camera is ready, the overlay samples it. */
|
|
3142
|
+
let liveShown = false;
|
|
3143
|
+
const pumpLive = () => {
|
|
3144
|
+
if (!live || liveShown || !live.active || !live.ready) return;
|
|
3145
|
+
viewer.setSnapshotSource(live.texture);
|
|
3146
|
+
liveShown = true;
|
|
3147
|
+
};
|
|
2844
3148
|
|
|
2845
3149
|
const release = (p) => {
|
|
2846
3150
|
if (!p) return;
|
|
@@ -2916,6 +3220,12 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
|
|
|
2916
3220
|
return out;
|
|
2917
3221
|
}
|
|
2918
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
|
+
|
|
2919
3229
|
adopt(entity);
|
|
2920
3230
|
if (transition === 'cut') {
|
|
2921
3231
|
release(prev);
|
|
@@ -2925,16 +3235,30 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
|
|
|
2925
3235
|
}
|
|
2926
3236
|
|
|
2927
3237
|
if (transition === 'wavefront' && snapped) {
|
|
2928
|
-
|
|
3238
|
+
if (live) {
|
|
3239
|
+
finishers.push(() => {
|
|
3240
|
+
viewer.stopLiveOutgoing();
|
|
3241
|
+
release(prev);
|
|
3242
|
+
});
|
|
3243
|
+
} else release(prev); // the snapshot shows it from here on
|
|
2929
3244
|
const band = plan.band;
|
|
2930
3245
|
const ridge = fx.play(
|
|
2931
3246
|
'wavefront',
|
|
2932
3247
|
{ durationMs: plan.durationMs, easing: plan.easing, band, ridge: plan.ridge, ridgeMaxDisparity: plan.ridgeMaxDisparity, scope: 'entity' },
|
|
2933
3248
|
{ entity, gate: afterTicks(2), internal: true },
|
|
2934
3249
|
);
|
|
2935
|
-
finishers.push(() =>
|
|
3250
|
+
finishers.push(() => {
|
|
3251
|
+
viewer.setSnapshotAlpha(0);
|
|
3252
|
+
viewer.releaseSnapshot();
|
|
3253
|
+
});
|
|
2936
3254
|
viewer._hooks.push(() => {
|
|
2937
3255
|
if (finished || removed) return false;
|
|
3256
|
+
if (live && !live.fits) {
|
|
3257
|
+
fx?.stop('wavefront', { finish: true, entity });
|
|
3258
|
+
finish();
|
|
3259
|
+
return false;
|
|
3260
|
+
}
|
|
3261
|
+
pumpLive();
|
|
2938
3262
|
const inst = fx?.scopes.get(entity)?.get('wavefront');
|
|
2939
3263
|
const amount = inst ? inst.amount : 1;
|
|
2940
3264
|
if (!viewer.setSnapshotAlpha(1, { t: amount, band, views: viewer.viewsAcross })) {
|
|
@@ -2953,14 +3277,21 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
|
|
|
2953
3277
|
// the 1.12.1 one-pass fade, over the same duration.
|
|
2954
3278
|
const fade = plan.durationMs;
|
|
2955
3279
|
if (plan.reveal) fx.play(plan.reveal.type, { ...plan.reveal.raw, scope: 'entity' }, { entity, gate: afterTicks(2) });
|
|
2956
|
-
if (snapped)
|
|
2957
|
-
|
|
3280
|
+
if (snapped) {
|
|
3281
|
+
if (!live) release(prev); // the snapshot shows it from here on
|
|
3282
|
+
} else {
|
|
2958
3283
|
setFade(entity, 0);
|
|
2959
3284
|
setFade(prev.entity, 1);
|
|
2960
3285
|
}
|
|
2961
3286
|
finishers.push(() => {
|
|
2962
|
-
if (snapped)
|
|
2963
|
-
|
|
3287
|
+
if (snapped) {
|
|
3288
|
+
viewer.setSnapshotAlpha(0);
|
|
3289
|
+
viewer.releaseSnapshot();
|
|
3290
|
+
if (live) {
|
|
3291
|
+
viewer.stopLiveOutgoing();
|
|
3292
|
+
release(prev);
|
|
3293
|
+
}
|
|
3294
|
+
} else {
|
|
2964
3295
|
setFade(entity, null);
|
|
2965
3296
|
release(prev);
|
|
2966
3297
|
}
|
|
@@ -2974,12 +3305,13 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
|
|
|
2974
3305
|
viewer._hooks.push((t) => {
|
|
2975
3306
|
if (removed) return (finish(), false);
|
|
2976
3307
|
if (finished) return false; // superseded: already finished
|
|
3308
|
+
pumpLive();
|
|
2977
3309
|
if (++ticks < 2) return true;
|
|
2978
3310
|
if (t0 === null) t0 = t;
|
|
2979
3311
|
const k = Math.min(1, Math.max(0, (t - t0) / fade));
|
|
2980
3312
|
if (snapped) {
|
|
2981
3313
|
// A resize or a 2D/3D switch mid-fade: the capture no longer fits the buffer — end now.
|
|
2982
|
-
if (!viewer.setSnapshotAlpha(1 - k)) {
|
|
3314
|
+
if (!viewer.setSnapshotAlpha(1 - k) || (live && !live.fits)) {
|
|
2983
3315
|
finish();
|
|
2984
3316
|
return false;
|
|
2985
3317
|
}
|
|
@@ -2997,6 +3329,108 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
|
|
|
2997
3329
|
return out;
|
|
2998
3330
|
}
|
|
2999
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
|
+
|
|
3000
3434
|
// The load promise is RETURNED, never written to `out.ready`: ./splat's addSplat owns that
|
|
3001
3435
|
// field (one promise, one owner).
|
|
3002
3436
|
return first
|
|
@@ -3008,3 +3442,5 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
|
|
|
3008
3442
|
}
|
|
3009
3443
|
|
|
3010
3444
|
let byteSeq = 0;
|
|
3445
|
+
/** Marks a prepareSource() result (so one from another handle is caught, not loaded as bytes). */
|
|
3446
|
+
const PREPARED_TAG = Symbol.for('@displayxr/inline3d/splat.prepared');
|