@displayxr/inline3d 1.11.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.
@@ -0,0 +1,82 @@
1
+ // inline3d-playcanvas-engine.js — the slice of `playcanvas` the SDK's two PlayCanvas backends use,
2
+ // as NAMED re-exports.
3
+ //
4
+ // Internal. ./model and ./splat import THIS module (dynamically) instead of `import('playcanvas')`.
5
+ // A dynamic import of the package hands back its whole namespace, and a bundler cannot know which
6
+ // members a namespace object will be asked for — so it keeps all of them: the full engine
7
+ // (physics, UI, audio, particles, XR, the WebGPU backend …) lands in the page's bundle. Naming
8
+ // the members here is what lets a bundler drop everything else. The page's own `import … from
9
+ // 'playcanvas'` still resolves to the SAME module instance, so entities a page adds under
10
+ // `handle.engine.root` are the engine's own types.
11
+ //
12
+ // Adding an engine member to either adapter? Add it here too; test/playcanvas-engine.test.mjs
13
+ // fails on a `pc.X` the adapters read that this list does not export.
14
+ export {
15
+ // app + device
16
+ AppBase,
17
+ AppOptions,
18
+ createGraphicsDevice,
19
+ DEVICETYPE_WEBGL2,
20
+ RESOLUTION_FIXED,
21
+ // component systems + resource handlers (PLAYCANVAS_SYSTEMS / PLAYCANVAS_HANDLERS)
22
+ CameraComponentSystem,
23
+ GSplatComponentSystem,
24
+ RenderComponentSystem,
25
+ LightComponentSystem,
26
+ AnimComponentSystem,
27
+ TextureHandler,
28
+ GSplatHandler,
29
+ ContainerHandler,
30
+ // scene graph + cameras
31
+ Entity,
32
+ GraphNode,
33
+ Camera,
34
+ RenderView,
35
+ Asset,
36
+ Color,
37
+ Vec4,
38
+ Quat,
39
+ LAYERID_SKYBOX,
40
+ LAYERID_UI,
41
+ // materials / meshes (the edge feather)
42
+ Mesh,
43
+ MeshInstance,
44
+ ShaderMaterial,
45
+ BlendState,
46
+ BLENDEQUATION_ADD,
47
+ BLENDMODE_SRC_ALPHA,
48
+ BLENDMODE_ZERO,
49
+ BLENDMODE_ONE,
50
+ BLENDMODE_ONE_MINUS_SRC_ALPHA,
51
+ CULLFACE_NONE,
52
+ SEMANTIC_POSITION,
53
+ SEMANTIC_TEXCOORD0,
54
+ ShaderChunks,
55
+ SHADERLANGUAGE_GLSL,
56
+ // setSource's frame snapshot
57
+ RenderTarget,
58
+ FILTER_NEAREST,
59
+ // splats
60
+ WORKBUFFER_UPDATE_ALWAYS,
61
+ WORKBUFFER_UPDATE_ONCE,
62
+ // tone mapping (TONE_MAPPINGS)
63
+ TONEMAP_NONE,
64
+ TONEMAP_LINEAR,
65
+ TONEMAP_NEUTRAL,
66
+ TONEMAP_ACES,
67
+ TONEMAP_ACES2,
68
+ TONEMAP_FILMIC,
69
+ TONEMAP_HEJL,
70
+ // lighting (./model)
71
+ Texture,
72
+ EnvLighting,
73
+ PIXELFORMAT_RGBA8,
74
+ TEXTURETYPE_RGBE,
75
+ TEXTUREPROJECTION_EQUIRECT,
76
+ ADDRESS_REPEAT,
77
+ ADDRESS_CLAMP_TO_EDGE,
78
+ // compressed glTF (./model)
79
+ dracoInitialize,
80
+ basisInitialize,
81
+ version,
82
+ } from 'playcanvas';
@@ -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
  /**
@@ -145,6 +147,17 @@ const PAGE_POSE_ERROR =
145
147
 
146
148
  const DEG = Math.PI / 180;
147
149
 
150
+ /** Tone-mapping names the viewer takes → the engine's constant names. */
151
+ export const TONE_MAPPINGS = Object.freeze({
152
+ none: 'TONEMAP_NONE',
153
+ linear: 'TONEMAP_LINEAR',
154
+ neutral: 'TONEMAP_NEUTRAL',
155
+ aces: 'TONEMAP_ACES',
156
+ aces2: 'TONEMAP_ACES2',
157
+ filmic: 'TONEMAP_FILMIC',
158
+ hejl: 'TONEMAP_HEJL',
159
+ });
160
+
148
161
  // ── pure matrix arithmetic (column-major, the layout XRView, three and the engine all use) ──
