@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 +15 -0
- package/js/inline3d-playcanvas-engine.js +5 -0
- package/js/inline3d-splat-playcanvas.js +217 -17
- package/js/inline3d-splat-shared.js +33 -0
- package/package.json +1 -1
- package/splat.d.ts +3 -1
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
|
-
*
|
|
1824
|
-
*
|
|
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
|
-
*
|
|
1827
|
-
*
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
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) {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2541
|
-
|
|
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
|
-
|
|
2554
|
-
|
|
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.
|
|
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
|
|
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).
|