@displayxr/inline3d 1.12.0 → 1.12.1

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,21 @@ 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.12.1 — 2026-09-24
9
+
10
+ ### Fixed
11
+
12
+ - **`setSource(…, { fadeMs })` is a real crossfade** (`./splat`, `engine: 'playcanvas'`). The fade
13
+ scaled every splat's alpha by t and drew both photos in one sorted pass, so whichever photo sat in
14
+ front won: an incoming photo in front covered 64 % of the picture at t = 0.2 (an 800 ms fade read
15
+ as a snap), one behind it only 18 % at t = 0.5 (a late pop). Now the last frame of the outgoing
16
+ asset is frozen into a texture (both eyes) and lerped with the live incoming one, per
17
+ premultiplied pixel, alpha included. Measured on three SHARP photo pairs, mono and a fake 2-view
18
+ stereo pair: the blend fraction is **t to ±0.001** at every tenth, identical in both eyes; the
19
+ frame at t = 0 equals the outgoing frame and the end state equals a `fadeMs: 0` swap (MAE 0.000,
20
+ colour and alpha). The outgoing photo holds still during the fade. If no frame is drawn within
21
+ 250 ms (a hidden tab) the old one-pass fade runs, now with a coverage-linear alpha remap.
22
+
8
23
  ## 1.12.0 — 2026-09-24
9
24
 
