@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 +62 -0
- package/README.md +9 -0
- package/js/inline3d-splat-playcanvas.js +394 -29
- package/js/inline3d-splat-shared.js +121 -0
- package/js/inline3d-splat.js +56 -2
- package/js/inline3d-viewer.js +57 -6
- package/package.json +1 -1
- package/splat.d.ts +73 -0
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.
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1982
|
-
const local =
|
|
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
|
-
|
|
2026
|
-
|
|
2027
|
-
|
|
2028
|
-
|
|
2029
|
-
|
|
2030
|
-
|
|
2031
|
-
|
|
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
|
+
}
|
package/js/inline3d-splat.js
CHANGED
|
@@ -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).
|
package/js/inline3d-viewer.js
CHANGED
|
@@ -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
|
-
//
|
|
116
|
-
//
|
|
117
|
-
//
|
|
118
|
-
const
|
|
119
|
-
const
|
|
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.
|
|
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`.
|