@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.
- package/CHANGELOG.md +70 -0
- package/README.md +18 -5
- package/js/inline3d-model-entry.js +242 -0
- package/js/inline3d-model-playcanvas.js +754 -0
- package/js/inline3d-playcanvas-engine.js +82 -0
- package/js/inline3d-splat-playcanvas.js +247 -25
- package/js/inline3d-splat-shared.js +33 -0
- package/model.d.ts +109 -30
- package/package.json +15 -3
- package/splat.d.ts +3 -1
|
@@ -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
|
-
|
|
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
|
-
|
|
1026
|
-
|
|
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
|
-
*
|
|
1804
|
-
*
|
|
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
|
-
*
|
|
1807
|
-
*
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
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) {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2519
|
-
|
|
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
|
-
|
|
2532
|
-
|
|
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
|
+
}
|