@displayxr/inline3d 1.19.1 → 1.19.2

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 CHANGED
@@ -5,6 +5,34 @@ entry points (`.`, `./three`) are frozen for 1.x, while the **scene subpaths** (
5
5
  `./splat`, `./model`) are a preview tier whose options may change in any release. Entries below say
6
6
  which tier they touch, because that is what tells you whether an upgrade can move your pixels.
7
7
 
8
+ ## 1.19.2 — 2026-09-24
9
+
10
+ Touches the **preview tier** (`./splat`, `engine: 'playcanvas'` only). No API change and no pixel
11
+ change: every frame of a `crossfade` or `wavefront` is the one 1.19.1 draws, and the end is still a
12
+ plain `cut` (MAE 0.000).
13
+
14
+ ### Changed
15
+
16
+ - **The live `wavefront` costs about one photo's draw, not two** (#36).
17
+ - Its depth ridge was an entity-scope work-buffer modifier: a full rewrite of the new photo's
18
+ 1.18M-gaussian work buffer and a CPU re-sort, every frame (0.9–1.0 of each per frame, measured).
19
+ It now runs at render time on the eye camera's gsplat manager, as the particle transitions do:
20
+ 0 rewrites and 0 sorts in the window.
21
+ - Each photo is drawn only on its side of the front: the new one left of where its commit
22
+ starts, the old one right of where it ends, so the two overlap only in the band. A gaussian
23
+ whose on-screen footprint (bounded the way the engine sizes its quad) lies wholly on the far
24
+ side in every eye gets alpha 0, which the engine culls in the vertex stage. Frames with the
25
+ cull on and off are bit-identical (both eyes, colour and alpha, 5 points of the window).
26
+ - An engine whose managers are not reachable keeps the entity-scope ridge (`_transitionPath`
27
+ says which ran).
28
+ - **No shader links inside a `crossfade` or `wavefront` window.** The overlay's two quads compiled
29
+ on the window's first frames (30–180 ms on the M1). `prepareSource()` now warms them whatever
30
+ transition follows, `prepareSource(src, { transition: 'wavefront' })` also builds the render-time
31
+ ridge and cull, and an unprepared `setSource` warms them while its asset loads.
32
+
33
+ Measured pacing and GPU cost: [`docs/splat-effects.md` § Wavefront: one draw's worth](docs/splat-effects.md#wavefront-one-draws-worth).
34
+ The live `crossfade` still draws both photos in full: at mid-fade every pixel needs both images.
35
+
8
36
  ## 1.19.1 — 2026-09-24
9
37
 
10
38
  Touches packaging only (`./splat` and `./model`, `engine: 'playcanvas'`). No pixels move: the
@@ -35,7 +35,8 @@
35
35
  // same splats; a decision keyed on a screen position would differ between the eyes and read as
36
36
  // rivalry. So no effect here reads gl_FragCoord or a view matrix, and the custom hook exposes no
37
37
  // screen-space input. (The one image-space piece, setSource's frame snapshot, lives in the
38
- // adapter and samples each eye's own half at the zero-disparity plane.)
38
+ // adapter and samples each eye's own half at the zero-disparity plane.) The one exception,
39
+ // `wipecull`, is not an effect: it only skips gaussians no shown pixel can receive.
39
40
  //
40
41
  // ── Sort caveat ───────────────────────────────────────────────────────────────────────────────
41
42
  //
@@ -46,7 +47,12 @@
46
47
  import { coverageExponent, FADE_TRANSMITTANCE_FLOOR } from './inline3d-splat-shared.js';
47
48
 
48
49
  /** The fixed composition order of a generated chunk. */
49
- export const STAGE_ORDER = Object.freeze(['grade', 'clip', 'reveal', 'pulse', 'custom']);
50
+ export const STAGE_ORDER = Object.freeze(['grade', 'clip', 'reveal', 'pulse', 'custom', 'cull']);
51
+
52
+ /** The most eye views setSource's wavefront culls for (more: every gaussian is drawn). */
53
+ export const WIPE_CULL_MAX_VIEWS = 4;
54
+ const IDENTITY16 = new Float32Array([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]);
55
+ const ZERO4 = new Float32Array(4);
50
56
 
51
57
  /** Named easings (a function `(x) => y` on [0, 1] is accepted too). */
52
58
  export const EASINGS = Object.freeze({
@@ -529,6 +535,74 @@ void ${P}color(vec3 c, inout vec4 col) {}
529
535
  }),
530
536
  },
