@displayxr/inline3d 1.21.0 → 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 CHANGED
@@ -5,6 +5,36 @@ 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
+
29
+ ## 1.21.1 — 2026-09-24
30
+
31
+ Touches the **preview tier** (`./splat`, `engine: 'playcanvas'` only). No API change; nothing changes at rest.
32
+
33
+ - **A gated `reveal` no longer shows its start state frozen.** The splat is hidden until the reveal's clock
34
+ starts, so its first visible frame is the first frame of motion; previously the start state (e.g.
35
+ `assemble`'s scatter) sat still for up to the rest of the `firstWoven` hold, then jumped. On a cached
36
+ asset the tile is empty for the rest of the hold instead.
37
+
8
38
  ## 1.21.0 — 2026-09-24
9
39
 
10
40
  Touches the **preview tier** (`./splat`, `engine: 'playcanvas'` only). Minor: a new `setSource`
@@ -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
- longTask(start, duration) {
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
- return out.length ? out.join(' + ') : 'CLEAN (no hold, no gap > 50 ms, no frozen image)';
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: norig, frozen, nowarm, cold, nooverlay.`);
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 nearestCentreToRay(full, ray.o, ray.d, undefined, c.alpha8);
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
@@ -3630,7 +3844,22 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
3630
3844
  // tile is woven (handle.firstWoven — which is immediate in 2D) and the first frames are built.
3631
3845
  if (revealSpec) {
3632
3846
  const gate = Promise.resolve(out.firstWoven).then(() => afterTicks(2));
3847
+ // The splat stays HIDDEN until the gate opens, so the first frame anyone sees is the first
3848
+ // frame of motion. The gate is `firstWoven`, a worst-case hold of up to 1.2 s past the
3849
+ // layer's birth (docs/woven-canvas-rules.md §5), and the browser usually joins the canvas
3850
+ // well before it ends. Drawn meanwhile, the start state (a particle reveal's scatter) sat
3851
+ // frozen on the woven panel for the rest of the hold and then jumped into motion. Hidden,
3852
+ // the tile is empty for that span, which also keeps stereo pixels off a canvas that may
3853
+ // not be joined yet (rule 5). Registered after fx.play's own `.then`, so it lands in the
3854
+ // same microtask that releases the effect: the clock starts on the frame it first shows.
3855
+ entity.enabled = false;
3633
3856
  fx.play(revealSpec.type, revealSpec.raw, { gate });
3857
+ gate.then(() => {
3858
+ if (removed || current?.entity !== entity) return;
3859
+ // setVideo took the splat off screen meanwhile: its exit restores what it saved.
3860
+ if (vid?.saved?.entity === entity) vid.saved.splatEnabled = true;
3861
+ else entity.enabled = true;
3862
+ });
3634
3863
  }
3635
3864
  for (const [what, name, arg] of fxPending.splice(0)) {
3636
3865
  if (what === 'set') fx.set(name, arg);
@@ -4202,10 +4431,13 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
4202
4431
  const finish = () => {
4203
4432
  if (finished) return;
4204
4433
  finished = true;
4434
+ const ts = performance.now();
4205
4435
  for (const f of finishers) f();
4436
+ perfSpan('settle:teardown', ts);
4206
4437
  if (gen === sourceGen) diag?.setPhase('settle');
4207
4438
  if (pendingSwap?.finish === finish) pendingSwap = null;
4208
4439
  settle();
4440
+ if (gen === sourceGen) diag?.settled(performance.now() - ts);
4209
4441
  };
4210
4442
  pendingSwap = { finish };
4211
4443
 
@@ -4414,12 +4646,15 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
4414
4646
  const finish = () => {
4415
4647
  if (finished) return;
4416
4648
  finished = true;
4649
+ const ts = performance.now();
4417
4650
  for (const f of finishers) f();
4651
+ perfSpan('settle:teardown', ts);
4418
4652
  for (const w of [...wakers]) w();
4419
4653
  wakers.clear();
4420
4654
  viewer._transitionState = null;
4421
4655
  if (gen === sourceGen) diag?.setPhase('settle');
4422
4656
  if (pendingSwap?.finish === finish) pendingSwap = null;
4657
+ if (gen === sourceGen) diag?.settled(performance.now() - ts);
4423
4658
  };
4424
4659
  const ac = typeof AbortController === 'function' ? new AbortController() : null;
4425
4660
  finishers.push(() => ac?.abort());
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@displayxr/inline3d",
3
- "version": "1.21.0",
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
  /**