@displayxr/inline3d 1.19.3 → 1.21.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 +51 -0
- package/js/inline3d-splat-diag.js +599 -0
- package/js/inline3d-splat-effects.js +101 -0
- package/js/inline3d-splat-live.js +81 -3
- package/js/inline3d-splat-playcanvas.js +493 -26
- package/package.json +2 -1
- package/splat.d.ts +61 -8
|
@@ -1164,6 +1164,58 @@ export function particleSpan(t, overlap, side) {
|
|
|
1164
1164
|
/** Options a page may pass through setSource to both sides of a particle transition. */
|
|
1165
1165
|
export const PARTICLE_TRANSITION_OPTIONS = Object.freeze(['order', 'stagger', 'jitter', 'maxDisparity', 'dotSize', 'noiseScale', 'layerSize', 'origin']);
|
|
1166
1166
|
|
|
1167
|
+
// ── setSource's SEQUENCE transitions ('reassemble', { type: 'sequence', out, in }) ────────────
|
|
1168
|
+
//
|
|
1169
|
+
// One photo at a time: the current photo plays a reveal BACKWARDS until nothing of it is drawn,
|
|
1170
|
+
// it is released, the next one is loaded and placed hidden, and it plays a reveal FORWARDS. No
|
|
1171
|
+
// second camera, no live-outgoing layer, no overlay target, no frozen capture: the eye camera
|
|
1172
|
+
// renders every frame, with one asset in the scene.
|
|
1173
|
+
//
|
|
1174
|
+
// A reveal can take part only if it draws NOTHING at amount 0 — otherwise the swap would pop from
|
|
1175
|
+
// a visible end state to an empty start (inflate's end state is a flat photo: that swap is
|
|
1176
|
+
// 'flip'). `dissolve` is left out for the reason it is not a setSource transition: its sway has a
|
|
1177
|
+
// depth component (docs/splat-effects.md §Comfort).
|
|
1178
|
+
|
|
1179
|
+
/**
|
|
1180
|
+
* The reveals a sequence can run, with the option overrides each side gets by default. The
|
|
1181
|
+
* particle ones reuse the tuned sides of the particle transitions (a sparse `density`, and a
|
|
1182
|
+
* `vanish` so the swarm thins out to nothing and gathers out of nothing).
|
|
1183
|
+
*/
|
|
1184
|
+
export const SEQUENCE_REVEALS = Object.freeze({
|
|
1185
|
+
assemble: { out: PARTICLE_TRANSITIONS.swarm.out.opts, in: PARTICLE_TRANSITIONS.swarm.in.opts },
|
|
1186
|
+
'dissolve-in': { out: PARTICLE_TRANSITIONS.dust.out.opts, in: PARTICLE_TRANSITIONS.dust.in.opts },
|
|
1187
|
+
converge: { out: PARTICLE_TRANSITIONS.burst.out.opts, in: PARTICLE_TRANSITIONS.burst.in.opts },
|
|
1188
|
+
shimmer: { out: PARTICLE_TRANSITIONS['shimmer-cross'].out.opts, in: PARTICLE_TRANSITIONS['shimmer-cross'].in.opts },
|
|
1189
|
+
sweep: { out: {}, in: {} },
|
|
1190
|
+
fade: { out: {}, in: {} },
|
|
1191
|
+
});
|
|
1192
|
+
|
|
1193
|
+
/** Named sequences: `transition: 'reassemble'` = assemble backwards, then forwards. */
|
|
1194
|
+
export const SEQUENCE_TRANSITIONS = Object.freeze({
|
|
1195
|
+
reassemble: { out: 'assemble', in: 'assemble', durationMs: 3000, easing: 'linear', beat: 0.1 },
|
|
1196
|
+
});
|
|
1197
|
+
|
|
1198
|
+
/**
|
|
1199
|
+
* Does `effect` with resolved options `o` draw nothing at amount 0? sweep's front starts one band
|
|
1200
|
+
* before its origin and fade's coverage is 0; a particle reveal needs `hiddenAtZero` (converge,
|
|
1201
|
+
* shimmer: not yet launched; assemble, dissolve-in: `vanish` > 0).
|
|
1202
|
+
*/
|
|
1203
|
+
export function emptyAtZero(effect, o) {
|
|
1204
|
+
if (effect === 'sweep' || effect === 'fade') return true;
|
|
1205
|
+
const def = EFFECTS[effect];
|
|
1206
|
+
return !!(def?.particle && def.hiddenAtZero?.(o));
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
/**
|
|
1210
|
+
* The three spans of a sequence over its clock, as fractions: out over [0, a], the empty beat
|
|
1211
|
+
* over [a, 1 − a], in over [1 − a, 1], with a = (1 − beat) / 2.
|
|
1212
|
+
*/
|
|
1213
|
+
export function sequenceSpans(beat) {
|
|
1214
|
+
const b = Math.min(0.9, Math.max(0, beat));
|
|
1215
|
+
const a = (1 - b) / 2;
|
|
1216
|
+
return { out: a, beat: b, in: a };
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1167
1219
|
|
|
1168
1220
|
/**
|
|
1169
1221
|
* The wavefront's per-column commit, lt ∈ [0, 1], for eased progress `t` at normalised u — the
|
|
@@ -1522,6 +1574,55 @@ export class SplatEffects {
|
|
|
1522
1574
|
};
|
|
1523
1575
|
}
|
|
1524
1576
|
|
|
1577
|
+
/**
|
|
1578
|
+
* A TILE-scope effect driven by the adapter's clock (setSource's sequence transitions: one photo
|
|
1579
|
+
* in the scene, so the tile material carries its values and every manager of the tile reads
|
|
1580
|
+
* them — a fresh one included, so a just-placed photo draws its hidden start state on its very
|
|
1581
|
+
* first frame). Render time: no work-buffer rewrite, no re-sort. Named `name` (the chunk's
|
|
1582
|
+
* prefix), so a sequence installs the same program as a particle transition of the same effect
|
|
1583
|
+
* and order. Hidden from effects() and from a page's stopEffect(). Returns
|
|
1584
|
+
* { set(amount, timeS), restart(), remove(), amount }.
|
|
1585
|
+
*/
|
|
1586
|
+
driveTile(name, effect, opts) {
|
|
1587
|
+
const o = resolveEffectOptions(effect, { ...opts, scope: 'tile', direction: 'in', progress: 0 }, 'set', { internal: true });
|
|
1588
|
+
const inst = this._makeInstance(name, o, 'set');
|
|
1589
|
+
inst.def = EFFECTS[effect];
|
|
1590
|
+
inst.hidden = true;
|
|
1591
|
+
inst.timeS = 0;
|
|
1592
|
+
this._replace('tile', name, inst);
|
|
1593
|
+
this._setupInstance(inst);
|
|
1594
|
+
this._apply('tile', inst, this.ctx.now());
|
|
1595
|
+
this._install('tile');
|
|
1596
|
+
const alive = () => !this._disposed && this.scopes.get('tile')?.get(name) === inst;
|
|
1597
|
+
return {
|
|
1598
|
+
set: (amount, timeS = inst.timeS) => {
|
|
1599
|
+
if (!alive()) return false;
|
|
1600
|
+
inst.opts.progress = Math.min(1, Math.max(0, amount));
|
|
1601
|
+
inst.timeS = timeS;
|
|
1602
|
+
this._apply('tile', inst, this.ctx.now());
|
|
1603
|
+
return true;
|
|
1604
|
+
},
|
|
1605
|
+
restart: () => {
|
|
1606
|
+
if (!alive()) return;
|
|
1607
|
+
inst.state = {};
|
|
1608
|
+
this._setupInstance(inst);
|
|
1609
|
+
this._apply('tile', inst, this.ctx.now());
|
|
1610
|
+
},
|
|
1611
|
+
remove: () => {
|
|
1612
|
+
if (!alive()) return;
|
|
1613
|
+
this.scopes.get('tile').delete(name);
|
|
1614
|
+
inst.resolve({ finished: true });
|
|
1615
|
+
this._install('tile');
|
|
1616
|
+
},
|
|
1617
|
+
get amount() {
|
|
1618
|
+
return inst.opts.progress;
|
|
1619
|
+
},
|
|
1620
|
+
get alive() {
|
|
1621
|
+
return alive();
|
|
1622
|
+
},
|
|
1623
|
+
};
|
|
1624
|
+
}
|
|
1625
|
+
|
|
1525
1626
|
/**
|
|
1526
1627
|
* ONE tile-scope body for the two photos of a setSource particle transition, with each photo's
|
|
1527
1628
|
* values on its OWN mesh instance (the engine draws each photo through its own gsplat manager and
|
|
@@ -148,6 +148,9 @@ export class LiveOutgoing {
|
|
|
148
148
|
this.frames = 0;
|
|
149
149
|
this.views = [];
|
|
150
150
|
this._frustumKey = '';
|
|
151
|
+
/** PRE-SORT (warm): the current asset is ALSO on the live layer, before the swap. */
|
|
152
|
+
this.warming = false;
|
|
153
|
+
this._warmLayers = null;
|
|
151
154
|
}
|
|
152
155
|
|
|
153
156
|
/** Engine support for this path: RenderViews on one camera, render targets, layers. */
|
|
@@ -212,21 +215,94 @@ export class LiveOutgoing {
|
|
|
212
215
|
this.w = this.h = 0;
|
|
213
216
|
}
|
|
214
217
|
|
|
218
|
+
/**
|
|
219
|
+
* PRE-SORT the live camera BEFORE the swap (the fix for the frozen bridge, #36). The current
|
|
220
|
+
* asset goes on the live layer IN ADDITION to its own (the eye keeps drawing it, untouched: its
|
|
221
|
+
* World placement set is unchanged, so its manager neither rebuilds nor re-sorts), and the live
|
|
222
|
+
* camera starts rendering it, through the same views, into its target. Its manager builds its
|
|
223
|
+
* work buffer and runs its first sort NOW, while the photo is still on screen and live. At the
|
|
224
|
+
* swap, start() takes the asset OFF World only: the live layer's placement set does not change,
|
|
225
|
+
* so its manager keeps its sorted state and the overlay can sample it on the very first frame.
|
|
226
|
+
*
|
|
227
|
+
* Without it, start() moved the asset onto a FRESH manager at the swap, and the overlay showed
|
|
228
|
+
* the frozen capture until that manager's first sort came back from the sort worker — a still
|
|
229
|
+
* image under a moving head for as long as that takes (the "tracking stops for a moment at
|
|
230
|
+
* every transition" report). Returns false if this engine build cannot (nothing changed).
|
|
231
|
+
*/
|
|
232
|
+
warm(entity) {
|
|
233
|
+
const v = this.viewer;
|
|
234
|
+
if (this.active || !LiveOutgoing.supported(v) || !entity?.gsplat) return false;
|
|
235
|
+
if (this.warming) {
|
|
236
|
+
if (this.entity === entity) return true;
|
|
237
|
+
this.cancelWarm();
|
|
238
|
+
}
|
|
239
|
+
try {
|
|
240
|
+
this._ensureEngineObjects();
|
|
241
|
+
this._ensureTarget(v.canvas.width, v.canvas.height);
|
|
242
|
+
const own = Array.isArray(entity.gsplat.layers) ? entity.gsplat.layers.slice() : [];
|
|
243
|
+
this._warmLayers = own;
|
|
244
|
+
entity.gsplat.layers = [...own.filter((id) => id !== this.layer.id), this.layer.id];
|
|
245
|
+
this.entity = entity;
|
|
246
|
+
this.oldFrame = null; // same asset, same rig: the chain is the rig node itself
|
|
247
|
+
this.frames = 0;
|
|
248
|
+
this.cam.enabled = true;
|
|
249
|
+
this.warming = true;
|
|
250
|
+
return true;
|
|
251
|
+
} catch (err) {
|
|
252
|
+
console.warn('[inline3d/splat] live outgoing pre-sort unavailable; the transition keeps the frozen bridge', err);
|
|
253
|
+
this.cancelWarm();
|
|
254
|
+
return false;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** The pre-sorted manager has drawn a sorted frame (what start() will then need no bridge for). */
|
|
259
|
+
get warmReady() {
|
|
260
|
+
if (!this.warming) return false;
|
|
261
|
+
const mgr = this.manager;
|
|
262
|
+
if (mgr === undefined) return this.frames >= 3;
|
|
263
|
+
const w = mgr?.world;
|
|
264
|
+
const st = w?.getState?.(w.lastWorldStateVersion);
|
|
265
|
+
return !!st?.sortedBefore && this.frames >= 1;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** Drop a pre-sort that did not become a live window: the asset back on only its own layers. */
|
|
269
|
+
cancelWarm() {
|
|
270
|
+
if (!this.warming) return;
|
|
271
|
+
const e = this.entity;
|
|
272
|
+
const own = this._warmLayers;
|
|
273
|
+
this.warming = false;
|
|
274
|
+
this._warmLayers = null;
|
|
275
|
+
this.entity = null;
|
|
276
|
+
if (this.cam) this.cam.enabled = false;
|
|
277
|
+
this._destroyTarget();
|
|
278
|
+
try {
|
|
279
|
+
if (e?.gsplat && own) e.gsplat.layers = own;
|
|
280
|
+
} catch {
|
|
281
|
+
/* entity already gone */
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
215
285
|
/**
|
|
216
286
|
* Take `entity` (the outgoing splat, still enabled) onto the live layer. `oldFrame` is
|
|
217
287
|
* viewer.lensFrame() read BEFORE the incoming asset's rig was adopted. Returns false if this
|
|
218
|
-
* engine build cannot (the caller keeps the frozen frame).
|
|
288
|
+
* engine build cannot (the caller keeps the frozen frame). After warm(entity) the live
|
|
289
|
+
* manager is kept (only the World placement goes), so `ready` holds from the first frame.
|
|
219
290
|
*/
|
|
220
291
|
start(entity, oldFrame) {
|
|
221
292
|
const v = this.viewer;
|
|
222
293
|
if (!LiveOutgoing.supported(v) || !entity?.gsplat) return false;
|
|
294
|
+
const warmed = this.warming && this.entity === entity;
|
|
295
|
+
if (this.warming && !warmed) this.cancelWarm();
|
|
223
296
|
try {
|
|
224
297
|
this._ensureEngineObjects();
|
|
225
298
|
this._ensureTarget(v.canvas.width, v.canvas.height);
|
|
226
299
|
entity.gsplat.layers = [this.layer.id];
|
|
227
300
|
this.entity = entity;
|
|
228
301
|
this.oldFrame = oldFrame;
|
|
229
|
-
this.frames = 0;
|
|
302
|
+
if (!warmed) this.frames = 0;
|
|
303
|
+
this.warming = false;
|
|
304
|
+
this._warmLayers = null;
|
|
305
|
+
this.warmed = warmed;
|
|
230
306
|
this.cam.enabled = true;
|
|
231
307
|
this.active = true;
|
|
232
308
|
return true;
|
|
@@ -275,7 +351,7 @@ export class LiveOutgoing {
|
|
|
275
351
|
* viewport), the eye's node pose, and the rig chain.
|
|
276
352
|
*/
|
|
277
353
|
sync(entries, rect, frustum) {
|
|
278
|
-
if (!this.active) return;
|
|
354
|
+
if (!this.active && !this.warming) return;
|
|
279
355
|
const v = this.viewer;
|
|
280
356
|
const pc = v.pc;
|
|
281
357
|
const rvs = this.views;
|
|
@@ -322,9 +398,11 @@ export class LiveOutgoing {
|
|
|
322
398
|
stop() {
|
|
323
399
|
// The outgoing entity is NOT put back on its old layer: the caller releases it right after,
|
|
324
400
|
// and a round trip through World would dirty the eye's manager (a work-buffer rebuild).
|
|
401
|
+
if (this.warming) return this.cancelWarm();
|
|
325
402
|
this.entity = null;
|
|
326
403
|
this.oldFrame = null;
|
|
327
404
|
this.active = false;
|
|
405
|
+
this.warmed = false;
|
|
328
406
|
if (this.cam) this.cam.enabled = false;
|
|
329
407
|
this._destroyTarget();
|
|
330
408
|
}
|