531
537
 
538
+ // Internal: setSource's wavefront, both photos — draw each photo only on ITS side of the image
539
+ // front. NOT an effect: nothing it removes could have reached a pixel the overlay shows (the
540
+ // frame is bit-exact with it on or off), it only saves the fragments of the half nobody sees.
541
+ // Each gaussian whose on-screen footprint lies wholly on the far side of the front in EVERY eye
542
+ // gets alpha 0, which the engine's own alpha clip culls in the vertex stage. The footprint is
543
+ // bounded the way the engine sizes its quad (gsplatCorner): λ₁ ≤ ‖J‖²·s²·σ²max + 0.3 with
544
+ // ‖J‖² = (f/z)²·(1 + (x² + y²)/z²), l₁ = 2·√(2λ₁), and a corner offset ≤ l₁ + l₂ ≤ 2·l₁, plus
545
+ // 4 px. The front is the overlay's own (setSnapshotAlpha's wipe), in each eye's viewport-
546
+ // relative NDC. The eye views are the camera's (its parent's world transform · each view's
547
+ // pose), set by the adapter just before the engine renders — the live outgoing camera has its
548
+ // own. It is the LAST stage, so it sees the final centre and scale of every stage before it.
549
+ // Per mesh instance: `side` +1 keeps the right of `edge` (the outgoing photo), −1 the left
550
+ // (the incoming one); the material default is off.
551
+ wipecull: {
552
+ stage: 'cull',
553
+ kind: 'transition',
554
+ internal: true,
555
+ defaults: { durationMs: 0, easing: 'linear', holdMs: 0, direction: 'in' },
556
+ glsl: (P) => {
557
+ const views = [0, 1, 2, 3];
558
+ return `
559
+ uniform float ${P}on;
560
+ uniform float ${P}side;
561
+ uniform float ${P}edge;
562
+ uniform float ${P}n;
563
+ ${views.map((i) => `uniform mat4 ${P}V${i};\nuniform vec4 ${P}X${i};\nuniform vec4 ${P}W${i};\nuniform vec4 ${P}K${i};`).join('\n')}
564
+ bool ${P}cut;
565
+ // true when some of the footprint may reach the kept side of the front in this view
566
+ bool ${P}reach(mat4 V, vec4 X, vec4 Wr, vec4 K, vec3 c, float s) {
567
+ vec4 p = vec4(c, 1.0);
568
+ vec4 v = V * p;
569
+ float z = -v.z;
570
+ float w = dot(Wr, p);
571
+ if (z <= 1e-6 || w <= 1e-6) return true; // at or behind the eye: the engine decides
572
+ float xn = dot(X, p) / w;
573
+ float jz = K.x / z;
574
+ float j2 = jz * jz * (1.0 + dot(v.xy, v.xy) / (z * z));
575
+ float l1 = 2.0 * sqrt(2.0 * (j2 * K.z * s * s + 0.3));
576
+ return ${P}side * (xn - ${P}edge) + (2.0 * l1 + 8.0) * K.y > 0.0;
577
+ }
578
+ void ${P}center(inout vec3 c) {}
579
+ void ${P}rs(vec3 oc, vec3 mc, inout vec4 r, inout vec3 sc) {
580
+ ${P}cut = false;
581
+ if (${P}on < 0.5) return;
582
+ float s = max(sc.x, max(sc.y, sc.z));
583
+ ${views.map((i) => ` if (${i > 0 ? `${P}n > ${i}.5 && ` : ''}${P}reach(${P}V${i}, ${P}X${i}, ${P}W${i}, ${P}K${i}, mc, s)) return;`).join('\n')}
584
+ ${P}cut = true;
585
+ }
586
+ void ${P}color(vec3 c, inout vec4 col) { if (${P}cut) col.a = 0.0; }
587
+ `;
588
+ },
589
+ // opts.cull() → { side, edge, views: [{ V, X, W, K }] } (at most WIPE_CULL_MAX_VIEWS), or null
590
+ // (off: every gaussian drawn). No `cull` (the material default): off.
591
+ uniforms: (ctx, inst) => {
592
+ const c = typeof inst.opts.cull === 'function' ? inst.opts.cull() : null;
593
+ const on = !!(c && c.views.length && c.views.length <= WIPE_CULL_MAX_VIEWS);
594
+ const out = { on: on ? 1 : 0, side: on ? c.side : 1, edge: on ? c.edge : 0, n: on ? c.views.length : 0 };
595
+ for (let i = 0; i < WIPE_CULL_MAX_VIEWS; i++) {
596
+ const v = on ? c.views[i] : null;
597
+ out['V' + i] = v ? v.V : IDENTITY16;
598
+ out['X' + i] = v ? v.X : ZERO4;
599
+ out['W' + i] = v ? v.W : ZERO4;
600
+ out['K' + i] = v ? v.K : ZERO4;
601
+ }
602
+ return out;
603
+ },
604
+ },
605
+
532
606
  // Internal: setSource's crossfade FALLBACK (no frame snapshot) — the coverage remap on one
