@displayxr/inline3d 1.1.0 → 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,72 @@ 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.1.1 — 2026-08-20
9
+
10
+ ### Fixed
11
+
12
+ - **`./viewer` validates a frame before it clears the canvas — the dark blink under GPU load
13
+ (web#12).** `SceneViewer.onFrame` cleared unconditionally and then rendered whatever it could.
14
+ Under load the session hands the callback a **short view list** — one view, or none, a per-frame
15
+ mono fallback — and the old loop turned that into a cleared buffer with a single origin-camera
16
+ view drawn into it whose content is entirely near-plane-clipped: a fully transparent
17
+ side-by-side buffer, i.e. **one dark woven tile**. The blink was the viewer's, not the weave's;
18
+ it was reported as a compositor fault (glTF and splat tiles blinking on a busy box) with the
19
+ whole submit/match path provably healthy.
20
+
21
+ Now every disqualifying condition — a short view list, a `null` or degenerate
22
+ `layer.getViewport(view)`, a disposed viewer — is checked **while the canvas still holds the
23
+ last good image**, and only a frame that will draw is allowed to clear. A frame that cannot
24
+ draw **replays the last good one** from per-eye `Float32Array(16)` copies of
25
+ `projectionMatrix` / `transform.matrix` plus the viewport rects (copies, because an `XRView` is
26
+ valid only inside its own frame callback), rather than skipping the commit — the SDK's
27
+ every-frame-repaint invariant is real, and an un-redrawn canvas can drop out of the aggregated
28
+ frame and leave the weave reading a stale sub-rect. A one-frame-stale eye pose is
29
+ imperceptible; a black frame and a smear are not. Before the first good frame there is nothing
30
+ to replay, and the frame simply returns without clearing.
31
+
32
+ **This changes pixels only on frames that were previously black.** A frame that passed
33
+ validation renders byte-for-byte as it did in 1.1.0 — same clear, same viewports, same
34
+ matrices, same order. *(preview tier)*
35
+
36
+ - **A no-op resize no longer blanks the tile (web#12).** `renderer.setSize()` writes
37
+ `canvas.width`/`canvas.height` unconditionally, and writing either — *including the same value*
38
+ — reallocates and clears the drawing buffer. `ResizeObserver` fires on things that leave the
39
+ buffer's dimensions exactly where they were (a sub-pixel reflow, a scrollbar coming and going, a
40
+ sibling settling), and its callback runs after rAF and before paint, so each one committed a
41
+ black frame with nothing on the way to repaint it. `_resize` now compares against
42
+ `renderer.domElement.width/height` and returns early when nothing moved; a real change resizes
43
+ and then **immediately** re-renders from the replay cache (rects scaled to the new buffer), so
44
+ the cleared store never reaches the compositor. Observer bursts coalesce to one animation frame,
45
+ matching what the core already does for its own windows. *(preview tier)*
46
+
47
+ - **`SceneViewer` without `useEyeCamera()` says so instead of rendering nothing.** With no
48
+ `./three` glue the 3D path had no eye camera, so it cleared and drew nothing every frame,
49
+ forever, in silence — and this module's own header example omitted the call, making the failure
50
+ reachable by copy-paste. It now warns once and renders the **mono camera** into both eye
51
+ viewports (flat, but visible), and the example passes `EyeCamera`. `./splat` and `./model` were
52
+ never affected — they supply the glue for you. *(preview tier)*
53
+
54
+ ### Added
55
+
56
+ - **`EyeCamera.setFromMatrices(projectionMatrix, transformMatrix)`** — the same two matrices an
57
+ `XRView` carries, handed over separately, for re-drawing a frame you have already drawn.
58
+ `setFromView` is now a one-line forward to it, so a replay path can never drift from the live
59
+ one. *(core tier — additive)*
60
+ - **`handle.stats()` → `{ frames, monoFrames }`** on the handle every `add*()` returns. For scene
61
+ windows, `monoFrames` counts the deliveries that carried fewer than two views — the
62
+ load-induced fallback that used to be invisible from the page, since nothing throws and nothing
63
+ logs. A rising ratio is the machine telling you the session is degrading before it becomes a bug
64
+ report about "blinking"; one throttled `console.debug` (the first, then 1-in-300) names the
65
+ rate. The core's own contract is unchanged: the view list is passed to `onFrame` exactly as
66
+ reported, filtered by nothing and synthesised from nothing. *(core tier — additive)*
67
+ - **Unit tests.** `test/*.test.mjs` under `node --test`, with the DOM and three.js stubbed by
68
+ hand (`test/stubs.mjs`) so the test run needs no dependency either. They pin the rules above:
69
+ zero `clear()` calls for an empty view list, a one-eye list, a null viewport and a missing
70
+ layer; a replay that renders the cached matrices and survives the UA recycling the views it
71
+ cached from; no `setSize` on a no-op resize; an immediate repaint after a real one. 13 of the
72
+ 15 fail against 1.1.0. Wired into CI as a second job.
73
+
8
74
  ## 1.1.0 — 2026-08-19
9
75
 
10
76
  ### Added
package/index.d.ts CHANGED
@@ -52,6 +52,18 @@ export interface TileHandle {
52
52
  * @deprecated See {@link TileHandle.exclude} — no-op on browsers with draw-order occlusion.
53
53
  */
54
54
  unexclude(el: Element): void;
55
+ /**
56
+ * Per-window frame counters, for diagnosing the load-induced mono fallback.
57
+ *
58
+ * `frames` counts `onFrame` deliveries; `monoFrames` counts the ones that carried fewer than
59
+ * two views — a session under GPU pressure reporting a single view where it normally reports
60
+ * two. `./viewer` replays its last good stereo frame for those rather than clearing (web#12);
61
+ * a rising ratio is the machine telling you the session is falling back, and is worth
62
+ * surfacing before it turns into a bug report about "blinking".
63
+ *
64
+ * Scene windows only — image/video windows always report `{ frames: 0, monoFrames: 0 }`.
65
+ */
66
+ stats(): { frames: number; monoFrames: number };
55
67
  }
56
68
 
57
69
  /** An open inline-3D session you add weaved windows to. Returned by {@link createInline3D}. */
@@ -60,10 +60,31 @@ export class EyeCamera {
60
60
 
61
61
  /** Set the camera's projection + world pose from an XRView (call once per eye per frame). */
62
62
  setFromView(view) {
63
+ return this.setFromMatrices(view.projectionMatrix, view.transform.matrix);
64
+ }
65
+
66
+ /**
67
+ * Set the camera from RAW matrices — the same two an XRView carries, handed over
68
+ * separately.
69
+ *
70
+ * WHY THIS EXISTS AND NOT JUST setFromView. An `XRView` is valid only inside the frame
71
+ * callback that produced it: hold one and its matrices are live views onto memory the UA
72
+ * recycles. So a renderer that wants to re-draw a frame it has ALREADY drawn — because
73
+ * this frame's view list arrived short, or because the backing store was just reallocated
74
+ * and cleared — cannot keep the view; it has to keep a COPY of the two matrices and feed
75
+ * them back here. `./viewer`'s last-good replay does exactly that (see SceneViewer.onFrame).
76
+ *
77
+ * Deliberately the single implementation of both: setFromView is a one-line forward, so
78
+ * the replay path can never drift from the live one.
79
+ *
80
+ * @param {ArrayLike<number>} projectionMatrix 16 floats, column-major (view.projectionMatrix).
81
+ * @param {ArrayLike<number>} transformMatrix 16 floats, column-major (view.transform.matrix).
82
+ */
83
+ setFromMatrices(projectionMatrix, transformMatrix) {
63
84
  const cam = this.camera;
64
- cam.projectionMatrix.fromArray(view.projectionMatrix);
85
+ cam.projectionMatrix.fromArray(projectionMatrix);
65
86
  cam.projectionMatrixInverse.copy(cam.projectionMatrix).invert();
66
- cam.matrix.fromArray(view.transform.matrix);
87
+ cam.matrix.fromArray(transformMatrix);
67
88
  cam.matrixWorld.copy(cam.matrix);
68
89
  cam.matrixWorldInverse.copy(cam.matrixWorld).invert();
69
90
  return cam;
@@ -20,9 +20,11 @@
20
20
  //
21
21
  // import * as THREE from 'three';
22
22
  // import { createInline3D } from '@displayxr/inline3d';
23
+ // import { EyeCamera } from '@displayxr/inline3d/three';
23
24
  // import { SceneViewer } from '@displayxr/inline3d/viewer';
24
25
  //
25
26
  // const viewer = new SceneViewer(THREE, canvas, { virtualDisplayHeight: 0.18 });
27
+ // viewer.useEyeCamera(EyeCamera); // REQUIRED for stereo; ./splat and ./model do it
26
28
  // viewer.content.add(myMesh);
27
29
  // viewer.fitTo(center, extent); // model-space bounds of the subject
28
30
  // const wall = await createInline3D();
@@ -226,6 +228,12 @@ export class SceneViewer {
226
228
  this._monoRaf = 0;
227
229
  this._mode = '3d'; // drives the backing-store shape; see _resize
228
230
  this._disposed = false;
231
+ this._resizePending = false;
232
+ // Last frame this viewer actually DREW, as raw matrices + viewport rects — never XRViews,
233
+ // which are only valid inside their own frame callback. See _cacheGood / _replayLastGood.
234
+ this._lastGood = null;
235
+ this._vps = []; // scratch, reused per frame so validation allocates nothing
236
+ this._warnedNoEye = false;
229
237
 
230
238
  this._reduceMotion =
231
239
  typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches;
@@ -238,7 +246,10 @@ export class SceneViewer {
238
246
  // plane to be in focus at, so we just look at the framed subject from the front.
239
247
  this.monoCamera = new THREE.PerspectiveCamera(35, 1, 0.001, 1000);
240
248
 
241
- this._onResize = () => this._resize();
249
+ // Coalesced: ResizeObserver and window resize both fire in BURSTS during a drag-resize or a
250
+ // zoom, and every genuine resize reallocates (and clears) the backing store. One rAF per
251
+ // burst, exactly as the core does for its own windows (inline3d.js _onBoxChange).
252
+ this._onResize = () => this._scheduleResize();
242
253
  this._ro = typeof ResizeObserver === 'function' ? new ResizeObserver(this._onResize) : null;
243
254
  if (this._ro) this._ro.observe(canvas);
244
255
  else addEventListener('resize', this._onResize);
@@ -348,6 +359,22 @@ export class SceneViewer {
348
359
  /**
349
360
  * The per-frame callback for `wall.addScene`. Renders the scene once per eye into the
350
361
  * side-by-side halves the layer reports.
362
+ *
363
+ * VALIDATE BEFORE YOU CLEAR — the dark-blink rule (web#12). `r.clear()` is the point of no
364
+ * return: after it the canvas is transparent-black, and if the frame then fails to draw
365
+ * anything over it, that empty buffer is what the weave consumes. Under GPU load the session
366
+ * can hand this callback a SHORT view list (one view, or none — a per-frame mono fallback),
367
+ * and the old loop cleared first and rendered what it could: a single origin-camera view whose
368
+ * content is entirely near-plane-clipped, i.e. a fully transparent side-by-side buffer, i.e.
369
+ * one dark woven tile. The blink was ours, not the weave's.
370
+ *
371
+ * So: everything that can disqualify a frame is checked while the canvas still holds the last
372
+ * good image, and only a frame that WILL draw is allowed to clear. A frame that cannot draw
373
+ * REPLAYS the last good one instead (see _replayLastGood) rather than skipping the commit —
374
+ * the SDK's every-frame-repaint invariant is real (inline3d.js `_frame`: a canvas that isn't
375
+ * redrawn can have its layer dropped from the aggregated frame and the weave then reads a
376
+ * stale sub-rect, which smears). A one-frame-stale eye pose is imperceptible; a smear and a
377
+ * black frame are not.
351
378
  */
352
379
  onFrame(views, layer) {
353
380
  if (this._disposed) return;
@@ -355,23 +382,62 @@ export class SceneViewer {
355
382
  // after a scroll-away/scroll-back). Take the buffer back to the SBS shape when that happens
356
383
  // — otherwise the first 3D frames render into a 1:1 store and each eye is half a subject.
357
384
  if (this._mode !== '3d') this.stopMono();
385
+ // Before the validation gate on purpose: a replayed frame still damps and still turns on the
386
+ // turntable, so only the EYE pose is one frame stale, not the whole scene.
358
387
  this._tick();
359
- const r = this.renderer;
388
+
389
+ // 1. A short view list is the load-induced mono fallback. Stereo needs two.
390
+ if (!views || views.length < 2) {
391
+ this._replayLastGood();
392
+ return;
393
+ }
394
+
395
+ // 2. No ./three glue: the 3D path has no eye camera to build. This used to clear and draw
396
+ // NOTHING, silently, forever — and this module's own header example omitted
397
+ // useEyeCamera() until now, so the failure was reachable by copy-paste. Both ends are
398
+ // fixed: the example passes it, and this says so once and renders the mono camera, which
399
+ // at least shows the subject (flat, both halves the same) instead of a dark tile.
360
400
  const eye = this._ensureEye();
401
+ if (!eye && !this._warnedNoEye) {
402
+ this._warnedNoEye = true;
403
+ console.warn(
404
+ '[inline3d] SceneViewer.onFrame without useEyeCamera(): falling back to the mono camera. ' +
405
+ 'Pass the ./three glue — viewer.useEyeCamera(EyeCamera, EdgeFeather) — for real ' +
406
+ 'off-axis stereo. (./splat and ./model do this for you.)',
407
+ );
408
+ }
409
+
410
+ // 3. Every eye must have a viewport to render into. A missing or degenerate one means this
411
+ // frame cannot fill the buffer, so it must not empty it either.
412
+ const vps = this._vps;
413
+ vps.length = 0;
414
+ for (const view of views) {
415
+ const vp = layer && typeof layer.getViewport === 'function' ? layer.getViewport(view) : null;
416
+ if (!vp || !(vp.width > 0) || !(vp.height > 0)) {
417
+ this._replayLastGood();
418
+ return;
419
+ }
420
+ vps.push(vp);
421
+ }
422
+
423
+ // Validated: this frame WILL draw over everything it clears.
424
+ const r = this.renderer;
361
425
  r.clear();
362
426
  r.setScissorTest(true);
363
- for (const view of views) {
364
- const vp = layer.getViewport(view);
365
- if (!vp) continue;
427
+ for (let i = 0; i < views.length; i++) {
428
+ const vp = vps[i];
366
429
  r.setViewport(vp.x, vp.y, vp.width, vp.height);
367
430
  r.setScissor(vp.x, vp.y, vp.width, vp.height);
368
431
  if (eye) {
369
- eye.setFromView(view);
432
+ eye.setFromView(views[i]);
370
433
  r.render(this.scene, eye.camera);
434
+ } else {
435
+ r.render(this.scene, this.monoCamera);
371
436
  }
372
437
  if (this._feather) this._feather.render(r, vp);
373
438
  }
374
439
  r.setScissorTest(false);
440
+ this._cacheGood(views, vps, !eye);
375
441
  }
376
442
 
377
443
  /**
@@ -418,6 +484,8 @@ export class SceneViewer {
418
484
 
419
485
  dispose() {
420
486
  this._disposed = true;
487
+ this._resizePending = false;
488
+ this._lastGood = null;
421
489
  this.stopMono();
422
490
  if (this._ro) this._ro.disconnect();
423
491
  else removeEventListener('resize', this._onResize);
@@ -432,6 +500,127 @@ export class SceneViewer {
432
500
  return this._eye;
433
501
  }
434
502
 
503
+ /**
504
+ * Remember the frame just drawn, so a frame that CANNOT draw has something to put on the
505
+ * canvas instead of a clear (web#12).
506
+ *
507
+ * COPIES, never references. An `XRView` — and the `projectionMatrix` / `transform.matrix`
508
+ * hanging off it — is valid only inside the frame callback that delivered it; the UA is free
509
+ * to recycle that memory afterwards. Retaining one would give a replay that reads whatever
510
+ * the next frame happened to write there, which is a worse bug than the blink. So each eye
511
+ * gets two `Float32Array(16)` copies, allocated once and overwritten in place: the cache
512
+ * costs 128 bytes an eye and zero allocations per frame.
513
+ *
514
+ * The buffer dimensions go in too, so a replay after a resize can scale the rects (the SBS
515
+ * split is proportional, so the scaling is exact).
516
+ */
517
+ _cacheGood(views, vps, mono) {
518
+ const el = this.renderer.domElement || this.canvas;
519
+ let g = this._lastGood;
520
+ if (!g || g.entries.length !== views.length) {
521
+ g = this._lastGood = { entries: [], mono, bufW: 0, bufH: 0 };
522
+ for (let i = 0; i < views.length; i++) {
523
+ g.entries.push({
524
+ proj: new Float32Array(16),
525
+ pose: new Float32Array(16),
526
+ x: 0,
527
+ y: 0,
528
+ width: 0,
529
+ height: 0,
530
+ });
531
+ }
532
+ }
533
+ g.mono = mono;
534
+ g.bufW = el.width || 0;
535
+ g.bufH = el.height || 0;
536
+ for (let i = 0; i < views.length; i++) {
537
+ const e = g.entries[i];
538
+ const vp = vps[i];
539
+ if (!mono) {
540
+ const view = views[i];
541
+ e.proj.set(view.projectionMatrix);
542
+ e.pose.set(view.transform.matrix);
543
+ }
544
+ e.x = vp.x;
545
+ e.y = vp.y;
546
+ e.width = vp.width;
547
+ e.height = vp.height;
548
+ }
549
+ }
550
+
551
+ /**
552
+ * Re-render the last good frame from the cached matrices. Returns false when there is no
553
+ * cache yet — and the caller must then do NOTHING, not clear: before the first good frame
554
+ * the canvas holds either the page's own initial state or the mono fallback's output, both
555
+ * of which are better than black.
556
+ */
557
+ _replayLastGood() {
558
+ const g = this._lastGood;
559
+ if (!g || this._disposed) return false;
560
+ const r = this.renderer;
561
+ const eye = g.mono ? null : this._ensureEye();
562
+ const el = this.renderer.domElement || this.canvas;
563
+ // A resize between the cache and the replay changes the buffer, not the split.
564
+ const sx = g.bufW > 0 && el.width ? el.width / g.bufW : 1;
565
+ const sy = g.bufH > 0 && el.height ? el.height / g.bufH : 1;
566
+ const scaled = sx !== 1 || sy !== 1;
567
+ r.clear();
568
+ r.setScissorTest(true);
569
+ for (const e of g.entries) {
570
+ const vp = scaled
571
+ ? {
572
+ x: Math.round(e.x * sx),
573
+ y: Math.round(e.y * sy),
574
+ width: Math.max(1, Math.round(e.width * sx)),
575
+ height: Math.max(1, Math.round(e.height * sy)),
576
+ }
577
+ : e;
578
+ r.setViewport(vp.x, vp.y, vp.width, vp.height);
579
+ r.setScissor(vp.x, vp.y, vp.width, vp.height);
580
+ if (eye) {
581
+ eye.setFromMatrices(e.proj, e.pose);
582
+ r.render(this.scene, eye.camera);
583
+ } else {
584
+ r.render(this.scene, this.monoCamera);
585
+ }
586
+ if (this._feather) this._feather.render(r, vp);
587
+ }
588
+ r.setScissorTest(false);
589
+ return true;
590
+ }
591
+
592
+ /** One rAF per burst of observer callbacks. See the _onResize comment. */
593
+ _scheduleResize() {
594
+ if (this._disposed || this._resizePending) return;
595
+ this._resizePending = true;
596
+ const run = () => {
597
+ if (!this._resizePending) return;
598
+ this._resizePending = false;
599
+ this._resize();
600
+ };
601
+ if (typeof requestAnimationFrame === 'function') requestAnimationFrame(run);
602
+ else run();
603
+ }
604
+
605
+ /**
606
+ * Put the last good frame back on a buffer that was just cleared, NOW — not on the next
607
+ * animation frame. A ResizeObserver callback runs after rAF and before paint, so the frame
608
+ * that reallocated the buffer is the frame that gets committed: without this the tile weaves
609
+ * one black frame per box change, with nothing on the way to repaint it. Mirrors the core's
610
+ * "repaint NOW: setting canvas.width cleared the buffer" (inline3d.js _onBoxChange).
611
+ */
612
+ _repaintAfterResize() {
613
+ if (this._disposed) return;
614
+ if (this._mode === 'mono') {
615
+ const r = this.renderer;
616
+ r.clear();
617
+ r.setViewport(0, 0, this.canvas.width, this.canvas.height);
618
+ r.render(this.scene, this.monoCamera);
619
+ return;
620
+ }
621
+ this._replayLastGood();
622
+ }
623
+
435
624
  _applyTransform() {
436
625
  const s = this._fitScale * this._zoom;
437
626
  this._pivot.scale.setScalar(s);
@@ -454,6 +643,13 @@ export class SceneViewer {
454
643
  * getViewport() splits canvas.width in half for the two eyes — the browser squashing that
455
644
  * 2:1 buffer into the 1:1 CSS box IS the side-by-side squeeze, and the weave un-squeezes it.
456
645
  * In mono it must stay 1:1 or the flat render is stretched.
646
+ *
647
+ * NON-DESTRUCTIVE (web#12). `setSize` writes `canvas.width`/`canvas.height` UNCONDITIONALLY,
648
+ * and writing either one reallocates and CLEARS the drawing buffer even when the value does
649
+ * not change. Since a ResizeObserver fires on plenty of things that leave the buffer's
650
+ * dimensions exactly where they were (a sub-pixel reflow, a scrollbar appearing and going, a
651
+ * sibling settling), the old unconditional call meant a black frame for every no-op. So:
652
+ * compare first, and when it IS a real change, put the picture back before the frame commits.
457
653
  */
458
654
  _resize() {
459
655
  if (this._disposed) return;
@@ -462,9 +658,14 @@ export class SceneViewer {
462
658
  const dpr = Math.min(window.devicePixelRatio || 1, 2) * this.renderScale;
463
659
  const w = Math.max(1, Math.round(box.width * dpr));
464
660
  const h = Math.max(1, Math.round(box.height * dpr));
465
- this.renderer.setSize(this._mode === 'mono' ? w : w * 2, h, false);
661
+ const bufW = this._mode === 'mono' ? w : w * 2;
662
+ // Cheap and always correct to refresh, whether or not the backing store moves.
466
663
  this.monoCamera.aspect = box.width / box.height;
467
664
  this.monoCamera.updateProjectionMatrix();
665
+ const el = this.renderer.domElement || this.canvas;
666
+ if (el.width === bufW && el.height === h) return; // observer fired, geometry didn't move
667
+ this.renderer.setSize(bufW, h, false);
668
+ this._repaintAfterResize();
468
669
  }
469
670
 
470
671
  /** Damping + idle turntable. Called once per rendered frame, 3D or mono. */
package/js/inline3d.js CHANGED
@@ -528,6 +528,9 @@ class Inline3D {
528
528
  if (!el || !win.excluded.delete(el)) return;
529
529
  this._dropExclusion(win, el);
530
530
  },
531
+ // Read-only counters, for pages that want to see the load-induced mono fallback rather
532
+ // than wait for a bug report about "blinking". Scene windows only; 0/0 elsewhere.
533
+ stats: () => ({ frames: win.frames, monoFrames: win.monoFrames }),
531
534
  };
532
535
  }
533
536
 
@@ -574,6 +577,10 @@ class Inline3D {
574
577
  // Box/dpr watch, live only while the window is (see _startSizeWatch).
575
578
  sizeObserver: null,
576
579
  resizePending: false,
580
+ // Scene diagnostics (web#12), read back through the handle's stats(). frames counts
581
+ // onFrame deliveries; monoFrames counts the ones that carried fewer than two views.
582
+ frames: 0,
583
+ monoFrames: 0,
577
584
  };
578
585
  this._windows.set(canvas, win);
579
586
  if (this._lazy && this._observer) {
@@ -1020,7 +1027,32 @@ class Inline3D {
1020
1027
  for (const win of this._windows.values()) {
1021
1028
  if (!win.layer) continue;
1022
1029
  if (win.kind === 'scene') {
1023
- if (views && win.onFrame) win.onFrame(views, win.layer, f);
1030
+ if (views && win.onFrame) {
1031
+ // Count the SHORT view lists and hand them over unchanged. Under GPU load the session
1032
+ // can report a single view (a per-frame mono fallback) where it normally reports two,
1033
+ // and a renderer that clears before it validates turns that into a dark tile
1034
+ // (web#12 — ./viewer now validates first and replays its last good frame instead).
1035
+ //
1036
+ // The core deliberately does NOT filter or synthesise: the contract is "here is what
1037
+ // the frame reported", and a window that can do something sensible with one view
1038
+ // (a mono preview, say) must be allowed to. What the core owes you is VISIBILITY —
1039
+ // this is otherwise invisible from the page, since nothing throws and nothing logs.
1040
+ win.frames++;
1041
+ if (views.length < 2) {
1042
+ win.monoFrames++;
1043
+ // 1-in-300 so a sustained rate is reported without the log itself becoming the load;
1044
+ // `% 300 === 1` also names the FIRST one immediately.
1045
+ if (win.monoFrames % 300 === 1 && typeof console !== 'undefined' && console.debug) {
1046
+ const pct = ((100 * win.monoFrames) / Math.max(1, win.frames)).toFixed(1);
1047
+ console.debug(
1048
+ `[inline3d] scene window: ${win.monoFrames} non-stereo view lists in ` +
1049
+ `${win.frames} frames (${pct}%). The viewer replays its last good stereo ` +
1050
+ 'frame for these; a rising rate means the session is falling back under load.',
1051
+ );
1052
+ }
1053
+ }
1054
+ win.onFrame(views, win.layer, f);
1055
+ }
1024
1056
  } else {
1025
1057
  // Repaint image AND video every frame. The weave reads each window's
1026
1058
  // composited canvas quad per frame; a canvas that isn't redrawn can have
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@displayxr/inline3d",
3
- "version": "1.1.0",
3
+ "version": "1.1.1",
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",
@@ -83,7 +83,7 @@
83
83
  },
84
84
  "scripts": {
85
85
  "typecheck": "tsc --noEmit -p tsconfig.json",
86
- "test": "npm run typecheck"
86
+ "test": "tsc --noEmit -p tsconfig.json && node --test \"test/*.test.mjs\""
87
87
  },
88
88
  "devDependencies": {
89
89
  "typescript": "^5.4.0",
package/three.d.ts CHANGED
@@ -15,6 +15,18 @@ export class EyeCamera {
15
15
  readonly camera: unknown;
16
16
  /** Set the camera's projection + world pose straight from an XRView (call once per eye). */
17
17
  setFromView(view: XRView): void;
18
+ /**
19
+ * Set the camera from the two raw matrices an XRView carries, handed over separately.
20
+ *
21
+ * For re-drawing a frame you have already drawn: an `XRView` is valid only inside its own
22
+ * frame callback, so a renderer that wants to repaint (a short view list, a backing store
23
+ * just reallocated and cleared) must keep COPIES of these two matrices, not the view.
24
+ * `setFromView` forwards to this, so both paths are the same code.
25
+ */
26
+ setFromMatrices(
27
+ projectionMatrix: ArrayLike<number>,
28
+ transformMatrix: ArrayLike<number>,
29
+ ): void;
18
30
  }
19
31
 
20
32
  /**
package/viewer.d.ts CHANGED
@@ -80,7 +80,14 @@ export declare class SceneViewer {
80
80
  setPose(pose?: OrbitPose): void;
81
81
  resetPose(): void;
82
82
 
83
- /** Pass straight to `wall.addScene(canvas, viewer.onFrame, …)`. Pre-bound. */
83
+ /**
84
+ * Pass straight to `wall.addScene(canvas, viewer.onFrame, …)`. Pre-bound.
85
+ *
86
+ * Validates before it clears: a frame that cannot draw both eyes (a short view list from a
87
+ * session falling back under load, a missing viewport) re-renders the last good frame from
88
+ * cached matrices instead of clearing the canvas to black — the canvas is committed every
89
+ * frame either way, so the tile never goes dark and never smears (web#12).
90
+ */
84
91
  onFrame(views: readonly XRView[], layer: object): void;
85
92
 
86
93
  /** Supply the ./three glue so the 3D path can build its eye camera. Returns `this`. */