@displayxr/inline3d 1.13.0 → 1.14.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 +73 -0
- package/README.md +4 -3
- package/js/inline3d-playcanvas-engine.js +3 -0
- package/js/inline3d-splat-effects.js +471 -4
- package/js/inline3d-splat-live.js +343 -0
- package/js/inline3d-splat-playcanvas.js +204 -26
- package/js/inline3d-splat.js +5 -0
- package/js/inline3d-viewer.js +25 -1
- package/package.json +2 -1
- package/splat.d.ts +97 -7
|
@@ -49,7 +49,7 @@ 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,
|
|
@@ -58,6 +58,7 @@ import {
|
|
|
58
58
|
resolveRevealOption,
|
|
59
59
|
validateEffectCall,
|
|
60
60
|
} from './inline3d-splat-effects.js';
|
|
61
|
+
import { LiveOutgoing, resolveOutgoingOption, defaultOutgoing, yieldIdle } from './inline3d-splat-live.js';
|
|
61
62
|
import {
|
|
62
63
|
clamp,
|
|
63
64
|
finite,
|
|
@@ -668,6 +669,8 @@ export class PlayCanvasSplatViewer {
|
|
|
668
669
|
this._captureWaiters = [];
|
|
669
670
|
/** The crossfade's frame snapshot: { w, h, tex, rt, mi, mat } once captured. */
|
|
670
671
|
this._snap = null;
|
|
672
|
+
/** setSource's live outgoing asset (./inline3d-splat-live.js), made on first use. */
|
|
673
|
+
this._live = null;
|
|
671
674
|
this.vH = virtualDisplayHeight;
|
|
672
675
|
this.fit = fit;
|
|
673
676
|
this.margin = margin;
|
|
@@ -987,6 +990,8 @@ export class PlayCanvasSplatViewer {
|
|
|
987
990
|
clearTimeout(w.timer);
|
|
988
991
|
w.resolve(false);
|
|
989
992
|
}
|
|
993
|
+
this._live?.destroy();
|
|
994
|
+
this._live = null;
|
|
990
995
|
try {
|
|
991
996
|
this.app?.destroy();
|
|
992
997
|
} catch (err) {
|
|
@@ -1223,8 +1228,12 @@ export class PlayCanvasSplatViewer {
|
|
|
1223
1228
|
const w = this.canvas.width;
|
|
1224
1229
|
const h = this.canvas.height;
|
|
1225
1230
|
let s = this._snap;
|
|
1226
|
-
if (!s || s.w !== w || s.h !== h) {
|
|
1227
|
-
|
|
1231
|
+
if (!s || !s.tex || s.w !== w || s.h !== h) {
|
|
1232
|
+
// A new size, or the texture was released after the last transition: a new texture.
|
|
1233
|
+
// The overlay quads (and their compiled materials) are kept.
|
|
1234
|
+
const parts = s?.parts || null;
|
|
1235
|
+
s?.rt?.destroy?.();
|
|
1236
|
+
s?.tex?.destroy?.();
|
|
1228
1237
|
const tex = new pc.Texture(device, {
|
|
1229
1238
|
name: 'inline3d-snapshot',
|
|
1230
1239
|
width: w,
|
|
@@ -1236,7 +1245,7 @@ export class PlayCanvasSplatViewer {
|
|
|
1236
1245
|
addressU: pc.ADDRESS_CLAMP_TO_EDGE,
|
|
1237
1246
|
addressV: pc.ADDRESS_CLAMP_TO_EDGE,
|
|
1238
1247
|
});
|
|
1239
|
-
s = this._snap = { w, h, tex, rt: new pc.RenderTarget({ colorBuffer: tex, depth: false }), parts
|
|
1248
|
+
s = this._snap = { w, h, tex, rt: new pc.RenderTarget({ colorBuffer: tex, depth: false }), parts };
|
|
1240
1249
|
}
|
|
1241
1250
|
// null source = the back buffer, still holding this frame (same task, before compositing).
|
|
1242
1251
|
ok = device.copyRenderTarget(null, s.rt, true, false) !== false;
|
|
@@ -1325,7 +1334,7 @@ export class PlayCanvasSplatViewer {
|
|
|
1325
1334
|
setSnapshotAlpha(alpha, wipe = null) {
|
|
1326
1335
|
const s = this._snap;
|
|
1327
1336
|
if (!s?.parts) return false;
|
|
1328
|
-
const fits = s.w === this.canvas.width && s.h === this.canvas.height;
|
|
1337
|
+
const fits = !!s.tex && s.w === this.canvas.width && s.h === this.canvas.height;
|
|
1329
1338
|
const show = alpha > 0 && fits;
|
|
1330
1339
|
for (const p of s.parts) {
|
|
1331
1340
|
p.mi.visible = show;
|
|
@@ -1341,6 +1350,67 @@ export class PlayCanvasSplatViewer {
|
|
|
1341
1350
|
return alpha <= 0 || fits;
|
|
1342
1351
|
}
|
|
1343
1352
|
|
|
1353
|
+
/**
|
|
1354
|
+
* The overlay's source: the live outgoing target, or null for the frozen capture. The same two
|
|
1355
|
+
* quads, the same lerp / wipe — only the texture changes.
|
|
1356
|
+
*/
|
|
1357
|
+
setSnapshotSource(tex) {
|
|
1358
|
+
const s = this._snap;
|
|
1359
|
+
if (!s?.parts) return;
|
|
1360
|
+
for (const p of s.parts) {
|
|
1361
|
+
p.mat.setParameter('dxrSnap', tex || s.tex);
|
|
1362
|
+
p.mat.update();
|
|
1363
|
+
}
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1366
|
+
/**
|
|
1367
|
+
* The mono/capture camera as a lens frame, for the live outgoing's rig chain
|
|
1368
|
+
* (./inline3d-splat-live.js): the rig node's matrix, the camera pose in rig space, the
|
|
1369
|
+
* convergence distance c (along the view axis to the focus, which the pivot puts at the orbit
|
|
1370
|
+
* centre) and t = tan(vertical fov / 2).
|
|
1371
|
+
*/
|
|
1372
|
+
lensFrame() {
|
|
1373
|
+
const pose = Float64Array.from(this.mono.pose);
|
|
1374
|
+
const o = this._orbitCentre;
|
|
1375
|
+
const fz = o.z + this._depthOffset;
|
|
1376
|
+
let c = -((o.x - pose[12]) * pose[8] + (o.y - pose[13]) * pose[9] + (fz - pose[14]) * pose[10]);
|
|
1377
|
+
if (!(c > 1e-6)) c = 1;
|
|
1378
|
+
return { rig: this.rigMatrix(), pose, c, t: Math.tan((this.mono.fov * DEG) / 2) };
|
|
1379
|
+
}
|
|
1380
|
+
|
|
1381
|
+
/** Can this tile render a live outgoing asset (the single-camera RenderView path)? */
|
|
1382
|
+
get canLiveOutgoing() {
|
|
1383
|
+
return LiveOutgoing.supported(this);
|
|
1384
|
+
}
|
|
1385
|
+
|
|
1386
|
+
/** Start rendering `entity` live into its own target (see ./inline3d-splat-live.js). */
|
|
1387
|
+
startLiveOutgoing(entity, oldFrame) {
|
|
1388
|
+
this._live ||= new LiveOutgoing(this);
|
|
1389
|
+
return this._live.start(entity, oldFrame) ? this._live : null;
|
|
1390
|
+
}
|
|
1391
|
+
|
|
1392
|
+
/** End the live window: overlay back on the frozen capture, camera off, target freed. */
|
|
1393
|
+
stopLiveOutgoing() {
|
|
1394
|
+
if (!this._live?.active) return;
|
|
1395
|
+
this.setSnapshotSource(null);
|
|
1396
|
+
this._live.stop();
|
|
1397
|
+
}
|
|
1398
|
+
|
|
1399
|
+
/**
|
|
1400
|
+
* A transition has ended: free the frozen frame's texture (7 MB at 2560×720; it used to stay
|
|
1401
|
+
* allocated until the next swap). The overlay quads stay, hidden; the next capture makes a new
|
|
1402
|
+
* texture.
|
|
1403
|
+
*/
|
|
1404
|
+
releaseSnapshot() {
|
|
1405
|
+
const s = this._snap;
|
|
1406
|
+
if (!s?.tex) return;
|
|
1407
|
+
for (const p of s.parts || []) p.mi.visible = false;
|
|
1408
|
+
s.rt?.destroy?.();
|
|
1409
|
+
s.tex.destroy?.();
|
|
1410
|
+
s.rt = s.tex = null;
|
|
1411
|
+
s.w = s.h = 0;
|
|
1412
|
+
}
|
|
1413
|
+
|
|
1344
1414
|
/** How many eye viewports sit side by side in the buffer right now (1 in mono). */
|
|
1345
1415
|
get viewsAcross() {
|
|
1346
1416
|
return this._mode === '3d' && this._lastGood ? this._lastGood.entries.length : 1;
|
|
@@ -1500,6 +1570,8 @@ export class PlayCanvasSplatViewer {
|
|
|
1500
1570
|
// The camera NODE drives the sort direction and LOD distance; the views ignore it (they
|
|
1501
1571
|
// compose the node's PARENT with their own pose). Park it on the first eye.
|
|
1502
1572
|
placeNode(this.eye, entries[0].pose);
|
|
1573
|
+
// setSource's live outgoing: the same views on its own camera, into its own target.
|
|
1574
|
+
if (this._live?.active) this._live.sync(entries, rect, f);
|
|
1503
1575
|
} else {
|
|
1504
1576
|
// Fallback: one camera per view, `rect` + `calculateProjection`.
|
|
1505
1577
|
const cams = this._views;
|
|
@@ -1640,7 +1712,7 @@ export class PlayCanvasSplatViewer {
|
|
|
1640
1712
|
/**
|
|
1641
1713
|
* The eyes' frame in WORLD (content) space, for effects keyed on "where the viewer is":
|
|
1642
1714
|
* `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-
|
|
1715
|
+
* the first eye's forward/right/up, `tanHalfFovX` / `tanHalfFovY` its lens half-extents.
|
|
1644
1716
|
*/
|
|
1645
1717
|
eyeFrame() {
|
|
1646
1718
|
const R = this.rigMatrix();
|
|
@@ -1667,6 +1739,7 @@ export class PlayCanvasSplatViewer {
|
|
|
1667
1739
|
right: unit(M[0], M[1], M[2]),
|
|
1668
1740
|
up: unit(M[4], M[5], M[6]),
|
|
1669
1741
|
tanHalfFovX: p0 > 0 ? 1 / p0 : 0.5,
|
|
1742
|
+
tanHalfFovY: es[0].proj?.[5] > 0 ? 1 / es[0].proj[5] : 0,
|
|
1670
1743
|
};
|
|
1671
1744
|
}
|
|
1672
1745
|
|
|
@@ -1859,7 +1932,7 @@ function perfSpan(name, t0) {
|
|
|
1859
1932
|
}
|
|
1860
1933
|
}
|
|
1861
1934
|
|
|
1862
|
-
export async function readCloud(resource) {
|
|
1935
|
+
export async function readCloud(resource, yielder = yieldToMain) {
|
|
1863
1936
|
if (!resource) return null;
|
|
1864
1937
|
const centers = resource.centers;
|
|
1865
1938
|
const data = resource.gsplatData;
|
|
@@ -1869,7 +1942,7 @@ export async function readCloud(resource) {
|
|
|
1869
1942
|
const total = Math.ceil(sourceTotal / stride);
|
|
1870
1943
|
// Not in the task that delivered the asset: the engine's own end-of-load work (its centre
|
|
1871
1944
|
// readback unpack) runs there, and stacking ours on it made one long task.
|
|
1872
|
-
await
|
|
1945
|
+
await yielder();
|
|
1873
1946
|
let t0 = performance.now();
|
|
1874
1947
|
const xyz = new Float32Array(total * 3);
|
|
1875
1948
|
for (let j = 0, i = 0; j < total; j++, i += stride) {
|
|
@@ -1878,7 +1951,7 @@ export async function readCloud(resource) {
|
|
|
1878
1951
|
xyz[j * 3 + 2] = centers[i * 3 + 2];
|
|
1879
1952
|
}
|
|
1880
1953
|
perfSpan('readCloud:copy', t0);
|
|
1881
|
-
await
|
|
1954
|
+
await yielder();
|
|
1882
1955
|
let opacity = null;
|
|
1883
1956
|
// Full-resolution peak opacity, ONE BYTE per splat, kept for the exact pick (which walks the
|
|
1884
1957
|
// engine's own full centre set at pick time): 1.18 MB on the 1.18M bench asset.
|
|
@@ -1888,7 +1961,7 @@ export async function readCloud(resource) {
|
|
|
1888
1961
|
t0 = performance.now();
|
|
1889
1962
|
const px = await data.sh0.read(0, 0, data.sh0.width, data.sh0.height, { mipLevel: 0, face: 0, immediate: true });
|
|
1890
1963
|
perfSpan('readCloud:sh0-readback(async)', t0);
|
|
1891
|
-
await
|
|
1964
|
+
await yielder();
|
|
1892
1965
|
t0 = performance.now();
|
|
1893
1966
|
if (px && px.length >= sourceTotal * 4) {
|
|
1894
1967
|
const v2 = data.meta?.version === 2;
|
|
@@ -2091,7 +2164,8 @@ export function resolveSwap(o = {}) {
|
|
|
2091
2164
|
if (transition === 'wavefront') {
|
|
2092
2165
|
EFFECTS.wavefront.validate({ band, ridge, ridgeMaxDisparity });
|
|
2093
2166
|
}
|
|
2094
|
-
|
|
2167
|
+
const outgoing = resolveOutgoingOption(o.outgoing);
|
|
2168
|
+
return { transition, durationMs: transition === 'crossfade' && durationMs === 0 ? 800 : durationMs, easing, reveal, band, ridge, ridgeMaxDisparity, outgoing };
|
|
2095
2169
|
}
|
|
2096
2170
|
|
|
2097
2171
|
/**
|
|
@@ -2164,6 +2238,9 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
|
|
|
2164
2238
|
let handle = null;
|
|
2165
2239
|
let unbindFocusInput = null;
|
|
2166
2240
|
let removed = false;
|
|
2241
|
+
/** prepareSource: prepared handle → { loaded, state: 'ready' | 'used' | 'disposed', dispose }. */
|
|
2242
|
+
const preparedAssets = new WeakMap();
|
|
2243
|
+
const livePrepared = new Set();
|
|
2167
2244
|
/** The asset on screen: its resource, kind, the pick data it keeps. */
|
|
2168
2245
|
let current = null; // { asset, entity, res, kind, streamedBounds, alpha8, pickCentres }
|
|
2169
2246
|
|
|
@@ -2232,6 +2309,7 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
|
|
|
2232
2309
|
return pickModel(clientX, clientY);
|
|
2233
2310
|
},
|
|
2234
2311
|
setSource,
|
|
2312
|
+
prepareSource,
|
|
2235
2313
|
/**
|
|
2236
2314
|
* Play a transition effect (inflate, deflate, sweep, dissolve, fade, pulse, custom) — see
|
|
2237
2315
|
* docs/splat-effects.md. Validated now; runs once the first asset is on screen. Resolves
|
|
@@ -2259,6 +2337,7 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
|
|
|
2259
2337
|
remove() {
|
|
2260
2338
|
removed = true;
|
|
2261
2339
|
current = null;
|
|
2340
|
+
for (const p of [...livePrepared]) p.dispose();
|
|
2262
2341
|
fx?.dispose();
|
|
2263
2342
|
fx = null;
|
|
2264
2343
|
unbindFocusInput?.();
|
|
@@ -2587,7 +2666,9 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
|
|
|
2587
2666
|
// ── load ──
|
|
2588
2667
|
let byteSeqLocal = 0;
|
|
2589
2668
|
/** Fetch/parse one source into an engine asset, and read what the waterfall needs from it. */
|
|
2590
|
-
async function loadOne(pc, app, source) {
|
|
2669
|
+
async function loadOne(pc, app, source, { background = false } = {}) {
|
|
2670
|
+
// prepareSource: our own passes run in idle periods; setSource: at the task's own priority.
|
|
2671
|
+
const y = background ? yieldIdle : yieldToMain;
|
|
2591
2672
|
let bytes = null;
|
|
2592
2673
|
if (typeof source !== 'string') {
|
|
2593
2674
|
const buf = source instanceof Blob ? await source.arrayBuffer() : source;
|
|
@@ -2634,7 +2715,7 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
|
|
|
2634
2715
|
// A URL `.sog` carries its meta in the resource (the engine keeps unknown keys); a Streamed
|
|
2635
2716
|
// SOG carries it at the top level of lod-meta.json. Both validated by the same reader.
|
|
2636
2717
|
if (!bytes && rigOpts.rig !== 'display') camera = sogCameraFromMeta(desc.meta);
|
|
2637
|
-
const cloud = desc.kind === 'flat' ? await readCloud(res) : null;
|
|
2718
|
+
const cloud = desc.kind === 'flat' ? await readCloud(res, y) : null;
|
|
2638
2719
|
// The cloud passes, each in its OWN task: framing, the rest-space sample and the pick set
|
|
2639
2720
|
// were one ~60 ms main-thread block on a 1.18M-gaussian swap — pointer input waited on it.
|
|
2640
2721
|
// Split with a yield between them (and a linear-time percentile in boundsFromPositions),
|
|
@@ -2642,20 +2723,26 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
|
|
|
2642
2723
|
const pre = { local: null, rest: null, pickCentres: null };
|
|
2643
2724
|
if (cloud) {
|
|
2644
2725
|
const walk = centresVisitor(cloud.xyz, cloud.opacity, cloud.total);
|
|
2645
|
-
await
|
|
2726
|
+
await y();
|
|
2646
2727
|
let t = performance.now();
|
|
2647
|
-
|
|
2728
|
+
const framed = sampleCloudCentres(cloud.total, walk) || [];
|
|
2729
|
+
perfSpan('cloud:frame-sample', t);
|
|
2730
|
+
await y();
|
|
2731
|
+
// The percentile core per axis + the window pass, each in its own task (bit-identical to
|
|
2732
|
+
// boundsFromPositions): one ~60-80 ms task per 1.18M gaussians at 4× CPU before.
|
|
2733
|
+
t = performance.now();
|
|
2734
|
+
pre.local = await boundsFromPositionsAsync(framed, undefined, y);
|
|
2648
2735
|
perfSpan('cloud:bounds', t);
|
|
2649
|
-
await
|
|
2736
|
+
await y();
|
|
2650
2737
|
t = performance.now();
|
|
2651
2738
|
if (rigNeedsCloud(camera)) pre.rest = sampleCloudRestSpace(cloud.total, walk, camera?.rest);
|
|
2652
2739
|
perfSpan('cloud:rest-sample', t);
|
|
2653
|
-
await
|
|
2740
|
+
await y();
|
|
2654
2741
|
t = performance.now();
|
|
2655
2742
|
const s = sampleCloudCentres(cloud.total, walk, { cap: RIG_SAMPLE_CAP });
|
|
2656
2743
|
pre.pickCentres = s ? s.slice() : null;
|
|
2657
2744
|
perfSpan('cloud:pick-set', t);
|
|
2658
|
-
await
|
|
2745
|
+
await y();
|
|
2659
2746
|
}
|
|
2660
2747
|
return { asset, res, desc, camera, cloud, pre };
|
|
2661
2748
|
}
|
|
@@ -2796,6 +2883,49 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
|
|
|
2796
2883
|
return out;
|
|
2797
2884
|
});
|
|
2798
2885
|
|
|
2886
|
+
// ── prepareSource: load the next asset in the background, for a setSource with no load ──
|
|
2887
|
+
/**
|
|
2888
|
+
* Fetch, decode and upload `src` now, without rendering it: the engine's load (its own unpack
|
|
2889
|
+
* runs when it must) and our cloud passes (framing, rig sample, pick set — in IDLE periods).
|
|
2890
|
+
* Resolves to an opaque handle for setSource(prepared, opts), which then starts on the next
|
|
2891
|
+
* frame with no load on the transition frame. `dispose()` if the page changes its mind.
|
|
2892
|
+
* Memory: the prepared asset is fully resident (a 1.18M-gaussian SOG: its GPU textures plus the
|
|
2893
|
+
* engine's centre array) alongside the current one until it is used or disposed.
|
|
2894
|
+
*/
|
|
2895
|
+
async function prepareSource(src) {
|
|
2896
|
+
const app = await booted;
|
|
2897
|
+
await first.catch(() => null);
|
|
2898
|
+
if (!app || removed) throw new Error('@displayxr/inline3d/splat: prepareSource on a removed tile.');
|
|
2899
|
+
const t0 = performance.now();
|
|
2900
|
+
const loaded = await loadOne(pcModule, app, src, { background: true });
|
|
2901
|
+
perfSpan('prepareSource', t0);
|
|
2902
|
+
const entry = { loaded, state: 'ready', dispose: null };
|
|
2903
|
+
const prepared = {
|
|
2904
|
+
[PREPARED_TAG]: true,
|
|
2905
|
+
/** The asset's own count (every splat of a flat source). */
|
|
2906
|
+
numSplats: loaded.desc.numSplats || loaded.cloud?.sourceTotal || 0,
|
|
2907
|
+
/** 'ready' until setSource uses it ('used') or dispose() drops it ('disposed'). */
|
|
2908
|
+
get state() {
|
|
2909
|
+
return entry.state;
|
|
2910
|
+
},
|
|
2911
|
+
dispose() {
|
|
2912
|
+
if (entry.state !== 'ready') return;
|
|
2913
|
+
entry.state = 'disposed';
|
|
2914
|
+
livePrepared.delete(entry);
|
|
2915
|
+
app.assets.remove(loaded.asset);
|
|
2916
|
+
loaded.asset.unload?.();
|
|
2917
|
+
},
|
|
2918
|
+
};
|
|
2919
|
+
entry.dispose = prepared.dispose;
|
|
2920
|
+
preparedAssets.set(prepared, entry);
|
|
2921
|
+
if (removed) {
|
|
2922
|
+
prepared.dispose();
|
|
2923
|
+
throw new Error('@displayxr/inline3d/splat: prepareSource on a removed tile.');
|
|
2924
|
+
}
|
|
2925
|
+
livePrepared.add(entry);
|
|
2926
|
+
return prepared;
|
|
2927
|
+
}
|
|
2928
|
+
|
|
2799
2929
|
// ── setSource: swap the asset — a cut, a crossfade, or a transition ──
|
|
2800
2930
|
let sourceGen = 0;
|
|
2801
2931
|
let pendingSwap = null;
|
|
@@ -2818,11 +2948,22 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
|
|
|
2818
2948
|
const plan = resolveSwap(o);
|
|
2819
2949
|
const { resetPose = false } = o;
|
|
2820
2950
|
const gen = ++sourceGen;
|
|
2951
|
+
// A prepareSource() result: already fetched, decoded and uploaded — no load on this path.
|
|
2952
|
+
const prep = preparedAssets.get(next) || null;
|
|
2953
|
+
if (prep) {
|
|
2954
|
+
if (prep.state !== 'ready') {
|
|
2955
|
+
throw new Error(`@displayxr/inline3d/splat: setSource got a prepared source that was already ${prep.state === 'used' ? 'used' : 'disposed'}.`);
|
|
2956
|
+
}
|
|
2957
|
+
prep.state = 'used';
|
|
2958
|
+
livePrepared.delete(prep);
|
|
2959
|
+
} else if (next && typeof next === 'object' && next[PREPARED_TAG]) {
|
|
2960
|
+
throw new Error('@displayxr/inline3d/splat: setSource got a prepared source from another handle.');
|
|
2961
|
+
}
|
|
2821
2962
|
const app = await booted;
|
|
2822
2963
|
await first.catch(() => null); // a failed first asset may be replaced
|
|
2823
2964
|
if (!app || removed) return out;
|
|
2824
2965
|
const pc = pcModule;
|
|
2825
|
-
const loaded = await loadOne(pc, app, next);
|
|
2966
|
+
const loaded = prep ? prep.loaded : await loadOne(pc, app, next);
|
|
2826
2967
|
if (removed || gen !== sourceGen) {
|
|
2827
2968
|
app.assets.remove(loaded.asset);
|
|
2828
2969
|
loaded.asset.unload?.();
|
|
@@ -2841,6 +2982,19 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
|
|
|
2841
2982
|
return out;
|
|
2842
2983
|
}
|
|
2843
2984
|
if (snapped) viewer.setSnapshotAlpha(1, transition === 'wavefront' ? { t: 0, band: plan.band, views: viewer.viewsAcross } : null);
|
|
2985
|
+
// LIVE outgoing (default in 3D): the old asset stays resident and keeps rendering, through the
|
|
2986
|
+
// same eye views, into its own target; the frozen capture above bridges the first frames until
|
|
2987
|
+
// its camera has drawn a sorted frame. Its lens frame is read BEFORE adopt() switches the rig.
|
|
2988
|
+
const outgoingMode = plan.outgoing || defaultOutgoing(viewer.is3D);
|
|
2989
|
+
const live =
|
|
2990
|
+
snapped && outgoingMode === 'live' && viewer.canLiveOutgoing ? viewer.startLiveOutgoing(prev.entity, viewer.lensFrame()) : null;
|
|
2991
|
+
/** Each frame of the window: once the live camera is ready, the overlay samples it. */
|
|
2992
|
+
let liveShown = false;
|
|
2993
|
+
const pumpLive = () => {
|
|
2994
|
+
if (!live || liveShown || !live.active || !live.ready) return;
|
|
2995
|
+
viewer.setSnapshotSource(live.texture);
|
|
2996
|
+
liveShown = true;
|
|
2997
|
+
};
|
|
2844
2998
|
|
|
2845
2999
|
const release = (p) => {
|
|
2846
3000
|
if (!p) return;
|
|
@@ -2925,16 +3079,30 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
|
|
|
2925
3079
|
}
|
|
2926
3080
|
|
|
2927
3081
|
if (transition === 'wavefront' && snapped) {
|
|
2928
|
-
|
|
3082
|
+
if (live) {
|
|
3083
|
+
finishers.push(() => {
|
|
3084
|
+
viewer.stopLiveOutgoing();
|
|
3085
|
+
release(prev);
|
|
3086
|
+
});
|
|
3087
|
+
} else release(prev); // the snapshot shows it from here on
|
|
2929
3088
|
const band = plan.band;
|
|
2930
3089
|
const ridge = fx.play(
|
|
2931
3090
|
'wavefront',
|
|
2932
3091
|
{ durationMs: plan.durationMs, easing: plan.easing, band, ridge: plan.ridge, ridgeMaxDisparity: plan.ridgeMaxDisparity, scope: 'entity' },
|
|
2933
3092
|
{ entity, gate: afterTicks(2), internal: true },
|
|
2934
3093
|
);
|
|
2935
|
-
finishers.push(() =>
|
|
3094
|
+
finishers.push(() => {
|
|
3095
|
+
viewer.setSnapshotAlpha(0);
|
|
3096
|
+
viewer.releaseSnapshot();
|
|
3097
|
+
});
|
|
2936
3098
|
viewer._hooks.push(() => {
|
|
2937
3099
|
if (finished || removed) return false;
|
|
3100
|
+
if (live && !live.fits) {
|
|
3101
|
+
fx?.stop('wavefront', { finish: true, entity });
|
|
3102
|
+
finish();
|
|
3103
|
+
return false;
|
|
3104
|
+
}
|
|
3105
|
+
pumpLive();
|
|
2938
3106
|
const inst = fx?.scopes.get(entity)?.get('wavefront');
|
|
2939
3107
|
const amount = inst ? inst.amount : 1;
|
|
2940
3108
|
if (!viewer.setSnapshotAlpha(1, { t: amount, band, views: viewer.viewsAcross })) {
|
|
@@ -2953,14 +3121,21 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
|
|
|
2953
3121
|
// the 1.12.1 one-pass fade, over the same duration.
|
|
2954
3122
|
const fade = plan.durationMs;
|
|
2955
3123
|
if (plan.reveal) fx.play(plan.reveal.type, { ...plan.reveal.raw, scope: 'entity' }, { entity, gate: afterTicks(2) });
|
|
2956
|
-
if (snapped)
|
|
2957
|
-
|
|
3124
|
+
if (snapped) {
|
|
3125
|
+
if (!live) release(prev); // the snapshot shows it from here on
|
|
3126
|
+
} else {
|
|
2958
3127
|
setFade(entity, 0);
|
|
2959
3128
|
setFade(prev.entity, 1);
|
|
2960
3129
|
}
|
|
2961
3130
|
finishers.push(() => {
|
|
2962
|
-
if (snapped)
|
|
2963
|
-
|
|
3131
|
+
if (snapped) {
|
|
3132
|
+
viewer.setSnapshotAlpha(0);
|
|
3133
|
+
viewer.releaseSnapshot();
|
|
3134
|
+
if (live) {
|
|
3135
|
+
viewer.stopLiveOutgoing();
|
|
3136
|
+
release(prev);
|
|
3137
|
+
}
|
|
3138
|
+
} else {
|
|
2964
3139
|
setFade(entity, null);
|
|
2965
3140
|
release(prev);
|
|
2966
3141
|
}
|
|
@@ -2974,12 +3149,13 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
|
|
|
2974
3149
|
viewer._hooks.push((t) => {
|
|
2975
3150
|
if (removed) return (finish(), false);
|
|
2976
3151
|
if (finished) return false; // superseded: already finished
|
|
3152
|
+
pumpLive();
|
|
2977
3153
|
if (++ticks < 2) return true;
|
|
2978
3154
|
if (t0 === null) t0 = t;
|
|
2979
3155
|
const k = Math.min(1, Math.max(0, (t - t0) / fade));
|
|
2980
3156
|
if (snapped) {
|
|
2981
3157
|
// A resize or a 2D/3D switch mid-fade: the capture no longer fits the buffer — end now.
|
|
2982
|
-
if (!viewer.setSnapshotAlpha(1 - k)) {
|
|
3158
|
+
if (!viewer.setSnapshotAlpha(1 - k) || (live && !live.fits)) {
|
|
2983
3159
|
finish();
|
|
2984
3160
|
return false;
|
|
2985
3161
|
}
|
|
@@ -3008,3 +3184,5 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
|
|
|
3008
3184
|
}
|
|
3009
3185
|
|
|
3010
3186
|
let byteSeq = 0;
|
|
3187
|
+
/** Marks a prepareSource() result (so one from another handle is caught, not loaded as bytes). */
|
|
3188
|
+
const PREPARED_TAG = Symbol.for('@displayxr/inline3d/splat.prepared');
|
package/js/inline3d-splat.js
CHANGED
|
@@ -369,6 +369,10 @@ export function addSplat(wall, canvas, src, opts = {}) {
|
|
|
369
369
|
"only; with the Spark backend, remove() this handle and addSplat() the new asset.",
|
|
370
370
|
);
|
|
371
371
|
},
|
|
372
|
+
/** Not on this backend: prepareSource() feeds setSource(), a PlayCanvas-backend feature. */
|
|
373
|
+
prepareSource() {
|
|
374
|
+
throw new Error("@displayxr/inline3d/splat: prepareSource() is implemented on the PlayCanvas backend only.");
|
|
375
|
+
},
|
|
372
376
|
/** Not on this backend (splat effects are PlayCanvas-only in this version). */
|
|
373
377
|
playEffect() {
|
|
374
378
|
throw effectsNotOnSpark('playEffect()');
|
|
@@ -706,6 +710,7 @@ function addSplatDeferred(wall, canvas, src, opts) {
|
|
|
706
710
|
// A swap requested before the first asset has landed runs once it has (the adapter's own
|
|
707
711
|
// setSource replaces this stub on the same object by then).
|
|
708
712
|
setSource: (...args) => out.ready.then(() => out.setSource(...args)),
|
|
713
|
+
prepareSource: (...args) => out.ready.then(() => out.prepareSource(...args)),
|
|
709
714
|
// Effects before the adapter has loaded: validated NOW (a bad call throws at its own line),
|
|
710
715
|
// then run once the first asset is on screen.
|
|
711
716
|
playEffect: (name, o) => {
|
package/js/inline3d-viewer.js
CHANGED
|
@@ -152,7 +152,30 @@ function swap(a, i, j) {
|
|
|
152
152
|
* Set 0 to get the old percentile-only box back.
|
|
153
153
|
* @returns {{center:number[], extent:number[]}|null} null if there is nothing to measure.
|
|
154
154
|
*/
|
|
155
|
-
export function boundsFromPositions(xyz,
|
|
155
|
+
export function boundsFromPositions(xyz, opts) {
|
|
156
|
+
const it = boundsSteps(xyz, opts);
|
|
157
|
+
let r = it.next();
|
|
158
|
+
while (!r.done) r = it.next();
|
|
159
|
+
return r.value;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* boundsFromPositions, in steps: the same code, the same numbers bit for bit, but a caller can
|
|
164
|
+
* give the main thread back between the per-axis selections and the window pass. On a 1.18M
|
|
165
|
+
* gaussian cloud under 4× CPU throttling the one-shot version was a single 60-80 ms task.
|
|
166
|
+
* `await boundsFromPositionsAsync(xyz, opts, yielder)` runs it with `yielder()` between steps.
|
|
167
|
+
*/
|
|
168
|
+
export async function boundsFromPositionsAsync(xyz, opts, yielder) {
|
|
169
|
+
const it = boundsSteps(xyz, opts);
|
|
170
|
+
let r = it.next();
|
|
171
|
+
while (!r.done) {
|
|
172
|
+
await yielder();
|
|
173
|
+
r = it.next();
|
|
174
|
+
}
|
|
175
|
+
return r.value;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function* boundsSteps(xyz, { lo = 0.05, hi = 0.95, expand = 2.5 } = {}) {
|
|
156
179
|
const n = Math.floor(xyz.length / 3);
|
|
157
180
|
if (n < 1) return null;
|
|
158
181
|
// Below a few hundred points the percentiles are noise — just use the true box.
|
|
@@ -171,6 +194,7 @@ export function boundsFromPositions(xyz, { lo = 0.05, hi = 0.95, expand = 2.5 }
|
|
|
171
194
|
const hiV = selectKth(axisVals, n, kHi);
|
|
172
195
|
center[axis] = 0.5 * (loV + hiV);
|
|
173
196
|
extent[axis] = Math.max(hiV - loV, 1e-6);
|
|
197
|
+
yield;
|
|
174
198
|
}
|
|
175
199
|
// Untrimmed already IS the true box, and expand 0 asks for the old behaviour.
|
|
176
200
|
if (!trim || expand <= 0) return { center, extent };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@displayxr/inline3d",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.14.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",
|
|
@@ -51,6 +51,7 @@
|
|
|
51
51
|
"js/inline3d-splat-playcanvas.js",
|
|
52
52
|
"js/inline3d-splat-shared.js",
|
|
53
53
|
"js/inline3d-splat-effects.js",
|
|
54
|
+
"js/inline3d-splat-live.js",
|
|
54
55
|
"js/inline3d-model.js",
|
|
55
56
|
"js/inline3d-model-entry.js",
|
|
56
57
|
"js/inline3d-model-playcanvas.js",
|
package/splat.d.ts
CHANGED
|
@@ -336,7 +336,62 @@ export interface SplatOptions {
|
|
|
336
336
|
}
|
|
337
337
|
|
|
338
338
|
/** The transition effects `reveal` accepts. */
|
|
339
|
-
export type SplatRevealName = 'inflate' | 'sweep' | 'dissolve' | 'fade';
|
|
339
|
+
export type SplatRevealName = 'inflate' | 'sweep' | 'dissolve' | 'fade' | SplatParticleRevealName;
|
|
340
|
+
|
|
341
|
+
/** The particle reveals: every gaussian is a particle with its own start time (docs/splat-effects.md §Particle reveals). */
|
|
342
|
+
export type SplatParticleRevealName = 'assemble' | 'dissolve-in' | 'converge' | 'shimmer';
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Options every particle reveal takes (plus its timing). All sizes are in PICTURE units, so the
|
|
346
|
+
* look is the same on a 2 cm object and a 40 m street.
|
|
347
|
+
*/
|
|
348
|
+
export interface SplatParticleOptions {
|
|
349
|
+
/**
|
|
350
|
+
* What staggers the particles: `'radial'` (distance in the picture from the origin), `'depth'`
|
|
351
|
+
* (near first), `'noise'` (fbm patches), `'random'`, or `'layers'` (a SHARP photo's grid order:
|
|
352
|
+
* layer 0, the visible surface, then layer 1, the disocclusion fill, each outward from the
|
|
353
|
+
* origin — reads `splat.index`, so it forces `scope: 'entity'`).
|
|
354
|
+
*/
|
|
355
|
+
order?: 'radial' | 'depth' | 'noise' | 'random' | 'layers';
|
|
356
|
+
/** Fraction of the duration spent launching particles, 0..0.95. */
|
|
357
|
+
stagger?: number;
|
|
358
|
+
/** How much of each particle's key is random, 0..1. */
|
|
359
|
+
jitter?: number;
|
|
360
|
+
/** A particle in flight is a dot this size (a fraction of the view width). */
|
|
361
|
+
dotSize?: number;
|
|
362
|
+
/** A particle grows back to its own splat over the last (1 − grow) of its flight. */
|
|
363
|
+
grow?: number;
|
|
364
|
+
/** In-flight alpha multiplier, 0..1. */
|
|
365
|
+
flightAlpha?: number;
|
|
366
|
+
/** In-flight tint, added as `color · glow`. */
|
|
367
|
+
color?: [number, number, number];
|
|
368
|
+
glow?: number;
|
|
369
|
+
/** Size of the noise patches, per half-view-width. */
|
|
370
|
+
noiseScale?: number;
|
|
371
|
+
/**
|
|
372
|
+
* Comfort cap: no particle is ever nearer the eyes than its home depth by more than this much
|
|
373
|
+
* extra disparity, a fraction of the eye view's width (default 0.004; 0 = never nearer than
|
|
374
|
+
* home; max 0.05). In 2D a nominal 64 mm-at-1.7 m separation is assumed.
|
|
375
|
+
*/
|
|
376
|
+
maxDisparity?: number;
|
|
377
|
+
/** `order: 'layers'`: gaussians per layer (default 768² = 589 824, SHARP). */
|
|
378
|
+
layerSize?: number;
|
|
379
|
+
/** `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). */
|
|
380
|
+
spread?: number;
|
|
381
|
+
swirl?: number;
|
|
382
|
+
turbulence?: number;
|
|
383
|
+
coherence?: number;
|
|
384
|
+
depth?: number;
|
|
385
|
+
/** `dissolve-in`: the wind's lift and the random drift (half-view-widths). */
|
|
386
|
+
lift?: number;
|
|
387
|
+
drift?: number;
|
|
388
|
+
/** `converge`: the spiral (rad) and the launch ball's radius (half-view-widths). */
|
|
389
|
+
spin?: number;
|
|
390
|
+
burst?: number;
|
|
391
|
+
/** `shimmer`: twinkle rate (rad/s) and sparkle strength. */
|
|
392
|
+
twinkle?: number;
|
|
393
|
+
sparkle?: number;
|
|
394
|
+
}
|
|
340
395
|
|
|
341
396
|
/** Named easings; a function `(x) => y` on [0, 1] also works. */
|
|
342
397
|
export type SplatEasing =
|
|
@@ -397,7 +452,7 @@ export interface SplatCustomEffect extends SplatEffectTiming {
|
|
|
397
452
|
hold?: boolean;
|
|
398
453
|
}
|
|
399
454
|
|
|
400
|
-
export type SplatEffectName = 'inflate' | 'deflate' | 'sweep' | 'dissolve' | 'fade' | 'pulse' | 'grade' | 'clip' | 'custom' | `custom:${string}`;
|
|
455
|
+
export type SplatEffectName = 'inflate' | 'deflate' | 'sweep' | 'dissolve' | 'fade' | SplatParticleRevealName | 'pulse' | 'grade' | 'clip' | 'custom' | `custom:${string}`;
|
|
401
456
|
|
|
402
457
|
/** `setEffect('grade', …)`. */
|
|
403
458
|
export interface SplatGradeParams {
|
|
@@ -454,6 +509,29 @@ export interface SplatSourceOptions {
|
|
|
454
509
|
ridge?: number;
|
|
455
510
|
/** `wavefront`: the ridge's disparity cap, a fraction of the eye view's width (default 0.004, max 0.05). */
|
|
456
511
|
ridgeMaxDisparity?: number;
|
|
512
|
+
/**
|
|
513
|
+
* `crossfade` / `wavefront`: how the OUTGOING photo is shown during the window.
|
|
514
|
+
* `'live'` (the default in a woven 3D session): it stays resident and keeps rendering every frame
|
|
515
|
+
* through the same eye views (head motion included) into its own target, blended per eye with
|
|
516
|
+
* the live incoming one — about 2× draw for the window, both assets resident. `'frozen'` (the
|
|
517
|
+
* default in 2D): its last frame, frozen into a texture (1.12.1); one draw, but no head parallax
|
|
518
|
+
* on the outgoing photo — on a tracked panel it reads as tracking pausing. Engine builds without
|
|
519
|
+
* the single-camera RenderView path always use `'frozen'`.
|
|
520
|
+
*/
|
|
521
|
+
outgoing?: 'live' | 'frozen';
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
/**
|
|
525
|
+
* What `prepareSource()` resolves to: an opaque, single-use handle for `setSource`. The asset is
|
|
526
|
+
* fully resident (GPU textures + the engine's centre array) until used or disposed.
|
|
527
|
+
*/
|
|
528
|
+
export interface SplatPreparedSource {
|
|
529
|
+
/** The asset's own count (every splat of a flat source). */
|
|
530
|
+
readonly numSplats: number;
|
|
531
|
+
/** `'ready'` until `setSource` uses it (`'used'`) or `dispose()` drops it (`'disposed'`). */
|
|
532
|
+
readonly state: 'ready' | 'used' | 'disposed';
|
|
533
|
+
/** Release the prepared asset (no-op once used or disposed). */
|
|
534
|
+
dispose(): void;
|
|
457
535
|
}
|
|
458
536
|
|
|
459
537
|
/** What `onBeforeFrame` receives. */
|
|
@@ -592,20 +670,32 @@ export interface SplatHandle {
|
|
|
592
670
|
/**
|
|
593
671
|
* Swap the asset (URL or bytes) in place. PlayCanvas backend only — throws on Spark.
|
|
594
672
|
*
|
|
595
|
-
* The new file loads BEHIND the current one
|
|
596
|
-
*
|
|
597
|
-
*
|
|
598
|
-
*
|
|
673
|
+
* The new file loads BEHIND the current one (or pass a `prepareSource()` result: no load at all
|
|
674
|
+
* on this path); then the two crossfade over `fadeMs` (0 = a cut) and the old one is released.
|
|
675
|
+
* The crossfade blends the two IMAGES linearly, per eye, so the mix is exactly `t` whatever the
|
|
676
|
+
* two photos' depth order. The old image is LIVE in a woven 3D session (it keeps rendering with
|
|
677
|
+
* head motion; `outgoing: 'frozen'` restores the 1.12.1 frozen last frame, the 2D default). The
|
|
678
|
+
* end state is exactly a plain swap. The rig waterfall re-runs for the new file (rig, lens, focus and
|
|
599
679
|
* frame update; `onFocusChange` fires). The pose (yaw/pitch/zoom/depth) is kept unless
|
|
600
680
|
* `resetPose`. A newer call supersedes an older one still loading. Resolves once the fade has
|
|
601
681
|
* finished; rejects if the new asset cannot be loaded (the current one stays on screen).
|
|
602
682
|
*/
|
|
603
|
-
setSource(src: string | Blob | ArrayBuffer | Uint8Array, opts?: SplatSourceOptions): Promise<SplatHandle>;
|
|
683
|
+
setSource(src: string | Blob | ArrayBuffer | Uint8Array | SplatPreparedSource, opts?: SplatSourceOptions): Promise<SplatHandle>;
|
|
684
|
+
/**
|
|
685
|
+
* PlayCanvas backend only — throws on Spark. Fetch, decode and upload `src` now, in the
|
|
686
|
+
* background (the SDK's own passes in idle periods; the engine's end-of-load unpack runs when
|
|
687
|
+
* it must), without rendering it. `setSource(prepared, opts)` then starts on the next frame with
|
|
688
|
+
* no load on the transition path. Single use; `dispose()` it if the page changes its mind.
|
|
689
|
+
* Memory: two full assets resident until the swap (≈ +70 MB of GPU textures per 1.18M-gaussian
|
|
690
|
+
* SOG). `remove()` disposes any still unused.
|
|
691
|
+
*/
|
|
692
|
+
prepareSource(src: string | Blob | ArrayBuffer | Uint8Array): Promise<SplatPreparedSource>;
|
|
604
693
|
/**
|
|
605
694
|
* `engine: 'playcanvas'` only (throws on Spark). Play a transition/pulse/custom effect;
|
|
606
695
|
* validated at the call, run once the first asset is on screen. Resolves `{ finished }` —
|
|
607
696
|
* false when stopped or replaced. docs/splat-effects.md.
|
|
608
697
|
*/
|
|
698
|
+
playEffect(name: SplatParticleRevealName, opts?: Omit<SplatEffectTiming, 'direction'> & SplatParticleOptions): Promise<{ finished: boolean }>;
|
|
609
699
|
playEffect(
|
|
610
700
|
name: Exclude<SplatEffectName, 'grade' | 'clip'>,
|
|
611
701
|
opts?: SplatEffectTiming & Record<string, unknown>,
|