10
25
  Touches the **preview tier** (`./model`, and `./splat`'s `engine: 'playcanvas'` import path). A
@@ -46,11 +46,16 @@ export {
46
46
  BLENDEQUATION_ADD,
47
47
  BLENDMODE_SRC_ALPHA,
48
48
  BLENDMODE_ZERO,
49
+ BLENDMODE_ONE,
50
+ BLENDMODE_ONE_MINUS_SRC_ALPHA,
49
51
  CULLFACE_NONE,
50
52
  SEMANTIC_POSITION,
51
53
  SEMANTIC_TEXCOORD0,
52
54
  ShaderChunks,
53
55
  SHADERLANGUAGE_GLSL,
56
+ // setSource's frame snapshot
57
+ RenderTarget,
58
+ FILTER_NEAREST,
54
59
  // splats
55
60
  WORKBUFFER_UPDATE_ALWAYS,
56
61
  WORKBUFFER_UPDATE_ONCE,
@@ -84,6 +84,8 @@ import {
84
84
  streamedBytesError,
85
85
  resolveControls,
86
86
  normalizeCameraPose,
87
+ coverageExponent,
88
+ FADE_TRANSMITTANCE_FLOOR,
87
89
  } from './inline3d-splat-shared.js';
88
90
 
89
91
  /**
@@ -653,6 +655,10 @@ export class PlayCanvasSplatViewer {
653
655
  this.onCaptureFov = null;
654
656
  /** Per-frame hooks, `(tMs) => boolean` — return false to be removed. setSource's crossfade. */
655
657
  this._hooks = [];
658
+ /** captureFrame() callers waiting for the next drawn frame. */
659
+ this._captureWaiters = [];
660
+ /** The crossfade's frame snapshot: { w, h, tex, rt, mi, mat } once captured. */
661
+ this._snap = null;
656
662
  this.vH = virtualDisplayHeight;
657
663
  this.fit = fit;
658
664
  this.margin = margin;
@@ -968,6 +974,10 @@ export class PlayCanvasSplatViewer {
968
974
  if (this._ro) this._ro.disconnect();
969
975
  else if (typeof removeEventListener === 'function') removeEventListener('resize', this._onResize);
970
976
  this._unbindOrbit();
977
+ for (const w of this._captureWaiters.splice(0)) {
978
+ clearTimeout(w.timer);
979
+ w.resolve(false);
980
+ }
971
981
  try {
972
982
  this.app?.destroy();
973
983
  } catch (err) {
@@ -1177,8 +1187,152 @@ export class PlayCanvasSplatViewer {
1177
1187
  if (this.app.scene.gsplat.splatBudget !== per) this.app.scene.gsplat.splatBudget = per;
1178
1188
  }
1179
1189
 
1190
+ /**
1191
+ * Copy the frame just drawn (the whole buffer: both eyes in 3D) into a texture, on the next
1192
+ * drawn frame. Resolves true once copied, false if no frame is drawn within `timeoutMs` (a
1193
+ * hidden tab) or the engine cannot copy. setSource's crossfade (FRAME_SNAPSHOT).
1194
+ */
1195
+ captureFrame(timeoutMs = 250) {
1196
+ return new Promise((resolve) => {
1197
+ if (!this.app || this._disposed || !this.pc?.RenderTarget) return resolve(false);
1198
+ const waiter = { resolve, timer: 0 };
1199
+ waiter.timer = setTimeout(() => {
1200
+ const i = this._captureWaiters.indexOf(waiter);
1201
+ if (i >= 0) this._captureWaiters.splice(i, 1);
1202
+ resolve(false);
1203
+ }, timeoutMs);
1204
+ this._captureWaiters.push(waiter);
1205
+ });
1206
+ }
1207
+
1208
+ _doCapture() {
1209
+ const run = this._captureWaiters.splice(0);
1210
+ let ok = false;
1211
+ try {
1212
+ const pc = this.pc;
1213
+ const device = this.app.graphicsDevice;
1214
+ const w = this.canvas.width;
1215
+ const h = this.canvas.height;
1216
+ let s = this._snap;
1217
+ if (!s || s.w !== w || s.h !== h) {
1218
+ this._destroySnapshot();
1219
+ const tex = new pc.Texture(device, {
1220
+ name: 'inline3d-snapshot',
1221
+ width: w,
1222
+ height: h,
1223
+ format: pc.PIXELFORMAT_RGBA8,
1224
+ mipmaps: false,
1225
+ minFilter: pc.FILTER_NEAREST,
1226
+ magFilter: pc.FILTER_NEAREST,
1227
+ addressU: pc.ADDRESS_CLAMP_TO_EDGE,
1228
+ addressV: pc.ADDRESS_CLAMP_TO_EDGE,
1229
+ });
1230
+ s = this._snap = { w, h, tex, rt: new pc.RenderTarget({ colorBuffer: tex, depth: false }), parts: null };
1231
+ }
1232
+ // null source = the back buffer, still holding this frame (same task, before compositing).
1233
+ ok = device.copyRenderTarget(null, s.rt, true, false) !== false;
1234
+ if (ok) this._ensureSnapshotOverlay();
1235
+ } catch (err) {
1236
+ console.warn('[inline3d/splat] frame snapshot failed; the crossfade falls back to one pass', err);
1237
+ ok = false;
1238
+ }
1239
+ for (const w of run) {
1240
+ clearTimeout(w.timer);
1241
+ w.resolve(ok);
1242
+ }
1243
+ }
1244
+
1245
+ /**
1246
+ * FRAME_SNAPSHOT: the captured frame lerped with the live scene, in two clip-space quads in the
1247
+ * UI layer (under the edge feather), drawn once per view like the feather. Each samples the
1248
+ * capture at gl_FragCoord, so an eye's viewport reads its own half. Quad 1 scales what the scene
1249
+ * drew by (1 − α) (blend ZERO / SRC_ALPHA, colour AND alpha); quad 2 adds α·A (ONE / ONE). The
1250
+ * result is the plain lerp of the two premultiplied images, `α·A + (1 − α)·B` — alpha included,
1251
+ * so a region only one of the two photos covers fades against the page, not against black.
1252
+ */
1253
+ _ensureSnapshotOverlay() {
1254
+ const s = this._snap;
1255
+ if (s.parts) {
1256
+ for (const p of s.parts) {
1257
+ p.mat.setParameter('dxrSnap', s.tex);
1258
+ p.mat.setParameter('dxrSnapInvSize', [1 / s.w, 1 / s.h]);
1259
+ p.mat.update();
1260
+ }
1261
+ return;
1262
+ }
1263
+ const pc = this.pc;
1264
+ const mesh = new pc.Mesh(this.app.graphicsDevice);
1265
+ mesh.setPositions(new Float32Array([-1, -1, 0, 1, -1, 0, 1, 1, 0, -1, 1, 0]));
1266
+ mesh.setIndices([0, 1, 2, 0, 2, 3]);
1267
+ mesh.update();
1268
+ const part = (name, body, src, dst, drawOrder) => {
1269
+ const mat = new pc.ShaderMaterial({
1270
+ uniqueName: name,
1271
+ attributes: { vertex_position: pc.SEMANTIC_POSITION },
1272
+ vertexGLSL: `
1273
+ attribute vec3 vertex_position;
1274
+ void main() { gl_Position = vec4(vertex_position.xy, 0.0, 1.0); }`,
1275
+ fragmentGLSL: `
1276
+ uniform sampler2D dxrSnap;
1277
+ uniform vec2 dxrSnapInvSize;
1278
+ uniform float dxrSnapAlpha;
1279
+ void main() { ${body} }`,
1280
+ });
1281
+ mat.blendState = new pc.BlendState(true, pc.BLENDEQUATION_ADD, src, dst, pc.BLENDEQUATION_ADD, src, dst);
1282
+ mat.depthTest = false;
1283
+ mat.depthWrite = false;
1284
+ mat.cull = pc.CULLFACE_NONE;
1285
+ mat.setParameter('dxrSnap', s.tex);
1286
+ mat.setParameter('dxrSnapInvSize', [1 / s.w, 1 / s.h]);
1287
+ mat.setParameter('dxrSnapAlpha', 0);
1288
+ mat.update();
1289
+ const mi = new pc.MeshInstance(mesh, mat, new pc.GraphNode(name));
1290
+ mi.cull = false;
1291
+ mi.drawOrder = drawOrder; // both before the edge feather (drawOrder 0)
1292
+ mi.visible = false;
1293
+ return { mat, mi };
1294
+ };
1295
+ s.parts = [
1296
+ part('inline3dSnapshotScale', 'gl_FragColor = vec4(0.0, 0.0, 0.0, 1.0 - dxrSnapAlpha);', pc.BLENDMODE_ZERO, pc.BLENDMODE_SRC_ALPHA, -2),
1297
+ part('inline3dSnapshotAdd', 'gl_FragColor = texture2D(dxrSnap, gl_FragCoord.xy * dxrSnapInvSize) * dxrSnapAlpha;', pc.BLENDMODE_ONE, pc.BLENDMODE_ONE, -1),
1298
+ ];
1299
+ this.app.scene.layers.getLayerById(pc.LAYERID_UI).addMeshInstances(s.parts.map((p) => p.mi));
1300
+ }
1301
+
1302
+ /**
1303
+ * Lerp the snapshot over the scene at `alpha` (0 hides it). Returns false when the canvas buffer
1304
+ * no longer matches the capture (resize, 2D/3D switch): the caller ends its fade.
1305
+ */
1306
+ setSnapshotAlpha(alpha) {
1307
+ const s = this._snap;
1308
+ if (!s?.parts) return false;
1309
+ const fits = s.w === this.canvas.width && s.h === this.canvas.height;
1310
+ const show = alpha > 0 && fits;
1311
+ for (const p of s.parts) {
1312
+ p.mi.visible = show;
1313
+ if (show) {
1314
+ p.mat.setParameter('dxrSnapAlpha', Math.min(1, alpha));
1315
+ p.mat.update();
1316
+ }
1317
+ }
1318
+ return alpha <= 0 || fits;
1319
+ }
1320
+
1321
+ _destroySnapshot() {
1322
+ const s = this._snap;
1323
+ if (!s) return;
1324
+ this._snap = null;
1325
+ if (s.parts) {
1326
+ this.app?.scene?.layers?.getLayerById(this.pc.LAYERID_UI)?.removeMeshInstances?.(s.parts.map((p) => p.mi));
1327
+ s.parts[0].mi.mesh?.destroy?.();
1328
+ }
1329
+ s.rt?.destroy?.();
1330
+ s.tex?.destroy?.();
1331
+ }
1332
+
1180
1333
  /** Read back what the tick just did. Cheap: two numbers off the renderer. */
1181
1334
  _afterTick() {
1335
+ if (this._captureWaiters.length) this._doCapture();
1182
1336
  const n = this.app?.renderer?._gsplatCount ?? 0;
1183
1337
  this.resident = n;
1184
1338
  if (n > this.peakResident) this.peakResident = n;
@@ -1820,23 +1974,48 @@ export function describeResource(res) {
1820
1974
  // ── the handle ──────────────────────────────────────────────────────────────────────────────
1821
1975
 
1822
1976
  /**
1823
- * The per-splat crossfade for `setSource`: a work-buffer modifier multiplying each splat's alpha
1824
- * by this ENTITY's `dxrFade` parameter.
1977
+ * setSource's crossfade.
1978
+ *
1979
+ * The 1.11 fade multiplied each splat's alpha by t and drew both assets in ONE sorted pass. That
1980
+ * is not a crossfade: whichever photo sits in FRONT wins. Measured on SHARP photo pairs (the blend
1981
+ * fraction c, least squares of the frame between A-alone and B-alone): the incoming photo in front
1982
+ * reached c = 0.64 at t = 0.2 (a snap), behind it c = 0.18 at t = 0.5 (a late pop). No per-splat
1983
+ * alpha remap fixes that, because a pixel's coverage depends on how many splats stack there and
1984
+ * which asset is nearer — both vary across the picture and between photos (docs/splat-effects.md
1985
+ * §Crossfade has the numbers).
1986
+ *
1987
+ * FRAME_SNAPSHOT: blend IMAGES instead. The last frame of the outgoing asset (both eyes) is copied
1988
+ * into a texture, the outgoing asset is released at once, and the incoming one is drawn untouched;
1989
+ * two overlay quads then lerp the frozen frame with it, `out = (1 − t)·A + t·B` per premultiplied
1990
+ * pixel, alpha included (PlayCanvasSplatViewer._ensureSnapshotOverlay). Linear by construction,
1991
+ * whatever the depth order, coverage or framing; each eye samples its own half of the capture, so
1992
+ * the eyes stay consistent. End state: overlay hidden — exactly the untouched incoming asset.
1993
+ * Trade-offs: the outgoing frame is frozen for the fade (camera and head motion stop for it), and
1994
+ * the new asset's rig applies at once (each photo is seen through its own rig).
1825
1995
  *
1826
- * Per entity, not per material: the engine's unified renderer composites every splat of a tile
1827
- * through ONE material and ONE work buffer, so a material uniform cannot tell the outgoing asset
1828
- * from the incoming one. `setWorkBufferModifier` + `setParameter` is the engine's supported
1829
- * per-component hook for exactly this; during a fade the work buffer is re-rendered each frame
1830
- * (WORKBUFFER_UPDATE_ALWAYS), and the modifier is removed when the fade ends.
1996
+ * Fallback (no frame drawn within 250 ms — a hidden tab — or no copy on this engine build): the
1997
+ * one-pass fade, with the coverage-linear alpha remap on both assets.
1998
+ */
1999
+
2000
+ /**
2001
+ * Per-entity work-buffer modifier: each splat's alpha becomes `1 − (1 − α)^k`, k = `dxrFade`
2002
+ * (coverageExponent). Per entity, not per material: the unified renderer composites every splat of
2003
+ * a tile through ONE material, so only `setWorkBufferModifier` + `setParameter` can tell the two
2004
+ * assets apart. During a fade the work buffer re-renders each frame (WORKBUFFER_UPDATE_ALWAYS);
2005
+ * the modifier is removed when the fade ends. The transmittance floor keeps a fully opaque splat
2006
+ * (SOG alpha 255/255, common in photos) fadeable at all: pow(0, k) is 0 for every k.
1831
2007
  */
1832
2008
  const FADE_MODIFIER_GLSL = `
1833
2009
  uniform float dxrFade;
1834
2010
  void modifySplatCenter(inout vec3 center) {}
1835
2011
  void modifySplatRotationScale(vec3 originalCenter, vec3 modifiedCenter, inout vec4 rotation, inout vec3 scale) {}
1836
- void modifySplatColor(vec3 center, inout vec4 color) { color.a *= dxrFade; }
2012
+ void modifySplatColor(vec3 center, inout vec4 color) {
2013
+ if (dxrFade >= 1.0) return;
2014
+ color.a = dxrFade <= 0.0 ? 0.0 : 1.0 - pow(max(1.0 - color.a, ${FADE_TRANSMITTANCE_FLOOR.toFixed(4)}), dxrFade);
2015
+ }
1837
2016
  `;
1838
2017
 
1839
- /** Set (or clear, with null) the crossfade alpha on one splat entity. */
2018
+ /** Set (or clear, with null) the crossfade alpha exponent on one splat entity. */
1840
2019
  function setFade(pc, entity, value) {
1841
2020
  const g = entity?.gsplat;
1842
2021
  if (!g) return;
@@ -2496,9 +2675,19 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
2496
2675
  return out;
2497
2676
  }
2498
2677
  const prev = current;
2499
- const entity = viewer.addSplatAsset(loaded.asset);
2500
2678
  const fade = Number.isFinite(fadeMs) && fadeMs > 0 ? fadeMs : 0;
2501
- if (fade && prev) setFade(pc, entity, 0);
2679
+ pendingSwap?.finish();
2680
+ // FRAME_SNAPSHOT: freeze the outgoing frame BEFORE anything of the new asset (or its rig) is
2681
+ // drawn. The overlay goes up in the same task, so no frame shows neither photo.
2682
+ const snapped = fade && prev ? await viewer.captureFrame() : false;
2683
+ if (removed || gen !== sourceGen) {
2684
+ app.assets.remove(loaded.asset);
2685
+ loaded.asset.unload?.();
2686
+ return out;
2687
+ }
2688
+ if (snapped) viewer.setSnapshotAlpha(1);
2689
+ const entity = viewer.addSplatAsset(loaded.asset);
2690
+ if (fade && prev && !snapped) setFade(pc, entity, 0);
2502
2691
  out.mesh = {
2503
2692
  numSplats: loaded.desc.numSplats || loaded.cloud?.sourceTotal || 0,
2504
2693
  entity,
@@ -2528,7 +2717,8 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
2528
2717
  release();
2529
2718
  return out;
2530
2719
  }
2531
- pendingSwap?.finish();
2720
+ if (snapped) release(); // the snapshot shows it from here on
2721
+ else setFade(pc, prev.entity, 1);
2532
2722
  await new Promise((resolve) => {
2533
2723
  // The fade clock starts on the SECOND tick after the swap, not now: the first frame that
2534
2724
  // draws the new asset also builds its work buffer (hundreds of ms for a 1M-splat file), and
@@ -2537,21 +2727,31 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
2537
2727
  let t0 = null;
2538
2728
  let ticks = 0;
2539
2729
  const finish = () => {
2540
- setFade(pc, entity, null);
2541
- release();
2730
+ if (snapped) viewer.setSnapshotAlpha(0);
2731
+ else {
2732
+ setFade(pc, entity, null);
2733
+ release();
2734
+ }
2542
2735
  pendingSwap = null;
2543
2736
  resolve();
2544
2737
  };
2545
2738
  pendingSwap = { finish };
2546
- setFade(pc, prev.entity, 1);
2547
2739
  viewer._hooks.push((t) => {
2548
2740
  if (removed) return (resolve(), false);
2549
2741
  if (pendingSwap?.finish !== finish) return false; // superseded: already finished
2550
2742
  if (++ticks < 2) return true;
2551
2743
  if (t0 === null) t0 = t;
2552
2744
  const k = Math.min(1, Math.max(0, (t - t0) / fade));
2553
- setFade(pc, entity, k);
2554
- setFade(pc, prev.entity, 1 - k);
2745
+ if (snapped) {
2746
+ // A resize or a 2D/3D switch mid-fade: the capture no longer fits the buffer — end now.
2747
+ if (!viewer.setSnapshotAlpha(1 - k)) {
2748
+ finish();
2749
+ return false;
2750
+ }
2751
+ } else {
2752
+ setFade(pc, entity, coverageExponent(k));
2753
+ setFade(pc, prev.entity, coverageExponent(1 - k));
2754
+ }
2555
2755
  if (k >= 1) {
2556
2756
  finish();
2557
2757
  return false;
@@ -437,3 +437,36 @@ export function normalizeCameraPose(matrixWorld, o = {}) {
437
437
  }
438
438
  return { matrixWorld: m, verticalFovDeg: fov, near, far, convergence };
439
439
  }
440
+
441
+ // ── coverage-linear alpha (setSource's crossfade, the `fade` effect) ────────────────────────────
442
+
443
+ /**
444
+ * Nominal optical depth of a dense photo's splat stack, per pixel: −ln(transmittance) through the
445
+ * whole stack at rest. The remap below is exact for a pixel of optical depth `L`. Real pictures
446
+ * spread widely around any single value (measured 13–25 on SHARP photos), which is why the
447
+ * crossfade blends IMAGES (FRAME_SNAPSHOT in ./inline3d-splat-playcanvas.js) and uses this only
448
+ * to shape ramps — never as the thing that makes a fade linear.
449
+ */
450
+ export const FADE_OPTICAL_DEPTH = 3;
451
+
452
+ /**
453
+ * Smallest per-splat transmittance the remap works from. A fully opaque splat (SOG alpha 255/255
454
+ * decodes to exactly 1, common in photos) has transmittance 0, and 0^k is 0 for every k > 0 — so
455
+ * without a floor it could never fade at all. 0.02 caps one splat's optical depth at ~3.9.
456
+ */
457
+ export const FADE_TRANSMITTANCE_FLOOR = 0.02;
458
+
459
+ /**
460
+ * The per-splat alpha exponent `k` that puts a stack of nominal optical depth `L` at screen
461
+ * coverage `c` (0..1).
462
+ *
463
+ * Scaling every splat's alpha by `c` saturates: a pixel under n near-opaque splats is covered
464
+ * `1 − (1 − c·α)^n`. Raising each splat's transmittance to a power instead, `α' = 1 − (1 − α)^k`,
465
+ * multiplies the stack's optical depth by k: coverage is `1 − e^{−kL}`. Solving for coverage =
466
+ * c·(1 − e^{−L}) gives this k. Exact ends: k(0) = 0 (α' = 0, invisible) and k(1) = 1 (untouched).
467
+ */
468
+ export function coverageExponent(c, L = FADE_OPTICAL_DEPTH) {
469
+ if (!(c > 0)) return 0;
470
+ if (c >= 1) return 1;
471
+ return -Math.log(1 - c * (1 - Math.exp(-L))) / L;
472
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@displayxr/inline3d",
3
- "version": "1.12.0",
3
+ "version": "1.12.1",
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",
package/splat.d.ts CHANGED
@@ -466,7 +466,9 @@ export interface SplatHandle {
466
466
  * Swap the asset (URL or bytes) in place. PlayCanvas backend only — throws on Spark.
467
467
  *
468
468
  * The new file loads BEHIND the current one; then the two crossfade over `fadeMs` (0 = a cut)
469
- * and the old one is released. The rig waterfall re-runs for the new file (rig, lens, focus and
469
+ * and the old one is released. The crossfade blends the two IMAGES linearly: the last frame of
470
+ * the old asset is frozen (both eyes) and lerped with the live new one, so the mix is exactly
471
+ * `t` whatever the two photos' depth order (the old asset's own motion stops during the fade). The rig waterfall re-runs for the new file (rig, lens, focus and
470
472
  * frame update; `onFocusChange` fires). The pose (yaw/pitch/zoom/depth) is kept unless
471
473
  * `resetPose`. A newer call supersedes an older one still loading. Resolves once the fade has
472
474
  * finished; rejects if the new asset cannot be loaded (the current one stays on screen).