@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.
@@ -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
  }