@displayxr/inline3d 1.21.1 → 1.23.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 +37 -0
- package/js/inline3d-splat-diag.js +242 -4
- package/js/inline3d-splat-layer-rig.js +423 -0
- package/js/inline3d-splat-playcanvas.js +317 -4
- package/js/inline3d-splat-video.js +97 -0
- package/js/inline3d-splat.js +18 -0
- package/package.json +2 -1
- package/splat.d.ts +70 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,43 @@ 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.23.0 — 2026-09-25
|
|
9
|
+
|
|
10
|
+
Touches the **preview tier** (`./splat`, `engine: 'playcanvas'` only). New API; no pixel change for a
|
|
11
|
+
page that does not call it.
|
|
12
|
+
|
|
13
|
+
- **`handle.setLayerRig(layer, 'display' | 'camera', { viewerDistance?, gain? })`** (and
|
|
14
|
+
`addSplat(…, { displayRigLayers: [...] })`): draw chosen engine layers through the display rig —
|
|
15
|
+
round objects with physical pop-out — while the splat and the declared view rig stay on the
|
|
16
|
+
photo's camera rig. The display-rig views are the runtime's own views times an exact shear that
|
|
17
|
+
fixes the convergence plane (a contact point never moves); the runtime's projection matrices are
|
|
18
|
+
used verbatim — no Kooima (docs/proposals/layer-display-rig.md). Mono / 2D is byte-identical to
|
|
19
|
+
today. Kill switch `?dxrdiag=nolayerrig`. `handle.layerRigState()` reports what it did.
|
|
20
|
+
- **`handle.makeSbsMaterial(texture, { format })`**: per-eye side-by-side (or top/bottom) sampling
|
|
21
|
+
on any quad — the left half to left views, the right half to right views, the left half in mono.
|
|
22
|
+
The eye split is a scene-wide uniform, `dxr_eye_split`, set every draw.
|
|
23
|
+
|
|
24
|
+
## 1.22.0 — 2026-09-25
|
|
25
|
+
|
|
26
|
+
Touches the **preview tier** (`./splat`, `engine: 'playcanvas'` only). No API change; no pixel change.
|
|
27
|
+
|
|
28
|
+
- **A burst of `pick()` calls no longer blocks the main thread.** Each pick scanned every centre of
|
|
29
|
+
the photo (~7.5 ms for 1.18M on an M1, more on a panel PC); a page that picks 24 then 81 times
|
|
30
|
+
right after `setSource` resolves (a photo slideshow app planning its companion's path) stalled
|
|
31
|
+
the main thread for ~1.3 s at every swap's end, so the woven image stopped following the head.
|
|
32
|
+
A second pick from the same eye position now builds a pick index (about two scans), and every
|
|
33
|
+
further pick from there reads a few cells: 105 picks went from ~820 ms to ~70 ms on an M1. The
|
|
34
|
+
answer is unchanged (the full scan's, exactly; the full scan still runs when the pick's cone is
|
|
35
|
+
empty). `?dxrdiag=oldpick` restores the full scan.
|
|
36
|
+
- **`?dxrdiag` names what is on the main thread.** Per transition phase: every GL call that can
|
|
37
|
+
block (compile, link, status queries, readbacks, syncs; count and ms), `pick()` calls and their
|
|
38
|
+
per-task bursts, how long the settle's task kept running after the SDK resolved `setSource` (the
|
|
39
|
+
page's continuation), and long animation frames with their top scripts (file + function). The
|
|
40
|
+
verdict names them. The settle teardown is a User Timing measure, `inline3d:settle:teardown`.
|
|
41
|
+
- **Measured: the SDK's settle does no GL work that can block.** Zero compile, link, status query,
|
|
42
|
+
readback or sync from the `setSource` call to 1.5 s after the settle, for `reassemble`,
|
|
43
|
+
`crossfade` and `wavefront` (docs/playcanvas-adapter.md § Diagnosing transition stalls).
|
|
44
|
+
|
|
8
45
|
## 1.21.1 — 2026-09-24
|
|
9
46
|
|
|
10
47
|
Touches the **preview tier** (`./splat`, `engine: 'playcanvas'` only). No API change; nothing changes at rest.
|
|
@@ -26,9 +26,25 @@
|
|
|
26
26
|
// cold — skip the live outgoing PRE-SORT (the 1.19.2 behaviour: the frozen capture bridges
|
|
27
27
|
// until a fresh manager has sorted).
|
|
28
28
|
// nooverlay — record + console + window.__dxrDiag, but no on-screen overlay.
|
|
29
|
+
// oldpick — handle.pick() always runs the full scan over every centre (the 1.21.1 path), no
|
|
30
|
+
// pick index. Tests "the page's picks are what blocks the main thread".
|
|
31
|
+
// nolayerrig — handle.setLayerRig is recorded but never applied: every layer stays on the eye
|
|
32
|
+
// camera and the photo's camera rig (the pre-1.23 path, byte for byte).
|
|
33
|
+
//
|
|
34
|
+
// WHAT RUNS ON THE MAIN THREAD, per transition phase (the fifth cause: a stall at the swap's END
|
|
35
|
+
// that is not the SDK's transition at all):
|
|
36
|
+
// - GL calls that can block: shader compile / program link / program + shader status queries
|
|
37
|
+
// (a link resolves there), readPixels, getBufferSubData, fenceSync / clientWaitSync, finish —
|
|
38
|
+
// counted and timed, per phase, on the tile's own context (./inline3d-splat-diag.js wraps
|
|
39
|
+
// them while diag is on).
|
|
40
|
+
// - handle.pick() calls, counted and timed per phase and grouped into BURSTS (one task's worth).
|
|
41
|
+
// - long animation frames (where the browser has the API) with their top SCRIPTS — the source
|
|
42
|
+
// file and function that ran: the page's own code or the SDK's.
|
|
43
|
+
// - the settle itself: the SDK's settle work in ms, then how long the SAME task kept running
|
|
44
|
+
// after it (the page's continuation of `await setSource`).
|
|
29
45
|
|
|
30
46
|
/** The switches `diag` / `?dxrdiag` understand, besides the plain on values. */
|
|
31
|
-
export const DIAG_SWITCHES = Object.freeze(['norig', 'frozen', 'nowarm', 'cold', 'nooverlay']);
|
|
47
|
+
export const DIAG_SWITCHES = Object.freeze(['norig', 'frozen', 'nowarm', 'cold', 'nooverlay', 'oldpick', 'nolayerrig']);
|
|
32
48
|
const ON_TOKENS = new Set(['1', 'on', 'true', 'yes']);
|
|
33
49
|
|
|
34
50
|
/**
|
|
@@ -179,6 +195,9 @@ export class DiagRecorder {
|
|
|
179
195
|
this._lto = null;
|
|
180
196
|
this.rigLocked = null; // norig: the summary of the rig kept
|
|
181
197
|
this._listeners = new Set();
|
|
198
|
+
this._lafo = null;
|
|
199
|
+
this._gl = null; // the wrapped context + its originals, for dispose()
|
|
200
|
+
this._burst = null; // the pick burst of the current task
|
|
182
201
|
}
|
|
183
202
|
|
|
184
203
|
has(sw) {
|
|
@@ -190,7 +209,7 @@ export class DiagRecorder {
|
|
|
190
209
|
try {
|
|
191
210
|
if (typeof PerformanceObserver !== 'function') return;
|
|
192
211
|
this._lto = new PerformanceObserver((list) => {
|
|
193
|
-
for (const e of list.getEntries()) this.longTask(e.startTime, e.duration);
|
|
212
|
+
for (const e of list.getEntries()) this.longTask(e.startTime, e.duration, e.attribution);
|
|
194
213
|
});
|
|
195
214
|
this._lto.observe({ type: 'longtask', buffered: false });
|
|
196
215
|
} catch {
|
|
@@ -198,8 +217,141 @@ export class DiagRecorder {
|
|
|
198
217
|
}
|
|
199
218
|
}
|
|
200
219
|
|
|
201
|
-
|
|
220
|
+
/**
|
|
221
|
+
* Observe long ANIMATION FRAMES (Chrome 123+): unlike a long task, each one names the scripts
|
|
222
|
+
* that ran in it — source file, function, what invoked it — so a stall is pinned on the page's
|
|
223
|
+
* code or the SDK's. Silently absent elsewhere.
|
|
224
|
+
*/
|
|
225
|
+
observeLongFrames() {
|
|
226
|
+
try {
|
|
227
|
+
if (typeof PerformanceObserver !== 'function' || !PerformanceObserver.supportedEntryTypes?.includes?.('long-animation-frame')) return;
|
|
228
|
+
this._lafo = new PerformanceObserver((list) => {
|
|
229
|
+
for (const e of list.getEntries()) this.longFrame(e);
|
|
230
|
+
});
|
|
231
|
+
this._lafo.observe({ type: 'long-animation-frame', buffered: false });
|
|
232
|
+
} catch {
|
|
233
|
+
this._lafo = null;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** One long animation frame: its span, its blocking time, and its top three scripts. */
|
|
238
|
+
longFrame(e) {
|
|
239
|
+
if (!e || !(e.duration >= 50)) return null;
|
|
240
|
+
const scripts = [...(e.scripts || [])]
|
|
241
|
+
.sort((a, b) => b.duration - a.duration)
|
|
242
|
+
.slice(0, 3)
|
|
243
|
+
.map((x) => ({
|
|
244
|
+
src: shortSrc(x.sourceURL),
|
|
245
|
+
fn: x.sourceFunctionName || '',
|
|
246
|
+
inv: String(x.invoker || x.invokerType || '').slice(0, 80),
|
|
247
|
+
d: round1(x.duration),
|
|
248
|
+
}));
|
|
249
|
+
return this._event('loaf', { s: round1(e.startTime), d: round1(e.duration), block: round1(e.blockingDuration ?? NaN), scripts }, e.startTime);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Count and time the GL calls that can block the main thread, on `gl` (the tile's own context:
|
|
254
|
+
* its own methods shadow the prototype's; dispose() removes them). Each call is booked to the
|
|
255
|
+
* phase it ran in (and to the open transition).
|
|
256
|
+
*/
|
|
257
|
+
instrumentGl(gl) {
|
|
258
|
+
if (!gl || this._gl) return;
|
|
259
|
+
const LINK = 0x8b82;
|
|
260
|
+
const COMPLETION = 0x91b1;
|
|
261
|
+
const kinds = {
|
|
262
|
+
compileShader: 'compile',
|
|
263
|
+
linkProgram: 'link',
|
|
264
|
+
getProgramParameter: (a) => (a[1] === COMPLETION ? 'poll' : a[1] === LINK ? 'linkQuery' : 'programQuery'),
|
|
265
|
+
getShaderParameter: 'shaderQuery',
|
|
266
|
+
readPixels: 'readPixels',
|
|
267
|
+
getBufferSubData: 'readback',
|
|
268
|
+
fenceSync: 'fence',
|
|
269
|
+
clientWaitSync: 'wait',
|
|
270
|
+
finish: 'finish',
|
|
271
|
+
};
|
|
272
|
+
const saved = [];
|
|
273
|
+
for (const [name, kind] of Object.entries(kinds)) {
|
|
274
|
+
const orig = gl[name];
|
|
275
|
+
if (typeof orig !== 'function') continue;
|
|
276
|
+
const rec = this;
|
|
277
|
+
gl[name] = function (...a) {
|
|
278
|
+
const t = performance.now();
|
|
279
|
+
try {
|
|
280
|
+
return orig.apply(this, a);
|
|
281
|
+
} finally {
|
|
282
|
+
rec.glCall(typeof kind === 'function' ? kind(a) : kind, performance.now() - t, t);
|
|
283
|
+
}
|
|
284
|
+
};
|
|
285
|
+
saved.push(name);
|
|
286
|
+
}
|
|
287
|
+
this._gl = { gl, saved };
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** One GL call of `kind` that took `ms`, at `t` (instrumentGl's wrappers; tests call it directly). */
|
|
291
|
+
glCall(kind, ms, t = this.now()) {
|
|
292
|
+
const b = (this._glByPhase ||= {});
|
|
293
|
+
const p = (b[this.phase] ||= {});
|
|
294
|
+
const k = (p[kind] ||= { n: 0, ms: 0 });
|
|
295
|
+
k.n++;
|
|
296
|
+
k.ms += ms;
|
|
297
|
+
if (this._open) {
|
|
298
|
+
const op = (this._open.gl[this.phase] ||= {});
|
|
299
|
+
const ok = (op[kind] ||= { n: 0, ms: 0 });
|
|
300
|
+
ok.n++;
|
|
301
|
+
ok.ms += ms;
|
|
302
|
+
}
|
|
303
|
+
// a call that blocked noticeably is an event of its own
|
|
304
|
+
if (ms >= 8) this._event('gl', { kind, ms: round1(ms) }, t);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* One handle.pick() that took `ms` (`how`: 'scan' the full scan, 'build' it built the pick
|
|
309
|
+
* index, 'index' it read the index, 'build+scan' both). Grouped into BURSTS: every pick until
|
|
310
|
+
* the task ends is one burst, logged as one event with its total.
|
|
311
|
+
*/
|
|
312
|
+
pick(ms, how = 'scan') {
|
|
313
|
+
const t = this.now();
|
|
314
|
+
if (this._open) {
|
|
315
|
+
const p = (this._open.picks[this.phase] ||= { n: 0, ms: 0 });
|
|
316
|
+
p.n++;
|
|
317
|
+
p.ms += ms;
|
|
318
|
+
}
|
|
319
|
+
let b = this._burst;
|
|
320
|
+
if (!b) {
|
|
321
|
+
b = this._burst = { t, phase: this.phase, n: 0, ms: 0, how: {} };
|
|
322
|
+
const end = () => {
|
|
323
|
+
if (this._burst !== b) return;
|
|
324
|
+
this._burst = null;
|
|
325
|
+
this._event('picks', { phase: b.phase, n: b.n, ms: round1(b.ms), how: b.how }, b.t);
|
|
326
|
+
if (b.ms >= 50) this.log(`picks: ${b.n} pick() calls in one task, ${round1(b.ms)} ms (${JSON.stringify(b.how)}) in phase ${b.phase}`);
|
|
327
|
+
};
|
|
328
|
+
postTask(end);
|
|
329
|
+
}
|
|
330
|
+
b.n++;
|
|
331
|
+
b.ms += ms;
|
|
332
|
+
b.how[how] = (b.how[how] || 0) + 1;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* The settle: `sdkMs` is the SDK's own settle work. Then the time until the task that ran it
|
|
337
|
+
* ended: what ran after the SDK resolved setSource — the page's continuation, in that same task.
|
|
338
|
+
*/
|
|
339
|
+
settled(sdkMs) {
|
|
340
|
+
const t = this.now();
|
|
341
|
+
const o = this._open;
|
|
342
|
+
this.mark('settle-sdk', { ms: round1(sdkMs) });
|
|
343
|
+
postTask(() => {
|
|
344
|
+
const ms = this.now() - t;
|
|
345
|
+
if (o) o.afterSettleTaskMs = round1(ms);
|
|
346
|
+
this._event('mark', { name: 'settle-task-end', detail: { ms: round1(ms) } }, t + ms);
|
|
347
|
+
if (o && ms >= 50) this.log(`settle: the task kept running ${round1(ms)} ms after the SDK's settle (${round1(sdkMs)} ms) — the page's continuation of setSource`);
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
longTask(start, duration, attribution) {
|
|
202
352
|
const lt = { s: round1(start), d: round1(duration) };
|
|
353
|
+
const a = attribution?.[0];
|
|
354
|
+
if (a && (a.containerType || a.containerName || a.containerSrc)) lt.attr = [a.containerType, a.containerName || a.containerSrc].filter(Boolean).join(':').slice(0, 80);
|
|
203
355
|
this.longTasks.push(lt);
|
|
204
356
|
if (this.longTasks.length > EVENT_CAP) this.longTasks.splice(0, this.longTasks.length - EVENT_CAP);
|
|
205
357
|
this._event('longtask', lt, start);
|
|
@@ -309,6 +461,9 @@ export class DiagRecorder {
|
|
|
309
461
|
firstFrameAt: NaN,
|
|
310
462
|
lastFrameAt: NaN,
|
|
311
463
|
window: [], // the frame records kept for the frozen overlay strip
|
|
464
|
+
gl: {}, // phase -> kind -> { n, ms }
|
|
465
|
+
picks: {}, // phase -> { n, ms }
|
|
466
|
+
afterSettleTaskMs: null,
|
|
312
467
|
};
|
|
313
468
|
// Frames already recorded in the lead-in (PRE_MS before the call) belong to it too.
|
|
314
469
|
for (const f of this.frames) if (f.t >= t - PRE_MS) this._open.window.push(f);
|
|
@@ -371,6 +526,12 @@ export class DiagRecorder {
|
|
|
371
526
|
heldRunMaxMs: o.heldRunMaxMs,
|
|
372
527
|
frozenImageFrames: o.frozenImg,
|
|
373
528
|
frozenImageMs: o.frozenImgMs,
|
|
529
|
+
gl: roundBuckets(o.gl),
|
|
530
|
+
picks: roundBuckets(o.picks),
|
|
531
|
+
afterSettleTaskMs: o.afterSettleTaskMs,
|
|
532
|
+
longFrames: this.events
|
|
533
|
+
.filter((e) => e.type === 'loaf' && e.s + e.d > o.callAt - PRE_MS && e.s < t)
|
|
534
|
+
.map((e) => ({ at: round1(e.s - o.callAt), d: e.d, block: e.block, scripts: e.scripts })),
|
|
374
535
|
};
|
|
375
536
|
s.verdict = verdict(s);
|
|
376
537
|
s.window = o.window.map((f) => ({ ...f, t: round1(f.t - o.callAt) }));
|
|
@@ -420,6 +581,16 @@ export class DiagRecorder {
|
|
|
420
581
|
/* already gone */
|
|
421
582
|
}
|
|
422
583
|
this._lto = null;
|
|
584
|
+
try {
|
|
585
|
+
this._lafo?.disconnect();
|
|
586
|
+
} catch {
|
|
587
|
+
/* already gone */
|
|
588
|
+
}
|
|
589
|
+
this._lafo = null;
|
|
590
|
+
if (this._gl) {
|
|
591
|
+
for (const name of this._gl.saved) delete this._gl.gl[name]; // the prototype's method again
|
|
592
|
+
this._gl = null;
|
|
593
|
+
}
|
|
423
594
|
this._listeners.clear();
|
|
424
595
|
}
|
|
425
596
|
}
|
|
@@ -434,7 +605,74 @@ export function verdict(s) {
|
|
|
434
605
|
if (s.heldRunMax >= 2) out.push(`TRACKING-HELD ${s.heldRunMax} frames (${s.heldRunMaxMs} ms) with frames still arriving`);
|
|
435
606
|
if (s.maxFrameGapMs > 50) out.push(`MAIN-THREAD gap ${s.maxFrameGapMs} ms (longest task ${s.longestTaskMs} ms)`);
|
|
436
607
|
if (s.frozenImageFrames >= 2) out.push(`IMAGE-FROZEN ${s.frozenImageFrames} frames (${s.frozenImageMs} ms) on the frozen capture`);
|
|
437
|
-
|
|
608
|
+
if (!out.length) return 'CLEAN (no hold, no gap > 50 ms, no frozen image)';
|
|
609
|
+
// What was on the main thread: blocking GL calls, the page's picks, the settle task's tail.
|
|
610
|
+
const why = [];
|
|
611
|
+
const gl = sumBuckets(s.gl, ['compile', 'link', 'linkQuery', 'readPixels', 'readback', 'wait', 'finish']);
|
|
612
|
+
if (gl.n) why.push(`GL compile/link/sync ×${gl.n} ${gl.ms} ms`);
|
|
613
|
+
const pk = sumBuckets(s.picks);
|
|
614
|
+
if (pk.n) why.push(`pick() ×${pk.n} ${pk.ms} ms`);
|
|
615
|
+
if (s.afterSettleTaskMs >= 50) why.push(`page code ${s.afterSettleTaskMs} ms in the settle task`);
|
|
616
|
+
const top = (s.longFrames || []).flatMap((f) => f.scripts || []).sort((a, b) => b.d - a.d)[0];
|
|
617
|
+
if (top) why.push(`top script ${top.fn || '(anonymous)'}@${top.src} ${top.d} ms`);
|
|
618
|
+
return out.join(' + ') + (why.length ? ` — ${why.join('; ')}` : '');
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
/** { phase: { kind: { n, ms } } } or { phase: { n, ms } } → the same, ms rounded. */
|
|
622
|
+
function roundBuckets(b) {
|
|
623
|
+
const out = {};
|
|
624
|
+
for (const [ph, v] of Object.entries(b || {})) {
|
|
625
|
+
if (v && typeof v.n === 'number') out[ph] = { n: v.n, ms: round1(v.ms) };
|
|
626
|
+
else {
|
|
627
|
+
out[ph] = {};
|
|
628
|
+
for (const [k, x] of Object.entries(v || {})) out[ph][k] = { n: x.n, ms: round1(x.ms) };
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
return out;
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
/** Total { n, ms } over every phase (and, for GL buckets, over `kinds`). */
|
|
635
|
+
function sumBuckets(b, kinds = null) {
|
|
636
|
+
let n = 0;
|
|
637
|
+
let ms = 0;
|
|
638
|
+
for (const v of Object.values(b || {})) {
|
|
639
|
+
if (v && typeof v.n === 'number') {
|
|
640
|
+
n += v.n;
|
|
641
|
+
ms += v.ms;
|
|
642
|
+
} else {
|
|
643
|
+
for (const [k, x] of Object.entries(v || {})) {
|
|
644
|
+
if (kinds && !kinds.includes(k)) continue;
|
|
645
|
+
n += x.n;
|
|
646
|
+
ms += x.ms;
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
return { n, ms: round1(ms) };
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
/** Run `fn` in a NEW task (after the current one and its microtasks): MessageChannel, else a timeout. */
|
|
654
|
+
function postTask(fn) {
|
|
655
|
+
try {
|
|
656
|
+
if (typeof MessageChannel === 'function') {
|
|
657
|
+
const ch = new MessageChannel();
|
|
658
|
+
ch.port1.onmessage = () => {
|
|
659
|
+
ch.port1.close();
|
|
660
|
+
fn();
|
|
661
|
+
};
|
|
662
|
+
ch.port2.postMessage(0);
|
|
663
|
+
return;
|
|
664
|
+
}
|
|
665
|
+
} catch {
|
|
666
|
+
/* fall through */
|
|
667
|
+
}
|
|
668
|
+
setTimeout(fn, 0);
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
/** A script URL, trimmed to its file name (plus a query, cut short). */
|
|
672
|
+
function shortSrc(url) {
|
|
673
|
+
if (!url) return '';
|
|
674
|
+
const m = /([^/?#]+)(\?[^#]*)?(#.*)?$/.exec(String(url));
|
|
675
|
+
return m ? m[1] + (m[2] ? m[2].slice(0, 20) : '') : String(url).slice(-60);
|
|
438
676
|
}
|
|
439
677
|
|
|
440
678
|
function defaultLog(msg) {
|