@displayxr/inline3d 1.21.1 → 1.22.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 +21 -0
- package/js/inline3d-splat-diag.js +240 -4
- package/js/inline3d-splat-playcanvas.js +223 -3
- package/package.json +1 -1
- package/splat.d.ts +3 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,27 @@ 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.22.0 — 2026-09-25
|
|
9
|
+
|
|
10
|
+
Touches the **preview tier** (`./splat`, `engine: 'playcanvas'` only). No API change; no pixel change.
|
|
11
|
+
|
|
12
|
+
- **A burst of `pick()` calls no longer blocks the main thread.** Each pick scanned every centre of
|
|
13
|
+
the photo (~7.5 ms for 1.18M on an M1, more on a panel PC); a page that picks 24 then 81 times
|
|
14
|
+
right after `setSource` resolves (a photo slideshow app planning its companion's path) stalled
|
|
15
|
+
the main thread for ~1.3 s at every swap's end, so the woven image stopped following the head.
|
|
16
|
+
A second pick from the same eye position now builds a pick index (about two scans), and every
|
|
17
|
+
further pick from there reads a few cells: 105 picks went from ~820 ms to ~70 ms on an M1. The
|
|
18
|
+
answer is unchanged (the full scan's, exactly; the full scan still runs when the pick's cone is
|
|
19
|
+
empty). `?dxrdiag=oldpick` restores the full scan.
|
|
20
|
+
- **`?dxrdiag` names what is on the main thread.** Per transition phase: every GL call that can
|
|
21
|
+
block (compile, link, status queries, readbacks, syncs; count and ms), `pick()` calls and their
|
|
22
|
+
per-task bursts, how long the settle's task kept running after the SDK resolved `setSource` (the
|
|
23
|
+
page's continuation), and long animation frames with their top scripts (file + function). The
|
|
24
|
+
verdict names them. The settle teardown is a User Timing measure, `inline3d:settle:teardown`.
|
|
25
|
+
- **Measured: the SDK's settle does no GL work that can block.** Zero compile, link, status query,
|
|
26
|
+
readback or sync from the `setSource` call to 1.5 s after the settle, for `reassemble`,
|
|
27
|
+
`crossfade` and `wavefront` (docs/playcanvas-adapter.md § Diagnosing transition stalls).
|
|
28
|
+
|
|
8
29
|
## 1.21.1 — 2026-09-24
|
|
9
30
|
|
|
10
31
|
Touches the **preview tier** (`./splat`, `engine: 'playcanvas'` only). No API change; nothing changes at rest.
|
|
@@ -26,9 +26,23 @@
|
|
|
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
|
+
//
|
|
32
|
+
// WHAT RUNS ON THE MAIN THREAD, per transition phase (the fifth cause: a stall at the swap's END
|
|
33
|
+
// that is not the SDK's transition at all):
|
|
34
|
+
// - GL calls that can block: shader compile / program link / program + shader status queries
|
|
35
|
+
// (a link resolves there), readPixels, getBufferSubData, fenceSync / clientWaitSync, finish —
|
|
36
|
+
// counted and timed, per phase, on the tile's own context (./inline3d-splat-diag.js wraps
|
|
37
|
+
// them while diag is on).
|
|
38
|
+
// - handle.pick() calls, counted and timed per phase and grouped into BURSTS (one task's worth).
|
|
39
|
+
// - long animation frames (where the browser has the API) with their top SCRIPTS — the source
|
|
40
|
+
// file and function that ran: the page's own code or the SDK's.
|
|
41
|
+
// - the settle itself: the SDK's settle work in ms, then how long the SAME task kept running
|
|
42
|
+
// after it (the page's continuation of `await setSource`).
|
|
29
43
|
|
|
30
44
|
/** The switches `diag` / `?dxrdiag` understand, besides the plain on values. */
|
|
31
|
-
export const DIAG_SWITCHES = Object.freeze(['norig', 'frozen', 'nowarm', 'cold', 'nooverlay']);
|
|
45
|
+
export const DIAG_SWITCHES = Object.freeze(['norig', 'frozen', 'nowarm', 'cold', 'nooverlay', 'oldpick']);
|
|
32
46
|
const ON_TOKENS = new Set(['1', 'on', 'true', 'yes']);
|
|
33
47
|
|
|
34
48
|
/**
|
|
@@ -179,6 +193,9 @@ export class DiagRecorder {
|
|
|
179
193
|
this._lto = null;
|
|
180
194
|
this.rigLocked = null; // norig: the summary of the rig kept
|
|
181
195
|
this._listeners = new Set();
|
|
196
|
+
this._lafo = null;
|
|
197
|
+
this._gl = null; // the wrapped context + its originals, for dispose()
|
|
198
|
+
this._burst = null; // the pick burst of the current task
|
|
182
199
|
}
|
|
183
200
|
|
|
184
201
|
has(sw) {
|
|
@@ -190,7 +207,7 @@ export class DiagRecorder {
|
|
|
190
207
|
try {
|
|
191
208
|
if (typeof PerformanceObserver !== 'function') return;
|
|
192
209
|
this._lto = new PerformanceObserver((list) => {
|
|
193
|
-
for (const e of list.getEntries()) this.longTask(e.startTime, e.duration);
|
|
210
|
+
for (const e of list.getEntries()) this.longTask(e.startTime, e.duration, e.attribution);
|
|
194
211
|
});
|
|
195
212
|
this._lto.observe({ type: 'longtask', buffered: false });
|
|
196
213
|
} catch {
|
|
@@ -198,8 +215,141 @@ export class DiagRecorder {
|
|
|
198
215
|
}
|
|
199
216
|
}
|
|
200
217
|
|
|
201
|
-
|
|
218
|
+
/**
|
|
219
|
+
* Observe long ANIMATION FRAMES (Chrome 123+): unlike a long task, each one names the scripts
|
|
220
|
+
* that ran in it — source file, function, what invoked it — so a stall is pinned on the page's
|
|
221
|
+
* code or the SDK's. Silently absent elsewhere.
|
|
222
|
+
*/
|
|
223
|
+
observeLongFrames() {
|
|
224
|
+
try {
|
|
225
|
+
if (typeof PerformanceObserver !== 'function' || !PerformanceObserver.supportedEntryTypes?.includes?.('long-animation-frame')) return;
|
|
226
|
+
this._lafo = new PerformanceObserver((list) => {
|
|
227
|
+
for (const e of list.getEntries()) this.longFrame(e);
|
|
228
|
+
});
|
|
229
|
+
this._lafo.observe({ type: 'long-animation-frame', buffered: false });
|
|
230
|
+
} catch {
|
|
231
|
+
this._lafo = null;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** One long animation frame: its span, its blocking time, and its top three scripts. */
|
|
236
|
+
longFrame(e) {
|
|
237
|
+
if (!e || !(e.duration >= 50)) return null;
|
|
238
|
+
const scripts = [...(e.scripts || [])]
|
|
239
|
+
.sort((a, b) => b.duration - a.duration)
|
|
240
|
+
.slice(0, 3)
|
|
241
|
+
.map((x) => ({
|
|
242
|
+
src: shortSrc(x.sourceURL),
|
|
243
|
+
fn: x.sourceFunctionName || '',
|
|
244
|
+
inv: String(x.invoker || x.invokerType || '').slice(0, 80),
|
|
245
|
+
d: round1(x.duration),
|
|
246
|
+
}));
|
|
247
|
+
return this._event('loaf', { s: round1(e.startTime), d: round1(e.duration), block: round1(e.blockingDuration ?? NaN), scripts }, e.startTime);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Count and time the GL calls that can block the main thread, on `gl` (the tile's own context:
|
|
252
|
+
* its own methods shadow the prototype's; dispose() removes them). Each call is booked to the
|
|
253
|
+
* phase it ran in (and to the open transition).
|
|
254
|
+
*/
|
|
255
|
+
instrumentGl(gl) {
|
|
256
|
+
if (!gl || this._gl) return;
|
|
257
|
+
const LINK = 0x8b82;
|
|
258
|
+
const COMPLETION = 0x91b1;
|
|
259
|
+
const kinds = {
|
|
260
|
+
compileShader: 'compile',
|
|
261
|
+
linkProgram: 'link',
|
|
262
|
+
getProgramParameter: (a) => (a[1] === COMPLETION ? 'poll' : a[1] === LINK ? 'linkQuery' : 'programQuery'),
|
|
263
|
+
getShaderParameter: 'shaderQuery',
|
|
264
|
+
readPixels: 'readPixels',
|
|
265
|
+
getBufferSubData: 'readback',
|
|
266
|
+
fenceSync: 'fence',
|
|
267
|
+
clientWaitSync: 'wait',
|
|
268
|
+
finish: 'finish',
|
|
269
|
+
};
|
|
270
|
+
const saved = [];
|
|
271
|
+
for (const [name, kind] of Object.entries(kinds)) {
|
|
272
|
+
const orig = gl[name];
|
|
273
|
+
if (typeof orig !== 'function') continue;
|
|
274
|
+
const rec = this;
|
|
275
|
+
gl[name] = function (...a) {
|
|
276
|
+
const t = performance.now();
|
|
277
|
+
try {
|
|
278
|
+
return orig.apply(this, a);
|
|
279
|
+
} finally {
|
|
280
|
+
rec.glCall(typeof kind === 'function' ? kind(a) : kind, performance.now() - t, t);
|
|
281
|
+
}
|
|
282
|
+
};
|
|
283
|
+
saved.push(name);
|
|
284
|
+
}
|
|
285
|
+
this._gl = { gl, saved };
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** One GL call of `kind` that took `ms`, at `t` (instrumentGl's wrappers; tests call it directly). */
|
|
289
|
+
glCall(kind, ms, t = this.now()) {
|
|
290
|
+
const b = (this._glByPhase ||= {});
|
|
291
|
+
const p = (b[this.phase] ||= {});
|
|
292
|
+
const k = (p[kind] ||= { n: 0, ms: 0 });
|
|
293
|
+
k.n++;
|
|
294
|
+
k.ms += ms;
|
|
295
|
+
if (this._open) {
|
|
296
|
+
const op = (this._open.gl[this.phase] ||= {});
|
|
297
|
+
const ok = (op[kind] ||= { n: 0, ms: 0 });
|
|
298
|
+
ok.n++;
|
|
299
|
+
ok.ms += ms;
|
|
300
|
+
}
|
|
301
|
+
// a call that blocked noticeably is an event of its own
|
|
302
|
+
if (ms >= 8) this._event('gl', { kind, ms: round1(ms) }, t);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* One handle.pick() that took `ms` (`how`: 'scan' the full scan, 'build' it built the pick
|
|
307
|
+
* index, 'index' it read the index, 'build+scan' both). Grouped into BURSTS: every pick until
|
|
308
|
+
* the task ends is one burst, logged as one event with its total.
|
|
309
|
+
*/
|
|
310
|
+
pick(ms, how = 'scan') {
|
|
311
|
+
const t = this.now();
|
|
312
|
+
if (this._open) {
|
|
313
|
+
const p = (this._open.picks[this.phase] ||= { n: 0, ms: 0 });
|
|
314
|
+
p.n++;
|
|
315
|
+
p.ms += ms;
|
|
316
|
+
}
|
|
317
|
+
let b = this._burst;
|
|
318
|
+
if (!b) {
|
|
319
|
+
b = this._burst = { t, phase: this.phase, n: 0, ms: 0, how: {} };
|
|
320
|
+
const end = () => {
|
|
321
|
+
if (this._burst !== b) return;
|
|
322
|
+
this._burst = null;
|
|
323
|
+
this._event('picks', { phase: b.phase, n: b.n, ms: round1(b.ms), how: b.how }, b.t);
|
|
324
|
+
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}`);
|
|
325
|
+
};
|
|
326
|
+
postTask(end);
|
|
327
|
+
}
|
|
328
|
+
b.n++;
|
|
329
|
+
b.ms += ms;
|
|
330
|
+
b.how[how] = (b.how[how] || 0) + 1;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* The settle: `sdkMs` is the SDK's own settle work. Then the time until the task that ran it
|
|
335
|
+
* ended: what ran after the SDK resolved setSource — the page's continuation, in that same task.
|
|
336
|
+
*/
|
|
337
|
+
settled(sdkMs) {
|
|
338
|
+
const t = this.now();
|
|
339
|
+
const o = this._open;
|
|
340
|
+
this.mark('settle-sdk', { ms: round1(sdkMs) });
|
|
341
|
+
postTask(() => {
|
|
342
|
+
const ms = this.now() - t;
|
|
343
|
+
if (o) o.afterSettleTaskMs = round1(ms);
|
|
344
|
+
this._event('mark', { name: 'settle-task-end', detail: { ms: round1(ms) } }, t + ms);
|
|
345
|
+
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`);
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
longTask(start, duration, attribution) {
|
|
202
350
|
const lt = { s: round1(start), d: round1(duration) };
|
|
351
|
+
const a = attribution?.[0];
|
|
352
|
+
if (a && (a.containerType || a.containerName || a.containerSrc)) lt.attr = [a.containerType, a.containerName || a.containerSrc].filter(Boolean).join(':').slice(0, 80);
|
|
203
353
|
this.longTasks.push(lt);
|
|
204
354
|
if (this.longTasks.length > EVENT_CAP) this.longTasks.splice(0, this.longTasks.length - EVENT_CAP);
|
|
205
355
|
this._event('longtask', lt, start);
|
|
@@ -309,6 +459,9 @@ export class DiagRecorder {
|
|
|
309
459
|
firstFrameAt: NaN,
|
|
310
460
|
lastFrameAt: NaN,
|
|
311
461
|
window: [], // the frame records kept for the frozen overlay strip
|
|
462
|
+
gl: {}, // phase -> kind -> { n, ms }
|
|
463
|
+
picks: {}, // phase -> { n, ms }
|
|
464
|
+
afterSettleTaskMs: null,
|
|
312
465
|
};
|
|
313
466
|
// Frames already recorded in the lead-in (PRE_MS before the call) belong to it too.
|
|
314
467
|
for (const f of this.frames) if (f.t >= t - PRE_MS) this._open.window.push(f);
|
|
@@ -371,6 +524,12 @@ export class DiagRecorder {
|
|
|
371
524
|
heldRunMaxMs: o.heldRunMaxMs,
|
|
372
525
|
frozenImageFrames: o.frozenImg,
|
|
373
526
|
frozenImageMs: o.frozenImgMs,
|
|
527
|
+
gl: roundBuckets(o.gl),
|
|
528
|
+
picks: roundBuckets(o.picks),
|
|
529
|
+
afterSettleTaskMs: o.afterSettleTaskMs,
|
|
530
|
+
longFrames: this.events
|
|
531
|
+
.filter((e) => e.type === 'loaf' && e.s + e.d > o.callAt - PRE_MS && e.s < t)
|
|
532
|
+
.map((e) => ({ at: round1(e.s - o.callAt), d: e.d, block: e.block, scripts: e.scripts })),
|
|
374
533
|
};
|
|
375
534
|
s.verdict = verdict(s);
|
|
376
535
|
s.window = o.window.map((f) => ({ ...f, t: round1(f.t - o.callAt) }));
|
|
@@ -420,6 +579,16 @@ export class DiagRecorder {
|
|
|
420
579
|
/* already gone */
|
|
421
580
|
}
|
|
422
581
|
this._lto = null;
|
|
582
|
+
try {
|
|
583
|
+
this._lafo?.disconnect();
|
|
584
|
+
} catch {
|
|
585
|
+
/* already gone */
|
|
586
|
+
}
|
|
587
|
+
this._lafo = null;
|
|
588
|
+
if (this._gl) {
|
|
589
|
+
for (const name of this._gl.saved) delete this._gl.gl[name]; // the prototype's method again
|
|
590
|
+
this._gl = null;
|
|
591
|
+
}
|
|
423
592
|
this._listeners.clear();
|
|
424
593
|
}
|
|
425
594
|
}
|
|
@@ -434,7 +603,74 @@ export function verdict(s) {
|
|
|
434
603
|
if (s.heldRunMax >= 2) out.push(`TRACKING-HELD ${s.heldRunMax} frames (${s.heldRunMaxMs} ms) with frames still arriving`);
|
|
435
604
|
if (s.maxFrameGapMs > 50) out.push(`MAIN-THREAD gap ${s.maxFrameGapMs} ms (longest task ${s.longestTaskMs} ms)`);
|
|
436
605
|
if (s.frozenImageFrames >= 2) out.push(`IMAGE-FROZEN ${s.frozenImageFrames} frames (${s.frozenImageMs} ms) on the frozen capture`);
|
|
437
|
-
|
|
606
|
+
if (!out.length) return 'CLEAN (no hold, no gap > 50 ms, no frozen image)';
|
|
607
|
+
// What was on the main thread: blocking GL calls, the page's picks, the settle task's tail.
|
|
608
|
+
const why = [];
|
|
609
|
+
const gl = sumBuckets(s.gl, ['compile', 'link', 'linkQuery', 'readPixels', 'readback', 'wait', 'finish']);
|
|
610
|
+
if (gl.n) why.push(`GL compile/link/sync ×${gl.n} ${gl.ms} ms`);
|
|
611
|
+
const pk = sumBuckets(s.picks);
|
|
612
|
+
if (pk.n) why.push(`pick() ×${pk.n} ${pk.ms} ms`);
|
|
613
|
+
if (s.afterSettleTaskMs >= 50) why.push(`page code ${s.afterSettleTaskMs} ms in the settle task`);
|
|
614
|
+
const top = (s.longFrames || []).flatMap((f) => f.scripts || []).sort((a, b) => b.d - a.d)[0];
|
|
615
|
+
if (top) why.push(`top script ${top.fn || '(anonymous)'}@${top.src} ${top.d} ms`);
|
|
616
|
+
return out.join(' + ') + (why.length ? ` — ${why.join('; ')}` : '');
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
/** { phase: { kind: { n, ms } } } or { phase: { n, ms } } → the same, ms rounded. */
|
|
620
|
+
function roundBuckets(b) {
|
|
621
|
+
const out = {};
|
|
622
|
+
for (const [ph, v] of Object.entries(b || {})) {
|
|
623
|
+
if (v && typeof v.n === 'number') out[ph] = { n: v.n, ms: round1(v.ms) };
|
|
624
|
+
else {
|
|
625
|
+
out[ph] = {};
|
|
626
|
+
for (const [k, x] of Object.entries(v || {})) out[ph][k] = { n: x.n, ms: round1(x.ms) };
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
return out;
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
/** Total { n, ms } over every phase (and, for GL buckets, over `kinds`). */
|
|
633
|
+
function sumBuckets(b, kinds = null) {
|
|
634
|
+
let n = 0;
|
|
635
|
+
let ms = 0;
|
|
636
|
+
for (const v of Object.values(b || {})) {
|
|
637
|
+
if (v && typeof v.n === 'number') {
|
|
638
|
+
n += v.n;
|
|
639
|
+
ms += v.ms;
|
|
640
|
+
} else {
|
|
641
|
+
for (const [k, x] of Object.entries(v || {})) {
|
|
642
|
+
if (kinds && !kinds.includes(k)) continue;
|
|
643
|
+
n += x.n;
|
|
644
|
+
ms += x.ms;
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
return { n, ms: round1(ms) };
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
/** Run `fn` in a NEW task (after the current one and its microtasks): MessageChannel, else a timeout. */
|
|
652
|
+
function postTask(fn) {
|
|
653
|
+
try {
|
|
654
|
+
if (typeof MessageChannel === 'function') {
|
|
655
|
+
const ch = new MessageChannel();
|
|
656
|
+
ch.port1.onmessage = () => {
|
|
657
|
+
ch.port1.close();
|
|
658
|
+
fn();
|
|
659
|
+
};
|
|
660
|
+
ch.port2.postMessage(0);
|
|
661
|
+
return;
|
|
662
|
+
}
|
|
663
|
+
} catch {
|
|
664
|
+
/* fall through */
|
|
665
|
+
}
|
|
666
|
+
setTimeout(fn, 0);
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
/** A script URL, trimmed to its file name (plus a query, cut short). */
|
|
670
|
+
function shortSrc(url) {
|
|
671
|
+
if (!url) return '';
|
|
672
|
+
const m = /([^/?#]+)(\?[^#]*)?(#.*)?$/.exec(String(url));
|
|
673
|
+
return m ? m[1] + (m[2] ? m[2].slice(0, 20) : '') : String(url).slice(-60);
|
|
438
674
|
}
|
|
439
675
|
|
|
440
676
|
function defaultLog(msg) {
|
|
@@ -68,7 +68,7 @@ import {
|
|
|
68
68
|
} from './inline3d-splat-effects.js';
|
|
69
69
|
import { LiveOutgoing, resolveOutgoingOption, defaultOutgoing, yieldIdle } from './inline3d-splat-live.js';
|
|
70
70
|
import { VideoPlane, validateSetVideo, PAGE_VIDEO_ERROR } from './inline3d-splat-video.js';
|
|
71
|
-
import { resolveDiag, DiagRecorder, startDiagLoop, registerDiag } from './inline3d-splat-diag.js';
|
|
71
|
+
import { resolveDiag, DiagRecorder, startDiagLoop, registerDiag, DIAG_SWITCHES } from './inline3d-splat-diag.js';
|
|
72
72
|
import {
|
|
73
73
|
clamp,
|
|
74
74
|
finite,
|
|
@@ -614,6 +614,136 @@ export function nearestCentreToRay(xyz, origin, dir, coneRad = PICK_CONE_RAD, al
|
|
|
614
614
|
return bestInCone || bestAny;
|
|
615
615
|
}
|
|
616
616
|
|
|
617
|
+
/** Cells per side of a pick index (PICK_INDEX_GRID² cells over the view's direction square). */
|
|
618
|
+
export const PICK_INDEX_GRID = 128;
|
|
619
|
+
|
|
620
|
+
/**
|
|
621
|
+
* A pick INDEX over one centre set, for one eye position: every usable centre (opacity at or above
|
|
622
|
+
* RIG_MIN_OPACITY, in front of the eye) bucketed by its DIRECTION from the eye — its gnomonic
|
|
623
|
+
* coordinates (u, v) = ((p − eye)·right, (p − eye)·up) / ((p − eye)·fwd) on a PICK_INDEX_GRID² grid
|
|
624
|
+
* over [−U, U] × [−V, V] (centres outside land in the border cells). One pass over the set, about
|
|
625
|
+
* one full-scan pick's cost; every later pick from the same eye then reads only the few cells
|
|
626
|
+
* around its ray (queryPickIndex), not the whole set.
|
|
627
|
+
*
|
|
628
|
+
* Why (a photo slideshow app, on the panel): a page that picks in bursts — 24 rays to plan a
|
|
629
|
+
* companion's waypoints, then an 81-ray depth grid, on every photo swap — ran 105 full scans of a
|
|
630
|
+
* 1.18M-centre photo on the main thread, two long tasks of ~0.3 s and ~1 s right at the swap's
|
|
631
|
+
* end: no session frames, so the woven image stopped following the head.
|
|
632
|
+
*
|
|
633
|
+
* `f`, `r`, `up`: an orthonormal frame (any; the eye's own is the tightest). Returns null when
|
|
634
|
+
* nothing usable is in front of the eye.
|
|
635
|
+
*/
|
|
636
|
+
export function buildPickIndex(xyz, count, alpha8, eye, f, r, up, U, V, G = PICK_INDEX_GRID) {
|
|
637
|
+
const n = count;
|
|
638
|
+
const cells = G * G;
|
|
639
|
+
const su = G / (2 * U);
|
|
640
|
+
const sv = G / (2 * V);
|
|
641
|
+
const minA = Math.ceil(RIG_MIN_OPACITY * 255);
|
|
642
|
+
const cellOf = new Uint16Array(n); // G ≤ 255: cells < 65535, which marks "not indexed"
|
|
643
|
+
const start = new Uint32Array(cells + 1);
|
|
644
|
+
const [ex, ey, ez] = eye;
|
|
645
|
+
const [f0, f1, f2] = f;
|
|
646
|
+
const [r0, r1, r2] = r;
|
|
647
|
+
const [u0, u1, u2] = up;
|
|
648
|
+
let m = 0;
|
|
649
|
+
for (let i = 0; i < n; i++) {
|
|
650
|
+
if (alpha8 && alpha8[i] < minA) {
|
|
651
|
+
cellOf[i] = 65535;
|
|
652
|
+
continue;
|
|
653
|
+
}
|
|
654
|
+
const rx = xyz[i * 3] - ex;
|
|
655
|
+
const ry = xyz[i * 3 + 1] - ey;
|
|
656
|
+
const rz = xyz[i * 3 + 2] - ez;
|
|
657
|
+
const z = rx * f0 + ry * f1 + rz * f2;
|
|
658
|
+
if (!(z > 0)) {
|
|
659
|
+
cellOf[i] = 65535;
|
|
660
|
+
continue;
|
|
661
|
+
}
|
|
662
|
+
// z > 0 held, so every coordinate is finite (a NaN or ∞ one makes z NaN). Truncation, not
|
|
663
|
+
// floor: below the grid's edge both land in the border cell after the clamp.
|
|
664
|
+
const iz = 1 / z;
|
|
665
|
+
let cu = ((rx * r0 + ry * r1 + rz * r2) * iz + U) * su;
|
|
666
|
+
let cv = ((rx * u0 + ry * u1 + rz * u2) * iz + V) * sv;
|
|
667
|
+
cu = cu < 0 ? 0 : cu >= G ? G - 1 : cu | 0;
|
|
668
|
+
cv = cv < 0 ? 0 : cv >= G ? G - 1 : cv | 0;
|
|
669
|
+
const c = cv * G + cu;
|
|
670
|
+
cellOf[i] = c;
|
|
671
|
+
start[c + 1]++;
|
|
672
|
+
m++;
|
|
673
|
+
}
|
|
674
|
+
if (!m) return null;
|
|
675
|
+
for (let c = 0; c < cells; c++) start[c + 1] += start[c];
|
|
676
|
+
const order = new Uint32Array(m);
|
|
677
|
+
const cursor = start.slice(0, cells);
|
|
678
|
+
for (let i = 0; i < n; i++) {
|
|
679
|
+
const c = cellOf[i];
|
|
680
|
+
if (c !== 65535) order[cursor[c]++] = i;
|
|
681
|
+
}
|
|
682
|
+
return { eye: [ex, ey, ez], f: [f0, f1, f2], r: [r0, r1, r2], up: [u0, u1, u2], U, V, G, su, sv, start, order, size: m };
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
/**
|
|
686
|
+
* nearestCentreToRay's IN-CONE answer from a pick index: the same point, exactly (the same
|
|
687
|
+
* arithmetic on the same centres, ties to the lowest index as the full scan's first-wins), read
|
|
688
|
+
* from the cells the cone can reach. The ray must pass through the index's eye (any pick ray of
|
|
689
|
+
* that view does: it runs from its near-plane point through the eye). Returns the point, or
|
|
690
|
+
* undefined when the index cannot answer — no centre inside the cone (the full scan's
|
|
691
|
+
* nearest-by-angle fallback needs every centre), a ray not through the eye, or one too far off
|
|
692
|
+
* the index's axis — and the caller runs the full scan.
|
|
693
|
+
*
|
|
694
|
+
* Why the cells suffice: along a ray through the eye, t from the near-plane origin is t from the
|
|
695
|
+
* eye minus the near distance, so perp / t (the cone test) ≥ perp / t_eye = tan of the angle at
|
|
696
|
+
* the eye. A centre inside the cone is within atan(coneRad) of the ray AT THE EYE, and a cap of
|
|
697
|
+
* angular radius θ around a direction φ0 off the axis projects (gnomonic) inside a radius of
|
|
698
|
+
* tan(φ0 + θ) − tan(φ0) around the ray's own (u, v). The search square takes 1.25× that plus a
|
|
699
|
+
* cell.
|
|
700
|
+
*/
|
|
701
|
+
export function queryPickIndex(ix, xyz, origin, dir, coneRad = PICK_CONE_RAD) {
|
|
702
|
+
const { eye, f, r, up, U, V, G, su, sv, start, order } = ix;
|
|
703
|
+
// the ray must pass through the eye (a relative tolerance: numerical noise, not a design gap)
|
|
704
|
+
const ox = eye[0] - origin[0], oy = eye[1] - origin[1], oz = eye[2] - origin[2];
|
|
705
|
+
const along = ox * dir[0] + oy * dir[1] + oz * dir[2];
|
|
706
|
+
const off2 = ox * ox + oy * oy + oz * oz - along * along;
|
|
707
|
+
if (!(along < 0) || off2 > 1e-12 * (along * along) + 1e-18) return undefined; // the eye sits BEHIND the near-plane origin
|
|
708
|
+
const dz = dir[0] * f[0] + dir[1] * f[1] + dir[2] * f[2];
|
|
709
|
+
if (!(dz > 0)) return undefined;
|
|
710
|
+
const du = (dir[0] * r[0] + dir[1] * r[1] + dir[2] * r[2]) / dz;
|
|
711
|
+
const dv = (dir[0] * up[0] + dir[1] * up[1] + dir[2] * up[2]) / dz;
|
|
712
|
+
const rho = Math.hypot(du, dv);
|
|
713
|
+
const phi0 = Math.atan(rho);
|
|
714
|
+
const th = Math.atan(coneRad) * 1.05 + 1e-6;
|
|
715
|
+
if (phi0 + th >= 1.4) return undefined; // ~80°: the projection's stretch is no longer worth bounding
|
|
716
|
+
const R = (Math.tan(phi0 + th) - rho) * 1.25;
|
|
717
|
+
const clampCell = (x) => (x < 0 ? 0 : x >= G ? G - 1 : x);
|
|
718
|
+
const cu0 = clampCell(Math.floor((du - R + U) * su) - 1);
|
|
719
|
+
const cu1 = clampCell(Math.floor((du + R + U) * su) + 1);
|
|
720
|
+
const cv0 = clampCell(Math.floor((dv - R + V) * sv) - 1);
|
|
721
|
+
const cv1 = clampCell(Math.floor((dv + R + V) * sv) + 1);
|
|
722
|
+
if (!(cu0 <= cu1 && cv0 <= cv1)) return undefined;
|
|
723
|
+
let bestT = Infinity;
|
|
724
|
+
let bestI = -1;
|
|
725
|
+
for (let cv = cv0; cv <= cv1; cv++) {
|
|
726
|
+
const row = cv * G;
|
|
727
|
+
for (let k = start[row + cu0], k1 = start[row + cu1 + 1]; k < k1; k++) {
|
|
728
|
+
const i = order[k];
|
|
729
|
+
// nearestCentreToRay's own arithmetic, term for term
|
|
730
|
+
const rx = xyz[i * 3] - origin[0];
|
|
731
|
+
const ry = xyz[i * 3 + 1] - origin[1];
|
|
732
|
+
const rz = xyz[i * 3 + 2] - origin[2];
|
|
733
|
+
const t = rx * dir[0] + ry * dir[1] + rz * dir[2];
|
|
734
|
+
if (!(t > 0)) continue;
|
|
735
|
+
const perp = Math.sqrt(Math.max(0, rx * rx + ry * ry + rz * rz - t * t));
|
|
736
|
+
const angle = perp / t;
|
|
737
|
+
if (angle <= coneRad && (t < bestT || (t === bestT && i < bestI))) {
|
|
738
|
+
bestT = t;
|
|
739
|
+
bestI = i;
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
if (bestI < 0) return undefined;
|
|
744
|
+
return [xyz[bestI * 3], xyz[bestI * 3 + 1], xyz[bestI * 3 + 2]];
|
|
745
|
+
}
|
|
746
|
+
|
|
617
747
|
// ── the viewer: pose state + frame loop + the engine ────────────────────────────────────────
|
|
618
748
|
|
|
619
749
|
/**
|
|
@@ -2695,13 +2825,14 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
|
|
|
2695
2825
|
|
|
2696
2826
|
// ── diagnostics (diag / ?dxrdiag — ./inline3d-splat-diag.js) ──
|
|
2697
2827
|
const diagCfg = resolveDiag(opts.diag);
|
|
2698
|
-
if (diagCfg.unknown.length) console.warn(`[inline3d/splat] diag: unknown switch(es) ${diagCfg.unknown.join(', ')} — known:
|
|
2828
|
+
if (diagCfg.unknown.length) console.warn(`[inline3d/splat] diag: unknown switch(es) ${diagCfg.unknown.join(', ')} — known: ${DIAG_SWITCHES.join(', ')}.`);
|
|
2699
2829
|
const diag = diagCfg.on ? new DiagRecorder({ switches: diagCfg.switches }) : null;
|
|
2700
2830
|
/** norig: armed by the first setSource — from then on the declared rig is kept. */
|
|
2701
2831
|
let rigLockArmed = false;
|
|
2702
2832
|
if (diag) {
|
|
2703
2833
|
diag.imageState = () => viewer.diagImageState();
|
|
2704
2834
|
diag.observeLongTasks();
|
|
2835
|
+
diag.observeLongFrames();
|
|
2705
2836
|
diag.log(`on — switches [${[...diag.switches].join(', ') || 'none'}]; dump: copy(__dxrDiag.dump())`);
|
|
2706
2837
|
}
|
|
2707
2838
|
let removed = false;
|
|
@@ -3156,11 +3287,91 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
|
|
|
3156
3287
|
return best;
|
|
3157
3288
|
}
|
|
3158
3289
|
const full = c.res?.centers;
|
|
3159
|
-
if (full && full.length) return
|
|
3290
|
+
if (full && full.length) return pickFull(c, full, ray);
|
|
3160
3291
|
// The engine released its centres: fall back to the strided set kept at load.
|
|
3161
3292
|
return c.pickCentres ? nearestCentreToRay(c.pickCentres, ray.o, ray.d) : null;
|
|
3162
3293
|
}
|
|
3163
3294
|
|
|
3295
|
+
/**
|
|
3296
|
+
* The eye of the view on screen and an orthonormal frame at it, in MODEL space, plus the
|
|
3297
|
+
* direction extent of its frustum (gnomonic, per axis): what a pick index is built for. Every
|
|
3298
|
+
* pick ray of this view passes through `eye`. null when the view is not a plain perspective one.
|
|
3299
|
+
*/
|
|
3300
|
+
function pickFrame() {
|
|
3301
|
+
const v = viewer.currentView();
|
|
3302
|
+
const invP = mat4Invert(v.proj);
|
|
3303
|
+
if (!invP) return null;
|
|
3304
|
+
const T = mat4Mul(viewer.rigMatrix(), v.pose);
|
|
3305
|
+
const at = (x, y, z) => contentToModel(transformPoint(T, x, y, z));
|
|
3306
|
+
const eye = at(0, 0, 0);
|
|
3307
|
+
const sub = (p) => [p[0] - eye[0], p[1] - eye[1], p[2] - eye[2]];
|
|
3308
|
+
const dot = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
|
|
3309
|
+
const norm = (a) => {
|
|
3310
|
+
const l = Math.hypot(a[0], a[1], a[2]);
|
|
3311
|
+
return l > 0 ? [a[0] / l, a[1] / l, a[2] / l] : null;
|
|
3312
|
+
};
|
|
3313
|
+
const f = norm(sub(at(0, 0, -1)));
|
|
3314
|
+
if (!f) return null;
|
|
3315
|
+
let r = sub(at(1, 0, 0));
|
|
3316
|
+
const rf = dot(r, f);
|
|
3317
|
+
r = norm([r[0] - rf * f[0], r[1] - rf * f[1], r[2] - rf * f[2]]);
|
|
3318
|
+
if (!r) return null;
|
|
3319
|
+
const up = [f[1] * r[2] - f[2] * r[1], f[2] * r[0] - f[0] * r[2], f[0] * r[1] - f[1] * r[0]];
|
|
3320
|
+
let U = 0;
|
|
3321
|
+
let V = 0;
|
|
3322
|
+
for (const [x, y] of [[-1, -1], [1, -1], [-1, 1], [1, 1]]) {
|
|
3323
|
+
const d = sub(at(...transformPoint(invP, x, y, 1)));
|
|
3324
|
+
const z = dot(d, f);
|
|
3325
|
+
if (!(z > 0)) return null;
|
|
3326
|
+
U = Math.max(U, Math.abs(dot(d, r) / z));
|
|
3327
|
+
V = Math.max(V, Math.abs(dot(d, up) / z));
|
|
3328
|
+
}
|
|
3329
|
+
if (!(U > 0 && V > 0)) return null;
|
|
3330
|
+
// a margin past the frustum: a pick at its edge reaches a little outside it
|
|
3331
|
+
U = U * 1.1 + 0.02;
|
|
3332
|
+
V = V * 1.1 + 0.02;
|
|
3333
|
+
return { eye, f, r, up, U, V, key: [...eye, ...f, ...r, U, V].join(',') };
|
|
3334
|
+
}
|
|
3335
|
+
|
|
3336
|
+
/**
|
|
3337
|
+
* A pick over the full centre set. The first pick from an eye position is the plain full scan
|
|
3338
|
+
* (a page picking once per frame pays what it always did); a SECOND pick from the same eye
|
|
3339
|
+
* builds a pick index for it (buildPickIndex: about one scan) and every further pick from there
|
|
3340
|
+
* reads a few cells — a burst of N picks costs ~2 scans, not N. Same answer as the full scan
|
|
3341
|
+
* (queryPickIndex falls back to it when it cannot answer exactly). `?dxrdiag=oldpick`: always
|
|
3342
|
+
* the full scan (the 1.21.1 path), for A/B.
|
|
3343
|
+
*/
|
|
3344
|
+
function pickFull(c, full, ray) {
|
|
3345
|
+
const t0 = diag ? performance.now() : 0;
|
|
3346
|
+
let how = 'scan';
|
|
3347
|
+
let hit;
|
|
3348
|
+
if (!diag?.has('oldpick')) {
|
|
3349
|
+
const fr = pickFrame();
|
|
3350
|
+
if (fr) {
|
|
3351
|
+
const same = (p) => p && p.key === fr.key && p.xyz === full && p.alpha8 === c.alpha8;
|
|
3352
|
+
let ix = same(c.pickIx) ? c.pickIx.ix : null;
|
|
3353
|
+
if (!ix && same(c.pickPrev)) {
|
|
3354
|
+
const count = Math.floor(full.length / 3);
|
|
3355
|
+
ix = buildPickIndex(full, count, c.alpha8, fr.eye, fr.f, fr.r, fr.up, fr.U, fr.V);
|
|
3356
|
+
c.pickIx = ix ? { key: fr.key, xyz: full, alpha8: c.alpha8, ix } : null;
|
|
3357
|
+
how = 'build';
|
|
3358
|
+
}
|
|
3359
|
+
c.pickPrev = { key: fr.key, xyz: full, alpha8: c.alpha8 };
|
|
3360
|
+
if (ix) {
|
|
3361
|
+
hit = queryPickIndex(ix, full, ray.o, ray.d);
|
|
3362
|
+
if (hit !== undefined && how === 'scan') how = 'index';
|
|
3363
|
+
}
|
|
3364
|
+
}
|
|
3365
|
+
}
|
|
3366
|
+
if (hit === undefined) {
|
|
3367
|
+
hit = nearestCentreToRay(full, ray.o, ray.d, undefined, c.alpha8);
|
|
3368
|
+
if (how === 'build') how = 'build+scan';
|
|
3369
|
+
else if (how === 'index') how = 'scan';
|
|
3370
|
+
}
|
|
3371
|
+
if (diag) diag.pick(performance.now() - t0, how);
|
|
3372
|
+
return hit;
|
|
3373
|
+
}
|
|
3374
|
+
|
|
3164
3375
|
function bindFocusInput() {
|
|
3165
3376
|
// controls:'page' binds no input at all: gestures are the page's (pick + setFocus remain).
|
|
3166
3377
|
if (pageMode || focusInput === false || unbindFocusInput) return;
|
|
@@ -3601,6 +3812,9 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
|
|
|
3601
3812
|
});
|
|
3602
3813
|
if (!app || removed) return null;
|
|
3603
3814
|
pcModule = pc;
|
|
3815
|
+
// diag: count the GL calls that can block (compile, link, status queries, readbacks, syncs),
|
|
3816
|
+
// from before the first asset's compile on
|
|
3817
|
+
if (diag) diag.instrumentGl(app.graphicsDevice?.gl);
|
|
3604
3818
|
/**
|
|
3605
3819
|
* ADVANCED, not covered by the semver promise: the engine objects behind this window.
|
|
3606
3820
|
* `app` is the tile's `pc.AppBase`; `root` the content root, in the splat's content space
|
|
@@ -4217,10 +4431,13 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
|
|
|
4217
4431
|
const finish = () => {
|
|
4218
4432
|
if (finished) return;
|
|
4219
4433
|
finished = true;
|
|
4434
|
+
const ts = performance.now();
|
|
4220
4435
|
for (const f of finishers) f();
|
|
4436
|
+
perfSpan('settle:teardown', ts);
|
|
4221
4437
|
if (gen === sourceGen) diag?.setPhase('settle');
|
|
4222
4438
|
if (pendingSwap?.finish === finish) pendingSwap = null;
|
|
4223
4439
|
settle();
|
|
4440
|
+
if (gen === sourceGen) diag?.settled(performance.now() - ts);
|
|
4224
4441
|
};
|
|
4225
4442
|
pendingSwap = { finish };
|
|
4226
4443
|
|
|
@@ -4429,12 +4646,15 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
|
|
|
4429
4646
|
const finish = () => {
|
|
4430
4647
|
if (finished) return;
|
|
4431
4648
|
finished = true;
|
|
4649
|
+
const ts = performance.now();
|
|
4432
4650
|
for (const f of finishers) f();
|
|
4651
|
+
perfSpan('settle:teardown', ts);
|
|
4433
4652
|
for (const w of [...wakers]) w();
|
|
4434
4653
|
wakers.clear();
|
|
4435
4654
|
viewer._transitionState = null;
|
|
4436
4655
|
if (gen === sourceGen) diag?.setPhase('settle');
|
|
4437
4656
|
if (pendingSwap?.finish === finish) pendingSwap = null;
|
|
4657
|
+
if (gen === sourceGen) diag?.settled(performance.now() - ts);
|
|
4438
4658
|
};
|
|
4439
4659
|
const ac = typeof AbortController === 'function' ? new AbortController() : null;
|
|
4440
4660
|
finishers.push(() => ac?.abort());
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@displayxr/inline3d",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.22.0",
|
|
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
|
@@ -941,7 +941,9 @@ export interface SplatHandle {
|
|
|
941
941
|
* What is under a point on the canvas, in the splat's own space — the double-click's pick.
|
|
942
942
|
* PlayCanvas: the nearest gaussian CENTRE to the ray over the FULL centre set (haze under 5 %
|
|
943
943
|
* opacity skipped); on a Streamed SOG, over the chunks currently resident. Spark: its surface
|
|
944
|
-
* raycast, falling back to the nearest centre.
|
|
944
|
+
* raycast, falling back to the nearest centre. PlayCanvas: a BURST of picks from one view (the
|
|
945
|
+
* same frame) builds a pick index at the second one, so N picks cost about three full scans, not
|
|
946
|
+
* N; the answer is the full scan's, exactly.
|
|
945
947
|
*/
|
|
946
948
|
pick(clientX: number, clientY: number): number[] | null;
|
|
947
949
|
/**
|