149
162
 
150
163
  /** three's `Matrix4.makePerspective(left, right, top, bottom, near, far)`, element for element. */
@@ -606,6 +619,7 @@ export class PlayCanvasSplatViewer {
606
619
  farClip,
607
620
  sky = false,
608
621
  pageCamera = false,
622
+ toneMapping = 'none',
609
623
  } = opts;
610
624
  this.canvas = canvas;
611
625
  // controls:'page': the PAGE owns the camera (setPageCamera). No orbit, no idle, no fit; the
@@ -620,6 +634,10 @@ export class PlayCanvasSplatViewer {
620
634
  // hidden (mid-setSource, or a page showing only its meshes). The SDK's contract is a
621
635
  // transparent canvas the page shows through, so the sky layer is off unless asked for.
622
636
  this.sky = sky === true;
637
+ // The eye camera's tone mapping, by name (the engine constant is looked up once the engine has
638
+ // loaded). 'none' for splats — their colours are already display-referred; ./model's mesh
639
+ // tiles pass 'neutral' (Khronos PBR Neutral), the glTF Sample Viewer's default.
640
+ this.toneMapping = toneMapping;
623
641
  // Depth range for a MIXED scene (meshes under handle.engine.root depth-test against each
624
642
  // other; splats only test against them). The projections' own near/far stay the adapter's —
625
643
  // these only raise the near (floor) and lower the far (cap). Unset: untouched.
@@ -637,6 +655,10 @@ export class PlayCanvasSplatViewer {
637
655
  this.onCaptureFov = null;
638
656
  /** Per-frame hooks, `(tMs) => boolean` — return false to be removed. setSource's crossfade. */
639
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;
640
662
  this.vH = virtualDisplayHeight;
641
663
  this.fit = fit;
642
664
  this.margin = margin;
@@ -952,6 +974,10 @@ export class PlayCanvasSplatViewer {
952
974
  if (this._ro) this._ro.disconnect();
953
975
  else if (typeof removeEventListener === 'function') removeEventListener('resize', this._onResize);
954
976
  this._unbindOrbit();
977
+ for (const w of this._captureWaiters.splice(0)) {
978
+ clearTimeout(w.timer);
979
+ w.resolve(false);
980
+ }
955
981
  try {
956
982
  this.app?.destroy();
957
983
  } catch (err) {
@@ -972,13 +998,15 @@ export class PlayCanvasSplatViewer {
972
998
  * @param {object} o.perf the resolved playcanvasPerfSettings().
973
999
  * @param {string} [o.viewPath] force 'cameras' | 'renderview' (diagnostics).
974
1000
  */
975
- async attachEngine(pc, { preserveDrawingBuffer = false, perf, viewPath } = {}) {
1001
+ async attachEngine(pc, { preserveDrawingBuffer = false, perf, viewPath, antialias = false, patchSplats = true } = {}) {
976
1002
  this.pc = pc;
977
1003
  const device = await pc.createGraphicsDevice(this.canvas, {
978
1004
  deviceTypes: [pc.DEVICETYPE_WEBGL2],
979
1005
  alpha: true,
980
1006
  premultipliedAlpha: true,
981
- antialias: false,
1007
+ // Off for splats (alpha-blended quads gain nothing from MSAA); ./model turns it on, as
1008
+ // three's SceneViewer has it, because mesh silhouettes alias visibly without it.
1009
+ antialias,
982
1010
  xrCompatible: false,
983
1011
  preserveDrawingBuffer,
984
1012
  });
@@ -1022,10 +1050,12 @@ export class PlayCanvasSplatViewer {
1022
1050
  // Footprint fix (§ patchGsplatFootprint) and the perf quad-extent cap, both as chunk
1023
1051
  // overrides. The gsplat chunks are registered by GSplatComponentSystem during init, so
1024
1052
  // after it; before any gsplat material compiles.
1025
- const chunks = pc.ShaderChunks.get(device, pc.SHADERLANGUAGE_GLSL);
1026
- const corner = patchGsplatFootprint(chunks.get('gsplatCornerVS'));
1053
+ // ./model passes patchSplats:false: its subject is a mesh, and a page adding a splat under
1054
+ // handle.engine.root on a model tile gets the engine's stock footprint (documented).
1055
+ const chunks = patchSplats ? pc.ShaderChunks.get(device, pc.SHADERLANGUAGE_GLSL) : null;
1056
+ const corner = chunks ? patchGsplatFootprint(chunks.get('gsplatCornerVS')) : { ok: false, skipped: true };
1027
1057
  if (corner.ok) chunks.set('gsplatCornerVS', corner.src);
1028
- else if (!warnedFootprint) {
1058
+ else if (!corner.skipped && !warnedFootprint) {
1029
1059
  warnedFootprint = true;
1030
1060
  console.warn(
1031
1061
  '[inline3d/splat] engine:playcanvas — this engine build does not have the gsplatCornerVS ' +
@@ -1035,7 +1065,7 @@ export class PlayCanvasSplatViewer {
1035
1065
  );
1036
1066
  }
1037
1067
  this.footprintPatched = corner.ok;
1038
- if (perf?.quadExtent) {
1068
+ if (chunks && perf?.quadExtent) {
1039
1069
  const q = patchPlayCanvasQuadExtent(chunks.get('gsplatCommonVS'), perf.quadExtent);
1040
1070
  if (q.ok) chunks.set('gsplatCommonVS', q.src);
1041
1071
  else if (!warnedQuadExtent) {
@@ -1157,8 +1187,152 @@ export class PlayCanvasSplatViewer {
1157
1187
  if (this.app.scene.gsplat.splatBudget !== per) this.app.scene.gsplat.splatBudget = per;
1158
1188
  }
1159
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
+
1160
1333
  /** Read back what the tick just did. Cheap: two numbers off the renderer. */
1161
1334
  _afterTick() {
1335
+ if (this._captureWaiters.length) this._doCapture();
1162
1336
  const n = this.app?.renderer?._gsplatCount ?? 0;
1163
1337
  this.resident = n;
1164
1338
  if (n > this.peakResident) this.peakResident = n;
@@ -1184,7 +1358,7 @@ export class PlayCanvasSplatViewer {
1184
1358
  // The engine's default camera tonemap is LINEAR, which routes every splat colour through
1185
1359
  // decodeGamma → toneMap → gammaCorrectOutput. Spark writes the stored colour straight out;
1186
1360
  // NONE is the same thing here (GAMMA_SRGB alone leaves a gamma-space colour untouched).
1187
- e.camera.toneMapping = pc.TONEMAP_NONE;
1361
+ e.camera.toneMapping = TONE_MAPPINGS[this.toneMapping] ? pc[TONE_MAPPINGS[this.toneMapping]] ?? pc.TONEMAP_NONE : pc.TONEMAP_NONE;
1188
1362
  this.rigNode.addChild(e);
1189
1363
  return e;
1190
1364
  }
@@ -1800,23 +1974,48 @@ export function describeResource(res) {
1800
1974
  // ── the handle ──────────────────────────────────────────────────────────────────────────────
1801
1975
 
1802
1976
  /**
1803
- * The per-splat crossfade for `setSource`: a work-buffer modifier multiplying each splat's alpha
1804
- * 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).
1805
1995
  *
1806
- * Per entity, not per material: the engine's unified renderer composites every splat of a tile
1807
- * through ONE material and ONE work buffer, so a material uniform cannot tell the outgoing asset
1808
- * from the incoming one. `setWorkBufferModifier` + `setParameter` is the engine's supported
1809
- * per-component hook for exactly this; during a fade the work buffer is re-rendered each frame
1810
- * (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.
1811
2007
  */
1812
2008
  const FADE_MODIFIER_GLSL = `
1813
2009
  uniform float dxrFade;
1814
2010
  void modifySplatCenter(inout vec3 center) {}
1815
2011
  void modifySplatRotationScale(vec3 originalCenter, vec3 modifiedCenter, inout vec4 rotation, inout vec3 scale) {}
1816
- 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
+ }
1817
2016
  `;
1818
2017
 
1819
- /** 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. */
1820
2019
  function setFade(pc, entity, value) {
1821
2020
  const g = entity?.gsplat;
1822
2021
  if (!g) return;
@@ -2415,7 +2614,9 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
2415
2614
 
2416
2615
  let pcModule = null;
2417
2616
  const booted = (async () => {
2418
- const pc = opts.playcanvas || (await import('playcanvas'));
2617
+ // Named re-exports, not the package namespace: lets a bundler drop the ~40% of the engine the
2618
+ // SDK never touches (./inline3d-playcanvas-engine.js).
2619
+ const pc = opts.playcanvas || (await import('./inline3d-playcanvas-engine.js'));
2419
2620
  if (removed) return null;
2420
2621
  const app = await viewer.attachEngine(pc, {
2421
2622
  preserveDrawingBuffer,
@@ -2474,9 +2675,19 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
2474
2675
  return out;
2475
2676
  }
2476
2677
  const prev = current;
2477
- const entity = viewer.addSplatAsset(loaded.asset);
2478
2678
  const fade = Number.isFinite(fadeMs) && fadeMs > 0 ? fadeMs : 0;
2479
- 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);
2480
2691
  out.mesh = {
2481
2692
  numSplats: loaded.desc.numSplats || loaded.cloud?.sourceTotal || 0,
2482
2693
  entity,
@@ -2506,7 +2717,8 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
2506
2717
  release();
2507
2718
  return out;
2508
2719
  }
2509
- pendingSwap?.finish();
2720
+ if (snapped) release(); // the snapshot shows it from here on
2721
+ else setFade(pc, prev.entity, 1);
2510
2722
  await new Promise((resolve) => {
2511
2723
  // The fade clock starts on the SECOND tick after the swap, not now: the first frame that
2512
2724
  // draws the new asset also builds its work buffer (hundreds of ms for a 1M-splat file), and
@@ -2515,21 +2727,31 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
2515
2727
  let t0 = null;
2516
2728
  let ticks = 0;
2517
2729
  const finish = () => {
2518
- setFade(pc, entity, null);
2519
- release();
2730
+ if (snapped) viewer.setSnapshotAlpha(0);
2731
+ else {
2732
+ setFade(pc, entity, null);
2733
+ release();
2734
+ }
2520
2735
  pendingSwap = null;
2521
2736
  resolve();
2522
2737
  };
2523
2738
  pendingSwap = { finish };
2524
- setFade(pc, prev.entity, 1);
2525
2739
  viewer._hooks.push((t) => {
2526
2740
  if (removed) return (resolve(), false);
2527
2741
  if (pendingSwap?.finish !== finish) return false; // superseded: already finished
2528
2742
  if (++ticks < 2) return true;
2529
2743
  if (t0 === null) t0 = t;
2530
2744
  const k = Math.min(1, Math.max(0, (t - t0) / fade));
2531
- setFade(pc, entity, k);
2532
- 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
+ }
2533
2755
  if (k >= 1) {
2534
2756
  finish();
2535
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
+ }