@displayxr/inline3d 1.14.0 → 1.16.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 +72 -0
- package/README.md +4 -1
- package/js/inline3d-model-playcanvas.js +1 -1
- package/js/inline3d-splat-effects.js +213 -5
- package/js/inline3d-splat-playcanvas.js +618 -31
- package/js/inline3d-splat.js +25 -0
- package/package.json +1 -1
- package/splat.d.ts +110 -5
|
@@ -50,13 +50,17 @@ import {
|
|
|
50
50
|
budgetPerManager,
|
|
51
51
|
} from './inline3d-splat-perf.js';
|
|
52
52
|
import { boundsFromPositions, boundsFromPositionsAsync } from './inline3d-viewer.js';
|
|
53
|
-
import { cameraRigFromPose } from './inline3d-three.js';
|
|
53
|
+
import { cameraRigFromPose, displayRig } 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';
|
|
61
65
|
import { LiveOutgoing, resolveOutgoingOption, defaultOutgoing, yieldIdle } from './inline3d-splat-live.js';
|
|
62
66
|
import {
|
|
@@ -908,6 +912,21 @@ export class PlayCanvasSplatViewer {
|
|
|
908
912
|
this._updateMonoProjection();
|
|
909
913
|
}
|
|
910
914
|
|
|
915
|
+
/**
|
|
916
|
+
* Switch the eye camera(s) to another tone mapping, by name (TONE_MAPPINGS). Live: the engine
|
|
917
|
+
* picks the new shader variant on the next draw. handle.setRig's display rig uses it to give a
|
|
918
|
+
* page's meshes addModel's 'neutral' while the splat is hidden.
|
|
919
|
+
*/
|
|
920
|
+
setToneMapping(name) {
|
|
921
|
+
const key = TONE_MAPPINGS[name] ? name : 'none';
|
|
922
|
+
this.toneMapping = key;
|
|
923
|
+
const pc = this.pc;
|
|
924
|
+
if (!pc) return;
|
|
925
|
+
const v = pc[TONE_MAPPINGS[key]] ?? pc.TONEMAP_NONE;
|
|
926
|
+
if (this.eye?.camera) this.eye.camera.toneMapping = v;
|
|
927
|
+
if (this._viewPath === 'cameras') for (const c of this._views) if (c?.camera) c.camera.toneMapping = v;
|
|
928
|
+
}
|
|
929
|
+
|
|
911
930
|
/** Forget the auto-fit (scale 1, no subject box) — the camera rig's framing. */
|
|
912
931
|
resetFit() {
|
|
913
932
|
this._fitScale = 1;
|
|
@@ -1269,8 +1288,9 @@ export class PlayCanvasSplatViewer {
|
|
|
1269
1288
|
* so a region only one of the two photos covers fades against the page, not against black.
|
|
1270
1289
|
*/
|
|
1271
1290
|
_ensureSnapshotOverlay() {
|
|
1272
|
-
const s = this._snap;
|
|
1291
|
+
const s = (this._snap ||= { w: 0, h: 0, tex: null, rt: null, parts: null }); // prewarm: no capture yet
|
|
1273
1292
|
if (s.parts) {
|
|
1293
|
+
if (!s.tex) return;
|
|
1274
1294
|
for (const p of s.parts) {
|
|
1275
1295
|
p.mat.setParameter('dxrSnap', s.tex);
|
|
1276
1296
|
p.mat.setParameter('dxrSnapInvSize', [1 / s.w, 1 / s.h]);
|
|
@@ -1295,6 +1315,7 @@ export class PlayCanvasSplatViewer {
|
|
|
1295
1315
|
uniform vec2 dxrSnapInvSize;
|
|
1296
1316
|
uniform float dxrSnapAlpha;
|
|
1297
1317
|
uniform vec3 dxrSnapWipe; // t, band, views across (t < -1: no wipe)
|
|
1318
|
+
uniform float dxrSnapOver; // 0 = lerp, 1 = A OVER the scene (the particle transitions)
|
|
1298
1319
|
float dxrSnapWeight() {
|
|
1299
1320
|
if (dxrSnapWipe.x < -1.0) return dxrSnapAlpha;
|
|
1300
1321
|
float vw = 1.0 / (dxrSnapInvSize.x * dxrSnapWipe.z);
|
|
@@ -1309,10 +1330,13 @@ export class PlayCanvasSplatViewer {
|
|
|
1309
1330
|
mat.depthTest = false;
|
|
1310
1331
|
mat.depthWrite = false;
|
|
1311
1332
|
mat.cull = pc.CULLFACE_NONE;
|
|
1312
|
-
|
|
1313
|
-
|
|
1333
|
+
if (s.tex) {
|
|
1334
|
+
mat.setParameter('dxrSnap', s.tex);
|
|
1335
|
+
mat.setParameter('dxrSnapInvSize', [1 / s.w, 1 / s.h]);
|
|
1336
|
+
}
|
|
1314
1337
|
mat.setParameter('dxrSnapAlpha', 0);
|
|
1315
1338
|
mat.setParameter('dxrSnapWipe', [-2, 0.1, 1]);
|
|
1339
|
+
mat.setParameter('dxrSnapOver', 0);
|
|
1316
1340
|
mat.update();
|
|
1317
1341
|
const mi = new pc.MeshInstance(mesh, mat, new pc.GraphNode(name));
|
|
1318
1342
|
mi.cull = false;
|
|
@@ -1321,7 +1345,14 @@ export class PlayCanvasSplatViewer {
|
|
|
1321
1345
|
return { mat, mi };
|
|
1322
1346
|
};
|
|
1323
1347
|
s.parts = [
|
|
1324
|
-
|
|
1348
|
+
// lerp: dst·(1 − w); over: dst·(1 − w·A.a) — premultiplied A over the scene
|
|
1349
|
+
part(
|
|
1350
|
+
'inline3dSnapshotScale',
|
|
1351
|
+
'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));',
|
|
1352
|
+
pc.BLENDMODE_ZERO,
|
|
1353
|
+
pc.BLENDMODE_SRC_ALPHA,
|
|
1354
|
+
-2,
|
|
1355
|
+
),
|
|
1325
1356
|
part('inline3dSnapshotAdd', 'gl_FragColor = texture2D(dxrSnap, gl_FragCoord.xy * dxrSnapInvSize) * dxrSnapWeight();', pc.BLENDMODE_ONE, pc.BLENDMODE_ONE, -1),
|
|
1326
1357
|
];
|
|
1327
1358
|
this.app.scene.layers.getLayerById(pc.LAYERID_UI).addMeshInstances(s.parts.map((p) => p.mi));
|
|
@@ -1331,7 +1362,7 @@ export class PlayCanvasSplatViewer {
|
|
|
1331
1362
|
* Lerp the snapshot over the scene at `alpha` (0 hides it). Returns false when the canvas buffer
|
|
1332
1363
|
* no longer matches the capture (resize, 2D/3D switch): the caller ends its fade.
|
|
1333
1364
|
*/
|
|
1334
|
-
setSnapshotAlpha(alpha, wipe = null) {
|
|
1365
|
+
setSnapshotAlpha(alpha, wipe = null, mix = null) {
|
|
1335
1366
|
const s = this._snap;
|
|
1336
1367
|
if (!s?.parts) return false;
|
|
1337
1368
|
const fits = !!s.tex && s.w === this.canvas.width && s.h === this.canvas.height;
|
|
@@ -1344,6 +1375,8 @@ export class PlayCanvasSplatViewer {
|
|
|
1344
1375
|
// SAME viewport-relative x in every eye — a front at the zero-disparity plane, so both
|
|
1345
1376
|
// eyes agree. `views` = eye viewports side by side across the buffer.
|
|
1346
1377
|
p.mat.setParameter('dxrSnapWipe', wipe ? [wipe.t, wipe.band, wipe.views] : [-2, 0.1, 1]);
|
|
1378
|
+
// setSource's particle transitions: the live outgoing image OVER the incoming one
|
|
1379
|
+
p.mat.setParameter('dxrSnapOver', mix?.over ? 1 : 0);
|
|
1347
1380
|
p.mat.update();
|
|
1348
1381
|
}
|
|
1349
1382
|
}
|
|
@@ -2133,14 +2166,140 @@ export function describeResource(res) {
|
|
|
2133
2166
|
* one-pass fade, with the coverage-linear alpha remap on both assets.
|
|
2134
2167
|
*/
|
|
2135
2168
|
|
|
2169
|
+
/**
|
|
2170
|
+
* handle.setRig's DISPLAY-rig defaults: addModel's, so a mesh framed through setRig('display')
|
|
2171
|
+
* sits exactly where `addModel(url, { engine: 'playcanvas' })` puts it (docs/playcanvas-adapter.md
|
|
2172
|
+
* §setRig). `toneMapping` applies to the page's meshes while the splat is hidden (the splat keeps
|
|
2173
|
+
* 'none' whenever it is shown); `environment: 'room'` installs addModel's neutral-studio IBL when
|
|
2174
|
+
* the page has no `scene.envAtlas` of its own, and removes it again when the rig changes back.
|
|
2175
|
+
*/
|
|
2176
|
+
export const SET_RIG_DISPLAY_DEFAULTS = Object.freeze({
|
|
2177
|
+
virtualDisplayHeight: 0.24,
|
|
2178
|
+
fit: 'contain',
|
|
2179
|
+
margin: 0.8,
|
|
2180
|
+
depthLimit: DEFAULT_DEPTH_LIMIT,
|
|
2181
|
+
fitSweep: true,
|
|
2182
|
+
idleSpin: 8,
|
|
2183
|
+
ipdFactor: 1,
|
|
2184
|
+
parallaxFactor: 1,
|
|
2185
|
+
perspectiveFactor: 1,
|
|
2186
|
+
toneMapping: 'neutral',
|
|
2187
|
+
environment: 'room',
|
|
2188
|
+
});
|
|
2189
|
+
|
|
2190
|
+
export const SET_RIG_TYPES = Object.freeze(['display', 'camera', 'auto']);
|
|
2191
|
+
const SET_RIG_FITS = ['contain', 'cover', 'height', 'none'];
|
|
2192
|
+
let warnedSetRigKeys = false;
|
|
2193
|
+
|
|
2194
|
+
const PAGE_SETRIG_ERROR =
|
|
2195
|
+
"@displayxr/inline3d/splat: setRig() is not available with controls:'page' — the page owns the " +
|
|
2196
|
+
'camera (its camera IS the rig). Drive it with handle.setCameraPose(matrixWorld, { verticalFovDeg, near, far }).';
|
|
2197
|
+
|
|
2198
|
+
/**
|
|
2199
|
+
* handle.setRig's arguments, validated and resolved (throws at the call, before anything runs).
|
|
2200
|
+
* @returns {{type:'display'|'camera'|'auto', o?:object}}
|
|
2201
|
+
*/
|
|
2202
|
+
export function validateSetRig(type, o = {}, pageMode = false) {
|
|
2203
|
+
if (pageMode) throw new Error(PAGE_SETRIG_ERROR);
|
|
2204
|
+
if (!SET_RIG_TYPES.includes(type)) {
|
|
2205
|
+
throw new Error(`@displayxr/inline3d/splat: setRig("${type}") — expected 'display', 'camera' or 'auto'.`);
|
|
2206
|
+
}
|
|
2207
|
+
if (o === null || typeof o !== 'object') throw new TypeError('@displayxr/inline3d/splat: setRig options must be an object.');
|
|
2208
|
+
if (type !== 'display') return { type };
|
|
2209
|
+
const D = SET_RIG_DISPLAY_DEFAULTS;
|
|
2210
|
+
const known = new Set([...Object.keys(D), 'frame']);
|
|
2211
|
+
const unknown = Object.keys(o).filter((k) => !known.has(k));
|
|
2212
|
+
if (unknown.length && !warnedSetRigKeys) {
|
|
2213
|
+
warnedSetRigKeys = true;
|
|
2214
|
+
console.warn(
|
|
2215
|
+
`[inline3d/splat] setRig('display') ignores ${unknown.join(', ')}` +
|
|
2216
|
+
(unknown.includes('transitionMs') ? ' — a rig switch is a clean cut (no eased transition in this version)' : '') +
|
|
2217
|
+
'.',
|
|
2218
|
+
);
|
|
2219
|
+
}
|
|
2220
|
+
const num = (k, ok) => {
|
|
2221
|
+
const v = o[k] === undefined ? D[k] : o[k];
|
|
2222
|
+
if (!Number.isFinite(v) || !ok(v)) throw new Error(`@displayxr/inline3d/splat: setRig('display') — bad ${k}: ${o[k]}.`);
|
|
2223
|
+
return v;
|
|
2224
|
+
};
|
|
2225
|
+
const fit = o.fit === undefined ? D.fit : o.fit;
|
|
2226
|
+
if (!SET_RIG_FITS.includes(fit)) throw new Error(`@displayxr/inline3d/splat: setRig('display') — fit "${fit}", expected ${SET_RIG_FITS.join(' | ')}.`);
|
|
2227
|
+
const toneMapping = o.toneMapping === undefined ? D.toneMapping : o.toneMapping;
|
|
2228
|
+
if (!TONE_MAPPINGS[toneMapping]) {
|
|
2229
|
+
throw new Error(`@displayxr/inline3d/splat: setRig('display') — toneMapping "${toneMapping}", expected ${Object.keys(TONE_MAPPINGS).join(' | ')}.`);
|
|
2230
|
+
}
|
|
2231
|
+
const environment = o.environment === undefined ? D.environment : o.environment;
|
|
2232
|
+
if (!['room', 'neutral', 'none'].includes(environment)) {
|
|
2233
|
+
throw new Error(`@displayxr/inline3d/splat: setRig('display') — environment "${environment}", expected 'room' | 'neutral' | 'none'.`);
|
|
2234
|
+
}
|
|
2235
|
+
let frame = null;
|
|
2236
|
+
if (o.frame != null) {
|
|
2237
|
+
const c = o.frame.center;
|
|
2238
|
+
const e = o.frame.extent;
|
|
2239
|
+
const v3 = (v) => (Array.isArray(v) || ArrayBuffer.isView(v) ? v.length >= 3 && [0, 1, 2].every((i) => Number.isFinite(v[i])) : v && [v.x, v.y, v.z].every(Number.isFinite));
|
|
2240
|
+
if (!v3(c) || !v3(e)) throw new Error("@displayxr/inline3d/splat: setRig('display') — frame must be { center: [x,y,z], extent: [x,y,z] }.");
|
|
2241
|
+
const a3 = (v) => (Array.isArray(v) || ArrayBuffer.isView(v) ? [v[0], v[1], v[2]] : [v.x, v.y, v.z]);
|
|
2242
|
+
frame = { center: a3(c), extent: a3(e) };
|
|
2243
|
+
}
|
|
2244
|
+
return {
|
|
2245
|
+
type,
|
|
2246
|
+
o: {
|
|
2247
|
+
vH: num('virtualDisplayHeight', (v) => v > 0),
|
|
2248
|
+
fit,
|
|
2249
|
+
margin: num('margin', (v) => v > 0),
|
|
2250
|
+
depthLimit: num('depthLimit', (v) => v > 0),
|
|
2251
|
+
fitSweep: o.fitSweep === undefined ? D.fitSweep : o.fitSweep !== false,
|
|
2252
|
+
idleSpin: num('idleSpin', () => true),
|
|
2253
|
+
ipdFactor: num('ipdFactor', (v) => v >= 0),
|
|
2254
|
+
parallaxFactor: num('parallaxFactor', (v) => v >= 0),
|
|
2255
|
+
perspectiveFactor: num('perspectiveFactor', (v) => v > 0),
|
|
2256
|
+
toneMapping,
|
|
2257
|
+
environment,
|
|
2258
|
+
frame,
|
|
2259
|
+
},
|
|
2260
|
+
};
|
|
2261
|
+
}
|
|
2262
|
+
|
|
2136
2263
|
/** setSource's transitions and their defaults. */
|
|
2137
2264
|
export const SOURCE_TRANSITIONS = Object.freeze({
|
|
2138
2265
|
cut: {},
|
|
2139
2266
|
crossfade: {},
|
|
2140
2267
|
flip: { durationMs: 2200, easing: 'easeInOutSine' },
|
|
2141
2268
|
wavefront: { durationMs: 2000, easing: 'easeInOutSine', band: 0.18, ridge: 0.03, ridgeMaxDisparity: 0.004 },
|
|
2269
|
+
// the particle transitions (./inline3d-splat-effects.js PARTICLE_TRANSITIONS)
|
|
2270
|
+
...Object.fromEntries(Object.entries(PARTICLE_TRANSITIONS).map(([k, v]) => [k, { durationMs: v.durationMs, easing: v.easing, particle: true }])),
|
|
2142
2271
|
});
|
|
2143
2272
|
|
|
2273
|
+
/**
|
|
2274
|
+
* A particle transition's two sides, resolved: { out: { effect, opts }, in: { effect, opts },
|
|
2275
|
+
* overlap } — the table's defaults, then the page's shared particle options, then its
|
|
2276
|
+
* per-side `outgoingFx` / `incomingFx` overrides. Every side is validated as its effect (throws).
|
|
2277
|
+
*/
|
|
2278
|
+
export function resolveParticleTransition(name, o = {}) {
|
|
2279
|
+
const spec = PARTICLE_TRANSITIONS[name];
|
|
2280
|
+
const shared = {};
|
|
2281
|
+
for (const k of PARTICLE_TRANSITION_OPTIONS) if (o[k] !== undefined) shared[k] = o[k];
|
|
2282
|
+
for (const k of ['outgoingFx', 'incomingFx']) {
|
|
2283
|
+
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.`);
|
|
2284
|
+
}
|
|
2285
|
+
const overlap = o.overlap ?? spec.overlap ?? 0;
|
|
2286
|
+
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}.`);
|
|
2287
|
+
const side = (which, dflt, extra) => {
|
|
2288
|
+
const effect = dflt.effect;
|
|
2289
|
+
const opts = { ...dflt.opts, ...shared, ...(extra || {}) };
|
|
2290
|
+
resolveEffectOptions(effect, { ...opts, scope: 'entity' }, 'set', { internal: true }); // throws on a bad one
|
|
2291
|
+
return { effect, opts };
|
|
2292
|
+
};
|
|
2293
|
+
const out = side('out', spec.out, o.outgoingFx);
|
|
2294
|
+
const inc = side('in', spec.in, o.incomingFx);
|
|
2295
|
+
// Both photos run ONE render-time body, compiled for one `order`; `layers` reads splat.index,
|
|
2296
|
+
// which is a file index only in a work-buffer modifier (not at render time).
|
|
2297
|
+
const order = (x) => x.opts.order ?? EFFECTS[x.effect].defaults.order;
|
|
2298
|
+
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)}').`);
|
|
2299
|
+
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).`);
|
|
2300
|
+
return { out, in: inc, overlap };
|
|
2301
|
+
}
|
|
2302
|
+
|
|
2144
2303
|
/** Validate setSource's options into a plan (throws on a page bug, before anything loads). */
|
|
2145
2304
|
export function resolveSwap(o = {}) {
|
|
2146
2305
|
if (o === null || typeof o !== 'object') throw new TypeError('@displayxr/inline3d/splat: setSource options must be an object.');
|
|
@@ -2155,9 +2314,10 @@ export function resolveSwap(o = {}) {
|
|
|
2155
2314
|
const easing = o.easing ?? d.easing ?? 'linear';
|
|
2156
2315
|
if (typeof easing !== 'function' && !EASINGS[easing]) throw new Error(`@displayxr/inline3d/splat: unknown easing '${easing}'.`);
|
|
2157
2316
|
const reveal = resolveRevealOption(o.reveal);
|
|
2158
|
-
if (reveal && (transition === 'flip' || transition === 'wavefront')) {
|
|
2317
|
+
if (reveal && (transition === 'flip' || transition === 'wavefront' || d.particle)) {
|
|
2159
2318
|
throw new Error(`@displayxr/inline3d/splat: setSource reveal plays with transition 'cut' or 'crossfade'; '${transition}' is its own reveal.`);
|
|
2160
2319
|
}
|
|
2320
|
+
const particles = d.particle ? resolveParticleTransition(transition, o) : null;
|
|
2161
2321
|
const band = o.band ?? d.band ?? 0.18;
|
|
2162
2322
|
const ridge = o.ridge ?? d.ridge ?? 0.03;
|
|
2163
2323
|
const ridgeMaxDisparity = o.ridgeMaxDisparity ?? d.ridgeMaxDisparity ?? 0.004;
|
|
@@ -2165,7 +2325,7 @@ export function resolveSwap(o = {}) {
|
|
|
2165
2325
|
EFFECTS.wavefront.validate({ band, ridge, ridgeMaxDisparity });
|
|
2166
2326
|
}
|
|
2167
2327
|
const outgoing = resolveOutgoingOption(o.outgoing);
|
|
2168
|
-
return { transition, durationMs: transition === 'crossfade' && durationMs === 0 ? 800 : durationMs, easing, reveal, band, ridge, ridgeMaxDisparity, outgoing };
|
|
2328
|
+
return { transition, durationMs: transition === 'crossfade' && durationMs === 0 ? 800 : durationMs, easing, reveal, band, ridge, ridgeMaxDisparity, outgoing, particles };
|
|
2169
2329
|
}
|
|
2170
2330
|
|
|
2171
2331
|
/**
|
|
@@ -2197,6 +2357,7 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
|
|
|
2197
2357
|
observe,
|
|
2198
2358
|
firstWovenHoldMs,
|
|
2199
2359
|
preserveDrawingBuffer = false,
|
|
2360
|
+
antialias = false,
|
|
2200
2361
|
} = opts;
|
|
2201
2362
|
// `sortIntervalMs` is accepted and has no effect here: the engine re-sorts when the camera
|
|
2202
2363
|
// ROTATES (one directional sort serves every view), not on a timer — docs/playcanvas-adapter.md.
|
|
@@ -2310,6 +2471,7 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
|
|
|
2310
2471
|
},
|
|
2311
2472
|
setSource,
|
|
2312
2473
|
prepareSource,
|
|
2474
|
+
setRig,
|
|
2313
2475
|
/**
|
|
2314
2476
|
* Play a transition effect (inflate, deflate, sweep, dissolve, fade, pulse, custom) — see
|
|
2315
2477
|
* docs/splat-effects.md. Validated now; runs once the first asset is on screen. Resolves
|
|
@@ -2537,6 +2699,13 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
|
|
|
2537
2699
|
|
|
2538
2700
|
// ── focus → view rig ──
|
|
2539
2701
|
let lastConvergence = Number.NaN;
|
|
2702
|
+
// The camera rig's descriptor object (rewritten in place per focus change) and the DISPLAY rig
|
|
2703
|
+
// declared through setViewRig, if any. `declaredDisplay` null + no camera rig pushed = the
|
|
2704
|
+
// `virtualDisplayHeight` shorthand addScene was built with, which is where every non-page tile
|
|
2705
|
+
// starts. A display rig after a camera rig has to be SENT (the shorthand cannot be restored),
|
|
2706
|
+
// as the explicit descriptor it is shorthand for (displayRig: identity pose, factors 1).
|
|
2707
|
+
let camRigObj = null;
|
|
2708
|
+
let declaredDisplay = pageMode ? 'page' : 'shorthand';
|
|
2540
2709
|
function pushViewRig(force) {
|
|
2541
2710
|
if (pageMode) return; // pageTick declares the rig every frame
|
|
2542
2711
|
if (!out.rig || out.rig.type !== 'camera') return;
|
|
@@ -2549,7 +2718,9 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
|
|
|
2549
2718
|
lastConvergence = d;
|
|
2550
2719
|
out.rig.convergence = d;
|
|
2551
2720
|
const q = quatFromMatrix(pose);
|
|
2552
|
-
|
|
2721
|
+
camRigObj ||= {};
|
|
2722
|
+
declaredDisplay = null;
|
|
2723
|
+
out.viewRig = camRigObj = cameraRigFromPose(
|
|
2553
2724
|
{
|
|
2554
2725
|
position: { x: pose[12], y: pose[13], z: pose[14] },
|
|
2555
2726
|
orientation: { x: q[0], y: q[1], z: q[2], w: q[3] },
|
|
@@ -2559,7 +2730,7 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
|
|
|
2559
2730
|
convergence: d > 0 ? d : 0,
|
|
2560
2731
|
ipdFactor: out.rig.ipdFactor,
|
|
2561
2732
|
parallaxFactor: out.rig.parallaxFactor,
|
|
2562
|
-
out:
|
|
2733
|
+
out: camRigObj,
|
|
2563
2734
|
},
|
|
2564
2735
|
);
|
|
2565
2736
|
handle?.setViewRig?.(out.viewRig);
|
|
@@ -2770,16 +2941,11 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
|
|
|
2770
2941
|
? lift(frame)
|
|
2771
2942
|
: null;
|
|
2772
2943
|
|
|
2773
|
-
|
|
2774
|
-
|
|
2775
|
-
const
|
|
2776
|
-
|
|
2777
|
-
|
|
2778
|
-
cloud: sample,
|
|
2779
|
-
canvasAspect: box.height > 0 ? box.width / box.height : 4 / 3,
|
|
2780
|
-
});
|
|
2781
|
-
resolved.focusDefault = resolved.focus.slice();
|
|
2782
|
-
resolved.focusDefaultSource = resolved.focusSource;
|
|
2944
|
+
// What the waterfall was fed, kept with the asset: handle.setRig re-resolves from exactly this
|
|
2945
|
+
// (never from a stale copy of the result), so setRig('camera') / ('auto') land where a load did.
|
|
2946
|
+
const rigIn = { camera: loaded.camera, sample: loaded.pre?.rest || null, bounds };
|
|
2947
|
+
// A setRig choice is STICKY across setSource: the new asset is resolved on the chosen rig.
|
|
2948
|
+
const resolved = resolveFor(rigIn, rigOverride ? rigOverride.type : 'auto');
|
|
2783
2949
|
perfSpan('applyLoaded:rig', tB);
|
|
2784
2950
|
out.camera = loaded.camera;
|
|
2785
2951
|
out.rig = resolved;
|
|
@@ -2811,8 +2977,66 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
|
|
|
2811
2977
|
}),
|
|
2812
2978
|
);
|
|
2813
2979
|
}
|
|
2814
|
-
} else
|
|
2980
|
+
} else {
|
|
2981
|
+
applyRig(resolved, bounds, false);
|
|
2982
|
+
}
|
|
2983
|
+
return { pickCentres, alpha8: cloud?.alpha8 || null, rigIn };
|
|
2984
|
+
}
|
|
2985
|
+
|
|
2986
|
+
/** resolveRig on an asset's kept inputs: 'auto' = the load-time waterfall, else forced. */
|
|
2987
|
+
function resolveFor(rigIn, type) {
|
|
2988
|
+
const box = canvas.getBoundingClientRect();
|
|
2989
|
+
const resolved = resolveRig({
|
|
2990
|
+
camera: rigIn.camera,
|
|
2991
|
+
opts: type === 'auto' ? rigOpts : { ...rigOpts, rig: type },
|
|
2992
|
+
cloud: rigIn.sample,
|
|
2993
|
+
canvasAspect: box.height > 0 ? box.width / box.height : 4 / 3,
|
|
2994
|
+
});
|
|
2995
|
+
if (type !== 'auto') resolved.typeSource = 'setRig';
|
|
2996
|
+
resolved.focusDefault = resolved.focus.slice();
|
|
2997
|
+
resolved.focusDefaultSource = resolved.focusSource;
|
|
2998
|
+
return resolved;
|
|
2999
|
+
}
|
|
3000
|
+
|
|
3001
|
+
/** The framing knobs the viewer's fit reads, as addSplat was booted with. */
|
|
3002
|
+
const bootFraming = { vH: virtualDisplayHeight, fit, margin, depthLimit, fitSweep };
|
|
3003
|
+
function setFraming(f) {
|
|
3004
|
+
viewer.vH = f.vH;
|
|
3005
|
+
viewer.fit = f.fit;
|
|
3006
|
+
viewer.margin = f.margin;
|
|
3007
|
+
viewer.depthLimit = f.depthLimit;
|
|
3008
|
+
viewer.fitSweep = f.fitSweep;
|
|
3009
|
+
}
|
|
3010
|
+
|
|
3011
|
+
/** Declare a DISPLAY rig to the runtime, unless that exact one is already declared. */
|
|
3012
|
+
function declareDisplay(f) {
|
|
3013
|
+
const key = `${f.vH}|${f.ipdFactor ?? 1}|${f.parallaxFactor ?? 1}|${f.perspectiveFactor ?? 1}`;
|
|
3014
|
+
if (declaredDisplay === key) return;
|
|
3015
|
+
// Back at the boot rig from the shorthand it was built with: nothing to say.
|
|
3016
|
+
if (declaredDisplay === 'shorthand' && key === `${bootFraming.vH}|1|1|1`) return;
|
|
3017
|
+
declaredDisplay = key;
|
|
3018
|
+
lastConvergence = Number.NaN;
|
|
3019
|
+
out.viewRig = displayRig({
|
|
3020
|
+
virtualDisplayHeight: f.vH,
|
|
3021
|
+
ipdFactor: f.ipdFactor ?? 1,
|
|
3022
|
+
parallaxFactor: f.parallaxFactor ?? 1,
|
|
3023
|
+
perspectiveFactor: f.perspectiveFactor ?? 1,
|
|
3024
|
+
});
|
|
3025
|
+
handle?.setViewRig?.(out.viewRig);
|
|
3026
|
+
}
|
|
3027
|
+
|
|
3028
|
+
/**
|
|
3029
|
+
* Put the viewer on a resolved rig: lens, framing, focus, and the rig declared to the runtime.
|
|
3030
|
+
* Shared by every load / setSource (reset=false: the pose is kept) and handle.setRig
|
|
3031
|
+
* (reset=true: the pose goes back to the rig's rest, yaw = pitch = 0, zoom 1, depth 0).
|
|
3032
|
+
*/
|
|
3033
|
+
function applyRig(resolved, bounds, reset) {
|
|
3034
|
+
const disp = rigOverride?.type === 'display' ? rigOverride.o : null;
|
|
3035
|
+
if (reset) viewer.resetPose();
|
|
3036
|
+
if (resolved.type === 'camera') {
|
|
3037
|
+
setFraming(bootFraming);
|
|
2815
3038
|
if (!('idleSpin' in opts)) viewer.idleSpin = 0;
|
|
3039
|
+
else if (reset) viewer.idleSpin = idleSpin;
|
|
2816
3040
|
// The camera rig never auto-fits (the capture IS the framing): a scale left by a previous
|
|
2817
3041
|
// display-rig asset (setSource) must not shrink this one.
|
|
2818
3042
|
viewer.resetFit();
|
|
@@ -2820,15 +3044,170 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
|
|
|
2820
3044
|
viewer.setFocus(modelToContent(resolved.focus), { snap: true, recentre: false });
|
|
2821
3045
|
lastConvergence = Number.NaN;
|
|
2822
3046
|
pushViewRig(true);
|
|
2823
|
-
|
|
2824
|
-
|
|
2825
|
-
|
|
2826
|
-
|
|
2827
|
-
|
|
2828
|
-
|
|
3047
|
+
return;
|
|
3048
|
+
}
|
|
3049
|
+
if (viewer.mono.capture) viewer.useDisplayCamera();
|
|
3050
|
+
// The display rig orbits the frame centre (SceneViewer's recentre), whatever a camera rig
|
|
3051
|
+
// before it left in the viewer.
|
|
3052
|
+
viewer._focusRecentres = true;
|
|
3053
|
+
const target = disp ? displayTarget(disp) : null;
|
|
3054
|
+
if (target) {
|
|
3055
|
+
// setRig('display') framing the page's content: addModel's fit, on addModel's defaults.
|
|
3056
|
+
setFraming(disp);
|
|
3057
|
+
viewer.idleSpin = disp.idleSpin;
|
|
3058
|
+
viewer.fitTo(target.center, target.extent);
|
|
3059
|
+
resolved.focus = contentToModel(target.center);
|
|
3060
|
+
resolved.focusSource = 'frame';
|
|
3061
|
+
resolved.focusDefault = resolved.focus.slice();
|
|
3062
|
+
resolved.focusDefaultSource = 'frame';
|
|
3063
|
+
resolved.frame = target;
|
|
3064
|
+
declareDisplay(disp);
|
|
3065
|
+
return;
|
|
3066
|
+
}
|
|
3067
|
+
// The splat itself, as a display-rig addSplat frames it (the load-time path).
|
|
3068
|
+
setFraming(disp || bootFraming);
|
|
3069
|
+
if (reset) viewer.idleSpin = disp ? disp.idleSpin : idleSpin;
|
|
3070
|
+
if (bounds) viewer.fitTo(bounds.center, bounds.extent);
|
|
3071
|
+
else console.warn('[inline3d/splat] no usable bounds — subject is UNFRAMED (model scale)', src);
|
|
3072
|
+
if (resolved.focusSource === 'caller' || resolved.focusSource === 'block') {
|
|
3073
|
+
viewer.setFocus(modelToContent(resolved.focus), { snap: true, recentre: true });
|
|
3074
|
+
}
|
|
3075
|
+
if (disp) resolved.frame = bounds ? { center: bounds.center.slice(), extent: bounds.extent.slice(), source: 'splat' } : null;
|
|
3076
|
+
declareDisplay(disp || { vH: bootFraming.vH });
|
|
3077
|
+
}
|
|
3078
|
+
|
|
3079
|
+
// ── handle.setRig: a live, reversible rig switch (docs/playcanvas-adapter.md §setRig) ──
|
|
3080
|
+
/** null = 'auto' (the per-asset waterfall); else { type: 'camera' } | { type: 'display', o }. */
|
|
3081
|
+
let rigOverride = null;
|
|
3082
|
+
let rigSeq = 0;
|
|
3083
|
+
/** The neutral-studio IBL setRig('display') installed, and what it replaced — or null. */
|
|
3084
|
+
let rigEnv = null;
|
|
3085
|
+
const baseToneMapping = viewer.toneMapping;
|
|
3086
|
+
|
|
3087
|
+
/** Is the splat on screen: its entity (and every ancestor) enabled. */
|
|
3088
|
+
function splatShown() {
|
|
3089
|
+
let e = current?.entity || null;
|
|
3090
|
+
if (!e) return false;
|
|
3091
|
+
for (; e; e = e.parent) if (e.enabled === false) return false;
|
|
3092
|
+
return true;
|
|
3093
|
+
}
|
|
3094
|
+
|
|
3095
|
+
/**
|
|
3096
|
+
* World AABB of the page's meshes under handle.engine.root — enabled render components only,
|
|
3097
|
+
* union of their mesh instances' `aabb` (the engine's world box, skinning included). Content
|
|
3098
|
+
* space. The same measurement addModel's boundsOfEntity takes of its glTF.
|
|
3099
|
+
*/
|
|
3100
|
+
function rootMeshBounds() {
|
|
3101
|
+
const root = viewer.content;
|
|
3102
|
+
const renders = root?.findComponents ? root.findComponents('render') : [];
|
|
3103
|
+
let min = null;
|
|
3104
|
+
let max = null;
|
|
3105
|
+
for (const r of renders) {
|
|
3106
|
+
if (r.enabled === false || r.entity?.enabled === false) continue;
|
|
3107
|
+
for (const mi of r.meshInstances || []) {
|
|
3108
|
+
const b = mi.aabb;
|
|
3109
|
+
if (!b) continue;
|
|
3110
|
+
const c = b.center;
|
|
3111
|
+
const h = b.halfExtents;
|
|
3112
|
+
const lo = [c.x - h.x, c.y - h.y, c.z - h.z];
|
|
3113
|
+
const hi = [c.x + h.x, c.y + h.y, c.z + h.z];
|
|
3114
|
+
if (!lo.every(Number.isFinite) || !hi.every(Number.isFinite)) continue;
|
|
3115
|
+
if (!min) {
|
|
3116
|
+
min = lo;
|
|
3117
|
+
max = hi;
|
|
3118
|
+
} else {
|
|
3119
|
+
for (let i = 0; i < 3; i++) {
|
|
3120
|
+
if (lo[i] < min[i]) min[i] = lo[i];
|
|
3121
|
+
if (hi[i] > max[i]) max[i] = hi[i];
|
|
3122
|
+
}
|
|
3123
|
+
}
|
|
2829
3124
|
}
|
|
2830
3125
|
}
|
|
2831
|
-
return {
|
|
3126
|
+
return min ? { min, max } : null;
|
|
3127
|
+
}
|
|
3128
|
+
|
|
3129
|
+
/**
|
|
3130
|
+
* What setRig('display') frames: the caller's `frame`, else the meshes under root (plus the
|
|
3131
|
+
* splat's measured box if it is shown), else null — the splat, as a display-rig addSplat would.
|
|
3132
|
+
*/
|
|
3133
|
+
function displayTarget(disp) {
|
|
3134
|
+
if (disp.frame) {
|
|
3135
|
+
return { center: toArray3(disp.frame.center), extent: toArray3(disp.frame.extent).map((v) => Math.max(Math.abs(v), 1e-6)), source: 'caller' };
|
|
3136
|
+
}
|
|
3137
|
+
const m = rootMeshBounds();
|
|
3138
|
+
if (!m) return null;
|
|
3139
|
+
let { min, max } = m;
|
|
3140
|
+
let source = 'root';
|
|
3141
|
+
const sb = splatShown() ? out.frame : null;
|
|
3142
|
+
if (sb) {
|
|
3143
|
+
source = 'root+splat';
|
|
3144
|
+
min = min.map((v, i) => Math.min(v, sb.center[i] - sb.extent[i] / 2));
|
|
3145
|
+
max = max.map((v, i) => Math.max(v, sb.center[i] + sb.extent[i] / 2));
|
|
3146
|
+
}
|
|
3147
|
+
return {
|
|
3148
|
+
center: [(min[0] + max[0]) / 2, (min[1] + max[1]) / 2, (min[2] + max[2]) / 2],
|
|
3149
|
+
extent: [Math.max(max[0] - min[0], 1e-6), Math.max(max[1] - min[1], 1e-6), Math.max(max[2] - min[2], 1e-6)],
|
|
3150
|
+
source,
|
|
3151
|
+
};
|
|
3152
|
+
}
|
|
3153
|
+
|
|
3154
|
+
/** Per tick: addModel's tone mapping for the page's meshes while the splat is hidden. */
|
|
3155
|
+
viewer.onTick = pageMode
|
|
3156
|
+
? viewer.onTick
|
|
3157
|
+
: () => {
|
|
3158
|
+
const want = rigOverride?.type === 'display' && !splatShown() ? rigOverride.o.toneMapping : baseToneMapping;
|
|
3159
|
+
if (want !== viewer.toneMapping) viewer.setToneMapping(want);
|
|
3160
|
+
};
|
|
3161
|
+
|
|
3162
|
+
/** Install addModel's default IBL for the display rig, if the page has none of its own. */
|
|
3163
|
+
async function ensureRigEnvironment(disp, seq) {
|
|
3164
|
+
if (disp.environment === 'none' || viewer.sky || rigEnv) return;
|
|
3165
|
+
const app = viewer.app;
|
|
3166
|
+
const scene = app?.scene;
|
|
3167
|
+
if (!scene || scene.envAtlas) return; // the page lights its own meshes: leave it alone
|
|
3168
|
+
const m = await import('./inline3d-model-playcanvas.js');
|
|
3169
|
+
if (removed || seq !== rigSeq || rigOverride?.type !== 'display' || scene.envAtlas || rigEnv) return;
|
|
3170
|
+
const prev = { skyboxIntensity: scene.skyboxIntensity, exposure: scene.exposure, skyboxRotation: scene.skyboxRotation };
|
|
3171
|
+
const atlas = m.useNeutralStudio(pcModule, app, m.ENV_YAW_DEG);
|
|
3172
|
+
rigEnv = { atlas, prev };
|
|
3173
|
+
}
|
|
3174
|
+
|
|
3175
|
+
/** Undo ensureRigEnvironment (only what it installed, only if the page has not replaced it). */
|
|
3176
|
+
function dropRigEnvironment() {
|
|
3177
|
+
if (!rigEnv) return;
|
|
3178
|
+
const scene = viewer.app?.scene;
|
|
3179
|
+
const { atlas, prev } = rigEnv;
|
|
3180
|
+
rigEnv = null;
|
|
3181
|
+
if (!scene || scene.envAtlas !== atlas) return;
|
|
3182
|
+
scene.envAtlas = null;
|
|
3183
|
+
scene.skyboxIntensity = prev.skyboxIntensity;
|
|
3184
|
+
scene.exposure = prev.exposure;
|
|
3185
|
+
if (prev.skyboxRotation !== undefined) scene.skyboxRotation = prev.skyboxRotation;
|
|
3186
|
+
atlas.destroy?.();
|
|
3187
|
+
}
|
|
3188
|
+
|
|
3189
|
+
/**
|
|
3190
|
+
* handle.setRig(type, options) — switch the rig the window is seen through, live: no remount,
|
|
3191
|
+
* no reload, no new session. 'display' frames the page's meshes under handle.engine.root (plus
|
|
3192
|
+
* the splat when shown) on addModel's defaults; 'camera' is the asset's capture rig re-resolved
|
|
3193
|
+
* from the waterfall's own inputs; 'auto' is exactly what the load resolved. The pose resets to
|
|
3194
|
+
* the new rig's rest. Sticky across setSource until setRig('auto'). Resolves to the handle once
|
|
3195
|
+
* applied (the display rig's default environment included).
|
|
3196
|
+
*/
|
|
3197
|
+
function setRig(type, o = {}) {
|
|
3198
|
+
const req = validateSetRig(type, o, pageMode);
|
|
3199
|
+
const seq = ++rigSeq;
|
|
3200
|
+
return first.then(async () => {
|
|
3201
|
+
if (removed || seq !== rigSeq || !current) return out;
|
|
3202
|
+
rigOverride = req.type === 'auto' ? null : req;
|
|
3203
|
+
if (req.type !== 'display') dropRigEnvironment();
|
|
3204
|
+
const resolved = resolveFor(current.rigIn, req.type);
|
|
3205
|
+
out.rig = resolved;
|
|
3206
|
+
applyRig(resolved, current.rigIn.bounds, true);
|
|
3207
|
+
viewer.onTick?.(); // tone mapping now, not a frame late
|
|
3208
|
+
if (req.type === 'display') await ensureRigEnvironment(req.o, seq);
|
|
3209
|
+
return out;
|
|
3210
|
+
});
|
|
2832
3211
|
}
|
|
2833
3212
|
|
|
2834
3213
|
let pcModule = null;
|
|
@@ -2841,6 +3220,7 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
|
|
|
2841
3220
|
preserveDrawingBuffer,
|
|
2842
3221
|
perf: perfResolved,
|
|
2843
3222
|
viewPath: opts.playcanvasViewPath,
|
|
3223
|
+
antialias: antialias === true,
|
|
2844
3224
|
});
|
|
2845
3225
|
if (!app || removed) return null;
|
|
2846
3226
|
pcModule = pc;
|
|
@@ -2892,13 +3272,17 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
|
|
|
2892
3272
|
* Memory: the prepared asset is fully resident (a 1.18M-gaussian SOG: its GPU textures plus the
|
|
2893
3273
|
* engine's centre array) alongside the current one until it is used or disposed.
|
|
2894
3274
|
*/
|
|
2895
|
-
async function prepareSource(src) {
|
|
3275
|
+
async function prepareSource(src, po = {}) {
|
|
3276
|
+
if (po === null || typeof po !== 'object') throw new TypeError('@displayxr/inline3d/splat: prepareSource options must be an object.');
|
|
3277
|
+
const warm = po.transition !== undefined ? resolveSwap(po) : null; // the setSource options it will get; throws on a bad one
|
|
2896
3278
|
const app = await booted;
|
|
2897
3279
|
await first.catch(() => null);
|
|
2898
3280
|
if (!app || removed) throw new Error('@displayxr/inline3d/splat: prepareSource on a removed tile.');
|
|
2899
3281
|
const t0 = performance.now();
|
|
2900
3282
|
const loaded = await loadOne(pcModule, app, src, { background: true });
|
|
2901
3283
|
perfSpan('prepareSource', t0);
|
|
3284
|
+
// The transition the page declared: compile its shader now, in the dwell, not on its first frame.
|
|
3285
|
+
if (warm?.particles) await prewarmTransition(warm);
|
|
2902
3286
|
const entry = { loaded, state: 'ready', dispose: null };
|
|
2903
3287
|
const prepared = {
|
|
2904
3288
|
[PREPARED_TAG]: true,
|
|
@@ -2926,6 +3310,98 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
|
|
|
2926
3310
|
return prepared;
|
|
2927
3311
|
}
|
|
2928
3312
|
|
|
3313
|
+
// ── shader pre-warm for the particle transitions ──
|
|
3314
|
+
const prewarmed = new Set();
|
|
3315
|
+
/**
|
|
3316
|
+
* Compile + link the render-time variant a particle transition will install, ahead of it. The
|
|
3317
|
+
* first frame of a transition otherwise blocks on the link (GetProgramiv: 35–45 ms on an M1,
|
|
3318
|
+
* measured with a trace) — on the very transition a user sees first. A throwaway material with
|
|
3319
|
+
* the eye renderer's own description, defines and chunks, plus the transition's chunk, asks the
|
|
3320
|
+
* engine's program library for the same variant the renderer will ask for (the library keys on
|
|
3321
|
+
* the generated source + processing options, not on the material), so the compile is issued
|
|
3322
|
+
* now and, with KHR_parallel_shader_compile, finishes off the main thread. Best effort: an
|
|
3323
|
+
* engine whose internals differ just compiles on the transition's first frame, as before.
|
|
3324
|
+
*/
|
|
3325
|
+
async function prewarmTransition(plan) {
|
|
3326
|
+
const key = `${plan.particles.in.effect}|${plan.particles.in.opts.order ?? ''}`;
|
|
3327
|
+
if (!fx || prewarmed.has(key)) return;
|
|
3328
|
+
await yieldIdle();
|
|
3329
|
+
if (removed || !fx) return;
|
|
3330
|
+
try {
|
|
3331
|
+
const pc = pcModule;
|
|
3332
|
+
const cams = [viewer.eye, viewer._live?.cam].filter(Boolean);
|
|
3333
|
+
const code = fx.sharedChunkCode('transition', plan.particles.in.effect, { order: plan.particles.in.opts.order });
|
|
3334
|
+
const made = [];
|
|
3335
|
+
let issued = 0;
|
|
3336
|
+
for (const cam of cams) {
|
|
3337
|
+
const cd = viewer.app?.renderer?.gsplatDirector?.camerasMap?.get?.(cam.camera?.camera);
|
|
3338
|
+
let mi = null;
|
|
3339
|
+
if (cd?.layersMap) for (const ld of cd.layersMap.values()) mi ||= ld?.gsplatManager?.renderer?.meshInstance ?? null;
|
|
3340
|
+
const src = mi?.material;
|
|
3341
|
+
const camera = cam.camera?.camera;
|
|
3342
|
+
if (!src?.shaderDesc || !camera?.shaderParams || typeof src.getShaderVariant !== 'function') continue;
|
|
3343
|
+
const m = new pc.ShaderMaterial(src.shaderDesc);
|
|
3344
|
+
src.defines.forEach((v, k) => m.setDefine(k, v));
|
|
3345
|
+
m.shaderChunks.copy(src.shaderChunks);
|
|
3346
|
+
m.getShaderChunks(pc.SHADERLANGUAGE_GLSL).set('gsplatModifyVS', code);
|
|
3347
|
+
m.blendState = src.blendState;
|
|
3348
|
+
made.push(m.getShaderVariant({
|
|
3349
|
+
device: viewer.app.graphicsDevice,
|
|
3350
|
+
scene: viewer.app.scene,
|
|
3351
|
+
objDefs: mi._shaderDefs,
|
|
3352
|
+
cameraShaderParams: camera.shaderParams,
|
|
3353
|
+
pass: 0, // SHADER_FORWARD
|
|
3354
|
+
sortedLights: [],
|
|
3355
|
+
viewUniformFormat: viewer.app.renderer?.viewUniformFormat ?? null,
|
|
3356
|
+
vertexFormat: mi.mesh?.vertexBuffer?.format,
|
|
3357
|
+
}));
|
|
3358
|
+
issued++;
|
|
3359
|
+
}
|
|
3360
|
+
// the overlay's two quads (created on the first capture otherwise, and compiled on its draw)
|
|
3361
|
+
const cam = viewer.eye?.camera?.camera;
|
|
3362
|
+
if (cam?.shaderParams) {
|
|
3363
|
+
viewer._ensureSnapshotOverlay();
|
|
3364
|
+
for (const p of viewer._snap?.parts || []) {
|
|
3365
|
+
made.push(p.mat.getShaderVariant?.({
|
|
3366
|
+
device: viewer.app.graphicsDevice,
|
|
3367
|
+
scene: viewer.app.scene,
|
|
3368
|
+
objDefs: p.mi._shaderDefs,
|
|
3369
|
+
cameraShaderParams: cam.shaderParams,
|
|
3370
|
+
pass: 0,
|
|
3371
|
+
sortedLights: [],
|
|
3372
|
+
viewUniformFormat: viewer.app.renderer?.viewUniformFormat ?? null,
|
|
3373
|
+
vertexFormat: p.mi.mesh?.vertexBuffer?.format,
|
|
3374
|
+
}));
|
|
3375
|
+
}
|
|
3376
|
+
}
|
|
3377
|
+
if (issued) prewarmed.add(key);
|
|
3378
|
+
await finalizeWhenLinked(made.filter(Boolean));
|
|
3379
|
+
} catch (err) {
|
|
3380
|
+
console.info('[inline3d/splat] transition shader pre-warm skipped', err);
|
|
3381
|
+
}
|
|
3382
|
+
}
|
|
3383
|
+
|
|
3384
|
+
/**
|
|
3385
|
+
* Creating a program only ISSUES its compile + link; the browser resolves the link when the
|
|
3386
|
+
* program is first queried — the engine's first draw, blocking (the 35–50 ms GetProgramiv the
|
|
3387
|
+
* trace shows, even for a program created seconds earlier). So finish the engine's own
|
|
3388
|
+
* finalize here, in the dwell: wait (in idle periods, not blocking) for the link to complete
|
|
3389
|
+
* where the browser reports it (KHR_parallel_shader_compile), then finalize.
|
|
3390
|
+
*/
|
|
3391
|
+
async function finalizeWhenLinked(shaders) {
|
|
3392
|
+
const dev = viewer.app?.graphicsDevice;
|
|
3393
|
+
if (!dev) return;
|
|
3394
|
+
const pending = () => shaders.filter((x) => x && !x.ready && !x.failed && x.impl?.finalize);
|
|
3395
|
+
for (let i = 0; i < 40 && pending().length; i++) {
|
|
3396
|
+
for (const x of pending()) if (x.impl.isLinked?.(dev)) x.impl.finalize(dev, x) || (x.failed = true);
|
|
3397
|
+
if (pending().length) await yieldIdle(50);
|
|
3398
|
+
if (removed) return;
|
|
3399
|
+
}
|
|
3400
|
+
// still linking (or no completion query): finalize now — a block in the dwell, not on the
|
|
3401
|
+
// transition's first frame
|
|
3402
|
+
for (const x of pending()) x.impl.finalize(dev, x) || (x.failed = true);
|
|
3403
|
+
}
|
|
3404
|
+
|
|
2929
3405
|
// ── setSource: swap the asset — a cut, a crossfade, or a transition ──
|
|
2930
3406
|
let sourceGen = 0;
|
|
2931
3407
|
let pendingSwap = null;
|
|
@@ -2972,9 +3448,10 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
|
|
|
2972
3448
|
const prev = current;
|
|
2973
3449
|
pendingSwap?.finish();
|
|
2974
3450
|
const transition = prev ? plan.transition : 'cut';
|
|
3451
|
+
const particle = plan.particles && transition in PARTICLE_TRANSITIONS ? plan.particles : null;
|
|
2975
3452
|
// FRAME_SNAPSHOT: freeze the outgoing frame BEFORE anything of the new asset (or its rig) is
|
|
2976
3453
|
// drawn. The overlay goes up in the same task, so no frame shows neither photo.
|
|
2977
|
-
const wantsSnapshot = transition === 'crossfade' || transition === 'wavefront';
|
|
3454
|
+
const wantsSnapshot = transition === 'crossfade' || transition === 'wavefront' || !!particle;
|
|
2978
3455
|
const snapped = wantsSnapshot ? await viewer.captureFrame() : false;
|
|
2979
3456
|
if (removed || gen !== sourceGen) {
|
|
2980
3457
|
app.assets.remove(loaded.asset);
|
|
@@ -2985,7 +3462,9 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
|
|
|
2985
3462
|
// LIVE outgoing (default in 3D): the old asset stays resident and keeps rendering, through the
|
|
2986
3463
|
// same eye views, into its own target; the frozen capture above bridges the first frames until
|
|
2987
3464
|
// its camera has drawn a sorted frame. Its lens frame is read BEFORE adopt() switches the rig.
|
|
2988
|
-
|
|
3465
|
+
// A particle transition's outgoing photo MOVES, so it is live in 2D too (where the engine has
|
|
3466
|
+
// the RenderView path); 'frozen' there = its snapshot fades out while the new one plays in.
|
|
3467
|
+
const outgoingMode = plan.outgoing || (particle ? 'live' : defaultOutgoing(viewer.is3D));
|
|
2989
3468
|
const live =
|
|
2990
3469
|
snapped && outgoingMode === 'live' && viewer.canLiveOutgoing ? viewer.startLiveOutgoing(prev.entity, viewer.lensFrame()) : null;
|
|
2991
3470
|
/** Each frame of the window: once the live camera is ready, the overlay samples it. */
|
|
@@ -3070,6 +3549,12 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
|
|
|
3070
3549
|
return out;
|
|
3071
3550
|
}
|
|
3072
3551
|
|
|
3552
|
+
if (particle && snapped) {
|
|
3553
|
+
playParticleTransition({ particle, plan, prev, entity, live, adopt, release, finishers, finish, isFinished: () => finished, pumpLive, isLiveShown: () => liveShown });
|
|
3554
|
+
await done;
|
|
3555
|
+
return out;
|
|
3556
|
+
}
|
|
3557
|
+
|
|
3073
3558
|
adopt(entity);
|
|
3074
3559
|
if (transition === 'cut') {
|
|
3075
3560
|
release(prev);
|
|
@@ -3173,6 +3658,108 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
|
|
|
3173
3658
|
return out;
|
|
3174
3659
|
}
|
|
3175
3660
|
|
|
3661
|
+
/**
|
|
3662
|
+
* setSource's particle transitions (swarm, burst, shimmer-cross, dust), once the frame snapshot
|
|
3663
|
+
* is up. The OUTGOING asset plays its effect in reverse, on the live camera, keyed on its own
|
|
3664
|
+
* frame (taken here, before the rig switches); the INCOMING one plays forwards on the eye camera,
|
|
3665
|
+
* its frame re-taken when the clock starts. One clock drives both, on overlapping spans
|
|
3666
|
+
* (particleSpan). The overlay puts the live outgoing image OVER the incoming one, per eye. With
|
|
3667
|
+
* no live camera (outgoing:'frozen', or an engine without the RenderView path) the frozen frame
|
|
3668
|
+
* fades out over the outgoing span while the incoming asset plays its side. The end: effects
|
|
3669
|
+
* removed (modifiers deleted), overlay hidden, live camera off, old asset released — the frame
|
|
3670
|
+
* is a plain cut's.
|
|
3671
|
+
*/
|
|
3672
|
+
function playParticleTransition({ particle, plan, prev, entity, live, adopt, release, finishers, finish, isFinished, pumpLive, isLiveShown }) {
|
|
3673
|
+
const ease = typeof plan.easing === 'function' ? plan.easing : EASINGS[plan.easing] || EASINGS.linear;
|
|
3674
|
+
const overlap = particle.overlap;
|
|
3675
|
+
// RENDER-TIME (the default): one tile-scope body, each photo's values on the mesh instance of
|
|
3676
|
+
// the gsplat manager that draws it — the eye camera's (incoming) and the live camera's
|
|
3677
|
+
// (outgoing). No work-buffer rewrite and no re-sort per frame. An engine that does not expose
|
|
3678
|
+
// the managers' mesh instances gets the entity-scope modifiers instead (a full work-buffer
|
|
3679
|
+
// rewrite + a re-sort per asset per frame: correct, but it hitches on a 1.18M photo).
|
|
3680
|
+
const managerMi = (cam, layer) => {
|
|
3681
|
+
const cd = viewer.app?.renderer?.gsplatDirector?.camerasMap?.get?.(cam?.camera?.camera);
|
|
3682
|
+
if (!cd?.layersMap) return null;
|
|
3683
|
+
if (layer) return cd.layersMap.get(layer)?.gsplatManager?.renderer?.meshInstance ?? null;
|
|
3684
|
+
for (const ld of cd.layersMap.values()) if (ld?.gsplatManager?.renderer?.meshInstance) return ld.gsplatManager.renderer.meshInstance;
|
|
3685
|
+
return null;
|
|
3686
|
+
};
|
|
3687
|
+
const eyeMi = () => managerMi(viewer.eye, null);
|
|
3688
|
+
const liveMi = () => (live?.active ? managerMi(live.cam, live.layer) : null);
|
|
3689
|
+
const shared = particle.out.effect === particle.in.effect && eyeMi() ? fx.driveShared('transition', particle.in.effect, { order: particle.in.opts.order }) : null;
|
|
3690
|
+
// the outgoing side, BEFORE adopt(): its frame (eyes, focus, framing) is the old asset's
|
|
3691
|
+
let outFx = null;
|
|
3692
|
+
if (live) {
|
|
3693
|
+
outFx = shared ? shared.side(liveMi, particle.out.opts) : fx.drive(prev.entity, particle.out.effect, particle.out.opts);
|
|
3694
|
+
outFx.set(1, 0);
|
|
3695
|
+
} else {
|
|
3696
|
+
release(prev); // the frozen frame shows it from here on
|
|
3697
|
+
}
|
|
3698
|
+
adopt(entity);
|
|
3699
|
+
// the incoming side, hidden (amount 0) until the clock starts
|
|
3700
|
+
const inFx = shared ? shared.side(eyeMi, particle.in.opts) : fx.drive(entity, particle.in.effect, particle.in.opts);
|
|
3701
|
+
inFx.set(0, 0);
|
|
3702
|
+
viewer._transitionPath = shared ? 'render' : 'entity'; // diagnostics (docs §Gates)
|
|
3703
|
+
finishers.push(() => {
|
|
3704
|
+
viewer._transitionState = null;
|
|
3705
|
+
if (shared) shared.remove();
|
|
3706
|
+
else {
|
|
3707
|
+
outFx?.remove();
|
|
3708
|
+
inFx.remove();
|
|
3709
|
+
}
|
|
3710
|
+
viewer.setSnapshotAlpha(0);
|
|
3711
|
+
viewer.releaseSnapshot();
|
|
3712
|
+
if (live) {
|
|
3713
|
+
viewer.stopLiveOutgoing();
|
|
3714
|
+
release(prev);
|
|
3715
|
+
}
|
|
3716
|
+
});
|
|
3717
|
+
const dur = plan.durationMs;
|
|
3718
|
+
let t0 = null;
|
|
3719
|
+
let ticks = 0;
|
|
3720
|
+
viewer._hooks.push((t) => {
|
|
3721
|
+
if (removed) return (finish(), false);
|
|
3722
|
+
if (isFinished()) return false;
|
|
3723
|
+
if (live && !live.fits) {
|
|
3724
|
+
finish(); // a resize or a 2D/3D switch mid-window: end now
|
|
3725
|
+
return false;
|
|
3726
|
+
}
|
|
3727
|
+
pumpLive();
|
|
3728
|
+
// The clock starts on the SECOND tick (the incoming asset's first frame builds its work
|
|
3729
|
+
// buffer) and, live, once the live camera has drawn a sorted frame (until then the frozen
|
|
3730
|
+
// capture stands in for the outgoing photo, untouched).
|
|
3731
|
+
if (++ticks < 2 || (live && !isLiveShown())) {
|
|
3732
|
+
if (shared) {
|
|
3733
|
+
// the managers (and their mesh instances) may be new: keep both photos' values on them
|
|
3734
|
+
outFx?.set(1, 0);
|
|
3735
|
+
inFx.set(0, 0);
|
|
3736
|
+
}
|
|
3737
|
+
if (!viewer.setSnapshotAlpha(1)) {
|
|
3738
|
+
finish();
|
|
3739
|
+
return false;
|
|
3740
|
+
}
|
|
3741
|
+
return true;
|
|
3742
|
+
}
|
|
3743
|
+
if (t0 === null) {
|
|
3744
|
+
t0 = t;
|
|
3745
|
+
inFx.restart(); // the incoming asset's frame, now its rig and first frames are in
|
|
3746
|
+
}
|
|
3747
|
+
const raw = dur > 0 ? Math.min(1, Math.max(0, (t - t0) / dur)) : 1;
|
|
3748
|
+
const te = ease(raw);
|
|
3749
|
+
const timeS = (t - t0) / 1000;
|
|
3750
|
+
viewer._transitionState = { raw, live: !!live }; // diagnostics (docs §Gates)
|
|
3751
|
+
const outSpan = particleSpan(te, overlap, 'out');
|
|
3752
|
+
outFx?.set(1 - outSpan, timeS);
|
|
3753
|
+
inFx.set(particleSpan(te, overlap, 'in'), timeS);
|
|
3754
|
+
const ok = live ? viewer.setSnapshotAlpha(1, null, { over: true }) : viewer.setSnapshotAlpha(1 - outSpan);
|
|
3755
|
+
if (!ok || raw >= 1) {
|
|
3756
|
+
finish();
|
|
3757
|
+
return false;
|
|
3758
|
+
}
|
|
3759
|
+
return true;
|
|
3760
|
+
});
|
|
3761
|
+
}
|
|
3762
|
+
|
|
3176
3763
|
// The load promise is RETURNED, never written to `out.ready`: ./splat's addSplat owns that
|
|
3177
3764
|
// field (one promise, one owner).
|
|
3178
3765
|
return first
|