@displayxr/inline3d 1.19.0 → 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.
@@ -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;
@@ -3223,7 +3255,8 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
3223
3255
  let rigSeq = 0;
3224
3256
  /** The IBL setRig('display') installed ({ kind, atlas, prev }), and what it replaced — or null. */
3225
3257
  let rigEnv = null;
3226
- /** ./inline3d-model-playcanvas.js once a display rig has loaded it (transmission, environments). */
3258
+ /** ./inline3d-pc-look.js once a display rig has loaded it (transmission, environments). Never the
3259
+ * model module: that one holds the optional meshoptimizer import, which ./splat must not reach. */
3227
3260
  let rigModelModule = null;
3228
3261
  /** Mesh instances / materials prepareTransmission has handled, and whether it turned the grab on. */
3229
3262
  const rigTransmissionSeen = new WeakSet();
@@ -3322,7 +3355,7 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
3322
3355
 
3323
3356
  /** Install addModel's IBL (`disp.environment`) for the display rig, if the page has none of its own. */
3324
3357
  async function ensureRigEnvironment(disp, seq) {
3325
- const m = (rigModelModule ||= await import('./inline3d-model-playcanvas.js'));
3358
+ const m = (rigModelModule ||= await import('./inline3d-pc-look.js'));
3326
3359
  if (removed || seq !== rigSeq || rigOverride?.type !== 'display') return;
3327
3360
  rigTick = 0; // transmission check on the next tick
3328
3361
  const kind = disp.environment;
@@ -3679,7 +3712,7 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
3679
3712
  const loaded = await loadOne(pcModule, app, src, { background: true });
3680
3713
  perfSpan('prepareSource', t0);
3681
3714
  // The transition the page declared: compile its shader now, in the dwell, not on its first frame.
3682
- if (warm?.particles) await prewarmTransition(warm);
3715
+ await prewarmTransition(warm); // null (no transition declared): the overlay only
3683
3716
  const entry = { loaded, state: 'ready', dispose: null };
3684
3717
  const prepared = {
3685
3718
  [PREPARED_TAG]: true,
@@ -3720,46 +3753,61 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
3720
3753
  * engine whose internals differ just compiles on the transition's first frame, as before.
3721
3754
  */
3722
3755
  async function prewarmTransition(plan) {
3723
- const key = `${plan.particles.in.effect}|${plan.particles.in.opts.order ?? ''}`;
3724
- 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;
3725
3767
  await yieldIdle();
3726
- if (removed || !fx) return;
3768
+ if (removed) return;
3727
3769
  try {
3728
3770
  const pc = pcModule;
3729
- const cams = [viewer.eye, viewer._live?.cam].filter(Boolean);
3730
- const code = fx.sharedChunkCode('transition', plan.particles.in.effect, { order: plan.particles.in.opts.order });
3731
3771
  const made = [];
3732
- let issued = 0;
3733
- for (const cam of cams) {
3734
- const cd = viewer.app?.renderer?.gsplatDirector?.camerasMap?.get?.(cam.camera?.camera);
3735
- let mi = null;
3736
- if (cd?.layersMap) for (const ld of cd.layersMap.values()) mi ||= ld?.gsplatManager?.renderer?.meshInstance ?? null;
3737
- const src = mi?.material;
3738
- const camera = cam.camera?.camera;
3739
- if (!src?.shaderDesc || !camera?.shaderParams || typeof src.getShaderVariant !== 'function') continue;
3740
- const m = new pc.ShaderMaterial(src.shaderDesc);
3741
- src.defines.forEach((v, k) => m.setDefine(k, v));
3742
- m.shaderChunks.copy(src.shaderChunks);
3743
- m.getShaderChunks(pc.SHADERLANGUAGE_GLSL).set('gsplatModifyVS', code);
3744
- m.blendState = src.blendState;
3745
- made.push(m.getShaderVariant({
3746
- device: viewer.app.graphicsDevice,
3747
- scene: viewer.app.scene,
3748
- objDefs: mi._shaderDefs,
3749
- cameraShaderParams: camera.shaderParams,
3750
- pass: 0, // SHADER_FORWARD
3751
- sortedLights: [],
3752
- viewUniformFormat: viewer.app.renderer?.viewUniformFormat ?? null,
3753
- vertexFormat: mi.mesh?.vertexBuffer?.format,
3754
- }));
3755
- 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);
3756
3804
  }
3757
- // the overlay's two quads (created on the first capture otherwise, and compiled on its draw)
3758
3805
  const cam = viewer.eye?.camera?.camera;
3759
- if (cam?.shaderParams) {
3806
+ if (wantOverlay && cam?.shaderParams) {
3760
3807
  viewer._ensureSnapshotOverlay();
3808
+ let issued = 0;
3761
3809
  for (const p of viewer._snap?.parts || []) {
3762
- made.push(p.mat.getShaderVariant?.({
3810
+ const v = p.mat.getShaderVariant?.({
3763
3811
  device: viewer.app.graphicsDevice,
3764
3812
  scene: viewer.app.scene,
3765
3813
  objDefs: p.mi._shaderDefs,
@@ -3768,16 +3816,18 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
3768
3816
  sortedLights: [],
3769
3817
  viewUniformFormat: viewer.app.renderer?.viewUniformFormat ?? null,
3770
3818
  vertexFormat: p.mi.mesh?.vertexBuffer?.format,
3771
- }));
3819
+ });
3820
+ if (v) (made.push(v), issued++);
3772
3821
  }
3822
+ if (issued) prewarmed.add('overlay');
3773
3823
  }
3774
- if (issued) prewarmed.add(key);
3775
3824
  await finalizeWhenLinked(made.filter(Boolean));
3776
3825
  } catch (err) {
3777
3826
  console.info('[inline3d/splat] transition shader pre-warm skipped', err);
3778
3827
  }
3779
3828
  }
3780
3829
 
3830
+
3781
3831
  /**
3782
3832
  * Creating a program only ISSUES its compile + link; the browser resolves the link when the
3783
3833
  * program is first queried — the engine's first draw, blocking (the 35–50 ms GetProgramiv the
@@ -3844,6 +3894,9 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
3844
3894
  await first.catch(() => null); // a failed first asset may be replaced
3845
3895
  if (!app || removed) return out;
3846
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);
3847
3900
  const loaded = prep ? prep.loaded : await loadOne(pc, app, next);
3848
3901
  if (removed || gen !== sourceGen) {
3849
3902
  app.assets.remove(loaded.asset);
@@ -3976,15 +4029,24 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
3976
4029
  });
3977
4030
  } else release(prev); // the snapshot shows it from here on
3978
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';
3979
4045
  const ridge = fx.play(
3980
4046
  'wavefront',
3981
4047
  { durationMs: plan.durationMs, easing: plan.easing, band, ridge: plan.ridge, ridgeMaxDisparity: plan.ridgeMaxDisparity, scope: 'entity' },
3982
4048
  { entity, gate: afterTicks(2), internal: true },
3983
4049
  );
3984
- finishers.push(() => {
3985
- viewer.setSnapshotAlpha(0);
3986
- viewer.releaseSnapshot();
3987
- });
3988
4050
  viewer._hooks.push(() => {
3989
4051
  if (finished || removed) return false;
3990
4052
  if (live && !live.fits) {
@@ -4063,6 +4125,94 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
4063
4125
  return out;
4064
4126
  }
4065
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
+
4066
4216
  /**
4067
4217
  * setSource's particle transitions (swarm, burst, shimmer-cross, dust), once the frame snapshot
4068
4218
  * is up. The OUTGOING asset plays its effect in reverse, on the live camera, keyed on its own
@@ -4082,13 +4232,6 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
4082
4232
  // (outgoing). No work-buffer rewrite and no re-sort per frame. An engine that does not expose
4083
4233
  // the managers' mesh instances gets the entity-scope modifiers instead (a full work-buffer
4084
4234
  // rewrite + a re-sort per asset per frame: correct, but it hitches on a 1.18M photo).
4085
- const managerMi = (cam, layer) => {
4086
- const cd = viewer.app?.renderer?.gsplatDirector?.camerasMap?.get?.(cam?.camera?.camera);
4087
- if (!cd?.layersMap) return null;
4088
- if (layer) return cd.layersMap.get(layer)?.gsplatManager?.renderer?.meshInstance ?? null;
4089
- for (const ld of cd.layersMap.values()) if (ld?.gsplatManager?.renderer?.meshInstance) return ld.gsplatManager.renderer.meshInstance;
4090
- return null;
4091
- };
4092
4235
  const eyeMi = () => managerMi(viewer.eye, null);
4093
4236
  const liveMi = () => (live?.active ? managerMi(live.cam, live.layer) : null);
4094
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.0",
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",
@@ -56,6 +56,7 @@
56
56
  "js/inline3d-model.js",
57
57
  "js/inline3d-model-entry.js",
58
58
  "js/inline3d-model-playcanvas.js",
59
+ "js/inline3d-pc-look.js",
59
60
  "js/inline3d-playcanvas-engine.js",
60
61
  "index.d.ts",
61
62
  "three.d.ts",