@displayxr/inline3d 1.10.1 → 1.12.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 +90 -0
- package/README.md +27 -5
- package/js/inline3d-model-entry.js +242 -0
- package/js/inline3d-model-playcanvas.js +754 -0
- package/js/inline3d-playcanvas-engine.js +77 -0
- package/js/inline3d-splat-playcanvas.js +345 -22
- package/js/inline3d-splat-shared.js +121 -0
- package/js/inline3d-splat.js +56 -2
- package/model.d.ts +109 -30
- package/package.json +15 -3
- package/splat.d.ts +68 -0
|
@@ -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,8 +139,23 @@ 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
|
|
|
148
|
+
/** Tone-mapping names the viewer takes → the engine's constant names. */
|
|
149
|
+
export const TONE_MAPPINGS = Object.freeze({
|
|
150
|
+
none: 'TONEMAP_NONE',
|
|
151
|
+
linear: 'TONEMAP_LINEAR',
|
|
152
|
+
neutral: 'TONEMAP_NEUTRAL',
|
|
153
|
+
aces: 'TONEMAP_ACES',
|
|
154
|
+
aces2: 'TONEMAP_ACES2',
|
|
155
|
+
filmic: 'TONEMAP_FILMIC',
|
|
156
|
+
hejl: 'TONEMAP_HEJL',
|
|
157
|
+
});
|
|
158
|
+
|
|
142
159
|
// ── pure matrix arithmetic (column-major, the layout XRView, three and the engine all use) ──
|
|
143
160
|
|
|
144
161
|
/** three's `Matrix4.makePerspective(left, right, top, bottom, near, far)`, element for element. */
|
|
@@ -362,6 +379,69 @@ export function pivotInverseTRS({ yaw = 0, pitch = 0, scale = 1, focus = [0, 0,
|
|
|
362
379
|
};
|
|
363
380
|
}
|
|
364
381
|
|
|
382
|
+
/**
|
|
383
|
+
* controls:'page' — the rig node's TRS from the page's camera matrix.
|
|
384
|
+
*
|
|
385
|
+
* `matrixWorld` is the page camera's world matrix in the SPLAT's model space (the space of the
|
|
386
|
+
* `.sog` camera block's `rest`), a three.js-convention camera (looks down −Z, +Y up). The splat
|
|
387
|
+
* entity carries the OpenCV → GL half-turn about X under `flipY`, so the camera rides the same
|
|
388
|
+
* turn: rig = F · M, F = Rx(180°) (or the identity without the flip). A uniform scale in M is
|
|
389
|
+
* kept — it is how a page whose world scales the splat (F1000: S(2.5)) expresses "page units",
|
|
390
|
+
* and it puts the runtime's eye offsets (metres) and the page's near/far into page units for free.
|
|
391
|
+
*
|
|
392
|
+
* @returns {{position:number[], rotation:number[], scale:number, uniform:boolean}}
|
|
393
|
+
*/
|
|
394
|
+
export function pageRigTRS(M, flipY = true) {
|
|
395
|
+
const m = Float64Array.from(M);
|
|
396
|
+
if (flipY) {
|
|
397
|
+
for (const c of [0, 4, 8, 12]) {
|
|
398
|
+
m[c + 1] = -m[c + 1];
|
|
399
|
+
m[c + 2] = -m[c + 2];
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
const len = (c) => Math.hypot(m[c], m[c + 1], m[c + 2]);
|
|
403
|
+
const lx = len(0), ly = len(4), lz = len(8);
|
|
404
|
+
const s = Math.cbrt(lx * ly * lz);
|
|
405
|
+
const uniform = Math.abs(lx - s) < 1e-4 * s && Math.abs(ly - s) < 1e-4 * s && Math.abs(lz - s) < 1e-4 * s;
|
|
406
|
+
const R = new Float64Array(16);
|
|
407
|
+
R[0] = m[0] / lx; R[1] = m[1] / lx; R[2] = m[2] / lx;
|
|
408
|
+
R[4] = m[4] / ly; R[5] = m[5] / ly; R[6] = m[6] / ly;
|
|
409
|
+
R[8] = m[8] / lz; R[9] = m[9] / lz; R[10] = m[10] / lz;
|
|
410
|
+
R[15] = 1;
|
|
411
|
+
return { position: [m[12], m[13], m[14]], rotation: quatFromMatrix(R), scale: s, uniform };
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/** pageRigTRS as a column-major matrix (the pick ray and the tests read it this way). */
|
|
415
|
+
export function pageRigMatrix(M, flipY = true) {
|
|
416
|
+
const t = pageRigTRS(M, flipY);
|
|
417
|
+
const out = poseMatrix(t.position, t.rotation);
|
|
418
|
+
for (let i = 0; i < 12; i++) if (i % 4 !== 3) out[i] *= t.scale;
|
|
419
|
+
return out;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* The page camera's view axis in MODEL space: its position and unit forward (−Z of the matrix),
|
|
424
|
+
* plus the matrix's uniform scale (model units per page unit).
|
|
425
|
+
*/
|
|
426
|
+
export function pageViewAxis(M) {
|
|
427
|
+
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]));
|
|
428
|
+
const fl = Math.hypot(M[8], M[9], M[10]) || 1;
|
|
429
|
+
return { origin: [M[12], M[13], M[14]], forward: [-M[8] / fl, -M[9] / fl, -M[10] / fl], scale: s };
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* The attach-pattern camera rig for controls:'page' — the auto-3D shim's buildRig, field for
|
|
434
|
+
* field: identity pose, the page's vertical FOV, convergence d, metersToVirtual = depth · d / 0.5
|
|
435
|
+
* (so comfort = ipd × m2v × (1/d) × 0.5 = depth by construction).
|
|
436
|
+
*/
|
|
437
|
+
export function pageViewRig({ verticalFovDeg, convergence, comfortDepth, ipdFactor = 1, parallaxFactor = 1 }, out = {}) {
|
|
438
|
+
const d = Math.max(1e-6, convergence);
|
|
439
|
+
return cameraRigFromPose(
|
|
440
|
+
{ fov: verticalFovDeg },
|
|
441
|
+
{ attach: true, convergence: d, ipdFactor, parallaxFactor, metersToVirtual: (comfortDepth * d) / 0.5, out },
|
|
442
|
+
);
|
|
443
|
+
}
|
|
444
|
+
|
|
365
445
|
/**
|
|
366
446
|
* SceneViewer.fitTo's scale, as a function. Same arithmetic, same order, same clamps — see the
|
|
367
447
|
* reasoning there (swept width, 'height' guard, depth backstop).
|
|
@@ -536,14 +616,26 @@ export class PlayCanvasSplatViewer {
|
|
|
536
616
|
nearClip,
|
|
537
617
|
farClip,
|
|
538
618
|
sky = false,
|
|
619
|
+
pageCamera = false,
|
|
620
|
+
toneMapping = 'none',
|
|
539
621
|
} = opts;
|
|
540
622
|
this.canvas = canvas;
|
|
623
|
+
// controls:'page': the PAGE owns the camera (setPageCamera). No orbit, no idle, no fit; the
|
|
624
|
+
// rig node carries the page's matrix instead of the inverse pivot.
|
|
625
|
+
this.pageCamera = pageCamera === true;
|
|
626
|
+
this.page = this.pageCamera
|
|
627
|
+
? { matrix: poseMatrix([0, 0, 0], [0, 0, 0, 1]), fov: MONO_FOV, near: MONO_NEAR, far: CAPTURE_FAR, set: false }
|
|
628
|
+
: null;
|
|
541
629
|
// The engine draws a sky box whenever the scene has something to draw it from — and
|
|
542
630
|
// `scene.envAtlas` counts: a page that sets one to light its own meshes under
|
|
543
631
|
// handle.engine.root got a grey gradient box behind the splat the instant the splat was
|
|
544
632
|
// hidden (mid-setSource, or a page showing only its meshes). The SDK's contract is a
|
|
545
633
|
// transparent canvas the page shows through, so the sky layer is off unless asked for.
|
|
546
634
|
this.sky = sky === true;
|
|
635
|
+
// The eye camera's tone mapping, by name (the engine constant is looked up once the engine has
|
|
636
|
+
// loaded). 'none' for splats — their colours are already display-referred; ./model's mesh
|
|
637
|
+
// tiles pass 'neutral' (Khronos PBR Neutral), the glTF Sample Viewer's default.
|
|
638
|
+
this.toneMapping = toneMapping;
|
|
547
639
|
// Depth range for a MIXED scene (meshes under handle.engine.root depth-test against each
|
|
548
640
|
// other; splats only test against them). The projections' own near/far stay the adapter's —
|
|
549
641
|
// these only raise the near (floor) and lower the far (cap). Unset: untouched.
|
|
@@ -568,7 +660,7 @@ export class PlayCanvasSplatViewer {
|
|
|
568
660
|
this.fitSweep = fitSweep;
|
|
569
661
|
this.renderScale = renderScale;
|
|
570
662
|
this.pitchLimit = pitchLimit;
|
|
571
|
-
this.idleSpin = idleSpin;
|
|
663
|
+
this.idleSpin = this.pageCamera ? 0 : idleSpin;
|
|
572
664
|
this.flipY = opts.flipY !== false;
|
|
573
665
|
|
|
574
666
|
this._fitScale = 1;
|
|
@@ -587,6 +679,8 @@ export class PlayCanvasSplatViewer {
|
|
|
587
679
|
this._focusSettled = true;
|
|
588
680
|
this.onFocusChange = null;
|
|
589
681
|
this.onTick = null;
|
|
682
|
+
/** controls:'page' — `(views|null) => void`, run once per frame before anything else. */
|
|
683
|
+
this._beforeFrame = null;
|
|
590
684
|
this._lastInput = now();
|
|
591
685
|
this._lastTick = 0;
|
|
592
686
|
this._monoRaf = 0;
|
|
@@ -608,6 +702,13 @@ export class PlayCanvasSplatViewer {
|
|
|
608
702
|
proj: new Float64Array(16),
|
|
609
703
|
capture: null, // intrinsics when on a camera rig
|
|
610
704
|
};
|
|
705
|
+
if (this.pageCamera) {
|
|
706
|
+
// The mono camera IS the page camera: identity under the rig node, the page's lens.
|
|
707
|
+
this.mono.fov = this.page.fov;
|
|
708
|
+
this.mono.near = this.page.near;
|
|
709
|
+
this.mono.far = this.page.far;
|
|
710
|
+
this.mono.pose = poseMatrix([0, 0, 0], [0, 0, 0, 1]);
|
|
711
|
+
}
|
|
611
712
|
this._placeMonoForFit();
|
|
612
713
|
|
|
613
714
|
// Engine objects — null until attachEngine().
|
|
@@ -635,7 +736,7 @@ export class PlayCanvasSplatViewer {
|
|
|
635
736
|
if (this._ro) this._ro.observe(canvas);
|
|
636
737
|
else if (typeof addEventListener === 'function') addEventListener('resize', this._onResize);
|
|
637
738
|
|
|
638
|
-
if (orbit) this._bindOrbit();
|
|
739
|
+
if (orbit && !this.pageCamera) this._bindOrbit();
|
|
639
740
|
this._resize();
|
|
640
741
|
|
|
641
742
|
this.onFrame = this.onFrame.bind(this);
|
|
@@ -658,6 +759,7 @@ export class PlayCanvasSplatViewer {
|
|
|
658
759
|
}
|
|
659
760
|
|
|
660
761
|
fitTo(center, extent) {
|
|
762
|
+
if (this.pageCamera) return; // the page frames its own camera
|
|
661
763
|
const c = Array.isArray(center) ? center : [center.x, center.y, center.z];
|
|
662
764
|
const e = Array.isArray(extent) ? extent : [extent.x, extent.y, extent.z];
|
|
663
765
|
this.setFocus(c, { snap: true });
|
|
@@ -677,6 +779,7 @@ export class PlayCanvasSplatViewer {
|
|
|
677
779
|
}
|
|
678
780
|
|
|
679
781
|
setPose({ yaw, pitch, zoom, depthOffset } = {}) {
|
|
782
|
+
if (this.pageCamera) throw new Error(PAGE_POSE_ERROR);
|
|
680
783
|
// A snap, as on SceneViewer; it also ends an orbit relax (the page is driving now).
|
|
681
784
|
if (yaw !== undefined || pitch !== undefined) this._orbitMode = null;
|
|
682
785
|
if (yaw !== undefined) this._targetYaw = this._yaw = yaw;
|
|
@@ -698,6 +801,7 @@ export class PlayCanvasSplatViewer {
|
|
|
698
801
|
}
|
|
699
802
|
|
|
700
803
|
resetPose() {
|
|
804
|
+
if (this.pageCamera) throw new Error(PAGE_POSE_ERROR);
|
|
701
805
|
this.setPose({ yaw: 0, pitch: 0, zoom: 1, depthOffset: 0 });
|
|
702
806
|
this._lastInput = now();
|
|
703
807
|
}
|
|
@@ -744,8 +848,40 @@ export class PlayCanvasSplatViewer {
|
|
|
744
848
|
return { x: v.x, y: v.y, z: v.z };
|
|
745
849
|
}
|
|
746
850
|
|
|
851
|
+
/**
|
|
852
|
+
* controls:'page' — take the page's camera for the next frame drawn (last call wins; no call
|
|
853
|
+
* keeps the last pose). `pose` is normalizeCameraPose()'s output. The rig node is written here
|
|
854
|
+
* and by the tick, through _applyTransform (still its only writer).
|
|
855
|
+
*/
|
|
856
|
+
setPageCamera(pose) {
|
|
857
|
+
if (!this.pageCamera) throw new Error("setPageCamera needs controls:'page'");
|
|
858
|
+
const p = this.page;
|
|
859
|
+
p.matrix.set(pose.matrixWorld);
|
|
860
|
+
p.set = true;
|
|
861
|
+
if (pose.verticalFovDeg !== p.fov || pose.near !== p.near || pose.far !== p.far) {
|
|
862
|
+
p.fov = pose.verticalFovDeg;
|
|
863
|
+
p.near = pose.near;
|
|
864
|
+
p.far = pose.far;
|
|
865
|
+
this.mono.fov = p.fov;
|
|
866
|
+
this.mono.near = p.near;
|
|
867
|
+
this.mono.far = p.far;
|
|
868
|
+
this._updateMonoProjection();
|
|
869
|
+
}
|
|
870
|
+
this._applyTransform();
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
/**
|
|
874
|
+
* The rig node's matrix (eye space → content space): the inverse pivot on the viewer's own
|
|
875
|
+
* controls, F · matrixWorld on controls:'page'.
|
|
876
|
+
*/
|
|
877
|
+
rigMatrix() {
|
|
878
|
+
if (this.pageCamera) return pageRigMatrix(this.page.matrix, this.flipY);
|
|
879
|
+
return mat4Invert(pivotMatrix(this.pivotState()));
|
|
880
|
+
}
|
|
881
|
+
|
|
747
882
|
/** Pose + lens the mono camera as the recording camera (applyCaptureCamera's counterpart). */
|
|
748
883
|
useCaptureCamera(rig) {
|
|
884
|
+
if (this.pageCamera) return; // the page's lens, not the recording's
|
|
749
885
|
const pose = capturePose(rig.rest, this.flipY);
|
|
750
886
|
this.mono.pose = pose.matrix;
|
|
751
887
|
this.mono.capture = rig.intrinsics;
|
|
@@ -763,6 +899,7 @@ export class PlayCanvasSplatViewer {
|
|
|
763
899
|
|
|
764
900
|
/** Back to the display rig's mono camera (setSource from a photo lift to an object). */
|
|
765
901
|
useDisplayCamera() {
|
|
902
|
+
if (this.pageCamera) return;
|
|
766
903
|
this.mono.capture = null;
|
|
767
904
|
this.mono.fov = MONO_FOV;
|
|
768
905
|
this.mono.far = MONO_FAR;
|
|
@@ -779,6 +916,8 @@ export class PlayCanvasSplatViewer {
|
|
|
779
916
|
onFrame(views, layer) {
|
|
780
917
|
if (this._disposed) return;
|
|
781
918
|
if (this._mode !== '3d') this.stopMono();
|
|
919
|
+
// BEFORE the tick: a pose the page sets in here is the one this very frame renders.
|
|
920
|
+
this._beforeFrame?.(views || null);
|
|
782
921
|
this._tick();
|
|
783
922
|
if (!views || views.length < 2) {
|
|
784
923
|
this._replayLastGood();
|
|
@@ -805,6 +944,8 @@ export class PlayCanvasSplatViewer {
|
|
|
805
944
|
const loop = () => {
|
|
806
945
|
if (this._disposed) return;
|
|
807
946
|
this._monoRaf = requestAnimationFrame(loop);
|
|
947
|
+
this._beforeFrame?.(null);
|
|
948
|
+
if (this._disposed || this._mode !== 'mono') return; // the callback removed or re-wove us
|
|
808
949
|
this._tick();
|
|
809
950
|
this._drawMono();
|
|
810
951
|
};
|
|
@@ -847,13 +988,15 @@ export class PlayCanvasSplatViewer {
|
|
|
847
988
|
* @param {object} o.perf the resolved playcanvasPerfSettings().
|
|
848
989
|
* @param {string} [o.viewPath] force 'cameras' | 'renderview' (diagnostics).
|
|
849
990
|
*/
|
|
850
|
-
async attachEngine(pc, { preserveDrawingBuffer = false, perf, viewPath } = {}) {
|
|
991
|
+
async attachEngine(pc, { preserveDrawingBuffer = false, perf, viewPath, antialias = false, patchSplats = true } = {}) {
|
|
851
992
|
this.pc = pc;
|
|
852
993
|
const device = await pc.createGraphicsDevice(this.canvas, {
|
|
853
994
|
deviceTypes: [pc.DEVICETYPE_WEBGL2],
|
|
854
995
|
alpha: true,
|
|
855
996
|
premultipliedAlpha: true,
|
|
856
|
-
|
|
997
|
+
// Off for splats (alpha-blended quads gain nothing from MSAA); ./model turns it on, as
|
|
998
|
+
// three's SceneViewer has it, because mesh silhouettes alias visibly without it.
|
|
999
|
+
antialias,
|
|
857
1000
|
xrCompatible: false,
|
|
858
1001
|
preserveDrawingBuffer,
|
|
859
1002
|
});
|
|
@@ -897,10 +1040,12 @@ export class PlayCanvasSplatViewer {
|
|
|
897
1040
|
// Footprint fix (§ patchGsplatFootprint) and the perf quad-extent cap, both as chunk
|
|
898
1041
|
// overrides. The gsplat chunks are registered by GSplatComponentSystem during init, so
|
|
899
1042
|
// after it; before any gsplat material compiles.
|
|
900
|
-
|
|
901
|
-
|
|
1043
|
+
// ./model passes patchSplats:false: its subject is a mesh, and a page adding a splat under
|
|
1044
|
+
// handle.engine.root on a model tile gets the engine's stock footprint (documented).
|
|
1045
|
+
const chunks = patchSplats ? pc.ShaderChunks.get(device, pc.SHADERLANGUAGE_GLSL) : null;
|
|
1046
|
+
const corner = chunks ? patchGsplatFootprint(chunks.get('gsplatCornerVS')) : { ok: false, skipped: true };
|
|
902
1047
|
if (corner.ok) chunks.set('gsplatCornerVS', corner.src);
|
|
903
|
-
else if (!warnedFootprint) {
|
|
1048
|
+
else if (!corner.skipped && !warnedFootprint) {
|
|
904
1049
|
warnedFootprint = true;
|
|
905
1050
|
console.warn(
|
|
906
1051
|
'[inline3d/splat] engine:playcanvas — this engine build does not have the gsplatCornerVS ' +
|
|
@@ -910,7 +1055,7 @@ export class PlayCanvasSplatViewer {
|
|
|
910
1055
|
);
|
|
911
1056
|
}
|
|
912
1057
|
this.footprintPatched = corner.ok;
|
|
913
|
-
if (perf?.quadExtent) {
|
|
1058
|
+
if (chunks && perf?.quadExtent) {
|
|
914
1059
|
const q = patchPlayCanvasQuadExtent(chunks.get('gsplatCommonVS'), perf.quadExtent);
|
|
915
1060
|
if (q.ok) chunks.set('gsplatCommonVS', q.src);
|
|
916
1061
|
else if (!warnedQuadExtent) {
|
|
@@ -1059,7 +1204,7 @@ export class PlayCanvasSplatViewer {
|
|
|
1059
1204
|
// The engine's default camera tonemap is LINEAR, which routes every splat colour through
|
|
1060
1205
|
// decodeGamma → toneMap → gammaCorrectOutput. Spark writes the stored colour straight out;
|
|
1061
1206
|
// NONE is the same thing here (GAMMA_SRGB alone leaves a gamma-space colour untouched).
|
|
1062
|
-
e.camera.toneMapping = pc.TONEMAP_NONE;
|
|
1207
|
+
e.camera.toneMapping = TONE_MAPPINGS[this.toneMapping] ? pc[TONE_MAPPINGS[this.toneMapping]] ?? pc.TONEMAP_NONE : pc.TONEMAP_NONE;
|
|
1063
1208
|
this.rigNode.addChild(e);
|
|
1064
1209
|
return e;
|
|
1065
1210
|
}
|
|
@@ -1092,7 +1237,7 @@ export class PlayCanvasSplatViewer {
|
|
|
1092
1237
|
|
|
1093
1238
|
_placeMonoForFit() {
|
|
1094
1239
|
// SceneViewer.fitTo: distance to make the frustum exactly vH tall at z = 0, looking down −z.
|
|
1095
|
-
if (this.mono.capture) return;
|
|
1240
|
+
if (this.mono.capture || this.pageCamera) return;
|
|
1096
1241
|
const d = (0.5 * this.vH) / Math.tan((this.mono.fov * DEG) / 2);
|
|
1097
1242
|
this.mono.pose = poseMatrix([0, 0, d], [0, 0, 0, 1]);
|
|
1098
1243
|
}
|
|
@@ -1134,7 +1279,14 @@ export class PlayCanvasSplatViewer {
|
|
|
1134
1279
|
const el = this.canvas;
|
|
1135
1280
|
const sx = cache && cache.bufW > 0 && el.width ? el.width / cache.bufW : 1;
|
|
1136
1281
|
const sy = cache && cache.bufH > 0 && el.height ? el.height / cache.bufH : 1;
|
|
1137
|
-
if (this.
|
|
1282
|
+
if (this.pageCamera) {
|
|
1283
|
+
// The page's near/far join the caller's nearClip/farClip as a floor and a cap: only the
|
|
1284
|
+
// depth mapping moves, the runtime's frustum (fov, skew, principal point) stays untouched.
|
|
1285
|
+
const p = this.page;
|
|
1286
|
+
const nf = this.nearClip !== null ? Math.max(this.nearClip, p.near) : p.near;
|
|
1287
|
+
const fc = this.farClip !== null ? Math.min(this.farClip, p.far) : p.far;
|
|
1288
|
+
for (const e of entries) clampProjectionDepth(e.proj, nf, fc);
|
|
1289
|
+
} else if (this.nearClip !== null || this.farClip !== null) {
|
|
1138
1290
|
for (const e of entries) clampProjectionDepth(e.proj, this.nearClip, this.farClip);
|
|
1139
1291
|
}
|
|
1140
1292
|
const rect = (e) =>
|
|
@@ -1344,7 +1496,7 @@ export class PlayCanvasSplatViewer {
|
|
|
1344
1496
|
*/
|
|
1345
1497
|
_applyTransform() {
|
|
1346
1498
|
if (!this.rigNode) return;
|
|
1347
|
-
const trs = pivotInverseTRS(this.pivotState());
|
|
1499
|
+
const trs = this.pageCamera ? pageRigTRS(this.page.matrix, this.flipY) : pivotInverseTRS(this.pivotState());
|
|
1348
1500
|
this.rigNode.setLocalPosition(trs.position[0], trs.position[1], trs.position[2]);
|
|
1349
1501
|
this.rigNode.setLocalRotation(trs.rotation[0], trs.rotation[1], trs.rotation[2], trs.rotation[3]);
|
|
1350
1502
|
this.rigNode.setLocalScale(trs.scale, trs.scale, trs.scale);
|
|
@@ -1736,8 +1888,22 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
|
|
|
1736
1888
|
// `sortIntervalMs` is accepted and has no effect here: the engine re-sorts when the camera
|
|
1737
1889
|
// ROTATES (one directional sort serves every view), not on a timer — docs/playcanvas-adapter.md.
|
|
1738
1890
|
|
|
1891
|
+
// controls:'page' — validated again here (./splat already did, at call time) because a test or
|
|
1892
|
+
// an advanced caller may reach this function directly.
|
|
1893
|
+
const ctl = resolveControls(opts);
|
|
1894
|
+
const pageMode = ctl.page;
|
|
1895
|
+
if (pageMode && ctl.ignored.length) {
|
|
1896
|
+
console.info(
|
|
1897
|
+
`[inline3d/splat] controls:'page' — the page owns the camera, so ${ctl.ignored.join(', ')} ` +
|
|
1898
|
+
`${ctl.ignored.length === 1 ? 'is' : 'are'} ignored.`,
|
|
1899
|
+
);
|
|
1900
|
+
}
|
|
1901
|
+
// The rig question is answered by the page: its camera IS the rig.
|
|
1902
|
+
const rigOpts = pageMode ? { ...opts, rig: 'camera' } : opts;
|
|
1903
|
+
|
|
1739
1904
|
const perfResolved = playcanvasPerfSettings(perf);
|
|
1740
1905
|
const viewer = new PlayCanvasSplatViewer(canvas, {
|
|
1906
|
+
pageCamera: pageMode,
|
|
1741
1907
|
virtualDisplayHeight,
|
|
1742
1908
|
fit,
|
|
1743
1909
|
margin,
|
|
@@ -1773,13 +1939,49 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
|
|
|
1773
1939
|
perf: perfResolved.applied,
|
|
1774
1940
|
setPose: (p) => viewer.setPose(p),
|
|
1775
1941
|
resetPose: () => viewer.resetPose(),
|
|
1942
|
+
/**
|
|
1943
|
+
* controls:'page' — the page's camera for the next frame drawn. See normalizeCameraPose for
|
|
1944
|
+
* the arguments; last call wins; a page that stops calling keeps its last pose.
|
|
1945
|
+
*/
|
|
1946
|
+
setCameraPose(matrixWorld, o) {
|
|
1947
|
+
if (!pageMode) {
|
|
1948
|
+
throw new Error(
|
|
1949
|
+
"@displayxr/inline3d/splat: setCameraPose() needs addSplat(…, { controls:'page' }) — " +
|
|
1950
|
+
'with the default controls the SDK owns the camera (use setPose).',
|
|
1951
|
+
);
|
|
1952
|
+
}
|
|
1953
|
+
const pose = normalizeCameraPose(matrixWorld, o);
|
|
1954
|
+
if (!warnedNonUniform && !pageRigTRS(pose.matrixWorld, flipY).uniform) {
|
|
1955
|
+
warnedNonUniform = true;
|
|
1956
|
+
console.warn(
|
|
1957
|
+
"[inline3d/splat] controls:'page' — matrixWorld has a NON-uniform scale; the adapter uses " +
|
|
1958
|
+
'its geometric mean. A camera pose is rotation + translation + at most a uniform scale.',
|
|
1959
|
+
);
|
|
1960
|
+
}
|
|
1961
|
+
lastPagePose = pose;
|
|
1962
|
+
viewer.setPageCamera(pose);
|
|
1963
|
+
return out;
|
|
1964
|
+
},
|
|
1965
|
+
/** The last pose the page set (a copy), or null before the first setCameraPose. */
|
|
1966
|
+
getCameraPose() {
|
|
1967
|
+
if (!lastPagePose) return null;
|
|
1968
|
+
return {
|
|
1969
|
+
matrixWorld: Float32Array.from(lastPagePose.matrixWorld),
|
|
1970
|
+
verticalFovDeg: lastPagePose.verticalFovDeg,
|
|
1971
|
+
near: lastPagePose.near,
|
|
1972
|
+
far: lastPagePose.far,
|
|
1973
|
+
convergence: lastPagePose.convergence,
|
|
1974
|
+
};
|
|
1975
|
+
},
|
|
1776
1976
|
getFocus: (o) => {
|
|
1977
|
+
if (pageMode) return pageFocusPoint();
|
|
1777
1978
|
// In MODEL space, like setFocus takes it.
|
|
1778
1979
|
const f = viewer.getFocus(o);
|
|
1779
1980
|
return contentToModel([f.x, f.y, f.z]);
|
|
1780
1981
|
},
|
|
1781
1982
|
setFocus(point, o = {}) {
|
|
1782
1983
|
if (!out.mesh || !out.rig) return out;
|
|
1984
|
+
if (pageMode) return pageSetFocus(point, o);
|
|
1783
1985
|
const model = point == null ? out.rig.focusDefault : toArray3(point);
|
|
1784
1986
|
out.rig.focus = model;
|
|
1785
1987
|
out.rig.focusSource = point == null ? out.rig.focusDefaultSource : 'set';
|
|
@@ -1815,9 +2017,21 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
|
|
|
1815
2017
|
return flipY ? [c[0], -c[1], -c[2]] : [c[0], c[1], c[2]];
|
|
1816
2018
|
}
|
|
1817
2019
|
|
|
2020
|
+
// ── controls:'page' state (null/unused on the viewer's own controls) ──
|
|
2021
|
+
let lastPagePose = null; // what the page last handed setCameraPose, validated
|
|
2022
|
+
let warnedNonUniform = false;
|
|
2023
|
+
// The convergence the adapter owns when the page gives none, in MODEL units (so it is stable
|
|
2024
|
+
// while the camera moves): the waterfall's on load / setSource, a setFocus after that. Eased
|
|
2025
|
+
// toward `target` at the viewer's FOCUS_EASE unless snapped.
|
|
2026
|
+
const pageFocus = { d: Number.NaN, target: Number.NaN, fired: Number.NaN, source: null };
|
|
2027
|
+
|
|
1818
2028
|
if (wall && wall.supported) {
|
|
1819
2029
|
handle = wall.addScene(canvas, viewer.onFrame, {
|
|
1820
|
-
|
|
2030
|
+
// controls:'page' starts on a camera rig (attach, provisional FOV/convergence until the page
|
|
2031
|
+
// and the waterfall say otherwise); the display rig's height means nothing there.
|
|
2032
|
+
...(pageMode
|
|
2033
|
+
? { viewRig: pageViewRig({ verticalFovDeg: viewer.page.fov, convergence: 2, comfortDepth: ctl.comfortDepth }) }
|
|
2034
|
+
: { virtualDisplayHeight }),
|
|
1821
2035
|
onLayerLost: viewer.onLayerLost,
|
|
1822
2036
|
...(observe ? { observe } : {}),
|
|
1823
2037
|
...(firstWovenHoldMs !== undefined ? { firstWovenHoldMs } : {}),
|
|
@@ -1841,9 +2055,100 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
|
|
|
1841
2055
|
out[name]?.(...args);
|
|
1842
2056
|
}
|
|
1843
2057
|
|
|
2058
|
+
// ── controls:'page': focus = the convergence point on the page camera's view axis ──
|
|
2059
|
+
/** The convergence distance for THIS frame, in page units, and where it came from. */
|
|
2060
|
+
function pageConvergence() {
|
|
2061
|
+
const ax = pageViewAxis(viewer.page.matrix);
|
|
2062
|
+
if (lastPagePose && lastPagePose.convergence !== null) {
|
|
2063
|
+
return { d: lastPagePose.convergence, ax, source: 'page' };
|
|
2064
|
+
}
|
|
2065
|
+
const dm = Number.isFinite(pageFocus.d) ? pageFocus.d : 2;
|
|
2066
|
+
return { d: dm / (ax.scale || 1), ax, source: out.rig?.focusSource ?? 'default' };
|
|
2067
|
+
}
|
|
2068
|
+
function pageFocusPoint() {
|
|
2069
|
+
if (!out.rig) return null;
|
|
2070
|
+
const { d, ax } = pageConvergence();
|
|
2071
|
+
const dm = d * ax.scale;
|
|
2072
|
+
return [ax.origin[0] + ax.forward[0] * dm, ax.origin[1] + ax.forward[1] * dm, ax.origin[2] + ax.forward[2] * dm];
|
|
2073
|
+
}
|
|
2074
|
+
function pageSetFocus(point, { snap = false } = {}) {
|
|
2075
|
+
let dm;
|
|
2076
|
+
if (point == null) {
|
|
2077
|
+
dm = out.rig.convergenceDefault;
|
|
2078
|
+
out.rig.focus = out.rig.focusDefault;
|
|
2079
|
+
out.rig.focusSource = out.rig.focusDefaultSource;
|
|
2080
|
+
} else {
|
|
2081
|
+
const m = toArray3(point);
|
|
2082
|
+
const ax = pageViewAxis(viewer.page.matrix);
|
|
2083
|
+
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];
|
|
2084
|
+
if (!(dm > 0)) {
|
|
2085
|
+
console.warn('[inline3d/splat] setFocus: that point is behind the page camera — focus unchanged');
|
|
2086
|
+
return out;
|
|
2087
|
+
}
|
|
2088
|
+
out.rig.focus = m;
|
|
2089
|
+
out.rig.focusSource = 'set';
|
|
2090
|
+
}
|
|
2091
|
+
pageFocus.target = dm;
|
|
2092
|
+
if (snap || !Number.isFinite(pageFocus.d)) pageFocus.d = dm;
|
|
2093
|
+
return out;
|
|
2094
|
+
}
|
|
2095
|
+
/** Per tick (before the draw, so the rig drives the next locate): ease, declare, notify. */
|
|
2096
|
+
function pageTick() {
|
|
2097
|
+
if (Number.isFinite(pageFocus.target) && pageFocus.d !== pageFocus.target) {
|
|
2098
|
+
const dd = pageFocus.target - pageFocus.d;
|
|
2099
|
+
pageFocus.d = Math.abs(dd) < 1e-6 ? pageFocus.target : pageFocus.d + dd * FOCUS_EASE;
|
|
2100
|
+
}
|
|
2101
|
+
const { d, source } = pageConvergence();
|
|
2102
|
+
out.viewRig = pageViewRig(
|
|
2103
|
+
{
|
|
2104
|
+
verticalFovDeg: viewer.page.fov,
|
|
2105
|
+
convergence: d,
|
|
2106
|
+
comfortDepth: ctl.comfortDepth,
|
|
2107
|
+
ipdFactor: out.rig ? out.rig.ipdFactor : Number.isFinite(opts.ipdFactor) ? opts.ipdFactor : 1,
|
|
2108
|
+
parallaxFactor: out.rig ? out.rig.parallaxFactor : Number.isFinite(opts.parallaxFactor) ? opts.parallaxFactor : 1,
|
|
2109
|
+
},
|
|
2110
|
+
out.viewRig || {},
|
|
2111
|
+
);
|
|
2112
|
+
handle?.setViewRig?.(out.viewRig);
|
|
2113
|
+
if (!out.rig) return;
|
|
2114
|
+
out.rig.convergence = d;
|
|
2115
|
+
if (Math.abs(d - pageFocus.fired) > 1e-3 || source !== pageFocus.source) {
|
|
2116
|
+
pageFocus.fired = d;
|
|
2117
|
+
pageFocus.source = source;
|
|
2118
|
+
const cb = out.onFocusChange;
|
|
2119
|
+
if (typeof cb === 'function') cb(pageFocusPoint(), { focusSource: source });
|
|
2120
|
+
}
|
|
2121
|
+
}
|
|
2122
|
+
if (pageMode) viewer.onTick = pageTick;
|
|
2123
|
+
|
|
2124
|
+
// onBeforeFrame: the page's game step INSIDE this adapter's frame, before the tick and the
|
|
2125
|
+
// draw — so a setCameraPose made in it is what this frame renders (zero lag, the attach
|
|
2126
|
+
// pattern). The session rAF (3D) and the window rAF have no guaranteed order, so a pose set
|
|
2127
|
+
// from the page's own rAF may be one frame late. A throw is the page's bug: warned once, and
|
|
2128
|
+
// the frame still renders (with whatever pose it had).
|
|
2129
|
+
if (pageMode && typeof opts.onBeforeFrame === 'function') {
|
|
2130
|
+
const cb = opts.onBeforeFrame;
|
|
2131
|
+
let lastT = 0;
|
|
2132
|
+
let warned = false;
|
|
2133
|
+
viewer._beforeFrame = (views) => {
|
|
2134
|
+
const t = now();
|
|
2135
|
+
const dt = lastT ? Math.min((t - lastT) / 1000, MAX_DT_S) : 0;
|
|
2136
|
+
lastT = t;
|
|
2137
|
+
try {
|
|
2138
|
+
cb({ time: t, views, dt });
|
|
2139
|
+
} catch (err) {
|
|
2140
|
+
if (!warned) {
|
|
2141
|
+
warned = true;
|
|
2142
|
+
console.warn('[inline3d/splat] onBeforeFrame threw (warned once; frames keep rendering)', err);
|
|
2143
|
+
}
|
|
2144
|
+
}
|
|
2145
|
+
};
|
|
2146
|
+
}
|
|
2147
|
+
|
|
1844
2148
|
// ── focus → view rig ──
|
|
1845
2149
|
let lastConvergence = Number.NaN;
|
|
1846
2150
|
function pushViewRig(force) {
|
|
2151
|
+
if (pageMode) return; // pageTick declares the rig every frame
|
|
1847
2152
|
if (!out.rig || out.rig.type !== 'camera') return;
|
|
1848
2153
|
const pose = viewer.mono.pose;
|
|
1849
2154
|
const f = viewer.getFocus();
|
|
@@ -1905,8 +2210,7 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
|
|
|
1905
2210
|
const v = viewer.currentView();
|
|
1906
2211
|
const invP = mat4Invert(v.proj);
|
|
1907
2212
|
if (!invP) return null;
|
|
1908
|
-
const
|
|
1909
|
-
const toContent = mat4Mul(Minv, v.pose);
|
|
2213
|
+
const toContent = mat4Mul(viewer.rigMatrix(), v.pose);
|
|
1910
2214
|
const a = transformPoint(invP, ndc.x, ndc.y, -1);
|
|
1911
2215
|
const b = transformPoint(invP, ndc.x, ndc.y, 1);
|
|
1912
2216
|
const o = contentToModel(transformPoint(toContent, a[0], a[1], a[2]));
|
|
@@ -1953,7 +2257,8 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
|
|
|
1953
2257
|
}
|
|
1954
2258
|
|
|
1955
2259
|
function bindFocusInput() {
|
|
1956
|
-
|
|
2260
|
+
// controls:'page' binds no input at all: gestures are the page's (pick + setFocus remain).
|
|
2261
|
+
if (pageMode || focusInput === false || unbindFocusInput) return;
|
|
1957
2262
|
unbindFocusInput = bindFocusGestures(canvas, {
|
|
1958
2263
|
onDoubleClick: (e) => {
|
|
1959
2264
|
const m = pickModel(e.clientX, e.clientY);
|
|
@@ -1993,7 +2298,7 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
|
|
|
1993
2298
|
// The camera block off the SAME bytes, before the engine takes them (as the Spark path).
|
|
1994
2299
|
let camera = null;
|
|
1995
2300
|
let t0 = performance.now();
|
|
1996
|
-
if (bytes && rig !== 'display') camera = sogCameraFromMeta(await readSogMeta(bytes));
|
|
2301
|
+
if (bytes && rigOpts.rig !== 'display') camera = sogCameraFromMeta(await readSogMeta(bytes));
|
|
1997
2302
|
perfSpan('readSogMeta(async)', t0);
|
|
1998
2303
|
|
|
1999
2304
|
const url = bytes
|
|
@@ -2017,7 +2322,7 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
|
|
|
2017
2322
|
const desc = describeResource(res);
|
|
2018
2323
|
// A URL `.sog` carries its meta in the resource (the engine keeps unknown keys); a Streamed
|
|
2019
2324
|
// SOG carries it at the top level of lod-meta.json. Both validated by the same reader.
|
|
2020
|
-
if (!bytes && rig !== 'display') camera = sogCameraFromMeta(desc.meta);
|
|
2325
|
+
if (!bytes && rigOpts.rig !== 'display') camera = sogCameraFromMeta(desc.meta);
|
|
2021
2326
|
const cloud = desc.kind === 'flat' ? await readCloud(res) : null;
|
|
2022
2327
|
// The cloud passes, each in its OWN task: framing, the rest-space sample and the pick set
|
|
2023
2328
|
// were one ~60 ms main-thread block on a 1.18M-gaussian swap — pointer input waited on it.
|
|
@@ -2071,7 +2376,7 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
|
|
|
2071
2376
|
const box = canvas.getBoundingClientRect();
|
|
2072
2377
|
const resolved = resolveRig({
|
|
2073
2378
|
camera: loaded.camera,
|
|
2074
|
-
opts,
|
|
2379
|
+
opts: rigOpts,
|
|
2075
2380
|
cloud: sample,
|
|
2076
2381
|
canvasAspect: box.height > 0 ? box.width / box.height : 4 / 3,
|
|
2077
2382
|
});
|
|
@@ -2092,7 +2397,23 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
|
|
|
2092
2397
|
// The strided pick fallback (used only if the engine releases its full centre set).
|
|
2093
2398
|
const pickCentres = loaded.pre?.pickCentres || null;
|
|
2094
2399
|
|
|
2095
|
-
if (
|
|
2400
|
+
if (pageMode) {
|
|
2401
|
+
// The page owns the pose; the waterfall only supplies the convergence (re-estimated here,
|
|
2402
|
+
// on load and on setSource, never per frame). Until the page's first setCameraPose the
|
|
2403
|
+
// camera sits at the asset's rest pose (the block's, else the origin looking down +z) with
|
|
2404
|
+
// the resolved lens, so the first frame is not an empty tile.
|
|
2405
|
+
resolved.convergenceDefault = resolved.convergence;
|
|
2406
|
+
pageFocus.target = pageFocus.d = resolved.convergence;
|
|
2407
|
+
pageFocus.fired = Number.NaN;
|
|
2408
|
+
if (!lastPagePose) {
|
|
2409
|
+
const r = resolved.rest;
|
|
2410
|
+
viewer.setPageCamera(
|
|
2411
|
+
normalizeCameraPose(poseMatrix(r.position, qmul(r.rotation, FLIP_Q)), {
|
|
2412
|
+
verticalFovDeg: captureVerticalFovDeg(resolved.intrinsics, NaN, MONO_NEAR, 'height'),
|
|
2413
|
+
}),
|
|
2414
|
+
);
|
|
2415
|
+
}
|
|
2416
|
+
} else if (resolved.type === 'camera') {
|
|
2096
2417
|
if (!('idleSpin' in opts)) viewer.idleSpin = 0;
|
|
2097
2418
|
// The camera rig never auto-fits (the capture IS the framing): a scale left by a previous
|
|
2098
2419
|
// display-rig asset (setSource) must not shrink this one.
|
|
@@ -2114,7 +2435,9 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
|
|
|
2114
2435
|
|
|
2115
2436
|
let pcModule = null;
|
|
2116
2437
|
const booted = (async () => {
|
|
2117
|
-
|
|
2438
|
+
// Named re-exports, not the package namespace: lets a bundler drop the ~40% of the engine the
|
|
2439
|
+
// SDK never touches (./inline3d-playcanvas-engine.js).
|
|
2440
|
+
const pc = opts.playcanvas || (await import('./inline3d-playcanvas-engine.js'));
|
|
2118
2441
|
if (removed) return null;
|
|
2119
2442
|
const app = await viewer.attachEngine(pc, {
|
|
2120
2443
|
preserveDrawingBuffer,
|
|
@@ -2184,7 +2507,7 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
|
|
|
2184
2507
|
};
|
|
2185
2508
|
const kept = applyLoaded(loaded);
|
|
2186
2509
|
current = { asset: loaded.asset, entity, res: loaded.res, kind: loaded.desc.kind, ...kept };
|
|
2187
|
-
if (resetPose) viewer.resetPose();
|
|
2510
|
+
if (resetPose && !pageMode) viewer.resetPose(); // the page owns the pose on controls:'page'
|
|
2188
2511
|
|
|
2189
2512
|
const release = () => {
|
|
2190
2513
|
if (!prev) return;
|