@displayxr/inline3d 1.12.0 → 1.13.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 +55 -0
- package/README.md +11 -0
- package/THIRD_PARTY_NOTICES.md +76 -0
- package/js/inline3d-playcanvas-engine.js +5 -0
- package/js/inline3d-splat-effects.js +1116 -0
- package/js/inline3d-splat-perf.js +3 -0
- package/js/inline3d-splat-playcanvas.js +517 -82
- package/js/inline3d-splat-shared.js +33 -0
- package/js/inline3d-splat.js +28 -0
- package/package.json +4 -2
- package/splat.d.ts +152 -5
|
@@ -51,6 +51,13 @@ import {
|
|
|
51
51
|
} from './inline3d-splat-perf.js';
|
|
52
52
|
import { boundsFromPositions } from './inline3d-viewer.js';
|
|
53
53
|
import { cameraRigFromPose } from './inline3d-three.js';
|
|
54
|
+
import {
|
|
55
|
+
SplatEffects,
|
|
56
|
+
EFFECTS,
|
|
57
|
+
EASINGS,
|
|
58
|
+
resolveRevealOption,
|
|
59
|
+
validateEffectCall,
|
|
60
|
+
} from './inline3d-splat-effects.js';
|
|
54
61
|
import {
|
|
55
62
|
clamp,
|
|
56
63
|
finite,
|
|
@@ -84,6 +91,7 @@ import {
|
|
|
84
91
|
streamedBytesError,
|
|
85
92
|
resolveControls,
|
|
86
93
|
normalizeCameraPose,
|
|
94
|
+
coverageExponent,
|
|
87
95
|
} from './inline3d-splat-shared.js';
|
|
88
96
|
|
|
89
97
|
/**
|
|
@@ -500,6 +508,9 @@ export function capturePose(rest, flipY) {
|
|
|
500
508
|
* (epic #36, P4d); until then the anchors are regexes (the npm build re-indents chunks with
|
|
501
509
|
* tabs) and a miss warns once and renders unpatched rather than failing.
|
|
502
510
|
*
|
|
511
|
+
* The rewritten lines are adapted from PlayCanvas engine src/scene/shader-lib/glsl/chunks/gsplat/vert/
|
|
512
|
+
* gsplatCorner.js (the `gsplatCornerVS` chunk) @ v2.22.3, MIT (THIRD_PARTY_NOTICES.md).
|
|
513
|
+
*
|
|
503
514
|
* @returns {{src:string, ok:boolean}}
|
|
504
515
|
*/
|
|
505
516
|
export function patchGsplatFootprint(src) {
|
|
@@ -653,6 +664,10 @@ export class PlayCanvasSplatViewer {
|
|
|
653
664
|
this.onCaptureFov = null;
|
|
654
665
|
/** Per-frame hooks, `(tMs) => boolean` — return false to be removed. setSource's crossfade. */
|
|
655
666
|
this._hooks = [];
|
|
667
|
+
/** captureFrame() callers waiting for the next drawn frame. */
|
|
668
|
+
this._captureWaiters = [];
|
|
669
|
+
/** The crossfade's frame snapshot: { w, h, tex, rt, mi, mat } once captured. */
|
|
670
|
+
this._snap = null;
|
|
656
671
|
this.vH = virtualDisplayHeight;
|
|
657
672
|
this.fit = fit;
|
|
658
673
|
this.margin = margin;
|
|
@@ -968,6 +983,10 @@ export class PlayCanvasSplatViewer {
|
|
|
968
983
|
if (this._ro) this._ro.disconnect();
|
|
969
984
|
else if (typeof removeEventListener === 'function') removeEventListener('resize', this._onResize);
|
|
970
985
|
this._unbindOrbit();
|
|
986
|
+
for (const w of this._captureWaiters.splice(0)) {
|
|
987
|
+
clearTimeout(w.timer);
|
|
988
|
+
w.resolve(false);
|
|
989
|
+
}
|
|
971
990
|
try {
|
|
972
991
|
this.app?.destroy();
|
|
973
992
|
} catch (err) {
|
|
@@ -1177,8 +1196,171 @@ export class PlayCanvasSplatViewer {
|
|
|
1177
1196
|
if (this.app.scene.gsplat.splatBudget !== per) this.app.scene.gsplat.splatBudget = per;
|
|
1178
1197
|
}
|
|
1179
1198
|
|
|
1199
|
+
/**
|
|
1200
|
+
* Copy the frame just drawn (the whole buffer: both eyes in 3D) into a texture, on the next
|
|
1201
|
+
* drawn frame. Resolves true once copied, false if no frame is drawn within `timeoutMs` (a
|
|
1202
|
+
* hidden tab) or the engine cannot copy. setSource's crossfade (FRAME_SNAPSHOT).
|
|
1203
|
+
*/
|
|
1204
|
+
captureFrame(timeoutMs = 250) {
|
|
1205
|
+
return new Promise((resolve) => {
|
|
1206
|
+
if (!this.app || this._disposed || !this.pc?.RenderTarget) return resolve(false);
|
|
1207
|
+
const waiter = { resolve, timer: 0 };
|
|
1208
|
+
waiter.timer = setTimeout(() => {
|
|
1209
|
+
const i = this._captureWaiters.indexOf(waiter);
|
|
1210
|
+
if (i >= 0) this._captureWaiters.splice(i, 1);
|
|
1211
|
+
resolve(false);
|
|
1212
|
+
}, timeoutMs);
|
|
1213
|
+
this._captureWaiters.push(waiter);
|
|
1214
|
+
});
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
_doCapture() {
|
|
1218
|
+
const run = this._captureWaiters.splice(0);
|
|
1219
|
+
let ok = false;
|
|
1220
|
+
try {
|
|
1221
|
+
const pc = this.pc;
|
|
1222
|
+
const device = this.app.graphicsDevice;
|
|
1223
|
+
const w = this.canvas.width;
|
|
1224
|
+
const h = this.canvas.height;
|
|
1225
|
+
let s = this._snap;
|
|
1226
|
+
if (!s || s.w !== w || s.h !== h) {
|
|
1227
|
+
this._destroySnapshot();
|
|
1228
|
+
const tex = new pc.Texture(device, {
|
|
1229
|
+
name: 'inline3d-snapshot',
|
|
1230
|
+
width: w,
|
|
1231
|
+
height: h,
|
|
1232
|
+
format: pc.PIXELFORMAT_RGBA8,
|
|
1233
|
+
mipmaps: false,
|
|
1234
|
+
minFilter: pc.FILTER_NEAREST,
|
|
1235
|
+
magFilter: pc.FILTER_NEAREST,
|
|
1236
|
+
addressU: pc.ADDRESS_CLAMP_TO_EDGE,
|
|
1237
|
+
addressV: pc.ADDRESS_CLAMP_TO_EDGE,
|
|
1238
|
+
});
|
|
1239
|
+
s = this._snap = { w, h, tex, rt: new pc.RenderTarget({ colorBuffer: tex, depth: false }), parts: null };
|
|
1240
|
+
}
|
|
1241
|
+
// null source = the back buffer, still holding this frame (same task, before compositing).
|
|
1242
|
+
ok = device.copyRenderTarget(null, s.rt, true, false) !== false;
|
|
1243
|
+
if (ok) this._ensureSnapshotOverlay();
|
|
1244
|
+
} catch (err) {
|
|
1245
|
+
console.warn('[inline3d/splat] frame snapshot failed; the crossfade falls back to one pass', err);
|
|
1246
|
+
ok = false;
|
|
1247
|
+
}
|
|
1248
|
+
for (const w of run) {
|
|
1249
|
+
clearTimeout(w.timer);
|
|
1250
|
+
w.resolve(ok);
|
|
1251
|
+
}
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
/**
|
|
1255
|
+
* FRAME_SNAPSHOT: the captured frame lerped with the live scene, in two clip-space quads in the
|
|
1256
|
+
* UI layer (under the edge feather), drawn once per view like the feather. Each samples the
|
|
1257
|
+
* capture at gl_FragCoord, so an eye's viewport reads its own half. Quad 1 scales what the scene
|
|
1258
|
+
* drew by (1 − α) (blend ZERO / SRC_ALPHA, colour AND alpha); quad 2 adds α·A (ONE / ONE). The
|
|
1259
|
+
* result is the plain lerp of the two premultiplied images, `α·A + (1 − α)·B` — alpha included,
|
|
1260
|
+
* so a region only one of the two photos covers fades against the page, not against black.
|
|
1261
|
+
*/
|
|
1262
|
+
_ensureSnapshotOverlay() {
|
|
1263
|
+
const s = this._snap;
|
|
1264
|
+
if (s.parts) {
|
|
1265
|
+
for (const p of s.parts) {
|
|
1266
|
+
p.mat.setParameter('dxrSnap', s.tex);
|
|
1267
|
+
p.mat.setParameter('dxrSnapInvSize', [1 / s.w, 1 / s.h]);
|
|
1268
|
+
p.mat.update();
|
|
1269
|
+
}
|
|
1270
|
+
return;
|
|
1271
|
+
}
|
|
1272
|
+
const pc = this.pc;
|
|
1273
|
+
const mesh = new pc.Mesh(this.app.graphicsDevice);
|
|
1274
|
+
mesh.setPositions(new Float32Array([-1, -1, 0, 1, -1, 0, 1, 1, 0, -1, 1, 0]));
|
|
1275
|
+
mesh.setIndices([0, 1, 2, 0, 2, 3]);
|
|
1276
|
+
mesh.update();
|
|
1277
|
+
const part = (name, body, src, dst, drawOrder) => {
|
|
1278
|
+
const mat = new pc.ShaderMaterial({
|
|
1279
|
+
uniqueName: name,
|
|
1280
|
+
attributes: { vertex_position: pc.SEMANTIC_POSITION },
|
|
1281
|
+
vertexGLSL: `
|
|
1282
|
+
attribute vec3 vertex_position;
|
|
1283
|
+
void main() { gl_Position = vec4(vertex_position.xy, 0.0, 1.0); }`,
|
|
1284
|
+
fragmentGLSL: `
|
|
1285
|
+
uniform sampler2D dxrSnap;
|
|
1286
|
+
uniform vec2 dxrSnapInvSize;
|
|
1287
|
+
uniform float dxrSnapAlpha;
|
|
1288
|
+
uniform vec3 dxrSnapWipe; // t, band, views across (t < -1: no wipe)
|
|
1289
|
+
float dxrSnapWeight() {
|
|
1290
|
+
if (dxrSnapWipe.x < -1.0) return dxrSnapAlpha;
|
|
1291
|
+
float vw = 1.0 / (dxrSnapInvSize.x * dxrSnapWipe.z);
|
|
1292
|
+
float u = mod(gl_FragCoord.x, vw) / vw;
|
|
1293
|
+
// wavefrontCommit(): column u has committed lt of the way to the new photo
|
|
1294
|
+
float lt = clamp((dxrSnapWipe.x - u * (1.0 - dxrSnapWipe.y)) / dxrSnapWipe.y, 0.0, 1.0);
|
|
1295
|
+
return dxrSnapAlpha * (1.0 - smoothstep(0.0, 1.0, lt));
|
|
1296
|
+
}
|
|
1297
|
+
void main() { ${body} }`,
|
|
1298
|
+
});
|
|
1299
|
+
mat.blendState = new pc.BlendState(true, pc.BLENDEQUATION_ADD, src, dst, pc.BLENDEQUATION_ADD, src, dst);
|
|
1300
|
+
mat.depthTest = false;
|
|
1301
|
+
mat.depthWrite = false;
|
|
1302
|
+
mat.cull = pc.CULLFACE_NONE;
|
|
1303
|
+
mat.setParameter('dxrSnap', s.tex);
|
|
1304
|
+
mat.setParameter('dxrSnapInvSize', [1 / s.w, 1 / s.h]);
|
|
1305
|
+
mat.setParameter('dxrSnapAlpha', 0);
|
|
1306
|
+
mat.setParameter('dxrSnapWipe', [-2, 0.1, 1]);
|
|
1307
|
+
mat.update();
|
|
1308
|
+
const mi = new pc.MeshInstance(mesh, mat, new pc.GraphNode(name));
|
|
1309
|
+
mi.cull = false;
|
|
1310
|
+
mi.drawOrder = drawOrder; // both before the edge feather (drawOrder 0)
|
|
1311
|
+
mi.visible = false;
|
|
1312
|
+
return { mat, mi };
|
|
1313
|
+
};
|
|
1314
|
+
s.parts = [
|
|
1315
|
+
part('inline3dSnapshotScale', 'gl_FragColor = vec4(0.0, 0.0, 0.0, 1.0 - dxrSnapWeight());', pc.BLENDMODE_ZERO, pc.BLENDMODE_SRC_ALPHA, -2),
|
|
1316
|
+
part('inline3dSnapshotAdd', 'gl_FragColor = texture2D(dxrSnap, gl_FragCoord.xy * dxrSnapInvSize) * dxrSnapWeight();', pc.BLENDMODE_ONE, pc.BLENDMODE_ONE, -1),
|
|
1317
|
+
];
|
|
1318
|
+
this.app.scene.layers.getLayerById(pc.LAYERID_UI).addMeshInstances(s.parts.map((p) => p.mi));
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
/**
|
|
1322
|
+
* Lerp the snapshot over the scene at `alpha` (0 hides it). Returns false when the canvas buffer
|
|
1323
|
+
* no longer matches the capture (resize, 2D/3D switch): the caller ends its fade.
|
|
1324
|
+
*/
|
|
1325
|
+
setSnapshotAlpha(alpha, wipe = null) {
|
|
1326
|
+
const s = this._snap;
|
|
1327
|
+
if (!s?.parts) return false;
|
|
1328
|
+
const fits = s.w === this.canvas.width && s.h === this.canvas.height;
|
|
1329
|
+
const show = alpha > 0 && fits;
|
|
1330
|
+
for (const p of s.parts) {
|
|
1331
|
+
p.mi.visible = show;
|
|
1332
|
+
if (show) {
|
|
1333
|
+
p.mat.setParameter('dxrSnapAlpha', Math.min(1, alpha));
|
|
1334
|
+
// WIPE (setSource's wavefront): the frozen frame gives way behind a vertical front at the
|
|
1335
|
+
// SAME viewport-relative x in every eye — a front at the zero-disparity plane, so both
|
|
1336
|
+
// eyes agree. `views` = eye viewports side by side across the buffer.
|
|
1337
|
+
p.mat.setParameter('dxrSnapWipe', wipe ? [wipe.t, wipe.band, wipe.views] : [-2, 0.1, 1]);
|
|
1338
|
+
p.mat.update();
|
|
1339
|
+
}
|
|
1340
|
+
}
|
|
1341
|
+
return alpha <= 0 || fits;
|
|
1342
|
+
}
|
|
1343
|
+
|
|
1344
|
+
/** How many eye viewports sit side by side in the buffer right now (1 in mono). */
|
|
1345
|
+
get viewsAcross() {
|
|
1346
|
+
return this._mode === '3d' && this._lastGood ? this._lastGood.entries.length : 1;
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
_destroySnapshot() {
|
|
1350
|
+
const s = this._snap;
|
|
1351
|
+
if (!s) return;
|
|
1352
|
+
this._snap = null;
|
|
1353
|
+
if (s.parts) {
|
|
1354
|
+
this.app?.scene?.layers?.getLayerById(this.pc.LAYERID_UI)?.removeMeshInstances?.(s.parts.map((p) => p.mi));
|
|
1355
|
+
s.parts[0].mi.mesh?.destroy?.();
|
|
1356
|
+
}
|
|
1357
|
+
s.rt?.destroy?.();
|
|
1358
|
+
s.tex?.destroy?.();
|
|
1359
|
+
}
|
|
1360
|
+
|
|
1180
1361
|
/** Read back what the tick just did. Cheap: two numbers off the renderer. */
|
|
1181
1362
|
_afterTick() {
|
|
1363
|
+
if (this._captureWaiters.length) this._doCapture();
|
|
1182
1364
|
const n = this.app?.renderer?._gsplatCount ?? 0;
|
|
1183
1365
|
this.resident = n;
|
|
1184
1366
|
if (n > this.peakResident) this.peakResident = n;
|
|
@@ -1450,9 +1632,44 @@ export class PlayCanvasSplatViewer {
|
|
|
1450
1632
|
const keep = run.filter((h) => h(t) !== false);
|
|
1451
1633
|
this._hooks = keep.concat(this._hooks);
|
|
1452
1634
|
}
|
|
1635
|
+
// Splat effects (./inline3d-splat-effects.js): clocks + uniforms, before the engine renders.
|
|
1636
|
+
this.onEffectsTick?.(t);
|
|
1453
1637
|
this.onTick?.();
|
|
1454
1638
|
}
|
|
1455
1639
|
|
|
1640
|
+
/**
|
|
1641
|
+
* The eyes' frame in WORLD (content) space, for effects keyed on "where the viewer is":
|
|
1642
|
+
* `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-width.
|
|
1644
|
+
*/
|
|
1645
|
+
eyeFrame() {
|
|
1646
|
+
const R = this.rigMatrix();
|
|
1647
|
+
const es = this._mode === '3d' && this._lastGood ? this._lastGood.entries : [{ pose: this.mono.pose, proj: this.mono.proj }];
|
|
1648
|
+
const o = [0, 0, 0];
|
|
1649
|
+
for (const e of es) {
|
|
1650
|
+
const M = mat4Mul(R, e.pose);
|
|
1651
|
+
o[0] += M[12] / es.length;
|
|
1652
|
+
o[1] += M[13] / es.length;
|
|
1653
|
+
o[2] += M[14] / es.length;
|
|
1654
|
+
}
|
|
1655
|
+
const M = mat4Mul(R, es[0].pose);
|
|
1656
|
+
const unit = (x, y, z) => {
|
|
1657
|
+
const l = Math.hypot(x, y, z) || 1;
|
|
1658
|
+
return [x / l, y / l, z / l];
|
|
1659
|
+
};
|
|
1660
|
+
const p0 = es[0].proj?.[0];
|
|
1661
|
+
const Mn = mat4Mul(R, es[es.length - 1].pose);
|
|
1662
|
+
return {
|
|
1663
|
+
origin: o,
|
|
1664
|
+
// first ↔ last eye, world units (0 in 2D)
|
|
1665
|
+
separation: es.length > 1 ? Math.hypot(Mn[12] - M[12], Mn[13] - M[13], Mn[14] - M[14]) : 0,
|
|
1666
|
+
axis: unit(-M[8], -M[9], -M[10]),
|
|
1667
|
+
right: unit(M[0], M[1], M[2]),
|
|
1668
|
+
up: unit(M[4], M[5], M[6]),
|
|
1669
|
+
tanHalfFovX: p0 > 0 ? 1 / p0 : 0.5,
|
|
1670
|
+
};
|
|
1671
|
+
}
|
|
1672
|
+
|
|
1456
1673
|
_easeFocus() {
|
|
1457
1674
|
if (this._focusSettled) return;
|
|
1458
1675
|
const f = this._focus;
|
|
@@ -1820,39 +2037,61 @@ export function describeResource(res) {
|
|
|
1820
2037
|
// ── the handle ──────────────────────────────────────────────────────────────────────────────
|
|
1821
2038
|
|
|
1822
2039
|
/**
|
|
1823
|
-
*
|
|
1824
|
-
* by this ENTITY's `dxrFade` parameter.
|
|
2040
|
+
* setSource's crossfade.
|
|
1825
2041
|
*
|
|
1826
|
-
*
|
|
1827
|
-
*
|
|
1828
|
-
*
|
|
1829
|
-
*
|
|
1830
|
-
*
|
|
2042
|
+
* The 1.11 fade multiplied each splat's alpha by t and drew both assets in ONE sorted pass. That
|
|
2043
|
+
* is not a crossfade: whichever photo sits in FRONT wins. Measured on SHARP photo pairs (the blend
|
|
2044
|
+
* fraction c, least squares of the frame between A-alone and B-alone): the incoming photo in front
|
|
2045
|
+
* reached c = 0.64 at t = 0.2 (a snap), behind it c = 0.18 at t = 0.5 (a late pop). No per-splat
|
|
2046
|
+
* alpha remap fixes that, because a pixel's coverage depends on how many splats stack there and
|
|
2047
|
+
* which asset is nearer — both vary across the picture and between photos (docs/splat-effects.md
|
|
2048
|
+
* §Crossfade has the numbers).
|
|
2049
|
+
*
|
|
2050
|
+
* FRAME_SNAPSHOT: blend IMAGES instead. The last frame of the outgoing asset (both eyes) is copied
|
|
2051
|
+
* into a texture, the outgoing asset is released at once, and the incoming one is drawn untouched;
|
|
2052
|
+
* two overlay quads then lerp the frozen frame with it, `out = (1 − t)·A + t·B` per premultiplied
|
|
2053
|
+
* pixel, alpha included (PlayCanvasSplatViewer._ensureSnapshotOverlay). Linear by construction,
|
|
2054
|
+
* whatever the depth order, coverage or framing; each eye samples its own half of the capture, so
|
|
2055
|
+
* the eyes stay consistent. End state: overlay hidden — exactly the untouched incoming asset.
|
|
2056
|
+
* Trade-offs: the outgoing frame is frozen for the fade (camera and head motion stop for it), and
|
|
2057
|
+
* the new asset's rig applies at once (each photo is seen through its own rig).
|
|
2058
|
+
*
|
|
2059
|
+
* Fallback (no frame drawn within 250 ms — a hidden tab — or no copy on this engine build): the
|
|
2060
|
+
* one-pass fade, with the coverage-linear alpha remap on both assets.
|
|
1831
2061
|
*/
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
if (
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
-
|
|
2062
|
+
|
|
2063
|
+
/** setSource's transitions and their defaults. */
|
|
2064
|
+
export const SOURCE_TRANSITIONS = Object.freeze({
|
|
2065
|
+
cut: {},
|
|
2066
|
+
crossfade: {},
|
|
2067
|
+
flip: { durationMs: 2200, easing: 'easeInOutSine' },
|
|
2068
|
+
wavefront: { durationMs: 2000, easing: 'easeInOutSine', band: 0.18, ridge: 0.03, ridgeMaxDisparity: 0.004 },
|
|
2069
|
+
});
|
|
2070
|
+
|
|
2071
|
+
/** Validate setSource's options into a plan (throws on a page bug, before anything loads). */
|
|
2072
|
+
export function resolveSwap(o = {}) {
|
|
2073
|
+
if (o === null || typeof o !== 'object') throw new TypeError('@displayxr/inline3d/splat: setSource options must be an object.');
|
|
2074
|
+
const fadeMs = Number.isFinite(o.fadeMs) && o.fadeMs > 0 ? o.fadeMs : 0;
|
|
2075
|
+
const transition = o.transition ?? (fadeMs > 0 ? 'crossfade' : 'cut');
|
|
2076
|
+
if (!(transition in SOURCE_TRANSITIONS)) {
|
|
2077
|
+
throw new Error(`@displayxr/inline3d/splat: setSource transition '${transition}' — expected one of ${Object.keys(SOURCE_TRANSITIONS).join(', ')}.`);
|
|
2078
|
+
}
|
|
2079
|
+
const d = SOURCE_TRANSITIONS[transition];
|
|
2080
|
+
const durationMs = o.durationMs ?? (transition === 'crossfade' ? fadeMs || 800 : d.durationMs ?? 0);
|
|
2081
|
+
if (!Number.isFinite(durationMs) || durationMs < 0) throw new RangeError(`@displayxr/inline3d/splat: setSource durationMs must be ≥ 0, got ${o.durationMs}.`);
|
|
2082
|
+
const easing = o.easing ?? d.easing ?? 'linear';
|
|
2083
|
+
if (typeof easing !== 'function' && !EASINGS[easing]) throw new Error(`@displayxr/inline3d/splat: unknown easing '${easing}'.`);
|
|
2084
|
+
const reveal = resolveRevealOption(o.reveal);
|
|
2085
|
+
if (reveal && (transition === 'flip' || transition === 'wavefront')) {
|
|
2086
|
+
throw new Error(`@displayxr/inline3d/splat: setSource reveal plays with transition 'cut' or 'crossfade'; '${transition}' is its own reveal.`);
|
|
2087
|
+
}
|
|
2088
|
+
const band = o.band ?? d.band ?? 0.18;
|
|
2089
|
+
const ridge = o.ridge ?? d.ridge ?? 0.03;
|
|
2090
|
+
const ridgeMaxDisparity = o.ridgeMaxDisparity ?? d.ridgeMaxDisparity ?? 0.004;
|
|
2091
|
+
if (transition === 'wavefront') {
|
|
2092
|
+
EFFECTS.wavefront.validate({ band, ridge, ridgeMaxDisparity });
|
|
2093
|
+
}
|
|
2094
|
+
return { transition, durationMs: transition === 'crossfade' && durationMs === 0 ? 800 : durationMs, easing, reveal, band, ridge, ridgeMaxDisparity };
|
|
1856
2095
|
}
|
|
1857
2096
|
|
|
1858
2097
|
/**
|
|
@@ -1993,9 +2232,35 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
|
|
|
1993
2232
|
return pickModel(clientX, clientY);
|
|
1994
2233
|
},
|
|
1995
2234
|
setSource,
|
|
2235
|
+
/**
|
|
2236
|
+
* Play a transition effect (inflate, deflate, sweep, dissolve, fade, pulse, custom) — see
|
|
2237
|
+
* docs/splat-effects.md. Validated now; runs once the first asset is on screen. Resolves
|
|
2238
|
+
* { finished } (false when stopped or replaced).
|
|
2239
|
+
*/
|
|
2240
|
+
playEffect(name, o = {}) {
|
|
2241
|
+
validateEffectCall(name, o, 'play');
|
|
2242
|
+
return first.then(() => (fx && !removed ? fx.play(name, o) : { finished: false }));
|
|
2243
|
+
},
|
|
2244
|
+
/** Set a persistent effect (grade, clip, custom), hold a transition at `progress`, or null to remove. */
|
|
2245
|
+
setEffect(name, params) {
|
|
2246
|
+
validateEffectCall(name, params, 'set');
|
|
2247
|
+
if (fx) fx.set(name, params);
|
|
2248
|
+
else fxPending.push(['set', name, params]);
|
|
2249
|
+
return out;
|
|
2250
|
+
},
|
|
2251
|
+
/** Stop one effect (or all): `finish: true` jumps to its end state, else removes it. */
|
|
2252
|
+
stopEffect(name, o = {}) {
|
|
2253
|
+
if (fx) fx.stop(name, o);
|
|
2254
|
+
else fxPending.push(['stop', name, o]);
|
|
2255
|
+
return out;
|
|
2256
|
+
},
|
|
2257
|
+
/** What is on: [{ name, scope, stage, playing, waiting, progress }]. */
|
|
2258
|
+
effects: () => (fx ? fx.list() : []),
|
|
1996
2259
|
remove() {
|
|
1997
2260
|
removed = true;
|
|
1998
2261
|
current = null;
|
|
2262
|
+
fx?.dispose();
|
|
2263
|
+
fx = null;
|
|
1999
2264
|
unbindFocusInput?.();
|
|
2000
2265
|
viewer.onFocusChange = null;
|
|
2001
2266
|
handle?.remove();
|
|
@@ -2017,6 +2282,52 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
|
|
|
2017
2282
|
return flipY ? [c[0], -c[1], -c[2]] : [c[0], c[1], c[2]];
|
|
2018
2283
|
}
|
|
2019
2284
|
|
|
2285
|
+
// ── splat effects (./inline3d-splat-effects.js) ──
|
|
2286
|
+
let fx = null;
|
|
2287
|
+
const fxPending = []; // setEffect / stopEffect before the first asset landed
|
|
2288
|
+
const revealSpec = resolveRevealOption(opts.reveal); // throws on a bad one (./splat did too)
|
|
2289
|
+
/** A promise resolved after `n` more viewer ticks: the first draw of a new asset builds its
|
|
2290
|
+
* work buffer (hundreds of ms on a big file) and must not eat an effect's clock. */
|
|
2291
|
+
const afterTicks = (n) =>
|
|
2292
|
+
new Promise((resolve) => {
|
|
2293
|
+
let k = 0;
|
|
2294
|
+
viewer._hooks.push(() => {
|
|
2295
|
+
if (++k < n) return true;
|
|
2296
|
+
resolve();
|
|
2297
|
+
return false;
|
|
2298
|
+
});
|
|
2299
|
+
});
|
|
2300
|
+
function makeEffects(pc) {
|
|
2301
|
+
const ctx = {
|
|
2302
|
+
pc: () => pc,
|
|
2303
|
+
app: () => viewer.app,
|
|
2304
|
+
now: () => now(),
|
|
2305
|
+
eyes: () => viewer.eyeFrame(),
|
|
2306
|
+
focus: () => {
|
|
2307
|
+
if (pageMode) {
|
|
2308
|
+
const f = pageFocusPoint();
|
|
2309
|
+
return f ? modelToContent(f) : viewer.eyeFrame().origin;
|
|
2310
|
+
}
|
|
2311
|
+
const f = viewer.getFocus();
|
|
2312
|
+
return [f.x, f.y, f.z];
|
|
2313
|
+
},
|
|
2314
|
+
framing: () => out.frame,
|
|
2315
|
+
pick: (x, y) => {
|
|
2316
|
+
const m = pickModel(x, y);
|
|
2317
|
+
return m ? modelToContent(m) : null;
|
|
2318
|
+
},
|
|
2319
|
+
modelToContent,
|
|
2320
|
+
entity: () => current?.entity ?? null,
|
|
2321
|
+
};
|
|
2322
|
+
const f = new SplatEffects(ctx);
|
|
2323
|
+
viewer.onEffectsTick = (t) => {
|
|
2324
|
+
if (!f.active) return;
|
|
2325
|
+
f.tick(t);
|
|
2326
|
+
f.flush();
|
|
2327
|
+
};
|
|
2328
|
+
return f;
|
|
2329
|
+
}
|
|
2330
|
+
|
|
2020
2331
|
// ── controls:'page' state (null/unused on the viewer's own controls) ──
|
|
2021
2332
|
let lastPagePose = null; // what the page last handed setCameraPose, validated
|
|
2022
2333
|
let warnedNonUniform = false;
|
|
@@ -2470,20 +2781,42 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
|
|
|
2470
2781
|
};
|
|
2471
2782
|
const kept = applyLoaded(loaded);
|
|
2472
2783
|
current = { asset: loaded.asset, entity, res: loaded.res, kind: loaded.desc.kind, ...kept };
|
|
2784
|
+
fx = makeEffects(pcModule);
|
|
2785
|
+
// `reveal`: installed at its START state before the asset's first frame, played once the
|
|
2786
|
+
// tile is woven (handle.firstWoven — which is immediate in 2D) and the first frames are built.
|
|
2787
|
+
if (revealSpec) {
|
|
2788
|
+
const gate = Promise.resolve(out.firstWoven).then(() => afterTicks(2));
|
|
2789
|
+
fx.play(revealSpec.type, revealSpec.raw, { gate });
|
|
2790
|
+
}
|
|
2791
|
+
for (const [what, name, arg] of fxPending.splice(0)) {
|
|
2792
|
+
if (what === 'set') fx.set(name, arg);
|
|
2793
|
+
else fx.stop(name, arg);
|
|
2794
|
+
}
|
|
2473
2795
|
bindFocusInput();
|
|
2474
2796
|
return out;
|
|
2475
2797
|
});
|
|
2476
2798
|
|
|
2477
|
-
// ── setSource: swap the asset,
|
|
2799
|
+
// ── setSource: swap the asset — a cut, a crossfade, or a transition ──
|
|
2478
2800
|
let sourceGen = 0;
|
|
2479
2801
|
let pendingSwap = null;
|
|
2802
|
+
/** setSource's crossfade FALLBACK: the coverage remap on one entity (null clears it). */
|
|
2803
|
+
const setFade = (entity, k) => fx?.setInternal(entity, 'xfade', k === null ? null : { k });
|
|
2480
2804
|
/**
|
|
2481
|
-
* Load `next` behind the current asset, then
|
|
2482
|
-
*
|
|
2483
|
-
*
|
|
2484
|
-
*
|
|
2805
|
+
* Load `next` behind the current asset, then swap to it:
|
|
2806
|
+
* transition 'cut' (fadeMs 0, the default) — at once;
|
|
2807
|
+
* 'crossfade' (fadeMs > 0) — the IMAGES lerp over fadeMs (FRAME_SNAPSHOT);
|
|
2808
|
+
* 'flip' — the outgoing photo flattens onto its convergence plane (zero disparity), the swap
|
|
2809
|
+
* happens there, the incoming one inflates out of its own plane;
|
|
2810
|
+
* 'wavefront' — a soft front crosses the picture left → right; behind it the new photo, with
|
|
2811
|
+
* a thin depth ridge riding the front.
|
|
2812
|
+
* `reveal` (cut/crossfade only) plays an entity-scope reveal on the INCOMING asset while the
|
|
2813
|
+
* outgoing one fades. The rig waterfall re-runs for the new file (rig, lens, focus, frame;
|
|
2814
|
+
* `onFocusChange` fires); the pose is kept unless `resetPose: true`. A newer call supersedes an
|
|
2815
|
+
* older one still loading. Resolves to the handle once the swap has finished.
|
|
2485
2816
|
*/
|
|
2486
|
-
async function setSource(next,
|
|
2817
|
+
async function setSource(next, o = {}) {
|
|
2818
|
+
const plan = resolveSwap(o);
|
|
2819
|
+
const { resetPose = false } = o;
|
|
2487
2820
|
const gen = ++sourceGen;
|
|
2488
2821
|
const app = await booted;
|
|
2489
2822
|
await first.catch(() => null); // a failed first asset may be replaced
|
|
@@ -2496,69 +2829,171 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
|
|
|
2496
2829
|
return out;
|
|
2497
2830
|
}
|
|
2498
2831
|
const prev = current;
|
|
2499
|
-
|
|
2500
|
-
const
|
|
2501
|
-
|
|
2502
|
-
|
|
2503
|
-
|
|
2504
|
-
|
|
2505
|
-
|
|
2506
|
-
|
|
2507
|
-
|
|
2508
|
-
|
|
2509
|
-
|
|
2510
|
-
if (
|
|
2832
|
+
pendingSwap?.finish();
|
|
2833
|
+
const transition = prev ? plan.transition : 'cut';
|
|
2834
|
+
// FRAME_SNAPSHOT: freeze the outgoing frame BEFORE anything of the new asset (or its rig) is
|
|
2835
|
+
// drawn. The overlay goes up in the same task, so no frame shows neither photo.
|
|
2836
|
+
const wantsSnapshot = transition === 'crossfade' || transition === 'wavefront';
|
|
2837
|
+
const snapped = wantsSnapshot ? await viewer.captureFrame() : false;
|
|
2838
|
+
if (removed || gen !== sourceGen) {
|
|
2839
|
+
app.assets.remove(loaded.asset);
|
|
2840
|
+
loaded.asset.unload?.();
|
|
2841
|
+
return out;
|
|
2842
|
+
}
|
|
2843
|
+
if (snapped) viewer.setSnapshotAlpha(1, transition === 'wavefront' ? { t: 0, band: plan.band, views: viewer.viewsAcross } : null);
|
|
2511
2844
|
|
|
2512
|
-
const release = () => {
|
|
2513
|
-
if (!
|
|
2845
|
+
const release = (p) => {
|
|
2846
|
+
if (!p) return;
|
|
2514
2847
|
// Disable first, destroy + unload a few frames LATER: the engine's gsplat world keeps the
|
|
2515
2848
|
// old placement in its list until its next rebuild, and tearing the resource down in the
|
|
2516
2849
|
// same frame throws inside the engine's update (found by running the swap).
|
|
2517
|
-
|
|
2850
|
+
p.entity.enabled = false;
|
|
2851
|
+
fx?.dropEntity(p.entity);
|
|
2518
2852
|
let frames = 0;
|
|
2519
2853
|
viewer._hooks.push(() => {
|
|
2520
2854
|
if (++frames < 4) return true;
|
|
2521
|
-
|
|
2522
|
-
app.assets.remove(
|
|
2523
|
-
|
|
2855
|
+
p.entity.destroy?.();
|
|
2856
|
+
app.assets.remove(p.asset);
|
|
2857
|
+
p.asset.unload?.();
|
|
2524
2858
|
return false;
|
|
2525
2859
|
});
|
|
2526
2860
|
};
|
|
2527
|
-
|
|
2528
|
-
|
|
2861
|
+
/** The incoming asset becomes THE asset: waterfall, rig, handle fields. */
|
|
2862
|
+
const adopt = (entity) => {
|
|
2863
|
+
out.mesh = {
|
|
2864
|
+
numSplats: loaded.desc.numSplats || loaded.cloud?.sourceTotal || 0,
|
|
2865
|
+
entity,
|
|
2866
|
+
asset: loaded.asset,
|
|
2867
|
+
resource: loaded.res,
|
|
2868
|
+
};
|
|
2869
|
+
const kept = applyLoaded(loaded);
|
|
2870
|
+
current = { asset: loaded.asset, entity, res: loaded.res, kind: loaded.desc.kind, ...kept };
|
|
2871
|
+
if (resetPose && !pageMode) viewer.resetPose(); // the page owns the pose on controls:'page'
|
|
2872
|
+
};
|
|
2873
|
+
|
|
2874
|
+
const entity = viewer.addSplatAsset(loaded.asset);
|
|
2875
|
+
let settle = () => {};
|
|
2876
|
+
const done = new Promise((resolve) => (settle = resolve));
|
|
2877
|
+
let finished = false;
|
|
2878
|
+
const finishers = [];
|
|
2879
|
+
const finish = () => {
|
|
2880
|
+
if (finished) return;
|
|
2881
|
+
finished = true;
|
|
2882
|
+
for (const f of finishers) f();
|
|
2883
|
+
if (pendingSwap?.finish === finish) pendingSwap = null;
|
|
2884
|
+
settle();
|
|
2885
|
+
};
|
|
2886
|
+
pendingSwap = { finish };
|
|
2887
|
+
|
|
2888
|
+
if (transition === 'flip') {
|
|
2889
|
+
// Phase 1: the outgoing photo flattens onto ITS convergence plane under ITS rig; the new
|
|
2890
|
+
// asset is resident but hidden (its first frames build its work buffer meanwhile), held
|
|
2891
|
+
// flat for phase 2. The rig switches at the flat moment — zero disparity on both sides.
|
|
2892
|
+
const half = plan.durationMs / 2;
|
|
2893
|
+
setFade(entity, 0);
|
|
2894
|
+
let openGate = () => {};
|
|
2895
|
+
const gate = new Promise((r) => (openGate = r));
|
|
2896
|
+
const flatOut = fx.play('deflate', { durationMs: half, easing: plan.easing, scope: 'entity' }, { entity: prev.entity });
|
|
2897
|
+
const inflateIn = fx.play('inflate', { durationMs: half, easing: plan.easing, scope: 'entity' }, { entity, gate });
|
|
2898
|
+
finishers.push(() => {
|
|
2899
|
+
openGate();
|
|
2900
|
+
fx?.stop('inflate', { finish: true, entity });
|
|
2901
|
+
});
|
|
2902
|
+
flatOut.then(() => {
|
|
2903
|
+
if (finished || removed) return;
|
|
2904
|
+
setFade(entity, null);
|
|
2905
|
+
release(prev);
|
|
2906
|
+
adopt(entity);
|
|
2907
|
+
afterTicks(2).then(openGate);
|
|
2908
|
+
});
|
|
2909
|
+
inflateIn.then(finish);
|
|
2910
|
+
await done;
|
|
2911
|
+
if (current?.entity !== entity && !removed) {
|
|
2912
|
+
// superseded before the flat moment: this asset never showed
|
|
2913
|
+
release(prev);
|
|
2914
|
+
adopt(entity);
|
|
2915
|
+
}
|
|
2529
2916
|
return out;
|
|
2530
2917
|
}
|
|
2531
|
-
|
|
2532
|
-
|
|
2533
|
-
|
|
2534
|
-
|
|
2535
|
-
|
|
2536
|
-
|
|
2537
|
-
|
|
2538
|
-
|
|
2539
|
-
|
|
2540
|
-
|
|
2541
|
-
|
|
2542
|
-
|
|
2543
|
-
|
|
2544
|
-
|
|
2545
|
-
|
|
2546
|
-
|
|
2547
|
-
|
|
2548
|
-
|
|
2549
|
-
|
|
2550
|
-
if (
|
|
2551
|
-
|
|
2552
|
-
const
|
|
2553
|
-
|
|
2554
|
-
|
|
2555
|
-
if (k >= 1) {
|
|
2918
|
+
|
|
2919
|
+
adopt(entity);
|
|
2920
|
+
if (transition === 'cut') {
|
|
2921
|
+
release(prev);
|
|
2922
|
+
if (plan.reveal) fx.play(plan.reveal.type, { ...plan.reveal.raw, scope: 'entity' }, { entity, gate: afterTicks(2) });
|
|
2923
|
+
finish();
|
|
2924
|
+
return out;
|
|
2925
|
+
}
|
|
2926
|
+
|
|
2927
|
+
if (transition === 'wavefront' && snapped) {
|
|
2928
|
+
release(prev); // the snapshot shows it from here on
|
|
2929
|
+
const band = plan.band;
|
|
2930
|
+
const ridge = fx.play(
|
|
2931
|
+
'wavefront',
|
|
2932
|
+
{ durationMs: plan.durationMs, easing: plan.easing, band, ridge: plan.ridge, ridgeMaxDisparity: plan.ridgeMaxDisparity, scope: 'entity' },
|
|
2933
|
+
{ entity, gate: afterTicks(2), internal: true },
|
|
2934
|
+
);
|
|
2935
|
+
finishers.push(() => viewer.setSnapshotAlpha(0));
|
|
2936
|
+
viewer._hooks.push(() => {
|
|
2937
|
+
if (finished || removed) return false;
|
|
2938
|
+
const inst = fx?.scopes.get(entity)?.get('wavefront');
|
|
2939
|
+
const amount = inst ? inst.amount : 1;
|
|
2940
|
+
if (!viewer.setSnapshotAlpha(1, { t: amount, band, views: viewer.viewsAcross })) {
|
|
2941
|
+
fx?.stop('wavefront', { finish: true, entity });
|
|
2556
2942
|
finish();
|
|
2557
2943
|
return false;
|
|
2558
2944
|
}
|
|
2559
2945
|
return true;
|
|
2560
2946
|
});
|
|
2947
|
+
ridge.then(finish);
|
|
2948
|
+
await done;
|
|
2949
|
+
return out;
|
|
2950
|
+
}
|
|
2951
|
+
|
|
2952
|
+
// crossfade — also the wavefront's fallback when no frame could be captured (a hidden tab):
|
|
2953
|
+
// the 1.12.1 one-pass fade, over the same duration.
|
|
2954
|
+
const fade = plan.durationMs;
|
|
2955
|
+
if (plan.reveal) fx.play(plan.reveal.type, { ...plan.reveal.raw, scope: 'entity' }, { entity, gate: afterTicks(2) });
|
|
2956
|
+
if (snapped) release(prev); // the snapshot shows it from here on
|
|
2957
|
+
else {
|
|
2958
|
+
setFade(entity, 0);
|
|
2959
|
+
setFade(prev.entity, 1);
|
|
2960
|
+
}
|
|
2961
|
+
finishers.push(() => {
|
|
2962
|
+
if (snapped) viewer.setSnapshotAlpha(0);
|
|
2963
|
+
else {
|
|
2964
|
+
setFade(entity, null);
|
|
2965
|
+
release(prev);
|
|
2966
|
+
}
|
|
2967
|
+
});
|
|
2968
|
+
// The fade clock starts on the SECOND tick after the swap, not now: the first frame that
|
|
2969
|
+
// draws the new asset also builds its work buffer (hundreds of ms for a 1M-splat file), and
|
|
2970
|
+
// a clock started before it would spend the whole fade inside that one frame — a cut, not a
|
|
2971
|
+
// crossfade (found by screenshotting mid-fade).
|
|
2972
|
+
let t0 = null;
|
|
2973
|
+
let ticks = 0;
|
|
2974
|
+
viewer._hooks.push((t) => {
|
|
2975
|
+
if (removed) return (finish(), false);
|
|
2976
|
+
if (finished) return false; // superseded: already finished
|
|
2977
|
+
if (++ticks < 2) return true;
|
|
2978
|
+
if (t0 === null) t0 = t;
|
|
2979
|
+
const k = Math.min(1, Math.max(0, (t - t0) / fade));
|
|
2980
|
+
if (snapped) {
|
|
2981
|
+
// A resize or a 2D/3D switch mid-fade: the capture no longer fits the buffer — end now.
|
|
2982
|
+
if (!viewer.setSnapshotAlpha(1 - k)) {
|
|
2983
|
+
finish();
|
|
2984
|
+
return false;
|
|
2985
|
+
}
|
|
2986
|
+
} else {
|
|
2987
|
+
setFade(entity, coverageExponent(k));
|
|
2988
|
+
setFade(prev.entity, coverageExponent(1 - k));
|
|
2989
|
+
}
|
|
2990
|
+
if (k >= 1) {
|
|
2991
|
+
finish();
|
|
2992
|
+
return false;
|
|
2993
|
+
}
|
|
2994
|
+
return true;
|
|
2561
2995
|
});
|
|
2996
|
+
await done;
|
|
2562
2997
|
return out;
|
|
2563
2998
|
}
|
|
2564
2999
|
|