533
607
  // entity, driven by the adapter.
534
608
  xfade: {
@@ -1528,9 +1602,18 @@ export class SplatEffects {
1528
1602
  * setSource's shader pre-warm compiles ahead of the transition.
1529
1603
  */
1530
1604
  sharedChunkCode(name, effect, opts) {
1531
- const o = resolveEffectOptions(effect, { ...opts, scope: 'tile', direction: 'in', progress: 1 }, 'set', { internal: true });
1532
- const insts = [...(this.scopes.get('tile')?.values() ?? [])].filter((i) => i.name !== name);
1533
- return composeModifier([...insts, { name, def: EFFECTS[effect], opts: o }]).code;
1605
+ return this.sharedChunkCodeFor([[name, effect, opts]]);
1606
+ }
1607
+
1608
+ /** sharedChunkCode for several driveShared bodies at once: [[name, effect, opts], …]. */
1609
+ sharedChunkCodeFor(list) {
1610
+ const names = new Set(list.map((x) => x[0]));
1611
+ const insts = [...(this.scopes.get('tile')?.values() ?? [])].filter((i) => !names.has(i.name));
1612
+ for (const [name, effect, opts] of list) {
1613
+ const o = resolveEffectOptions(effect, { ...opts, scope: 'tile', direction: 'in', progress: 1 }, 'set', { internal: true });
1614
+ insts.push({ name, def: EFFECTS[effect], opts: o });
1615
+ }
1616
+ return composeModifier(insts).code;
1534
1617
  }
1535
1618
 
1536
1619
  _setupInstance(inst) {
@@ -693,6 +693,8 @@ export class PlayCanvasSplatViewer {
693
693
  this._snap = null;
694
694
  /** setSource's live outgoing asset (./inline3d-splat-live.js), made on first use. */
695
695
  this._live = null;
696
+ /** (entries, rect) => void: once per drawn frame, after the views are set, before the engine renders. */
697
+ this.onBeforeRender = null;
696
698
  this.vH = virtualDisplayHeight;
697
699
  this.fit = fit;
698
700
  this.margin = margin;
@@ -1447,6 +1449,34 @@ export class PlayCanvasSplatViewer {
1447
1449
  return { rig: this.rigMatrix(), pose, c, t: Math.tan((this.mono.fov * DEG) / 2) };
1448
1450
  }
1449
1451
 
1452
+ /**
1453
+ * One camera's eye views as the engine composes them this frame (RenderView.updateTransforms:
1454
+ * the camera node's PARENT world transform · each view's pose), packed for the wavefront's cull
1455
+ * (EFFECTS.wipecull): V = the view matrix, X / W = rows 0 and 3 of proj · V, K = (the engine's
1456
+ * focal f = viewport width · proj[0], 1 / viewport width, the view's squared scale). `into` is
1457
+ * reused frame to frame. Null when a matrix is singular.
1458
+ */
1459
+ cullViews(cam, entries, rect, into = []) {
1460
+ const parent = cam?.parent?.getWorldTransform?.()?.data ?? null;
1461
+ into.length = entries.length;
1462
+ for (let i = 0; i < entries.length; i++) {
1463
+ const e = entries[i];
1464
+ const V = mat4Invert(parent ? mat4Mul(parent, e.pose) : e.pose);
1465
+ if (!V) return null;
1466
+ const C = mat4Mul(e.proj, V);
1467
+ const w = Math.max(1, rect(e)[2]);
1468
+ const o = (into[i] ||= { V: new Float32Array(16), X: new Float32Array(4), W: new Float32Array(4), K: new Float32Array(4) });
1469
+ o.V.set(V);
1470
+ o.X[0] = C[0]; o.X[1] = C[4]; o.X[2] = C[8]; o.X[3] = C[12];
1471
+ o.W[0] = C[3]; o.W[1] = C[7]; o.W[2] = C[11]; o.W[3] = C[15];
1472
+ o.K[0] = w * e.proj[0];
1473
+ o.K[1] = 1 / w;
1474
+ o.K[2] = V[0] * V[0] + V[1] * V[1] + V[2] * V[2];
1475
+ o.K[3] = 0;
1476
+ }
1477
+ return into;
1478
+ }
1479
+
1450
1480
  /** Can this tile render a live outgoing asset (the single-camera RenderView path)? */
1451
1481
  get canLiveOutgoing() {
1452
1482
  return LiveOutgoing.supported(this);
@@ -1643,6 +1673,8 @@ export class PlayCanvasSplatViewer {
1643
1673
  placeNode(this.eye, entries[0].pose);
1644
1674
  // setSource's live outgoing: the same views on its own camera, into its own target.
1645
1675
  if (this._live?.active) this._live.sync(entries, rect, f);
1676
+ // setSource's wavefront: this frame's eye views, as the engine is about to compose them.
1677
+ this.onBeforeRender?.(entries, rect);
1646
1678
  } else {
1647
1679
  // Fallback: one camera per view, `rect` + `calculateProjection`.
1648
1680
  const cams = this._views;
@@ -3680,7 +3712,7 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
3680
3712
  const loaded = await loadOne(pcModule, app, src, { background: true });
3681
3713
  perfSpan('prepareSource', t0);
3682
3714
  // The transition the page declared: compile its shader now, in the dwell, not on its first frame.
3683
- if (warm?.particles) await prewarmTransition(warm);
3715
+ await prewarmTransition(warm); // null (no transition declared): the overlay only
3684
3716
  const entry = { loaded, state: 'ready', dispose: null };
3685
3717
  const prepared = {
3686
3718
  [PREPARED_TAG]: true,
@@ -3721,46 +3753,61 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
3721
3753
  * engine whose internals differ just compiles on the transition's first frame, as before.
3722
3754
  */
3723
3755
  async function prewarmTransition(plan) {
3724
- const key = `${plan.particles.in.effect}|${plan.particles.in.opts.order ?? ''}`;
3725
- if (!fx || prewarmed.has(key)) return;
3756
+ // The chunk variant the transition installs (particles, wavefront), and the overlay's two quads
3757
+ // (crossfade, wavefront and the particles all composite through them; created on the first
3758
+ // capture otherwise, and compiled on its draw: 30–180 ms on the first frames of the window).
3759
+ const chunkKey = plan?.particles
3760
+ ? `${plan.particles.in.effect}|${plan.particles.in.opts.order ?? ''}`
3761
+ : plan?.transition === 'wavefront'
3762
+ ? 'wavefront'
3763
+ : null;
3764
+ const wantChunk = !!(fx && chunkKey && !prewarmed.has(chunkKey));
3765
+ const wantOverlay = !prewarmed.has('overlay');
3766
+ if (!wantChunk && !wantOverlay) return;
3726
3767
  await yieldIdle();
3727
- if (removed || !fx) return;
3768
+ if (removed) return;
3728
3769
  try {
3729
3770
  const pc = pcModule;
3730
- const cams = [viewer.eye, viewer._live?.cam].filter(Boolean);
3731
- const code = fx.sharedChunkCode('transition', plan.particles.in.effect, { order: plan.particles.in.opts.order });
3732
3771
  const made = [];
3733
- let issued = 0;
3734
- for (const cam of cams) {
3735
- const cd = viewer.app?.renderer?.gsplatDirector?.camerasMap?.get?.(cam.camera?.camera);
3736
- let mi = null;
3737
- if (cd?.layersMap) for (const ld of cd.layersMap.values()) mi ||= ld?.gsplatManager?.renderer?.meshInstance ?? null;
3738
- const src = mi?.material;
3739
- const camera = cam.camera?.camera;
3740
- if (!src?.shaderDesc || !camera?.shaderParams || typeof src.getShaderVariant !== 'function') continue;
3741
- const m = new pc.ShaderMaterial(src.shaderDesc);
3742
- src.defines.forEach((v, k) => m.setDefine(k, v));
3743
- m.shaderChunks.copy(src.shaderChunks);
3744
- m.getShaderChunks(pc.SHADERLANGUAGE_GLSL).set('gsplatModifyVS', code);
3745
- m.blendState = src.blendState;
3746
- made.push(m.getShaderVariant({
3747
- device: viewer.app.graphicsDevice,
3748
- scene: viewer.app.scene,
3749
- objDefs: mi._shaderDefs,
3750
- cameraShaderParams: camera.shaderParams,
3751
- pass: 0, // SHADER_FORWARD
3752
- sortedLights: [],
3753
- viewUniformFormat: viewer.app.renderer?.viewUniformFormat ?? null,
3754
- vertexFormat: mi.mesh?.vertexBuffer?.format,
3755
- }));
3756
- issued++;
3772
+ if (wantChunk && fx) {
3773
+ const cams = [viewer.eye, viewer._live?.cam].filter(Boolean);
3774
+ const code = plan.particles
3775
+ ? fx.sharedChunkCode('transition', plan.particles.in.effect, { order: plan.particles.in.opts.order })
3776
+ : fx.sharedChunkCodeFor([
3777
+ ['transition', 'wavefront', {}],
3778
+ ['transition-cull', 'wipecull', {}],
3779
+ ]);
3780
+ let issued = 0;
3781
+ for (const cam of cams) {
3782
+ const mi = managerMi(cam, null);
3783
+ const src = mi?.material;
3784
+ const camera = cam.camera?.camera;
3785
+ if (!src?.shaderDesc || !camera?.shaderParams || typeof src.getShaderVariant !== 'function') continue;
3786
+ const m = new pc.ShaderMaterial(src.shaderDesc);
3787
+ src.defines.forEach((v, k) => m.setDefine(k, v));
3788
+ m.shaderChunks.copy(src.shaderChunks);
3789
+ m.getShaderChunks(pc.SHADERLANGUAGE_GLSL).set('gsplatModifyVS', code);
3790
+ m.blendState = src.blendState;
3791
+ made.push(m.getShaderVariant({
3792
+ device: viewer.app.graphicsDevice,
3793
+ scene: viewer.app.scene,
3794
+ objDefs: mi._shaderDefs,
3795
+ cameraShaderParams: camera.shaderParams,
3796
+ pass: 0, // SHADER_FORWARD
3797
+ sortedLights: [],
3798
+ viewUniformFormat: viewer.app.renderer?.viewUniformFormat ?? null,
3799
+ vertexFormat: mi.mesh?.vertexBuffer?.format,
3800
+ }));
3801
+ issued++;
3802
+ }
3803
+ if (issued) prewarmed.add(chunkKey);
3757
3804
  }
3758
- // the overlay's two quads (created on the first capture otherwise, and compiled on its draw)
3759
3805
  const cam = viewer.eye?.camera?.camera;
3760
- if (cam?.shaderParams) {
3806
+ if (wantOverlay && cam?.shaderParams) {
3761
3807
  viewer._ensureSnapshotOverlay();
3808
+ let issued = 0;
3762
3809
  for (const p of viewer._snap?.parts || []) {
3763
- made.push(p.mat.getShaderVariant?.({
3810
+ const v = p.mat.getShaderVariant?.({
3764
3811
  device: viewer.app.graphicsDevice,
3765
3812
  scene: viewer.app.scene,
3766
3813
  objDefs: p.mi._shaderDefs,
@@ -3769,16 +3816,18 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
3769
3816
  sortedLights: [],
3770
3817
  viewUniformFormat: viewer.app.renderer?.viewUniformFormat ?? null,
3771
3818
  vertexFormat: p.mi.mesh?.vertexBuffer?.format,
3772
- }));
3819
+ });
3820
+ if (v) (made.push(v), issued++);
3773
3821
  }
3822
+ if (issued) prewarmed.add('overlay');
3774
3823
  }
3775
- if (issued) prewarmed.add(key);
3776
3824
  await finalizeWhenLinked(made.filter(Boolean));
3777
3825
  } catch (err) {
3778
3826
  console.info('[inline3d/splat] transition shader pre-warm skipped', err);
3779
3827
  }
3780
3828
  }
3781
3829
 
3830
+
3782
3831
  /**
3783
3832
  * Creating a program only ISSUES its compile + link; the browser resolves the link when the
3784
3833
  * program is first queried — the engine's first draw, blocking (the 35–50 ms GetProgramiv the
@@ -3845,6 +3894,9 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
3845
3894
  await first.catch(() => null); // a failed first asset may be replaced
3846
3895
  if (!app || removed) return out;
3847
3896
  const pc = pcModule;
3897
+ // Not prepared: compile what the transition draws with (its chunk, the overlay) while the asset
3898
+ // loads, rather than on the window's first frames. Best effort, not awaited.
3899
+ if (!prep && current && plan.transition !== 'cut') prewarmTransition(plan);
3848
3900
  const loaded = prep ? prep.loaded : await loadOne(pc, app, next);
3849
3901
  if (removed || gen !== sourceGen) {
3850
3902
  app.assets.remove(loaded.asset);
@@ -3977,15 +4029,24 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
3977
4029
  });
3978
4030
  } else release(prev); // the snapshot shows it from here on
3979
4031
  const band = plan.band;
4032
+ finishers.push(() => {
4033
+ viewer._transitionState = null;
4034
+ viewer.setSnapshotAlpha(0);
4035
+ viewer.releaseSnapshot();
4036
+ });
4037
+ if (fx && managerMi(viewer.eye, null)) {
4038
+ playWavefrontRender({ plan, entity, live, finishers, finish, isFinished: () => finished, pumpLive });
4039
+ await done;
4040
+ return out;
4041
+ }
4042
+ // FALLBACK (an engine whose managers are not reachable): the ridge as an entity-scope
4043
+ // modifier — correct, but it rewrites the new photo's work buffer and re-sorts it every frame.
4044
+ viewer._transitionPath = 'entity';
3980
4045
  const ridge = fx.play(
3981
4046
  'wavefront',
3982
4047
  { durationMs: plan.durationMs, easing: plan.easing, band, ridge: plan.ridge, ridgeMaxDisparity: plan.ridgeMaxDisparity, scope: 'entity' },
3983
4048
  { entity, gate: afterTicks(2), internal: true },
3984
4049
  );
3985
- finishers.push(() => {
3986
- viewer.setSnapshotAlpha(0);
3987
- viewer.releaseSnapshot();
3988
- });
3989
4050
  viewer._hooks.push(() => {
3990
4051
  if (finished || removed) return false;
3991
4052
  if (live && !live.fits) {
@@ -4064,6 +4125,94 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
4064
4125
  return out;
4065
4126
  }
4066
4127
 
4128
+ /** The mesh instance of the gsplat manager that `cam` draws `layer` with (any layer: null). */
4129
+ const managerMi = (cam, layer) => {
4130
+ const cd = viewer.app?.renderer?.gsplatDirector?.camerasMap?.get?.(cam?.camera?.camera);
4131
+ if (!cd?.layersMap) return null;
4132
+ if (layer) return cd.layersMap.get(layer)?.gsplatManager?.renderer?.meshInstance ?? null;
4133
+ for (const ld of cd.layersMap.values()) if (ld?.gsplatManager?.renderer?.meshInstance) return ld.gsplatManager.renderer.meshInstance;
4134
+ return null;
4135
+ };
4136
+
4137
+ /**
4138
+ * setSource's wavefront, RENDER-TIME (the default): the ridge is a tile-scope body whose values
4139
+ * sit on the eye camera's manager (the incoming photo), as the particle transitions do — no
4140
+ * work-buffer rewrite and no re-sort per frame. Each photo is drawn only on its side of the
4141
+ * front (EFFECTS.wipecull, the last stage): the incoming one left of where its commit starts,
4142
+ * the outgoing one (on the live camera) right of where it ends, so the two share only the
4143
+ * `band`. The overlay still wipes the same two live images on the same lt; the frame is the
4144
+ * one the full draws give, bit for bit. The clock starts on the second tick (the incoming
4145
+ * asset's first frame builds its work buffer), as the crossfade's does.
4146
+ */
4147
+ function playWavefrontRender({ plan, entity, live, finishers, finish, isFinished, pumpLive }) {
4148
+ const band = plan.band;
4149
+ const ease = typeof plan.easing === 'function' ? plan.easing : EASINGS[plan.easing] || EASINGS.linear;
4150
+ const eyeMi = () => managerMi(viewer.eye, null);
4151
+ const liveMi = () => (live?.active ? managerMi(live.cam, live.layer) : null);
4152
+ const ridgeFx = fx.driveShared('transition', 'wavefront', {});
4153
+ const cullFx = fx.driveShared('transition-cull', 'wipecull', {});
4154
+ const ridgeIn = ridgeFx.side(eyeMi, { band, ridge: plan.ridge, ridgeMaxDisparity: plan.ridgeMaxDisparity });
4155
+ const cull = { in: null, out: null };
4156
+ const cullIn = cullFx.side(eyeMi, { cull: () => cull.in });
4157
+ const cullOut = live ? cullFx.side(liveMi, { cull: () => cull.out }) : null;
4158
+ const views = { in: [], out: [] };
4159
+ let amount = 0;
4160
+ ridgeIn.set(0);
4161
+ viewer._transitionPath = 'render';
4162
+ // Where each photo still shows, in each eye's viewport-relative NDC (setSnapshotAlpha's wipe:
4163
+ // column u has committed lt = clamp((t − u·(1 − band)) / band, 0, 1) to the new photo).
4164
+ const clampEdge = (x) => Math.min(3, Math.max(-3, x));
4165
+ viewer.onBeforeRender = (entries, rect) => {
4166
+ if (isFinished()) return;
4167
+ const span = 1 - band;
4168
+ // band 1: the whole picture is the band — draw both in full. `_wipeCull = false`: the
4169
+ // diagnostics switch the exactness gate compares against (docs §Gates).
4170
+ if (!(span > 1e-6) || viewer._wipeCull === false) {
4171
+ cull.in = cull.out = null;
4172
+ cullIn.set(amount);
4173
+ cullOut?.set(amount);
4174
+ return;
4175
+ }
4176
+ const vin = viewer.cullViews(viewer.eye, entries, rect, views.in);
4177
+ cull.in = vin ? { side: -1, edge: clampEdge((2 * amount) / span - 1), views: vin } : null;
4178
+ cullIn.set(amount);
4179
+ if (cullOut) {
4180
+ const vout = viewer.cullViews(live.cam, entries, rect, views.out);
4181
+ cull.out = vout ? { side: 1, edge: clampEdge((2 * (amount - band)) / span - 1), views: vout } : null;
4182
+ cullOut.set(amount);
4183
+ }
4184
+ };
4185
+ finishers.push(() => {
4186
+ if (viewer.onBeforeRender) viewer.onBeforeRender = null;
4187
+ ridgeFx.remove();
4188
+ cullFx.remove();
4189
+ });
4190
+ let t0 = null;
4191
+ let ticks = 0;
4192
+ viewer._hooks.push((t) => {
4193
+ if (removed) return (finish(), false);
4194
+ if (isFinished()) return false;
4195
+ if (live && !live.fits) {
4196
+ finish(); // a resize or a 2D/3D switch mid-window: end now
4197
+ return false;
4198
+ }
4199
+ pumpLive();
4200
+ if (++ticks >= 2 && t0 === null) {
4201
+ t0 = t;
4202
+ ridgeIn.restart(); // the eyes' frame, now the new rig and first frames are in
4203
+ }
4204
+ const raw = t0 === null ? 0 : plan.durationMs > 0 ? Math.min(1, Math.max(0, (t - t0) / plan.durationMs)) : 1;
4205
+ amount = ease(raw);
4206
+ viewer._transitionState = { raw, live: !!live }; // diagnostics
4207
+ ridgeIn.set(amount);
4208
+ if (!viewer.setSnapshotAlpha(1, { t: amount, band, views: viewer.viewsAcross }) || raw >= 1) {
4209
+ finish();
4210
+ return false;
4211
+ }
4212
+ return true;
4213
+ });
4214
+ }
4215
+
4067
4216
  /**
4068
4217
  * setSource's particle transitions (swarm, burst, shimmer-cross, dust), once the frame snapshot
4069
4218
  * is up. The OUTGOING asset plays its effect in reverse, on the live camera, keyed on its own
@@ -4083,13 +4232,6 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
4083
4232
  // (outgoing). No work-buffer rewrite and no re-sort per frame. An engine that does not expose
4084
4233
  // the managers' mesh instances gets the entity-scope modifiers instead (a full work-buffer
4085
4234
  // rewrite + a re-sort per asset per frame: correct, but it hitches on a 1.18M photo).
4086
- const managerMi = (cam, layer) => {
4087
- const cd = viewer.app?.renderer?.gsplatDirector?.camerasMap?.get?.(cam?.camera?.camera);
4088
- if (!cd?.layersMap) return null;
4089
- if (layer) return cd.layersMap.get(layer)?.gsplatManager?.renderer?.meshInstance ?? null;
4090
- for (const ld of cd.layersMap.values()) if (ld?.gsplatManager?.renderer?.meshInstance) return ld.gsplatManager.renderer.meshInstance;
4091
- return null;
4092
- };
4093
4235
  const eyeMi = () => managerMi(viewer.eye, null);
4094
4236
  const liveMi = () => (live?.active ? managerMi(live.cam, live.layer) : null);
4095
4237
  const shared = particle.out.effect === particle.in.effect && eyeMi() ? fx.driveShared('transition', particle.in.effect, { order: particle.in.opts.order }) : null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@displayxr/inline3d",
3
- "version": "1.19.1",
3
+ "version": "1.19.2",
4
4
  "description": "Turn any HTML <canvas> into a glasses-free-3D window on a DisplayXR display, inside an ordinary web page. Dependency-free; progressive enhancement (falls back to plain 2D everywhere else).",
5
5
  "type": "module",
6
6
  "types": "./index.d.ts",