@displayxr/inline3d 1.13.0 → 1.14.0
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 +73 -0
- package/README.md +4 -3
- package/js/inline3d-playcanvas-engine.js +3 -0
- package/js/inline3d-splat-effects.js +471 -4
- package/js/inline3d-splat-live.js +343 -0
- package/js/inline3d-splat-playcanvas.js +204 -26
- package/js/inline3d-splat.js +5 -0
- package/js/inline3d-viewer.js +25 -1
- package/package.json +2 -1
- package/splat.d.ts +97 -7
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
// inline3d-splat-live.js — setSource's LIVE outgoing asset (engine:'playcanvas'), and the
|
|
2
|
+
// background yield prepareSource's own passes run on.
|
|
3
|
+
//
|
|
4
|
+
// Internal. Used by ./inline3d-splat-playcanvas.js only.
|
|
5
|
+
//
|
|
6
|
+
// WHY. Since 1.12.1 a crossfade / wavefront blends IMAGES (FRAME_SNAPSHOT): the outgoing photo is
|
|
7
|
+
// the last frame it drew, frozen into a texture for the whole transition. On a tracked 3D panel a
|
|
8
|
+
// still image under a moving head reads exactly as "tracking hung" — the field report that led
|
|
9
|
+
// here. LIVE keeps the outgoing asset resident and renders it every frame of the window, through
|
|
10
|
+
// the same eye views as the incoming one, into its own render target; the same two overlay quads
|
|
11
|
+
// then lerp (or wipe) the two LIVE images per eye. The blend maths is unchanged — only the texture
|
|
12
|
+
// the overlay samples is live instead of frozen.
|
|
13
|
+
//
|
|
14
|
+
// HOW (the engine's single-camera N-RenderView path):
|
|
15
|
+
// - a second camera ("outgoing eye"), priority −1 so it renders before the eye, into an RGBA8
|
|
16
|
+
// target the size of the canvas buffer: every eye viewport sits in it exactly where it sits in
|
|
17
|
+
// the canvas, so the overlay's gl_FragCoord lookup reads the matching eye (as with the frozen
|
|
18
|
+
// capture). Its OWN RenderViews, set from the same entries as the eye's every frame.
|
|
19
|
+
// - its own layer. The engine's gsplat director keeps ONE manager per (camera, layer) — own
|
|
20
|
+
// work buffer, own sort, own budget — so the outgoing entity moves to this layer and ONLY
|
|
21
|
+
// this camera renders it; the eye camera keeps the World layer with ONLY the incoming asset.
|
|
22
|
+
// Anything else would put both 1.18M assets in both managers.
|
|
23
|
+
// - the window ends by disabling the camera: the director destroys its manager (work buffer,
|
|
24
|
+
// sorter) on the next update, and the outgoing asset is released as before.
|
|
25
|
+
//
|
|
26
|
+
// THE RIG. The incoming asset's rig (camera rig: its capture pose, lens and convergence; display
|
|
27
|
+
// rig: its fit and focus) is adopted at once, as in the frozen path, so the views the runtime
|
|
28
|
+
// hands us after that are the INCOMING photo's. The outgoing camera is parented under a node
|
|
29
|
+
// chain that maps them back to the OUTGOING photo's framing:
|
|
30
|
+
// N = R_o · K_o · D_o · D_n⁻¹ · K_n⁻¹
|
|
31
|
+
// with R the rig node (inverse pivot), K the mono/capture pose in rig space and
|
|
32
|
+
// D = diag(c·t, c·t, c) (c = convergence distance, t = tan(vfov/2)). The eye poses (head
|
|
33
|
+
// motion) pass through untouched; the outgoing photo keeps its own window and zero-disparity
|
|
34
|
+
// plane. Two display-rig assets cancel to N = R_o (the old fit and focus); in controls:'page' the
|
|
35
|
+
// page owns the camera and N is the rig node itself.
|
|
36
|
+
|
|
37
|
+
/** setSource's `outgoing` option. */
|
|
38
|
+
export const OUTGOING_MODES = Object.freeze(['live', 'frozen']);
|
|
39
|
+
|
|
40
|
+
/** Validate `outgoing` (undefined = the default, resolved at swap time). */
|
|
41
|
+
export function resolveOutgoingOption(v) {
|
|
42
|
+
if (v === undefined || v === null) return null;
|
|
43
|
+
if (!OUTGOING_MODES.includes(v)) {
|
|
44
|
+
throw new Error(`@displayxr/inline3d/splat: setSource outgoing '${v}' — expected 'live' or 'frozen'.`);
|
|
45
|
+
}
|
|
46
|
+
return v;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The default: live in a woven (3D) session, frozen in 2D (one draw instead of two). */
|
|
50
|
+
export function defaultOutgoing(is3D) {
|
|
51
|
+
return is3D ? 'live' : 'frozen';
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Background yield for prepareSource: an idle period where the browser has one (bounded, so a
|
|
56
|
+
* busy page still makes progress), else a macrotask. The swap path keeps `scheduler.yield()`.
|
|
57
|
+
*/
|
|
58
|
+
export function yieldIdle(timeout = 200) {
|
|
59
|
+
if (typeof requestIdleCallback === 'function') return new Promise((r) => requestIdleCallback(() => r(), { timeout }));
|
|
60
|
+
return new Promise((r) => setTimeout(r, 0));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ── small matrix helpers (column-major 4×4), kept local: no import cycle with the adapter ──
|
|
64
|
+
|
|
65
|
+
function mul(a, b, out = new Float64Array(16)) {
|
|
66
|
+
for (let c = 0; c < 4; c++) {
|
|
67
|
+
for (let r = 0; r < 4; r++) {
|
|
68
|
+
out[c * 4 + r] = a[r] * b[c * 4] + a[4 + r] * b[c * 4 + 1] + a[8 + r] * b[c * 4 + 2] + a[12 + r] * b[c * 4 + 3];
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return out;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Inverse of a similarity (rotation · uniform scale + translation). */
|
|
75
|
+
function invertSimilarity(m, out = new Float64Array(16)) {
|
|
76
|
+
const s2 = m[0] * m[0] + m[1] * m[1] + m[2] * m[2] || 1;
|
|
77
|
+
// R⁻¹ = Rᵀ / s²
|
|
78
|
+
for (let c = 0; c < 3; c++) for (let r = 0; r < 3; r++) out[c * 4 + r] = m[r * 4 + c] / s2;
|
|
79
|
+
out[3] = out[7] = out[11] = 0;
|
|
80
|
+
for (let r = 0; r < 3; r++) out[12 + r] = -(out[r] * m[12] + out[4 + r] * m[13] + out[8 + r] * m[14]);
|
|
81
|
+
out[15] = 1;
|
|
82
|
+
return out;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Position, unit quaternion (xyzw) and uniform scale of a similarity. */
|
|
86
|
+
export function similarityTRS(m) {
|
|
87
|
+
const s = Math.hypot(m[0], m[1], m[2]) || 1;
|
|
88
|
+
const m11 = m[0] / s, m12 = m[4] / s, m13 = m[8] / s;
|
|
89
|
+
const m21 = m[1] / s, m22 = m[5] / s, m23 = m[9] / s;
|
|
90
|
+
const m31 = m[2] / s, m32 = m[6] / s, m33 = m[10] / s;
|
|
91
|
+
const tr = m11 + m22 + m33;
|
|
92
|
+
let x, y, z, w;
|
|
93
|
+
if (tr > 0) {
|
|
94
|
+
const k = 0.5 / Math.sqrt(tr + 1);
|
|
95
|
+
w = 0.25 / k; x = (m32 - m23) * k; y = (m13 - m31) * k; z = (m21 - m12) * k;
|
|
96
|
+
} else if (m11 > m22 && m11 > m33) {
|
|
97
|
+
const k = 2 * Math.sqrt(1 + m11 - m22 - m33);
|
|
98
|
+
w = (m32 - m23) / k; x = 0.25 * k; y = (m12 + m21) / k; z = (m13 + m31) / k;
|
|
99
|
+
} else if (m22 > m33) {
|
|
100
|
+
const k = 2 * Math.sqrt(1 + m22 - m11 - m33);
|
|
101
|
+
w = (m13 - m31) / k; x = (m12 + m21) / k; y = 0.25 * k; z = (m23 + m32) / k;
|
|
102
|
+
} else {
|
|
103
|
+
const k = 2 * Math.sqrt(1 + m33 - m11 - m22);
|
|
104
|
+
w = (m21 - m12) / k; x = (m13 + m31) / k; y = (m23 + m32) / k; z = 0.25 * k;
|
|
105
|
+
}
|
|
106
|
+
return { position: [m[12], m[13], m[14]], rotation: [x, y, z, w], scale: s };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* The node chain's three pieces from two lens frames (viewer.lensFrame(): { rig, pose, c, t }):
|
|
111
|
+
* N1 = R_o·K_o (similarity), N2 = diag(a, a, b), N3 = K_n⁻¹ (rigid). N1·N2·N3 = N.
|
|
112
|
+
*/
|
|
113
|
+
export function outgoingChain(oldFrame, newFrame) {
|
|
114
|
+
const n1 = mul(oldFrame.rig, oldFrame.pose);
|
|
115
|
+
const a = (oldFrame.c * oldFrame.t) / (newFrame.c * newFrame.t);
|
|
116
|
+
const b = oldFrame.c / newFrame.c;
|
|
117
|
+
const n3 = invertSimilarity(newFrame.pose);
|
|
118
|
+
return {
|
|
119
|
+
n1: similarityTRS(n1),
|
|
120
|
+
scale: [Number.isFinite(a) && a > 0 ? a : 1, Number.isFinite(a) && a > 0 ? a : 1, Number.isFinite(b) && b > 0 ? b : 1],
|
|
121
|
+
n3: similarityTRS(n3),
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function setTRS(node, trs) {
|
|
126
|
+
node.setLocalPosition(trs.position[0], trs.position[1], trs.position[2]);
|
|
127
|
+
node.setLocalRotation(trs.rotation[0], trs.rotation[1], trs.rotation[2], trs.rotation[3]);
|
|
128
|
+
node.setLocalScale(trs.scale, trs.scale, trs.scale);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* The live outgoing renderer for one viewer. Engine objects are made on first use and kept (the
|
|
133
|
+
* layer, the camera entity, the node chain); the render target lives for one window only.
|
|
134
|
+
*/
|
|
135
|
+
export class LiveOutgoing {
|
|
136
|
+
constructor(viewer) {
|
|
137
|
+
this.viewer = viewer;
|
|
138
|
+
this.active = false;
|
|
139
|
+
this.entity = null;
|
|
140
|
+
this.layer = null;
|
|
141
|
+
this.cam = null;
|
|
142
|
+
this.nodes = null;
|
|
143
|
+
this.rt = null;
|
|
144
|
+
this.tex = null;
|
|
145
|
+
this.w = 0;
|
|
146
|
+
this.h = 0;
|
|
147
|
+
this.oldFrame = null;
|
|
148
|
+
this.frames = 0;
|
|
149
|
+
this.views = [];
|
|
150
|
+
this._frustumKey = '';
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Engine support for this path: RenderViews on one camera, render targets, layers. */
|
|
154
|
+
static supported(viewer) {
|
|
155
|
+
const pc = viewer.pc;
|
|
156
|
+
return !!(viewer.app && pc && viewer._viewPath === 'renderview' && pc.RenderTarget && pc.Texture && pc.Layer && viewer.eye);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
_ensureEngineObjects() {
|
|
160
|
+
const v = this.viewer;
|
|
161
|
+
const pc = v.pc;
|
|
162
|
+
const app = v.app;
|
|
163
|
+
if (!this.layer) {
|
|
164
|
+
this.layer = new pc.Layer({ name: 'inline3d-outgoing' });
|
|
165
|
+
app.scene.layers.push(this.layer);
|
|
166
|
+
}
|
|
167
|
+
if (!this.cam) {
|
|
168
|
+
const n1 = new pc.Entity('inline3d-outgoing-rig', app);
|
|
169
|
+
const n2 = new pc.Entity('inline3d-outgoing-lens', app);
|
|
170
|
+
const n3 = new pc.Entity('inline3d-outgoing-pose', app);
|
|
171
|
+
app.root.addChild(n1);
|
|
172
|
+
n1.addChild(n2);
|
|
173
|
+
n2.addChild(n3);
|
|
174
|
+
this.nodes = { n1, n2, n3 };
|
|
175
|
+
const cam = v._makeCamera('inline3d-outgoing-eye', null); // same clear, tone mapping, near/far
|
|
176
|
+
v.rigNode.removeChild?.(cam);
|
|
177
|
+
n3.addChild(cam);
|
|
178
|
+
cam.camera.layers = [this.layer.id];
|
|
179
|
+
cam.camera.priority = -1; // before the eye: the eye's overlay samples its target
|
|
180
|
+
cam.enabled = false;
|
|
181
|
+
this.cam = cam;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
_ensureTarget(w, h) {
|
|
186
|
+
if (this.rt && this.w === w && this.h === h) return;
|
|
187
|
+
this._destroyTarget();
|
|
188
|
+
const pc = this.viewer.pc;
|
|
189
|
+
const device = this.viewer.app.graphicsDevice;
|
|
190
|
+
this.tex = new pc.Texture(device, {
|
|
191
|
+
name: 'inline3d-outgoing',
|
|
192
|
+
width: w,
|
|
193
|
+
height: h,
|
|
194
|
+
format: pc.PIXELFORMAT_RGBA8,
|
|
195
|
+
mipmaps: false,
|
|
196
|
+
minFilter: pc.FILTER_NEAREST,
|
|
197
|
+
magFilter: pc.FILTER_NEAREST,
|
|
198
|
+
addressU: pc.ADDRESS_CLAMP_TO_EDGE,
|
|
199
|
+
addressV: pc.ADDRESS_CLAMP_TO_EDGE,
|
|
200
|
+
});
|
|
201
|
+
this.rt = new pc.RenderTarget({ colorBuffer: this.tex, depth: false });
|
|
202
|
+
this.w = w;
|
|
203
|
+
this.h = h;
|
|
204
|
+
if (this.cam) this.cam.camera.renderTarget = this.rt;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
_destroyTarget() {
|
|
208
|
+
if (this.cam) this.cam.camera.renderTarget = null;
|
|
209
|
+
this.rt?.destroy?.();
|
|
210
|
+
this.tex?.destroy?.();
|
|
211
|
+
this.rt = this.tex = null;
|
|
212
|
+
this.w = this.h = 0;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Take `entity` (the outgoing splat, still enabled) onto the live layer. `oldFrame` is
|
|
217
|
+
* viewer.lensFrame() read BEFORE the incoming asset's rig was adopted. Returns false if this
|
|
218
|
+
* engine build cannot (the caller keeps the frozen frame).
|
|
219
|
+
*/
|
|
220
|
+
start(entity, oldFrame) {
|
|
221
|
+
const v = this.viewer;
|
|
222
|
+
if (!LiveOutgoing.supported(v) || !entity?.gsplat) return false;
|
|
223
|
+
try {
|
|
224
|
+
this._ensureEngineObjects();
|
|
225
|
+
this._ensureTarget(v.canvas.width, v.canvas.height);
|
|
226
|
+
entity.gsplat.layers = [this.layer.id];
|
|
227
|
+
this.entity = entity;
|
|
228
|
+
this.oldFrame = oldFrame;
|
|
229
|
+
this.frames = 0;
|
|
230
|
+
this.cam.enabled = true;
|
|
231
|
+
this.active = true;
|
|
232
|
+
return true;
|
|
233
|
+
} catch (err) {
|
|
234
|
+
console.warn('[inline3d/splat] live outgoing unavailable; the transition keeps the frozen frame', err);
|
|
235
|
+
this.stop();
|
|
236
|
+
return false;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** The target the overlay samples. */
|
|
241
|
+
get texture() {
|
|
242
|
+
return this.tex;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** Fits the canvas buffer (a resize or a 2D/3D switch mid-window ends the transition). */
|
|
246
|
+
get fits() {
|
|
247
|
+
return this.w === this.viewer.canvas.width && this.h === this.viewer.canvas.height;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* The outgoing camera has drawn a sorted frame: its manager exists and its world state has been
|
|
252
|
+
* sorted at least once. Until then the overlay keeps the frozen bridge (the first frames of a
|
|
253
|
+
* fresh manager draw nothing while its work buffer and first sort are built). An engine build
|
|
254
|
+
* whose internals differ: ready after 3 drawn frames.
|
|
255
|
+
*/
|
|
256
|
+
get ready() {
|
|
257
|
+
if (!this.active) return false;
|
|
258
|
+
const mgr = this.manager;
|
|
259
|
+
if (mgr === undefined) return this.frames >= 3;
|
|
260
|
+
const w = mgr?.world;
|
|
261
|
+
const st = w?.getState?.(w.lastWorldStateVersion);
|
|
262
|
+
return !!st?.sortedBefore && this.frames >= 1;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** The director's manager for (this camera, this layer): null = none yet, undefined = unknown engine. */
|
|
266
|
+
get manager() {
|
|
267
|
+
const dir = this.viewer.app?.renderer?.gsplatDirector;
|
|
268
|
+
if (!dir?.camerasMap || !this.cam) return undefined;
|
|
269
|
+
const cd = dir.camerasMap.get(this.cam.camera.camera);
|
|
270
|
+
return cd?.layersMap?.get(this.layer)?.gsplatManager ?? null;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Per frame, before the engine tick: the eye's views on the outgoing camera (same proj, pose,
|
|
275
|
+
* viewport), the eye's node pose, and the rig chain.
|
|
276
|
+
*/
|
|
277
|
+
sync(entries, rect, frustum) {
|
|
278
|
+
if (!this.active) return;
|
|
279
|
+
const v = this.viewer;
|
|
280
|
+
const pc = v.pc;
|
|
281
|
+
const rvs = this.views;
|
|
282
|
+
if (rvs.length !== entries.length) {
|
|
283
|
+
rvs.length = 0;
|
|
284
|
+
for (let i = 0; i < entries.length; i++) rvs.push(new pc.RenderView());
|
|
285
|
+
this.cam.camera.camera.xrViews = rvs.slice();
|
|
286
|
+
}
|
|
287
|
+
for (let i = 0; i < entries.length; i++) {
|
|
288
|
+
rvs[i].setView(entries[i].proj, entries[i].pose);
|
|
289
|
+
const [x, y, w, h] = rect(entries[i]);
|
|
290
|
+
rvs[i].setViewport(x, y, w, h);
|
|
291
|
+
}
|
|
292
|
+
if (frustum) {
|
|
293
|
+
const key = `${frustum.fov.toFixed(4)}|${frustum.aspectRatio.toFixed(4)}|${frustum.nearClip}|${frustum.farClip}`;
|
|
294
|
+
if (key !== this._frustumKey) {
|
|
295
|
+
this._frustumKey = key;
|
|
296
|
+
this.cam.camera.camera.setXrProperties({ ...frustum, horizontalFov: false });
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
// The node chain (see the header). controls:'page': the page's camera, for both assets.
|
|
300
|
+
const { n1, n2, n3 } = this.nodes;
|
|
301
|
+
if (v.pageCamera || !this.oldFrame) {
|
|
302
|
+
const r = similarityTRS(v.rigMatrix());
|
|
303
|
+
setTRS(n1, r);
|
|
304
|
+
n2.setLocalScale(1, 1, 1);
|
|
305
|
+
setTRS(n3, { position: [0, 0, 0], rotation: [0, 0, 0, 1], scale: 1 });
|
|
306
|
+
} else {
|
|
307
|
+
const ch = outgoingChain(this.oldFrame, v.lensFrame());
|
|
308
|
+
setTRS(n1, ch.n1);
|
|
309
|
+
n2.setLocalScale(ch.scale[0], ch.scale[1], ch.scale[2]);
|
|
310
|
+
setTRS(n3, ch.n3);
|
|
311
|
+
}
|
|
312
|
+
// The camera NODE (sort direction, LOD distance) parks on the first eye, as the eye's does.
|
|
313
|
+
const e = v.eye;
|
|
314
|
+
const p = e.getLocalPosition();
|
|
315
|
+
const q = e.getLocalRotation();
|
|
316
|
+
this.cam.setLocalPosition(p.x, p.y, p.z);
|
|
317
|
+
this.cam.setLocalRotation(q.x, q.y, q.z, q.w);
|
|
318
|
+
this.frames++;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/** End the window: camera off (the director drops its manager), target freed. */
|
|
322
|
+
stop() {
|
|
323
|
+
// The outgoing entity is NOT put back on its old layer: the caller releases it right after,
|
|
324
|
+
// and a round trip through World would dirty the eye's manager (a work-buffer rebuild).
|
|
325
|
+
this.entity = null;
|
|
326
|
+
this.oldFrame = null;
|
|
327
|
+
this.active = false;
|
|
328
|
+
if (this.cam) this.cam.enabled = false;
|
|
329
|
+
this._destroyTarget();
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
destroy() {
|
|
333
|
+
this.stop();
|
|
334
|
+
try {
|
|
335
|
+
if (this.layer) this.viewer.app?.scene?.layers?.remove?.(this.layer);
|
|
336
|
+
} catch {
|
|
337
|
+
/* app already gone */
|
|
338
|
+
}
|
|
339
|
+
this.layer = null;
|
|
340
|
+
this.cam = null;
|
|
341
|
+
this.nodes = null;
|
|
342
|
+
}
|
|
343
|
+
}
|