@displayxr/inline3d 1.10.0 → 1.11.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,68 @@ 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.11.0 — 2026-09-24
9
+
10
+ Touches the **preview tier** (`./splat`, `engine: 'playcanvas'` only), additively. Nothing changes
11
+ for a page that does not pass `controls: 'page'`.
12
+
13
+ ### Added
14
+
15
+ - **`controls: 'page'`: the page owns the camera.** This is for a game, or any page with its own
16
+ camera (F1000's phase 1). With `addSplat(…, { engine: 'playcanvas', controls: 'page' })` the
17
+ adapter runs no orbit, idle spin, auto-fit or focus gestures. It keeps the eye math: the
18
+ `RenderView` list, the footprint fix, near/far and the mono fallback.
19
+ - **`handle.setCameraPose(matrixWorld, { verticalFovDeg, near?, far?, convergence? })`**, called
20
+ every frame. `matrixWorld` is the page camera in the splat's own space (three.js convention).
21
+ A uniform scale is allowed: F1000 passes `inv(splatWorld) · camera.matrixWorld`, which carries
22
+ 0.4. The adapter applies the OpenCV → engine flip itself. The last call wins, and a page that
23
+ stops calling keeps its last pose. **`handle.getCameraPose()`** returns a copy of it.
24
+ - **`onBeforeFrame(frame)`** (`{ time, views, dt }`) runs once per adapter frame, before
25
+ anything renders. A pose set inside it is what that frame draws, with zero lag. A throw is
26
+ warned once and frames keep rendering.
27
+ - **Mono** renders exactly that camera: fov × canvas aspect, principal point centred.
28
+ - **3D** is the attach pattern. Each eye = `matrixWorld × view.transform`, with the runtime's
29
+ projection untouched: the page's near/far rewrite only the depth mapping. The rig is declared
30
+ every frame and is the auto-3D shim's, field for field: `type: 'camera'`, identity pose,
31
+ `verticalFov`, `convergenceDiopters = 1/d`, `metersToVirtual = comfortDepth · d / 0.5`.
32
+ **`comfortDepth`** is a new option, default 0.3, in (0, 1].
33
+ - `d` is the page's `convergence`, else the focus waterfall's. It stays fixed while the camera
34
+ moves and is re-estimated only on `setSource`. `setFocus(point)` sets `d` to the point's
35
+ distance along the view axis. `getFocus` / `onFocusChange` report the convergence point.
36
+ - `setPose` / `resetPose` throw, and `rig: 'display'` throws. `fit`, `virtualDisplayHeight`,
37
+ `orbit`, `idleSpin`, `focusInput` and the other framing/orbit knobs are ignored, named once in
38
+ a `console.info`.
39
+ - **Spark:** `controls: 'page'` throws, naming `engine: 'playcanvas'`. Spark would need
40
+ SceneViewer (shared with `./viewer` and `./model`) to take an external camera on both its
41
+ paths.
42
+
43
+ ## 1.10.1 — 2026-09-23
44
+
45
+ Touches the **preview tier** (`./splat`, `engine: 'playcanvas'` only), and `boundsFromPositions` in
46
+ `./viewer` (same results, less time). Nothing changes for Spark callers' pixels.
47
+
48
+ ### Fixed
49
+
50
+ - **No grey sky box behind the splat.** The engine draws a sky whenever the scene has something to
51
+ draw it from, and `scene.envAtlas` counts. A page that set one to light its meshes under
52
+ `handle.engine.root` got a grey gradient box the instant the splat was hidden (mid-`setSource`,
53
+ or a page showing only its own meshes). The eye camera now renders without the Skybox layer, so
54
+ the canvas stays transparent. Image-based lighting still lights the meshes. The new **`sky: true`**
55
+ brings the engine's sky back.
56
+ - Verified headless with `envAtlas` set and the splat hidden: the corner pixel reads
57
+ `[140,140,140,255]` in 1.10.0 and `[0,0,0,0]` now (and `[140,140,140,255]` again with
58
+ `sky: true`).
59
+ - **`setSource` no longer blocks input for its cloud passes.** The SDK's framing, rest-space sample
60
+ and pick-set passes ran in one main-thread task stacked on the engine's end-of-load work. Now
61
+ each runs in its own task (with `scheduler.yield()` where available).
62
+ - `boundsFromPositions` takes its percentiles with a linear-time select instead of full sorts.
63
+ The values are bit-identical, pinned by a test.
64
+ - On a 1.18M-gaussian swap (headless, M1 Pro), the longest main-thread task went from
65
+ **60–64 ms to under 50 ms** (none reported), and the longest frame gap from 65–77 to
66
+ 35–44 ms. At 4× CPU throttling it went from 218–238 ms to 60–65 ms, which is the engine's own
67
+ remainder, documented per 1M gaussians with the recommended pre-load pattern.
68
+ - The load stages are visible as `performance.measure` entries named `inline3d:*`.
69
+
8
70
  ## 1.10.0 — 2026-09-23
9
71
 
10
72
  Touches the **core tier** (`.`), additively. The frozen 1.x surface gains one handle member and
package/README.md CHANGED
@@ -60,6 +60,15 @@ one dense sheet that is fully on screen, so it gains nothing from streaming. How
60
60
  and budget one, with measurements:
61
61
  [`docs/playcanvas-adapter.md` §Streamed SOG](docs/playcanvas-adapter.md#streamed-sog-p2).
62
62
 
63
+ **A game owns its camera (preview).** `addSplat(wall, canvas, src, { engine: 'playcanvas',
64
+ controls: 'page', onBeforeFrame })` hands the camera to the page. Call
65
+ `handle.setCameraPose(matrixWorld, { verticalFovDeg, near, far, convergence? })` inside
66
+ `onBeforeFrame` and that frame renders it. The SDK runs no orbit, idle spin, fit or focus
67
+ gestures. It keeps the eye math: the attach-pattern camera rig (the auto-3D shim's, with
68
+ `comfortDepth` 0.3 by default), the runtime's projections, and the mono fallback. Spark refuses
69
+ this mode. Contract and numbers:
70
+ [`docs/playcanvas-adapter.md` §controls:'page'](docs/playcanvas-adapter.md#controlspage--the-page-owns-the-camera).
71
+
63
72
  Stability & what's covered by semver (and the deferred N-view / web-components / CSS-native roadmap
64
73
  that is intentionally **not** in 1.0): [`docs/sdk-stability.md`](docs/sdk-stability.md).
65
74
 
@@ -82,6 +82,8 @@ import {
82
82
  engineFormatFor,
83
83
  pathOf,
84
84
  streamedBytesError,
85
+ resolveControls,
86
+ normalizeCameraPose,
85
87
  } from './inline3d-splat-shared.js';
86
88
 
87
89
  /**
@@ -137,6 +139,10 @@ export const PLAYCANVAS_TESTED = '2.22.3';
137
139
 
138
140
  const PICK_CONE_RAD = 0.02;
139
141
 
142
+ const PAGE_POSE_ERROR =
143
+ "@displayxr/inline3d/splat: setPose()/resetPose() are not available with controls:'page' — the " +
144
+ 'page owns the camera. Drive it with handle.setCameraPose(matrixWorld, { verticalFovDeg, near, far }).';
145
+
140
146
  const DEG = Math.PI / 180;
141
147
 
142
148
  // ── pure matrix arithmetic (column-major, the layout XRView, three and the engine all use) ──
@@ -362,6 +368,69 @@ export function pivotInverseTRS({ yaw = 0, pitch = 0, scale = 1, focus = [0, 0,
362
368
  };
363
369
  }
364
370
 
371
+ /**
372
+ * controls:'page' — the rig node's TRS from the page's camera matrix.
373
+ *
374
+ * `matrixWorld` is the page camera's world matrix in the SPLAT's model space (the space of the
375
+ * `.sog` camera block's `rest`), a three.js-convention camera (looks down −Z, +Y up). The splat
376
+ * entity carries the OpenCV → GL half-turn about X under `flipY`, so the camera rides the same
377
+ * turn: rig = F · M, F = Rx(180°) (or the identity without the flip). A uniform scale in M is
378
+ * kept — it is how a page whose world scales the splat (F1000: S(2.5)) expresses "page units",
379
+ * and it puts the runtime's eye offsets (metres) and the page's near/far into page units for free.
380
+ *
381
+ * @returns {{position:number[], rotation:number[], scale:number, uniform:boolean}}
382
+ */
383
+ export function pageRigTRS(M, flipY = true) {
384
+ const m = Float64Array.from(M);
385
+ if (flipY) {
386
+ for (const c of [0, 4, 8, 12]) {
387
+ m[c + 1] = -m[c + 1];
388
+ m[c + 2] = -m[c + 2];
389
+ }
390
+ }
391
+ const len = (c) => Math.hypot(m[c], m[c + 1], m[c + 2]);
392
+ const lx = len(0), ly = len(4), lz = len(8);
393
+ const s = Math.cbrt(lx * ly * lz);
394
+ const uniform = Math.abs(lx - s) < 1e-4 * s && Math.abs(ly - s) < 1e-4 * s && Math.abs(lz - s) < 1e-4 * s;
395
+ const R = new Float64Array(16);
396
+ R[0] = m[0] / lx; R[1] = m[1] / lx; R[2] = m[2] / lx;
397
+ R[4] = m[4] / ly; R[5] = m[5] / ly; R[6] = m[6] / ly;
398
+ R[8] = m[8] / lz; R[9] = m[9] / lz; R[10] = m[10] / lz;
399
+ R[15] = 1;
400
+ return { position: [m[12], m[13], m[14]], rotation: quatFromMatrix(R), scale: s, uniform };
401
+ }
402
+
403
+ /** pageRigTRS as a column-major matrix (the pick ray and the tests read it this way). */
404
+ export function pageRigMatrix(M, flipY = true) {
405
+ const t = pageRigTRS(M, flipY);
406
+ const out = poseMatrix(t.position, t.rotation);
407
+ for (let i = 0; i < 12; i++) if (i % 4 !== 3) out[i] *= t.scale;
408
+ return out;
409
+ }
410
+
411
+ /**
412
+ * The page camera's view axis in MODEL space: its position and unit forward (−Z of the matrix),
413
+ * plus the matrix's uniform scale (model units per page unit).
414
+ */
415
+ export function pageViewAxis(M) {
416
+ const s = Math.cbrt(Math.hypot(M[0], M[1], M[2]) * Math.hypot(M[4], M[5], M[6]) * Math.hypot(M[8], M[9], M[10]));
417
+ const fl = Math.hypot(M[8], M[9], M[10]) || 1;
418
+ return { origin: [M[12], M[13], M[14]], forward: [-M[8] / fl, -M[9] / fl, -M[10] / fl], scale: s };
419
+ }
420
+
421
+ /**
422
+ * The attach-pattern camera rig for controls:'page' — the auto-3D shim's buildRig, field for
423
+ * field: identity pose, the page's vertical FOV, convergence d, metersToVirtual = depth · d / 0.5
424
+ * (so comfort = ipd × m2v × (1/d) × 0.5 = depth by construction).
425
+ */
426
+ export function pageViewRig({ verticalFovDeg, convergence, comfortDepth, ipdFactor = 1, parallaxFactor = 1 }, out = {}) {
427
+ const d = Math.max(1e-6, convergence);
428
+ return cameraRigFromPose(
429
+ { fov: verticalFovDeg },
430
+ { attach: true, convergence: d, ipdFactor, parallaxFactor, metersToVirtual: (comfortDepth * d) / 0.5, out },
431
+ );
432
+ }
433
+
365
434
  /**
366
435
  * SceneViewer.fitTo's scale, as a function. Same arithmetic, same order, same clamps — see the
367
436
  * reasoning there (swept width, 'height' guard, depth backstop).
@@ -535,8 +604,22 @@ export class PlayCanvasSplatViewer {
535
604
  captureFit = 'height',
536
605
  nearClip,
537
606
  farClip,
607
+ sky = false,
608
+ pageCamera = false,
538
609
  } = opts;
539
610
  this.canvas = canvas;
611
+ // controls:'page': the PAGE owns the camera (setPageCamera). No orbit, no idle, no fit; the
612
+ // rig node carries the page's matrix instead of the inverse pivot.
613
+ this.pageCamera = pageCamera === true;
614
+ this.page = this.pageCamera
615
+ ? { matrix: poseMatrix([0, 0, 0], [0, 0, 0, 1]), fov: MONO_FOV, near: MONO_NEAR, far: CAPTURE_FAR, set: false }
616
+ : null;
617
+ // The engine draws a sky box whenever the scene has something to draw it from — and
618
+ // `scene.envAtlas` counts: a page that sets one to light its own meshes under
619
+ // handle.engine.root got a grey gradient box behind the splat the instant the splat was
620
+ // hidden (mid-setSource, or a page showing only its meshes). The SDK's contract is a
621
+ // transparent canvas the page shows through, so the sky layer is off unless asked for.
622
+ this.sky = sky === true;
540
623
  // Depth range for a MIXED scene (meshes under handle.engine.root depth-test against each
541
624
  // other; splats only test against them). The projections' own near/far stay the adapter's —
542
625
  // these only raise the near (floor) and lower the far (cap). Unset: untouched.
@@ -561,7 +644,7 @@ export class PlayCanvasSplatViewer {
561
644
  this.fitSweep = fitSweep;
562
645
  this.renderScale = renderScale;
563
646
  this.pitchLimit = pitchLimit;
564
- this.idleSpin = idleSpin;
647
+ this.idleSpin = this.pageCamera ? 0 : idleSpin;
565
648
  this.flipY = opts.flipY !== false;
566
649
 
567
650
  this._fitScale = 1;
@@ -580,6 +663,8 @@ export class PlayCanvasSplatViewer {
580
663
  this._focusSettled = true;
581
664
  this.onFocusChange = null;
582
665
  this.onTick = null;
666
+ /** controls:'page' — `(views|null) => void`, run once per frame before anything else. */
667
+ this._beforeFrame = null;
583
668
  this._lastInput = now();
584
669
  this._lastTick = 0;
585
670
  this._monoRaf = 0;
@@ -601,6 +686,13 @@ export class PlayCanvasSplatViewer {
601
686
  proj: new Float64Array(16),
602
687
  capture: null, // intrinsics when on a camera rig
603
688
  };
689
+ if (this.pageCamera) {
690
+ // The mono camera IS the page camera: identity under the rig node, the page's lens.
691
+ this.mono.fov = this.page.fov;
692
+ this.mono.near = this.page.near;
693
+ this.mono.far = this.page.far;
694
+ this.mono.pose = poseMatrix([0, 0, 0], [0, 0, 0, 1]);
695
+ }
604
696
  this._placeMonoForFit();
605
697
 
606
698
  // Engine objects — null until attachEngine().
@@ -628,7 +720,7 @@ export class PlayCanvasSplatViewer {
628
720
  if (this._ro) this._ro.observe(canvas);
629
721
  else if (typeof addEventListener === 'function') addEventListener('resize', this._onResize);
630
722
 
631
- if (orbit) this._bindOrbit();
723
+ if (orbit && !this.pageCamera) this._bindOrbit();
632
724
  this._resize();
633
725
 
634
726
  this.onFrame = this.onFrame.bind(this);
@@ -651,6 +743,7 @@ export class PlayCanvasSplatViewer {
651
743
  }
652
744
 
653
745
  fitTo(center, extent) {
746
+ if (this.pageCamera) return; // the page frames its own camera
654
747
  const c = Array.isArray(center) ? center : [center.x, center.y, center.z];
655
748
  const e = Array.isArray(extent) ? extent : [extent.x, extent.y, extent.z];
656
749
  this.setFocus(c, { snap: true });
@@ -670,6 +763,7 @@ export class PlayCanvasSplatViewer {
670
763
  }
671
764
 
672
765
  setPose({ yaw, pitch, zoom, depthOffset } = {}) {
766
+ if (this.pageCamera) throw new Error(PAGE_POSE_ERROR);
673
767
  // A snap, as on SceneViewer; it also ends an orbit relax (the page is driving now).
674
768
  if (yaw !== undefined || pitch !== undefined) this._orbitMode = null;
675
769
  if (yaw !== undefined) this._targetYaw = this._yaw = yaw;
@@ -691,6 +785,7 @@ export class PlayCanvasSplatViewer {
691
785
  }
692
786
 
693
787
  resetPose() {
788
+ if (this.pageCamera) throw new Error(PAGE_POSE_ERROR);
694
789
  this.setPose({ yaw: 0, pitch: 0, zoom: 1, depthOffset: 0 });
695
790
  this._lastInput = now();
696
791
  }
@@ -737,8 +832,40 @@ export class PlayCanvasSplatViewer {
737
832
  return { x: v.x, y: v.y, z: v.z };
738
833
  }
739
834
 
835
+ /**
836
+ * controls:'page' — take the page's camera for the next frame drawn (last call wins; no call
837
+ * keeps the last pose). `pose` is normalizeCameraPose()'s output. The rig node is written here
838
+ * and by the tick, through _applyTransform (still its only writer).
839
+ */
840
+ setPageCamera(pose) {
841
+ if (!this.pageCamera) throw new Error("setPageCamera needs controls:'page'");
842
+ const p = this.page;
843
+ p.matrix.set(pose.matrixWorld);
844
+ p.set = true;
845
+ if (pose.verticalFovDeg !== p.fov || pose.near !== p.near || pose.far !== p.far) {
846
+ p.fov = pose.verticalFovDeg;
847
+ p.near = pose.near;
848
+ p.far = pose.far;
849
+ this.mono.fov = p.fov;
850
+ this.mono.near = p.near;
851
+ this.mono.far = p.far;
852
+ this._updateMonoProjection();
853
+ }
854
+ this._applyTransform();
855
+ }
856
+
857
+ /**
858
+ * The rig node's matrix (eye space → content space): the inverse pivot on the viewer's own
859
+ * controls, F · matrixWorld on controls:'page'.
860
+ */
861
+ rigMatrix() {
862
+ if (this.pageCamera) return pageRigMatrix(this.page.matrix, this.flipY);
863
+ return mat4Invert(pivotMatrix(this.pivotState()));
864
+ }
865
+
740
866
  /** Pose + lens the mono camera as the recording camera (applyCaptureCamera's counterpart). */
741
867
  useCaptureCamera(rig) {
868
+ if (this.pageCamera) return; // the page's lens, not the recording's
742
869
  const pose = capturePose(rig.rest, this.flipY);
743
870
  this.mono.pose = pose.matrix;
744
871
  this.mono.capture = rig.intrinsics;
@@ -756,6 +883,7 @@ export class PlayCanvasSplatViewer {
756
883
 
757
884
  /** Back to the display rig's mono camera (setSource from a photo lift to an object). */
758
885
  useDisplayCamera() {
886
+ if (this.pageCamera) return;
759
887
  this.mono.capture = null;
760
888
  this.mono.fov = MONO_FOV;
761
889
  this.mono.far = MONO_FAR;
@@ -772,6 +900,8 @@ export class PlayCanvasSplatViewer {
772
900
  onFrame(views, layer) {
773
901
  if (this._disposed) return;
774
902
  if (this._mode !== '3d') this.stopMono();
903
+ // BEFORE the tick: a pose the page sets in here is the one this very frame renders.
904
+ this._beforeFrame?.(views || null);
775
905
  this._tick();
776
906
  if (!views || views.length < 2) {
777
907
  this._replayLastGood();
@@ -798,6 +928,8 @@ export class PlayCanvasSplatViewer {
798
928
  const loop = () => {
799
929
  if (this._disposed) return;
800
930
  this._monoRaf = requestAnimationFrame(loop);
931
+ this._beforeFrame?.(null);
932
+ if (this._disposed || this._mode !== 'mono') return; // the callback removed or re-wove us
801
933
  this._tick();
802
934
  this._drawMono();
803
935
  };
@@ -1044,6 +1176,11 @@ export class PlayCanvasSplatViewer {
1044
1176
  fov: MONO_FOV,
1045
1177
  ...(rect ? { rect } : {}),
1046
1178
  });
1179
+ // No sky unless the page asked (see the constructor): the image-based lighting a page sets
1180
+ // still lights its meshes — only the BACKGROUND the sky layer would draw is dropped.
1181
+ if (!this.sky && Array.isArray(e.camera.layers)) {
1182
+ e.camera.layers = e.camera.layers.filter((id) => id !== pc.LAYERID_SKYBOX);
1183
+ }
1047
1184
  // The engine's default camera tonemap is LINEAR, which routes every splat colour through
1048
1185
  // decodeGamma → toneMap → gammaCorrectOutput. Spark writes the stored colour straight out;
1049
1186
  // NONE is the same thing here (GAMMA_SRGB alone leaves a gamma-space colour untouched).
@@ -1080,7 +1217,7 @@ export class PlayCanvasSplatViewer {
1080
1217
 
1081
1218
  _placeMonoForFit() {
1082
1219
  // SceneViewer.fitTo: distance to make the frustum exactly vH tall at z = 0, looking down −z.
1083
- if (this.mono.capture) return;
1220
+ if (this.mono.capture || this.pageCamera) return;
1084
1221
  const d = (0.5 * this.vH) / Math.tan((this.mono.fov * DEG) / 2);
1085
1222
  this.mono.pose = poseMatrix([0, 0, d], [0, 0, 0, 1]);
1086
1223
  }
@@ -1122,7 +1259,14 @@ export class PlayCanvasSplatViewer {
1122
1259
  const el = this.canvas;
1123
1260
  const sx = cache && cache.bufW > 0 && el.width ? el.width / cache.bufW : 1;
1124
1261
  const sy = cache && cache.bufH > 0 && el.height ? el.height / cache.bufH : 1;
1125
- if (this.nearClip !== null || this.farClip !== null) {
1262
+ if (this.pageCamera) {
1263
+ // The page's near/far join the caller's nearClip/farClip as a floor and a cap: only the
1264
+ // depth mapping moves, the runtime's frustum (fov, skew, principal point) stays untouched.
1265
+ const p = this.page;
1266
+ const nf = this.nearClip !== null ? Math.max(this.nearClip, p.near) : p.near;
1267
+ const fc = this.farClip !== null ? Math.min(this.farClip, p.far) : p.far;
1268
+ for (const e of entries) clampProjectionDepth(e.proj, nf, fc);
1269
+ } else if (this.nearClip !== null || this.farClip !== null) {
1126
1270
  for (const e of entries) clampProjectionDepth(e.proj, this.nearClip, this.farClip);
1127
1271
  }
1128
1272
  const rect = (e) =>
@@ -1332,7 +1476,7 @@ export class PlayCanvasSplatViewer {
1332
1476
  */
1333
1477
  _applyTransform() {
1334
1478
  if (!this.rigNode) return;
1335
- const trs = pivotInverseTRS(this.pivotState());
1479
+ const trs = this.pageCamera ? pageRigTRS(this.page.matrix, this.flipY) : pivotInverseTRS(this.pivotState());
1336
1480
  this.rigNode.setLocalPosition(trs.position[0], trs.position[1], trs.position[2]);
1337
1481
  this.rigNode.setLocalRotation(trs.rotation[0], trs.rotation[1], trs.rotation[2], trs.rotation[3]);
1338
1482
  this.rigNode.setLocalScale(trs.scale, trs.scale, trs.scale);
@@ -1456,6 +1600,28 @@ function placeNode(node, m) {
1456
1600
  * @returns {Promise<{xyz:Float32Array, opacity:Float32Array|null, total:number, stride:number,
1457
1601
  * sourceTotal:number}|null>} `total` is the number of splats IN the sample.
1458
1602
  */
1603
+ /**
1604
+ * A `performance.measure` named `inline3d:<name>` from `t0` to now — so a load's stages show up in
1605
+ * DevTools' Performance panel and in `performance.getEntriesByType('measure')`. Never throws.
1606
+ */
1607
+ /**
1608
+ * Give the main thread back for one turn — input, rAF, the engine's own tick — before the next
1609
+ * chunk of cloud work. `scheduler.yield()` where the browser has it (keeps our task's priority),
1610
+ * else a macrotask.
1611
+ */
1612
+ function yieldToMain() {
1613
+ if (typeof scheduler !== 'undefined' && typeof scheduler.yield === 'function') return scheduler.yield();
1614
+ return new Promise((r) => setTimeout(r, 0));
1615
+ }
1616
+
1617
+ function perfSpan(name, t0) {
1618
+ try {
1619
+ performance.measure?.(`inline3d:${name}`, { start: t0, end: performance.now() });
1620
+ } catch {
1621
+ /* no User Timing L3 here */
1622
+ }
1623
+ }
1624
+
1459
1625
  export async function readCloud(resource) {
1460
1626
  if (!resource) return null;
1461
1627
  const centers = resource.centers;
@@ -1464,19 +1630,29 @@ export async function readCloud(resource) {
1464
1630
  if (!centers || !sourceTotal) return null;
1465
1631
  const stride = Math.max(1, Math.ceil(sourceTotal / FRAME_SAMPLE_CAP));
1466
1632
  const total = Math.ceil(sourceTotal / stride);
1633
+ // Not in the task that delivered the asset: the engine's own end-of-load work (its centre
1634
+ // readback unpack) runs there, and stacking ours on it made one long task.
1635
+ await yieldToMain();
1636
+ let t0 = performance.now();
1467
1637
  const xyz = new Float32Array(total * 3);
1468
1638
  for (let j = 0, i = 0; j < total; j++, i += stride) {
1469
1639
  xyz[j * 3] = centers[i * 3];
1470
1640
  xyz[j * 3 + 1] = centers[i * 3 + 1];
1471
1641
  xyz[j * 3 + 2] = centers[i * 3 + 2];
1472
1642
  }
1643
+ perfSpan('readCloud:copy', t0);
1644
+ await yieldToMain();
1473
1645
  let opacity = null;
1474
1646
  // Full-resolution peak opacity, ONE BYTE per splat, kept for the exact pick (which walks the
1475
1647
  // engine's own full centre set at pick time): 1.18 MB on the 1.18M bench asset.
1476
1648
  let alpha8 = null;
1477
1649
  try {
1478
1650
  if (data?.isSog && data.sh0?.read) {
1651
+ t0 = performance.now();
1479
1652
  const px = await data.sh0.read(0, 0, data.sh0.width, data.sh0.height, { mipLevel: 0, face: 0, immediate: true });
1653
+ perfSpan('readCloud:sh0-readback(async)', t0);
1654
+ await yieldToMain();
1655
+ t0 = performance.now();
1480
1656
  if (px && px.length >= sourceTotal * 4) {
1481
1657
  const v2 = data.meta?.version === 2;
1482
1658
  const mn = data.meta?.sh0?.mins?.[3];
@@ -1491,6 +1667,7 @@ export async function readCloud(resource) {
1491
1667
  if (v2 || mn === undefined) for (let i = 0; i < sourceTotal; i++) alpha8[i] = px[i * 4 + 3];
1492
1668
  else for (let i = 0; i < sourceTotal; i++) alpha8[i] = Math.round(op(i) * 255);
1493
1669
  }
1670
+ perfSpan('readCloud:opacity', t0);
1494
1671
  } else if (typeof data?.getProp === 'function') {
1495
1672
  const o = data.getProp('opacity');
1496
1673
  if (o && o.length >= sourceTotal) {
@@ -1691,8 +1868,22 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
1691
1868
  // `sortIntervalMs` is accepted and has no effect here: the engine re-sorts when the camera
1692
1869
  // ROTATES (one directional sort serves every view), not on a timer — docs/playcanvas-adapter.md.
1693
1870
 
1871
+ // controls:'page' — validated again here (./splat already did, at call time) because a test or
1872
+ // an advanced caller may reach this function directly.
1873
+ const ctl = resolveControls(opts);
1874
+ const pageMode = ctl.page;
1875
+ if (pageMode && ctl.ignored.length) {
1876
+ console.info(
1877
+ `[inline3d/splat] controls:'page' — the page owns the camera, so ${ctl.ignored.join(', ')} ` +
1878
+ `${ctl.ignored.length === 1 ? 'is' : 'are'} ignored.`,
1879
+ );
1880
+ }
1881
+ // The rig question is answered by the page: its camera IS the rig.
1882
+ const rigOpts = pageMode ? { ...opts, rig: 'camera' } : opts;
1883
+
1694
1884
  const perfResolved = playcanvasPerfSettings(perf);
1695
1885
  const viewer = new PlayCanvasSplatViewer(canvas, {
1886
+ pageCamera: pageMode,
1696
1887
  virtualDisplayHeight,
1697
1888
  fit,
1698
1889
  margin,
@@ -1708,6 +1899,7 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
1708
1899
  captureFit,
1709
1900
  nearClip: opts.nearClip,
1710
1901
  farClip: opts.farClip,
1902
+ sky: opts.sky,
1711
1903
  });
1712
1904
 
1713
1905
  let handle = null;
@@ -1727,13 +1919,49 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
1727
1919
  perf: perfResolved.applied,
1728
1920
  setPose: (p) => viewer.setPose(p),
1729
1921
  resetPose: () => viewer.resetPose(),
1922
+ /**
1923
+ * controls:'page' — the page's camera for the next frame drawn. See normalizeCameraPose for
1924
+ * the arguments; last call wins; a page that stops calling keeps its last pose.
1925
+ */
1926
+ setCameraPose(matrixWorld, o) {
1927
+ if (!pageMode) {
1928
+ throw new Error(
1929
+ "@displayxr/inline3d/splat: setCameraPose() needs addSplat(…, { controls:'page' }) — " +
1930
+ 'with the default controls the SDK owns the camera (use setPose).',
1931
+ );
1932
+ }
1933
+ const pose = normalizeCameraPose(matrixWorld, o);
1934
+ if (!warnedNonUniform && !pageRigTRS(pose.matrixWorld, flipY).uniform) {
1935
+ warnedNonUniform = true;
1936
+ console.warn(
1937
+ "[inline3d/splat] controls:'page' — matrixWorld has a NON-uniform scale; the adapter uses " +
1938
+ 'its geometric mean. A camera pose is rotation + translation + at most a uniform scale.',
1939
+ );
1940
+ }
1941
+ lastPagePose = pose;
1942
+ viewer.setPageCamera(pose);
1943
+ return out;
1944
+ },
1945
+ /** The last pose the page set (a copy), or null before the first setCameraPose. */
1946
+ getCameraPose() {
1947
+ if (!lastPagePose) return null;
1948
+ return {
1949
+ matrixWorld: Float32Array.from(lastPagePose.matrixWorld),
1950
+ verticalFovDeg: lastPagePose.verticalFovDeg,
1951
+ near: lastPagePose.near,
1952
+ far: lastPagePose.far,
1953
+ convergence: lastPagePose.convergence,
1954
+ };
1955
+ },
1730
1956
  getFocus: (o) => {
1957
+ if (pageMode) return pageFocusPoint();
1731
1958
  // In MODEL space, like setFocus takes it.
1732
1959
  const f = viewer.getFocus(o);
1733
1960
  return contentToModel([f.x, f.y, f.z]);
1734
1961
  },
1735
1962
  setFocus(point, o = {}) {
1736
1963
  if (!out.mesh || !out.rig) return out;
1964
+ if (pageMode) return pageSetFocus(point, o);
1737
1965
  const model = point == null ? out.rig.focusDefault : toArray3(point);
1738
1966
  out.rig.focus = model;
1739
1967
  out.rig.focusSource = point == null ? out.rig.focusDefaultSource : 'set';
@@ -1769,9 +1997,21 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
1769
1997
  return flipY ? [c[0], -c[1], -c[2]] : [c[0], c[1], c[2]];
1770
1998
  }
1771
1999
 
2000
+ // ── controls:'page' state (null/unused on the viewer's own controls) ──
2001
+ let lastPagePose = null; // what the page last handed setCameraPose, validated
2002
+ let warnedNonUniform = false;
2003
+ // The convergence the adapter owns when the page gives none, in MODEL units (so it is stable
2004
+ // while the camera moves): the waterfall's on load / setSource, a setFocus after that. Eased
2005
+ // toward `target` at the viewer's FOCUS_EASE unless snapped.
2006
+ const pageFocus = { d: Number.NaN, target: Number.NaN, fired: Number.NaN, source: null };
2007
+
1772
2008
  if (wall && wall.supported) {
1773
2009
  handle = wall.addScene(canvas, viewer.onFrame, {
1774
- virtualDisplayHeight,
2010
+ // controls:'page' starts on a camera rig (attach, provisional FOV/convergence until the page
2011
+ // and the waterfall say otherwise); the display rig's height means nothing there.
2012
+ ...(pageMode
2013
+ ? { viewRig: pageViewRig({ verticalFovDeg: viewer.page.fov, convergence: 2, comfortDepth: ctl.comfortDepth }) }
2014
+ : { virtualDisplayHeight }),
1775
2015
  onLayerLost: viewer.onLayerLost,
1776
2016
  ...(observe ? { observe } : {}),
1777
2017
  ...(firstWovenHoldMs !== undefined ? { firstWovenHoldMs } : {}),
@@ -1795,9 +2035,100 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
1795
2035
  out[name]?.(...args);
1796
2036
  }
1797
2037
 
2038
+ // ── controls:'page': focus = the convergence point on the page camera's view axis ──
2039
+ /** The convergence distance for THIS frame, in page units, and where it came from. */
2040
+ function pageConvergence() {
2041
+ const ax = pageViewAxis(viewer.page.matrix);
2042
+ if (lastPagePose && lastPagePose.convergence !== null) {
2043
+ return { d: lastPagePose.convergence, ax, source: 'page' };
2044
+ }
2045
+ const dm = Number.isFinite(pageFocus.d) ? pageFocus.d : 2;
2046
+ return { d: dm / (ax.scale || 1), ax, source: out.rig?.focusSource ?? 'default' };
2047
+ }
2048
+ function pageFocusPoint() {
2049
+ if (!out.rig) return null;
2050
+ const { d, ax } = pageConvergence();
2051
+ const dm = d * ax.scale;
2052
+ return [ax.origin[0] + ax.forward[0] * dm, ax.origin[1] + ax.forward[1] * dm, ax.origin[2] + ax.forward[2] * dm];
2053
+ }
2054
+ function pageSetFocus(point, { snap = false } = {}) {
2055
+ let dm;
2056
+ if (point == null) {
2057
+ dm = out.rig.convergenceDefault;
2058
+ out.rig.focus = out.rig.focusDefault;
2059
+ out.rig.focusSource = out.rig.focusDefaultSource;
2060
+ } else {
2061
+ const m = toArray3(point);
2062
+ const ax = pageViewAxis(viewer.page.matrix);
2063
+ dm = (m[0] - ax.origin[0]) * ax.forward[0] + (m[1] - ax.origin[1]) * ax.forward[1] + (m[2] - ax.origin[2]) * ax.forward[2];
2064
+ if (!(dm > 0)) {
2065
+ console.warn('[inline3d/splat] setFocus: that point is behind the page camera — focus unchanged');
2066
+ return out;
2067
+ }
2068
+ out.rig.focus = m;
2069
+ out.rig.focusSource = 'set';
2070
+ }
2071
+ pageFocus.target = dm;
2072
+ if (snap || !Number.isFinite(pageFocus.d)) pageFocus.d = dm;
2073
+ return out;
2074
+ }
2075
+ /** Per tick (before the draw, so the rig drives the next locate): ease, declare, notify. */
2076
+ function pageTick() {
2077
+ if (Number.isFinite(pageFocus.target) && pageFocus.d !== pageFocus.target) {
2078
+ const dd = pageFocus.target - pageFocus.d;
2079
+ pageFocus.d = Math.abs(dd) < 1e-6 ? pageFocus.target : pageFocus.d + dd * FOCUS_EASE;
2080
+ }
2081
+ const { d, source } = pageConvergence();
2082
+ out.viewRig = pageViewRig(
2083
+ {
2084
+ verticalFovDeg: viewer.page.fov,
2085
+ convergence: d,
2086
+ comfortDepth: ctl.comfortDepth,
2087
+ ipdFactor: out.rig ? out.rig.ipdFactor : Number.isFinite(opts.ipdFactor) ? opts.ipdFactor : 1,
2088
+ parallaxFactor: out.rig ? out.rig.parallaxFactor : Number.isFinite(opts.parallaxFactor) ? opts.parallaxFactor : 1,
2089
+ },
2090
+ out.viewRig || {},
2091
+ );
2092
+ handle?.setViewRig?.(out.viewRig);
2093
+ if (!out.rig) return;
2094
+ out.rig.convergence = d;
2095
+ if (Math.abs(d - pageFocus.fired) > 1e-3 || source !== pageFocus.source) {
2096
+ pageFocus.fired = d;
2097
+ pageFocus.source = source;
2098
+ const cb = out.onFocusChange;
2099
+ if (typeof cb === 'function') cb(pageFocusPoint(), { focusSource: source });
2100
+ }
2101
+ }
2102
+ if (pageMode) viewer.onTick = pageTick;
2103
+
2104
+ // onBeforeFrame: the page's game step INSIDE this adapter's frame, before the tick and the
2105
+ // draw — so a setCameraPose made in it is what this frame renders (zero lag, the attach
2106
+ // pattern). The session rAF (3D) and the window rAF have no guaranteed order, so a pose set
2107
+ // from the page's own rAF may be one frame late. A throw is the page's bug: warned once, and
2108
+ // the frame still renders (with whatever pose it had).
2109
+ if (pageMode && typeof opts.onBeforeFrame === 'function') {
2110
+ const cb = opts.onBeforeFrame;
2111
+ let lastT = 0;
2112
+ let warned = false;
2113
+ viewer._beforeFrame = (views) => {
2114
+ const t = now();
2115
+ const dt = lastT ? Math.min((t - lastT) / 1000, MAX_DT_S) : 0;
2116
+ lastT = t;
2117
+ try {
2118
+ cb({ time: t, views, dt });
2119
+ } catch (err) {
2120
+ if (!warned) {
2121
+ warned = true;
2122
+ console.warn('[inline3d/splat] onBeforeFrame threw (warned once; frames keep rendering)', err);
2123
+ }
2124
+ }
2125
+ };
2126
+ }
2127
+
1798
2128
  // ── focus → view rig ──
1799
2129
  let lastConvergence = Number.NaN;
1800
2130
  function pushViewRig(force) {
2131
+ if (pageMode) return; // pageTick declares the rig every frame
1801
2132
  if (!out.rig || out.rig.type !== 'camera') return;
1802
2133
  const pose = viewer.mono.pose;
1803
2134
  const f = viewer.getFocus();
@@ -1859,8 +2190,7 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
1859
2190
  const v = viewer.currentView();
1860
2191
  const invP = mat4Invert(v.proj);
1861
2192
  if (!invP) return null;
1862
- const Minv = mat4Invert(pivotMatrix(viewer.pivotState()));
1863
- const toContent = mat4Mul(Minv, v.pose);
2193
+ const toContent = mat4Mul(viewer.rigMatrix(), v.pose);
1864
2194
  const a = transformPoint(invP, ndc.x, ndc.y, -1);
1865
2195
  const b = transformPoint(invP, ndc.x, ndc.y, 1);
1866
2196
  const o = contentToModel(transformPoint(toContent, a[0], a[1], a[2]));
@@ -1907,7 +2237,8 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
1907
2237
  }
1908
2238
 
1909
2239
  function bindFocusInput() {
1910
- if (focusInput === false || unbindFocusInput) return;
2240
+ // controls:'page' binds no input at all: gestures are the page's (pick + setFocus remain).
2241
+ if (pageMode || focusInput === false || unbindFocusInput) return;
1911
2242
  unbindFocusInput = bindFocusGestures(canvas, {
1912
2243
  onDoubleClick: (e) => {
1913
2244
  const m = pickModel(e.clientX, e.clientY);
@@ -1946,7 +2277,9 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
1946
2277
  }
1947
2278
  // The camera block off the SAME bytes, before the engine takes them (as the Spark path).
1948
2279
  let camera = null;
1949
- if (bytes && rig !== 'display') camera = sogCameraFromMeta(await readSogMeta(bytes));
2280
+ let t0 = performance.now();
2281
+ if (bytes && rigOpts.rig !== 'display') camera = sogCameraFromMeta(await readSogMeta(bytes));
2282
+ perfSpan('readSogMeta(async)', t0);
1950
2283
 
1951
2284
  const url = bytes
1952
2285
  ? `inline3d-bytes-${++byteSeq}-${++byteSeqLocal}.${fmt.ext}`
@@ -1958,18 +2291,42 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
1958
2291
  : { url, filename: pathOf(url).split('/').pop() || url };
1959
2292
  const asset = new pc.Asset(url, 'gsplat', file);
1960
2293
  app.assets.add(asset);
2294
+ t0 = performance.now();
1961
2295
  await new Promise((resolve, reject) => {
1962
2296
  asset.ready(resolve);
1963
2297
  asset.once('error', (err) => reject(err instanceof Error ? err : new Error(String(err))));
1964
2298
  app.assets.load(asset);
1965
2299
  });
2300
+ perfSpan('engine-load(async)', t0);
1966
2301
  const res = asset.resource;
1967
2302
  const desc = describeResource(res);
1968
2303
  // A URL `.sog` carries its meta in the resource (the engine keeps unknown keys); a Streamed
1969
2304
  // SOG carries it at the top level of lod-meta.json. Both validated by the same reader.
1970
- if (!bytes && rig !== 'display') camera = sogCameraFromMeta(desc.meta);
2305
+ if (!bytes && rigOpts.rig !== 'display') camera = sogCameraFromMeta(desc.meta);
1971
2306
  const cloud = desc.kind === 'flat' ? await readCloud(res) : null;
1972
- return { asset, res, desc, camera, cloud };
2307
+ // The cloud passes, each in its OWN task: framing, the rest-space sample and the pick set
2308
+ // were one ~60 ms main-thread block on a 1.18M-gaussian swap — pointer input waited on it.
2309
+ // Split with a yield between them (and a linear-time percentile in boundsFromPositions),
2310
+ // no single step is a long task any more. Same numbers, same order.
2311
+ const pre = { local: null, rest: null, pickCentres: null };
2312
+ if (cloud) {
2313
+ const walk = centresVisitor(cloud.xyz, cloud.opacity, cloud.total);
2314
+ await yieldToMain();
2315
+ let t = performance.now();
2316
+ pre.local = boundsFromPositions(sampleCloudCentres(cloud.total, walk) || []);
2317
+ perfSpan('cloud:bounds', t);
2318
+ await yieldToMain();
2319
+ t = performance.now();
2320
+ if (rigNeedsCloud(camera)) pre.rest = sampleCloudRestSpace(cloud.total, walk, camera?.rest);
2321
+ perfSpan('cloud:rest-sample', t);
2322
+ await yieldToMain();
2323
+ t = performance.now();
2324
+ const s = sampleCloudCentres(cloud.total, walk, { cap: RIG_SAMPLE_CAP });
2325
+ pre.pickCentres = s ? s.slice() : null;
2326
+ perfSpan('cloud:pick-set', t);
2327
+ await yieldToMain();
2328
+ }
2329
+ return { asset, res, desc, camera, cloud, pre };
1973
2330
  }
1974
2331
 
1975
2332
  /**
@@ -1978,8 +2335,8 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
1978
2335
  */
1979
2336
  function applyLoaded(loaded) {
1980
2337
  const { cloud, desc } = loaded;
1981
- const walk = cloud ? centresVisitor(cloud.xyz, cloud.opacity, cloud.total) : null;
1982
- const local = walk ? boundsFromPositions(sampleCloudCentres(cloud.total, walk) || []) : null;
2338
+ const tB = performance.now();
2339
+ const local = loaded.pre?.local || null;
1983
2340
  const lift = (b) => ({ center: modelToContent(b.center), extent: b.extent.slice(0, 3) });
1984
2341
  // Measured first — the Spark path's order (a supplied frame is only a fallback there too).
1985
2342
  // A Streamed SOG has no cloud: there a caller's `frame` beats the octree-derived bounds
@@ -1995,21 +2352,17 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
1995
2352
  ? lift(frame)
1996
2353
  : null;
1997
2354
 
1998
- const sample =
1999
- !rigNeedsCloud(loaded.camera)
2000
- ? null
2001
- : walk
2002
- ? sampleCloudRestSpace(cloud.total, walk, loaded.camera?.rest)
2003
- : null;
2355
+ const sample = loaded.pre?.rest || null;
2004
2356
  const box = canvas.getBoundingClientRect();
2005
2357
  const resolved = resolveRig({
2006
2358
  camera: loaded.camera,
2007
- opts,
2359
+ opts: rigOpts,
2008
2360
  cloud: sample,
2009
2361
  canvasAspect: box.height > 0 ? box.width / box.height : 4 / 3,
2010
2362
  });
2011
2363
  resolved.focusDefault = resolved.focus.slice();
2012
2364
  resolved.focusDefaultSource = resolved.focusSource;
2365
+ perfSpan('applyLoaded:rig', tB);
2013
2366
  out.camera = loaded.camera;
2014
2367
  out.rig = resolved;
2015
2368
  out.frame = bounds;
@@ -2022,13 +2375,25 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
2022
2375
  }
2023
2376
 
2024
2377
  // The strided pick fallback (used only if the engine releases its full centre set).
2025
- let pickCentres = null;
2026
- if (walk) {
2027
- const s = sampleCloudCentres(cloud.total, walk, { cap: RIG_SAMPLE_CAP });
2028
- pickCentres = s ? s.slice() : null;
2029
- }
2030
-
2031
- if (resolved.type === 'camera') {
2378
+ const pickCentres = loaded.pre?.pickCentres || null;
2379
+
2380
+ if (pageMode) {
2381
+ // The page owns the pose; the waterfall only supplies the convergence (re-estimated here,
2382
+ // on load and on setSource, never per frame). Until the page's first setCameraPose the
2383
+ // camera sits at the asset's rest pose (the block's, else the origin looking down +z) with
2384
+ // the resolved lens, so the first frame is not an empty tile.
2385
+ resolved.convergenceDefault = resolved.convergence;
2386
+ pageFocus.target = pageFocus.d = resolved.convergence;
2387
+ pageFocus.fired = Number.NaN;
2388
+ if (!lastPagePose) {
2389
+ const r = resolved.rest;
2390
+ viewer.setPageCamera(
2391
+ normalizeCameraPose(poseMatrix(r.position, qmul(r.rotation, FLIP_Q)), {
2392
+ verticalFovDeg: captureVerticalFovDeg(resolved.intrinsics, NaN, MONO_NEAR, 'height'),
2393
+ }),
2394
+ );
2395
+ }
2396
+ } else if (resolved.type === 'camera') {
2032
2397
  if (!('idleSpin' in opts)) viewer.idleSpin = 0;
2033
2398
  // The camera rig never auto-fits (the capture IS the framing): a scale left by a previous
2034
2399
  // display-rig asset (setSource) must not shrink this one.
@@ -2120,7 +2485,7 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
2120
2485
  };
2121
2486
  const kept = applyLoaded(loaded);
2122
2487
  current = { asset: loaded.asset, entity, res: loaded.res, kind: loaded.desc.kind, ...kept };
2123
- if (resetPose) viewer.resetPose();
2488
+ if (resetPose && !pageMode) viewer.resetPose(); // the page owns the pose on controls:'page'
2124
2489
 
2125
2490
  const release = () => {
2126
2491
  if (!prev) return;
@@ -316,3 +316,124 @@ export function playcanvasCannotRead(src, { fileType, fileName } = {}) {
316
316
  `${what}. Pass engine:'spark' for .spz / .splat / .ksplat / .rad.`
317
317
  );
318
318
  }
319
+
320
+ // ── controls:'page' — the page drives the camera (docs/playcanvas-adapter.md §controls:'page') ──
321
+
322
+ /** Who owns the camera: the SDK's viewer (orbit, idle, fit, focus gestures) or the page. */
323
+ export const SPLAT_CONTROLS = Object.freeze(['viewer', 'page']);
324
+
325
+ /**
326
+ * `comfortDepth` default — the auto-3D shim's `depth`, so a game on `controls:'page'` gets the
327
+ * same stereo as the same game under the shim. It is the runtime's comfort number
328
+ * `ipd × metersToVirtual × convergenceDiopters × 0.5`, which the rig below makes equal to it by
329
+ * construction (`metersToVirtual = comfortDepth · d / 0.5`, `convergenceDiopters = 1 / d`).
330
+ */
331
+ export const PAGE_COMFORT_DEPTH = 0.3;
332
+
333
+ /**
334
+ * Options that mean nothing when the page owns the camera. Passing one is not an error (a page
335
+ * switching an existing call site over should not have to prune it first); it is named ONCE in a
336
+ * console.info line and ignored.
337
+ */
338
+ export const PAGE_IGNORED_OPTIONS = Object.freeze([
339
+ 'fit',
340
+ 'virtualDisplayHeight',
341
+ 'orbit',
342
+ 'idleSpin',
343
+ 'focusInput',
344
+ 'margin',
345
+ 'fitSweep',
346
+ 'depthLimit',
347
+ 'orbitMaxDeg',
348
+ 'orbitEase',
349
+ 'captureFit',
350
+ ]);
351
+
352
+ /**
353
+ * Validate the `controls` family at CALL time (both engines call this before anything loads).
354
+ *
355
+ * @returns {{page:boolean, comfortDepth:number, ignored:string[]}}
356
+ * @throws on an unknown `controls`, a `comfortDepth` outside (0, 1], or `rig:'display'` with
357
+ * `controls:'page'` (the page's camera IS the rig; a display rig has no camera).
358
+ */
359
+ export function resolveControls(opts = {}) {
360
+ const controls = opts.controls ?? 'viewer';
361
+ if (!SPLAT_CONTROLS.includes(controls)) {
362
+ throw new Error(
363
+ `@displayxr/inline3d/splat: controls "${controls}" — expected ` +
364
+ `${SPLAT_CONTROLS.map((c) => `'${c}'`).join(' or ')} (default 'viewer').`,
365
+ );
366
+ }
367
+ const page = controls === 'page';
368
+ let comfortDepth = PAGE_COMFORT_DEPTH;
369
+ if (opts.comfortDepth !== undefined) {
370
+ const c = opts.comfortDepth;
371
+ if (typeof c !== 'number' || !Number.isFinite(c) || c <= 0 || c > 1) {
372
+ throw new Error(
373
+ `@displayxr/inline3d/splat: comfortDepth ${c} — expected a number in (0, 1] ` +
374
+ `(the runtime's comfort rule caps it at 1; the default is ${PAGE_COMFORT_DEPTH}).`,
375
+ );
376
+ }
377
+ comfortDepth = c;
378
+ }
379
+ if (page && opts.rig === 'display') {
380
+ throw new Error(
381
+ "@displayxr/inline3d/splat: controls:'page' with rig:'display' — the page's camera IS the " +
382
+ "rig, so it is always a camera rig. Drop `rig`, or use the default controls for a display rig.",
383
+ );
384
+ }
385
+ if (opts.onBeforeFrame !== undefined) {
386
+ if (typeof opts.onBeforeFrame !== 'function') {
387
+ throw new Error('@displayxr/inline3d/splat: onBeforeFrame must be a function (frame) => void.');
388
+ }
389
+ if (!page) {
390
+ throw new Error(
391
+ "@displayxr/inline3d/splat: onBeforeFrame needs controls:'page' — with the default controls " +
392
+ 'the SDK owns the camera and there is no page pose to set before the draw.',
393
+ );
394
+ }
395
+ }
396
+ const ignored = page ? PAGE_IGNORED_OPTIONS.filter((k) => opts[k] !== undefined) : [];
397
+ return { page, comfortDepth, ignored };
398
+ }
399
+
400
+ /**
401
+ * Validate one `setCameraPose(matrixWorld, o)` call and return a private copy (the page may
402
+ * reuse its arrays). Throws synchronously on a malformed call: a NaN in a camera matrix is a page
403
+ * bug that would otherwise render nothing, silently.
404
+ *
405
+ * @returns {{matrixWorld:Float64Array, verticalFovDeg:number, near:number, far:number,
406
+ * convergence:number|null}}
407
+ */
408
+ export function normalizeCameraPose(matrixWorld, o = {}) {
409
+ const bad = (why) => new Error(`@displayxr/inline3d/splat: setCameraPose — ${why}`);
410
+ if (!matrixWorld || typeof matrixWorld.length !== 'number' || matrixWorld.length !== 16) {
411
+ throw bad('matrixWorld must be 16 numbers, column-major (a Float32Array(16) or an array).');
412
+ }
413
+ const m = new Float64Array(16);
414
+ for (let i = 0; i < 16; i++) {
415
+ const v = matrixWorld[i];
416
+ if (typeof v !== 'number' || !Number.isFinite(v)) throw bad(`matrixWorld[${i}] is ${v}, not a finite number.`);
417
+ m[i] = v;
418
+ }
419
+ const det =
420
+ m[0] * (m[5] * m[10] - m[9] * m[6]) - m[4] * (m[1] * m[10] - m[9] * m[2]) + m[8] * (m[1] * m[6] - m[5] * m[2]);
421
+ if (!(det > 1e-12)) {
422
+ throw bad(`matrixWorld is singular or mirrored (det ${det.toPrecision(3)}); a camera pose is a rotation, a translation and at most a uniform scale.`);
423
+ }
424
+ const fov = o.verticalFovDeg;
425
+ if (typeof fov !== 'number' || !(fov > 0 && fov < 180)) {
426
+ throw bad(`verticalFovDeg ${fov} — the FULL vertical angle in degrees, in (0, 180).`);
427
+ }
428
+ const near = o.near === undefined ? MONO_NEAR : o.near;
429
+ const far = o.far === undefined ? CAPTURE_FAR : o.far;
430
+ if (typeof near !== 'number' || !(near > 0) || !Number.isFinite(near)) throw bad(`near ${near} — expected a finite number > 0.`);
431
+ if (typeof far !== 'number' || !(far > near) || !Number.isFinite(far)) throw bad(`far ${far} — expected a finite number > near.`);
432
+ let convergence = null;
433
+ if (o.convergence !== undefined && o.convergence !== null) {
434
+ const c = o.convergence;
435
+ if (typeof c !== 'number' || !(c > 0) || !Number.isFinite(c)) throw bad(`convergence ${c} — metres along the view axis, > 0 (omit it for the adapter's own).`);
436
+ convergence = c;
437
+ }
438
+ return { matrixWorld: m, verticalFovDeg: fov, near, far, convergence };
439
+ }
@@ -34,6 +34,8 @@ import {
34
34
  playcanvasCannotRead,
35
35
  isStreamedUrl,
36
36
  STREAMED_NEEDS_PLAYCANVAS,
37
+ resolveControls,
38
+ normalizeCameraPose,
37
39
  } from './inline3d-splat-shared.js';
38
40
  import {
39
41
  resolveRig,
@@ -163,6 +165,9 @@ export function addSplat(wall, canvas, src, opts = {}) {
163
165
  `${CAPTURE_FITS.map((f) => `'${f}'`).join(' or ')}.`,
164
166
  );
165
167
  }
168
+ // WHO OWNS THE CAMERA. Validated here, synchronously, for both engines (an unknown `controls`
169
+ // or a comfortDepth out of range is a page bug true of every call).
170
+ const { page: pageControls } = resolveControls(opts);
166
171
  // WHICH ENGINE. Unset (or 'spark') is everything below, untouched. 'playcanvas' goes to
167
172
  // ./inline3d-splat-playcanvas.js, imported DYNAMICALLY so a page that never asks for it never
168
173
  // resolves `playcanvas` — see addSplatDeferred.
@@ -174,6 +179,16 @@ export function addSplat(wall, canvas, src, opts = {}) {
174
179
  return addSplatDeferred(wall, canvas, src, opts);
175
180
  }
176
181
 
182
+ // controls:'page' is a PlayCanvas-backend feature (docs/playcanvas-adapter.md §controls:'page'):
183
+ // on Spark it would need SceneViewer — which ./viewer and ./model share — to take an external
184
+ // camera in both its mono and eye paths. Refused by name rather than half-supported.
185
+ if (pageControls) {
186
+ throw new Error(
187
+ "@displayxr/inline3d/splat: controls:'page' is not supported on Spark (the default engine) — " +
188
+ "pass engine:'playcanvas'. Spark's viewer owns its camera; see docs/playcanvas-adapter.md.",
189
+ );
190
+ }
191
+
177
192
  // A Streamed SOG on Spark is a page bug (Spark has no lod-meta.json reader): say so now, by
178
193
  // name, rather than let Spark fail on an "unknown file type" that reads like a corrupt asset.
179
194
  if (isStreamedUrl(src)) throw new Error(`@displayxr/inline3d/splat: ${STREAMED_NEEDS_PLAYCANVAS}`);
@@ -336,6 +351,13 @@ export function addSplat(wall, canvas, src, opts = {}) {
336
351
  },
337
352
  /** Called with the live focus, in the splat's own space, whenever it moves. */
338
353
  onFocusChange: null,
354
+ /** Not on this backend: controls:'page' is a PlayCanvas-backend feature. */
355
+ setCameraPose() {
356
+ throw new Error(
357
+ "@displayxr/inline3d/splat: setCameraPose() needs addSplat(…, { engine:'playcanvas', controls:'page' }).",
358
+ );
359
+ },
360
+ getCameraPose: () => null,
339
361
  /** Not on this backend: the crossfading asset swap is a PlayCanvas-backend feature. */
340
362
  setSource() {
341
363
  throw new Error(
@@ -622,6 +644,16 @@ function addSplatDeferred(wall, canvas, src, opts) {
622
644
  pending.push([name, args]);
623
645
  return name === 'setFocus' ? out : undefined;
624
646
  };
647
+ const page = opts.controls === 'page';
648
+ const pageOnly = (name) => () => {
649
+ throw new Error(
650
+ `@displayxr/inline3d/splat: ${name}() is not available with controls:'page' — the page owns the ` +
651
+ 'camera. Drive it with handle.setCameraPose(matrixWorld, { verticalFovDeg, near, far }).',
652
+ );
653
+ };
654
+ // Before the adapter lands, a page calling setCameraPose every frame keeps ONE pending pose
655
+ // (validated now, so a bad call throws at its own line): last call wins, as it will after.
656
+ let pendingPose = null;
625
657
  const out = {
626
658
  backend: 'playcanvas',
627
659
  engine: null,
@@ -631,8 +663,30 @@ function addSplatDeferred(wall, canvas, src, opts) {
631
663
  camera: null,
632
664
  rig: null,
633
665
  perf: null,
634
- setPose: queue('setPose'),
635
- resetPose: queue('resetPose'),
666
+ setPose: page ? pageOnly('setPose') : queue('setPose'),
667
+ resetPose: page ? pageOnly('resetPose') : queue('resetPose'),
668
+ setCameraPose(matrixWorld, o) {
669
+ if (!page) {
670
+ throw new Error(
671
+ "@displayxr/inline3d/splat: setCameraPose() needs addSplat(…, { controls:'page' }) — " +
672
+ 'with the default controls the SDK owns the camera (use setPose).',
673
+ );
674
+ }
675
+ const pose = normalizeCameraPose(matrixWorld, o);
676
+ if (pendingPose) pendingPose[1] = [pose.matrixWorld, pose];
677
+ else pending.push((pendingPose = ['setCameraPose', [pose.matrixWorld, pose]]));
678
+ return out;
679
+ },
680
+ getCameraPose: () =>
681
+ pendingPose
682
+ ? {
683
+ matrixWorld: Float32Array.from(pendingPose[1][1].matrixWorld),
684
+ verticalFovDeg: pendingPose[1][1].verticalFovDeg,
685
+ near: pendingPose[1][1].near,
686
+ far: pendingPose[1][1].far,
687
+ convergence: pendingPose[1][1].convergence,
688
+ }
689
+ : null,
636
690
  setFocus: queue('setFocus'),
637
691
  // A swap requested before the first asset has landed runs once it has (the adapter's own
638
692
  // setSource replaces this stub on the same object by then).
@@ -70,6 +70,56 @@ const clamp = (v, lo, hi) => (v < lo ? lo : v > hi ? hi : v);
70
70
  // matrix and every vertex lands undefined. Reject at the setter instead.
71
71
  const finite = (v, fallback) => (typeof v === 'number' && Number.isFinite(v) ? v : fallback);
72
72
 
73
+ /**
74
+ * The k-th smallest of the first `n` entries of `a` — exactly what `a.subarray(0, n).sort()[k]`
75
+ * returns, NaN placement included (a TypedArray sort puts NaN last) — by quickselect, reordering
76
+ * `a` in place. O(n) on average.
77
+ */
78
+ export function selectKth(a, n, k) {
79
+ // NaN to the end first, as the numeric TypedArray sort does; select among the rest.
80
+ let m = n;
81
+ for (let i = 0; i < m; ) {
82
+ if (a[i] !== a[i]) {
83
+ m--;
84
+ const t = a[i];
85
+ a[i] = a[m];
86
+ a[m] = t;
87
+ } else i++;
88
+ }
89
+ if (k >= m) return NaN;
90
+ let left = 0;
91
+ let right = m - 1;
92
+ while (right > left) {
93
+ // Median-of-three pivot keeps sorted / reverse-sorted input O(n).
94
+ const mid = (left + right) >> 1;
95
+ if (a[mid] < a[left]) swap(a, mid, left);
96
+ if (a[right] < a[left]) swap(a, right, left);
97
+ if (a[right] < a[mid]) swap(a, right, mid);
98
+ const pivot = a[mid];
99
+ let i = left;
100
+ let j = right;
101
+ while (i <= j) {
102
+ while (a[i] < pivot) i++;
103
+ while (a[j] > pivot) j--;
104
+ if (i <= j) {
105
+ swap(a, i, j);
106
+ i++;
107
+ j--;
108
+ }
109
+ }
110
+ if (k <= j) right = j;
111
+ else if (k >= i) left = i;
112
+ else return a[k];
113
+ }
114
+ return a[k];
115
+ }
116
+
117
+ function swap(a, i, j) {
118
+ const t = a[i];
119
+ a[i] = a[j];
120
+ a[j] = t;
121
+ }
122
+
73
123
  /**
74
124
  * Robust model-space bounds from a flat array of splat/vertex centres.
75
125
  *
@@ -110,14 +160,15 @@ export function boundsFromPositions(xyz, { lo = 0.05, hi = 0.95, expand = 2.5 }
110
160
  const center = [0, 0, 0];
111
161
  const extent = [0, 0, 0];
112
162
  const axisVals = new Float64Array(n);
163
+ const kLo = trim ? Math.floor(lo * (n - 1)) : 0;
164
+ const kHi = trim ? Math.floor(hi * (n - 1)) : n - 1;
113
165
  for (let axis = 0; axis < 3; axis++) {
114
166
  for (let i = 0; i < n; i++) axisVals[i] = xyz[i * 3 + axis];
115
- // TypedArray sort is numeric and in-place — no comparator, no copy. That matters here:
116
- // this runs over every splat centre, and a boxed Array round-trip on a 500k-splat model
117
- // is the difference between a hitch and an imperceptible pause.
118
- const sorted = axisVals.sort();
119
- const loV = trim ? sorted[Math.floor(lo * (n - 1))] : sorted[0];
120
- const hiV = trim ? sorted[Math.floor(hi * (n - 1))] : sorted[n - 1];
167
+ // Only two ORDER STATISTICS are needed, not a sorted array: selecting them is O(n) where the
168
+ // sort was O(n log n) — the same two values, bit for bit (a sort's k-th element IS the k-th
169
+ // order statistic), at a fraction of the main-thread time on a 200k-point sample (1.10.1).
170
+ const loV = selectKth(axisVals, n, kLo);
171
+ const hiV = selectKth(axisVals, n, kHi);
121
172
  center[axis] = 0.5 * (loV + hiV);
122
173
  extent[axis] = Math.max(hiV - loV, 1e-6);
123
174
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@displayxr/inline3d",
3
- "version": "1.10.0",
3
+ "version": "1.11.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
@@ -266,6 +266,11 @@ export interface SplatOptions {
266
266
  * `handle.engine.root`). Anything nearer is clipped, splats included. Unset: untouched.
267
267
  */
268
268
  nearClip?: number;
269
+ /**
270
+ * PlayCanvas: draw the engine's sky box (default false). Off, nothing is drawn behind the splat
271
+ * even when a page sets `scene.envAtlas` to light its own meshes; the canvas stays transparent.
272
+ */
273
+ sky?: boolean;
269
274
  /** PlayCanvas: a CAP on the projection's far plane (only ever lowers it). Unset: untouched. */
270
275
  farClip?: number;
271
276
  /** Camera rig only: the distance in world metres that sits ON the glass. */
@@ -299,6 +304,61 @@ export interface SplatOptions {
299
304
  observe?: Element;
300
305
  /** Forwarded to the core window: see `TileOptions.firstWovenHoldMs`. */
301
306
  firstWovenHoldMs?: number;
307
+ /**
308
+ * Who owns the camera. `'viewer'` (the default): the SDK's orbit, idle spin, auto-fit and focus
309
+ * gestures. `'page'` (`engine: 'playcanvas'` only; throws on Spark): the page drives the camera
310
+ * every frame through {@link SplatHandle.setCameraPose} and the adapter keeps only the eye math —
311
+ * the attach-pattern camera rig, the runtime's projections, the mono fallback. `fit`,
312
+ * `virtualDisplayHeight`, `orbit`, `idleSpin`, `focusInput` (and the other framing/orbit knobs)
313
+ * are ignored, named once in a `console.info`; `rig: 'display'` throws.
314
+ */
315
+ controls?: 'viewer' | 'page';
316
+ /**
317
+ * `controls: 'page'`: the comfort number `ipd × metersToVirtual × convergenceDiopters × 0.5` the
318
+ * rig is built to, in (0, 1]. Default 0.3, the auto-3D shim's. `metersToVirtual = comfortDepth ·
319
+ * d / 0.5`, so the depth budget is the same for a 10 cm subject and a 150 m castle.
320
+ */
321
+ comfortDepth?: number;
322
+ /**
323
+ * `controls: 'page'` only: called once per adapter frame — the wall's session frame in 3D, the
324
+ * mono rAF in 2D — BEFORE anything renders. Call `handle.setCameraPose(…)` synchronously in
325
+ * here and THIS frame renders that pose (zero lag, the attach pattern); a pose set from the
326
+ * page's own rAF may be one frame late, since the two rAFs have no guaranteed order. A throw is
327
+ * caught and warned once; frames keep rendering.
328
+ */
329
+ onBeforeFrame?: (frame: SplatFrameInfo) => void;
330
+ }
331
+
332
+ /** What `onBeforeFrame` receives. */
333
+ export interface SplatFrameInfo {
334
+ /** `performance.now()` at the call. */
335
+ time: number;
336
+ /** The runtime's view list in 3D (valid only inside the call — copy what you keep); null in mono. */
337
+ views: readonly XRView[] | null;
338
+ /** Seconds since the previous call (0 on the first; capped at 0.1). */
339
+ dt: number;
340
+ }
341
+
342
+ /** A page camera, as `setCameraPose` takes it and `getCameraPose` returns it. */
343
+ export interface SplatCameraPose {
344
+ /**
345
+ * Column-major world matrix of the page camera in the SPLAT's own space (the space of the camera
346
+ * block's `rest`), three.js convention: looks down −Z, +Y up. Rotation + translation + at most a
347
+ * UNIFORM scale (a page whose world scales the splat passes `inv(splatWorld) · camera.matrixWorld`;
348
+ * the scale is how page units reach the adapter). The adapter applies its own OpenCV → engine flip.
349
+ */
350
+ matrixWorld: Float32Array;
351
+ /** Full vertical field of view, degrees. */
352
+ verticalFovDeg: number;
353
+ /** Page units. Default 0.001. A floor on every projection's near (depth mapping only). */
354
+ near: number;
355
+ /** Page units. Default 5000. A cap on every projection's far. */
356
+ far: number;
357
+ /**
358
+ * Zero-disparity distance along the view axis, page units; null = the adapter's own (the focus
359
+ * waterfall's, fixed until `setFocus` / `setSource`).
360
+ */
361
+ convergence: number | null;
302
362
  }
303
363
 
304
364
  /** `handle.stats()` on `engine: 'playcanvas'`. */
@@ -379,8 +439,21 @@ export interface SplatHandle {
379
439
  /** Resolves once the asset has loaded and been framed; rejects if the load failed. */
380
440
  readonly ready: Promise<SplatHandle>;
381
441
 
442
+ /** Throws on `controls: 'page'` (the page owns the camera). */
382
443
  setPose(pose?: OrbitPose): void;
444
+ /** Throws on `controls: 'page'`. */
383
445
  resetPose(): void;
446
+ /**
447
+ * `controls: 'page'` only (throws otherwise): the camera for the next frame drawn — call it every
448
+ * frame you render, ideally inside `onBeforeFrame`. Last call wins; a page that stops calling
449
+ * keeps its last pose. Throws synchronously on a malformed matrix or lens.
450
+ */
451
+ setCameraPose(
452
+ matrixWorld: ArrayLike<number>,
453
+ opts: { verticalFovDeg: number; near?: number; far?: number; convergence?: number },
454
+ ): SplatHandle;
455
+ /** The last pose the page set (a copy), or null before the first `setCameraPose`. */
456
+ getCameraPose(): SplatCameraPose | null;
384
457
  /**
385
458
  * Point the window at something, in the SPLAT's own space (the space the `camera` block's
386
459
  * `focus.point` is in). Null returns to whatever the waterfall resolved. Eased unless `snap`.