@displayxr/inline3d 1.21.1 → 1.23.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 +37 -0
- package/js/inline3d-splat-diag.js +242 -4
- package/js/inline3d-splat-layer-rig.js +423 -0
- package/js/inline3d-splat-playcanvas.js +317 -4
- package/js/inline3d-splat-video.js +97 -0
- package/js/inline3d-splat.js +18 -0
- package/package.json +2 -1
- package/splat.d.ts +70 -1
|
@@ -67,8 +67,9 @@ import {
|
|
|
67
67
|
sequenceSpans,
|
|
68
68
|
} from './inline3d-splat-effects.js';
|
|
69
69
|
import { LiveOutgoing, resolveOutgoingOption, defaultOutgoing, yieldIdle } from './inline3d-splat-live.js';
|
|
70
|
-
import { VideoPlane, validateSetVideo, PAGE_VIDEO_ERROR } from './inline3d-splat-video.js';
|
|
71
|
-
import {
|
|
70
|
+
import { VideoPlane, validateSetVideo, PAGE_VIDEO_ERROR, eyeSplit, EYE_SPLIT_UNIFORM, makeSbsMaterial } from './inline3d-splat-video.js';
|
|
71
|
+
import { LayerRigCameras, validateLayerRig } from './inline3d-splat-layer-rig.js';
|
|
72
|
+
import { resolveDiag, DiagRecorder, startDiagLoop, registerDiag, DIAG_SWITCHES } from './inline3d-splat-diag.js';
|
|
72
73
|
import {
|
|
73
74
|
clamp,
|
|
74
75
|
finite,
|
|
@@ -614,6 +615,136 @@ export function nearestCentreToRay(xyz, origin, dir, coneRad = PICK_CONE_RAD, al
|
|
|
614
615
|
return bestInCone || bestAny;
|
|
615
616
|
}
|
|
616
617
|
|
|
618
|
+
/** Cells per side of a pick index (PICK_INDEX_GRID² cells over the view's direction square). */
|
|
619
|
+
export const PICK_INDEX_GRID = 128;
|
|
620
|
+
|
|
621
|
+
/**
|
|
622
|
+
* A pick INDEX over one centre set, for one eye position: every usable centre (opacity at or above
|
|
623
|
+
* RIG_MIN_OPACITY, in front of the eye) bucketed by its DIRECTION from the eye — its gnomonic
|
|
624
|
+
* coordinates (u, v) = ((p − eye)·right, (p − eye)·up) / ((p − eye)·fwd) on a PICK_INDEX_GRID² grid
|
|
625
|
+
* over [−U, U] × [−V, V] (centres outside land in the border cells). One pass over the set, about
|
|
626
|
+
* one full-scan pick's cost; every later pick from the same eye then reads only the few cells
|
|
627
|
+
* around its ray (queryPickIndex), not the whole set.
|
|
628
|
+
*
|
|
629
|
+
* Why (a photo slideshow app, on the panel): a page that picks in bursts — 24 rays to plan a
|
|
630
|
+
* companion's waypoints, then an 81-ray depth grid, on every photo swap — ran 105 full scans of a
|
|
631
|
+
* 1.18M-centre photo on the main thread, two long tasks of ~0.3 s and ~1 s right at the swap's
|
|
632
|
+
* end: no session frames, so the woven image stopped following the head.
|
|
633
|
+
*
|
|
634
|
+
* `f`, `r`, `up`: an orthonormal frame (any; the eye's own is the tightest). Returns null when
|
|
635
|
+
* nothing usable is in front of the eye.
|
|
636
|
+
*/
|
|
637
|
+
export function buildPickIndex(xyz, count, alpha8, eye, f, r, up, U, V, G = PICK_INDEX_GRID) {
|
|
638
|
+
const n = count;
|
|
639
|
+
const cells = G * G;
|
|
640
|
+
const su = G / (2 * U);
|
|
641
|
+
const sv = G / (2 * V);
|
|
642
|
+
const minA = Math.ceil(RIG_MIN_OPACITY * 255);
|
|
643
|
+
const cellOf = new Uint16Array(n); // G ≤ 255: cells < 65535, which marks "not indexed"
|
|
644
|
+
const start = new Uint32Array(cells + 1);
|
|
645
|
+
const [ex, ey, ez] = eye;
|
|
646
|
+
const [f0, f1, f2] = f;
|
|
647
|
+
const [r0, r1, r2] = r;
|
|
648
|
+
const [u0, u1, u2] = up;
|
|
649
|
+
let m = 0;
|
|
650
|
+
for (let i = 0; i < n; i++) {
|
|
651
|
+
if (alpha8 && alpha8[i] < minA) {
|
|
652
|
+
cellOf[i] = 65535;
|
|
653
|
+
continue;
|
|
654
|
+
}
|
|
655
|
+
const rx = xyz[i * 3] - ex;
|
|
656
|
+
const ry = xyz[i * 3 + 1] - ey;
|
|
657
|
+
const rz = xyz[i * 3 + 2] - ez;
|
|
658
|
+
const z = rx * f0 + ry * f1 + rz * f2;
|
|
659
|
+
if (!(z > 0)) {
|
|
660
|
+
cellOf[i] = 65535;
|
|
661
|
+
continue;
|
|
662
|
+
}
|
|
663
|
+
// z > 0 held, so every coordinate is finite (a NaN or ∞ one makes z NaN). Truncation, not
|
|
664
|
+
// floor: below the grid's edge both land in the border cell after the clamp.
|
|
665
|
+
const iz = 1 / z;
|
|
666
|
+
let cu = ((rx * r0 + ry * r1 + rz * r2) * iz + U) * su;
|
|
667
|
+
let cv = ((rx * u0 + ry * u1 + rz * u2) * iz + V) * sv;
|
|
668
|
+
cu = cu < 0 ? 0 : cu >= G ? G - 1 : cu | 0;
|
|
669
|
+
cv = cv < 0 ? 0 : cv >= G ? G - 1 : cv | 0;
|
|
670
|
+
const c = cv * G + cu;
|
|
671
|
+
cellOf[i] = c;
|
|
672
|
+
start[c + 1]++;
|
|
673
|
+
m++;
|
|
674
|
+
}
|
|
675
|
+
if (!m) return null;
|
|
676
|
+
for (let c = 0; c < cells; c++) start[c + 1] += start[c];
|
|
677
|
+
const order = new Uint32Array(m);
|
|
678
|
+
const cursor = start.slice(0, cells);
|
|
679
|
+
for (let i = 0; i < n; i++) {
|
|
680
|
+
const c = cellOf[i];
|
|
681
|
+
if (c !== 65535) order[cursor[c]++] = i;
|
|
682
|
+
}
|
|
683
|
+
return { eye: [ex, ey, ez], f: [f0, f1, f2], r: [r0, r1, r2], up: [u0, u1, u2], U, V, G, su, sv, start, order, size: m };
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
/**
|
|
687
|
+
* nearestCentreToRay's IN-CONE answer from a pick index: the same point, exactly (the same
|
|
688
|
+
* arithmetic on the same centres, ties to the lowest index as the full scan's first-wins), read
|
|
689
|
+
* from the cells the cone can reach. The ray must pass through the index's eye (any pick ray of
|
|
690
|
+
* that view does: it runs from its near-plane point through the eye). Returns the point, or
|
|
691
|
+
* undefined when the index cannot answer — no centre inside the cone (the full scan's
|
|
692
|
+
* nearest-by-angle fallback needs every centre), a ray not through the eye, or one too far off
|
|
693
|
+
* the index's axis — and the caller runs the full scan.
|
|
694
|
+
*
|
|
695
|
+
* Why the cells suffice: along a ray through the eye, t from the near-plane origin is t from the
|
|
696
|
+
* eye minus the near distance, so perp / t (the cone test) ≥ perp / t_eye = tan of the angle at
|
|
697
|
+
* the eye. A centre inside the cone is within atan(coneRad) of the ray AT THE EYE, and a cap of
|
|
698
|
+
* angular radius θ around a direction φ0 off the axis projects (gnomonic) inside a radius of
|
|
699
|
+
* tan(φ0 + θ) − tan(φ0) around the ray's own (u, v). The search square takes 1.25× that plus a
|
|
700
|
+
* cell.
|
|
701
|
+
*/
|
|
702
|
+
export function queryPickIndex(ix, xyz, origin, dir, coneRad = PICK_CONE_RAD) {
|
|
703
|
+
const { eye, f, r, up, U, V, G, su, sv, start, order } = ix;
|
|
704
|
+
// the ray must pass through the eye (a relative tolerance: numerical noise, not a design gap)
|
|
705
|
+
const ox = eye[0] - origin[0], oy = eye[1] - origin[1], oz = eye[2] - origin[2];
|
|
706
|
+
const along = ox * dir[0] + oy * dir[1] + oz * dir[2];
|
|
707
|
+
const off2 = ox * ox + oy * oy + oz * oz - along * along;
|
|
708
|
+
if (!(along < 0) || off2 > 1e-12 * (along * along) + 1e-18) return undefined; // the eye sits BEHIND the near-plane origin
|
|
709
|
+
const dz = dir[0] * f[0] + dir[1] * f[1] + dir[2] * f[2];
|
|
710
|
+
if (!(dz > 0)) return undefined;
|
|
711
|
+
const du = (dir[0] * r[0] + dir[1] * r[1] + dir[2] * r[2]) / dz;
|
|
712
|
+
const dv = (dir[0] * up[0] + dir[1] * up[1] + dir[2] * up[2]) / dz;
|
|
713
|
+
const rho = Math.hypot(du, dv);
|
|
714
|
+
const phi0 = Math.atan(rho);
|
|
715
|
+
const th = Math.atan(coneRad) * 1.05 + 1e-6;
|
|
716
|
+
if (phi0 + th >= 1.4) return undefined; // ~80°: the projection's stretch is no longer worth bounding
|
|
717
|
+
const R = (Math.tan(phi0 + th) - rho) * 1.25;
|
|
718
|
+
const clampCell = (x) => (x < 0 ? 0 : x >= G ? G - 1 : x);
|
|
719
|
+
const cu0 = clampCell(Math.floor((du - R + U) * su) - 1);
|
|
720
|
+
const cu1 = clampCell(Math.floor((du + R + U) * su) + 1);
|
|
721
|
+
const cv0 = clampCell(Math.floor((dv - R + V) * sv) - 1);
|
|
722
|
+
const cv1 = clampCell(Math.floor((dv + R + V) * sv) + 1);
|
|
723
|
+
if (!(cu0 <= cu1 && cv0 <= cv1)) return undefined;
|
|
724
|
+
let bestT = Infinity;
|
|
725
|
+
let bestI = -1;
|
|
726
|
+
for (let cv = cv0; cv <= cv1; cv++) {
|
|
727
|
+
const row = cv * G;
|
|
728
|
+
for (let k = start[row + cu0], k1 = start[row + cu1 + 1]; k < k1; k++) {
|
|
729
|
+
const i = order[k];
|
|
730
|
+
// nearestCentreToRay's own arithmetic, term for term
|
|
731
|
+
const rx = xyz[i * 3] - origin[0];
|
|
732
|
+
const ry = xyz[i * 3 + 1] - origin[1];
|
|
733
|
+
const rz = xyz[i * 3 + 2] - origin[2];
|
|
734
|
+
const t = rx * dir[0] + ry * dir[1] + rz * dir[2];
|
|
735
|
+
if (!(t > 0)) continue;
|
|
736
|
+
const perp = Math.sqrt(Math.max(0, rx * rx + ry * ry + rz * rz - t * t));
|
|
737
|
+
const angle = perp / t;
|
|
738
|
+
if (angle <= coneRad && (t < bestT || (t === bestT && i < bestI))) {
|
|
739
|
+
bestT = t;
|
|
740
|
+
bestI = i;
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
if (bestI < 0) return undefined;
|
|
745
|
+
return [xyz[bestI * 3], xyz[bestI * 3 + 1], xyz[bestI * 3 + 2]];
|
|
746
|
+
}
|
|
747
|
+
|
|
617
748
|
// ── the viewer: pose state + frame loop + the engine ────────────────────────────────────────
|
|
618
749
|
|
|
619
750
|
/**
|
|
@@ -686,6 +817,13 @@ export class PlayCanvasSplatViewer {
|
|
|
686
817
|
this.inputLocked = false;
|
|
687
818
|
/** handle.setVideo's plane (./inline3d-splat-video.js) while a video is on, else null. */
|
|
688
819
|
this._videoPlane = null;
|
|
820
|
+
/** handle.setLayerRig's per-rig cameras (./inline3d-splat-layer-rig.js), made on first use. */
|
|
821
|
+
this.layerRigs = null;
|
|
822
|
+
/** () => the view-rig descriptor currently declared to the runtime (or null). */
|
|
823
|
+
this.layerRigSource = null;
|
|
824
|
+
/** That descriptor as it stood when THIS frame's views were pulled (snapshotted in onFrame). */
|
|
825
|
+
this._rigSnap = null;
|
|
826
|
+
this._eyeSplit = NaN;
|
|
689
827
|
this.boxAspect = 1;
|
|
690
828
|
this.featherPx = feather > 0 ? feather : 0;
|
|
691
829
|
this.captureFit = captureFit;
|
|
@@ -995,6 +1133,9 @@ export class PlayCanvasSplatViewer {
|
|
|
995
1133
|
onFrame(views, layer) {
|
|
996
1134
|
if (this._disposed) return;
|
|
997
1135
|
if (this._mode !== '3d') this.stopMono();
|
|
1136
|
+
// The rig these views were located with: Blink chained the rig declared BEFORE this callback,
|
|
1137
|
+
// and the tick below may declare a new one (a focus ease) for the NEXT locate.
|
|
1138
|
+
if (this.layerRigs?.active) this._rigSnap = snapshotRig(this.layerRigSource?.(), this._rigSnap);
|
|
998
1139
|
// BEFORE the tick: a pose the page sets in here is the one this very frame renders.
|
|
999
1140
|
this._beforeFrame?.(views || null);
|
|
1000
1141
|
this._tick();
|
|
@@ -1055,6 +1196,7 @@ export class PlayCanvasSplatViewer {
|
|
|
1055
1196
|
this._live = null;
|
|
1056
1197
|
this._videoPlane?.destroy();
|
|
1057
1198
|
this._videoPlane = null;
|
|
1199
|
+
this.layerRigs = null; // its cameras go with the app
|
|
1058
1200
|
try {
|
|
1059
1201
|
this.app?.destroy();
|
|
1060
1202
|
} catch (err) {
|
|
@@ -1736,6 +1878,8 @@ export class PlayCanvasSplatViewer {
|
|
|
1736
1878
|
if (this._live?.active || this._live?.warming) this._live.sync(entries, rect, f);
|
|
1737
1879
|
// setSource's wavefront: this frame's eye views, as the engine is about to compose them.
|
|
1738
1880
|
this.onBeforeRender?.(entries, rect);
|
|
1881
|
+
// handle.setLayerRig: the display / post run cameras, on the same views (display: rounded).
|
|
1882
|
+
if (this.layerRigs && this.layerRigs.sync()) this.layerRigs.frame(entries, rect, f, cache ? cache.rig : null);
|
|
1739
1883
|
} else {
|
|
1740
1884
|
// Fallback: one camera per view, `rect` + `calculateProjection`.
|
|
1741
1885
|
const cams = this._views;
|
|
@@ -1769,6 +1913,12 @@ export class PlayCanvasSplatViewer {
|
|
|
1769
1913
|
}
|
|
1770
1914
|
}
|
|
1771
1915
|
this._updateFeather(entries[0].width * sx, entries[0].height * sy);
|
|
1916
|
+
// handle.makeSbsMaterial: the eye split, scene-wide (a material only declares the uniform).
|
|
1917
|
+
const split = eyeSplit(entries, rect);
|
|
1918
|
+
if (split !== this._eyeSplit) {
|
|
1919
|
+
this._eyeSplit = split;
|
|
1920
|
+
app.graphicsDevice?.scope?.resolve?.(EYE_SPLIT_UNIFORM)?.setValue(split);
|
|
1921
|
+
}
|
|
1772
1922
|
// handle.setVideo's plane: size, eye split, and a new frame's upload (./inline3d-splat-video.js).
|
|
1773
1923
|
this._videoPlane?.beforeDraw(entries, rect);
|
|
1774
1924
|
app.tick(now());
|
|
@@ -1787,6 +1937,7 @@ export class PlayCanvasSplatViewer {
|
|
|
1787
1937
|
}
|
|
1788
1938
|
g.bufW = el.width || 0;
|
|
1789
1939
|
g.bufH = el.height || 0;
|
|
1940
|
+
g.rig = this._rigSnap ? { ...this._rigSnap } : null; // a replay re-uses the rig of its views
|
|
1790
1941
|
for (let i = 0; i < views.length; i++) {
|
|
1791
1942
|
const e = g.entries[i];
|
|
1792
1943
|
const vp = vps[i];
|
|
@@ -2129,6 +2280,23 @@ export class PlayCanvasSplatViewer {
|
|
|
2129
2280
|
}
|
|
2130
2281
|
}
|
|
2131
2282
|
|
|
2283
|
+
/**
|
|
2284
|
+
* A copy of the declared rig's frame fields (the SDK rewrites its camera-rig descriptor in place),
|
|
2285
|
+
* or null. Only what the layer rig reads: kind, pose, convergence, metres-to-virtual.
|
|
2286
|
+
*/
|
|
2287
|
+
export function snapshotRig(rig, into = null) {
|
|
2288
|
+
if (!rig || typeof rig !== 'object') return null;
|
|
2289
|
+
const o = into || { position: {}, orientation: {} };
|
|
2290
|
+
o.type = rig.type;
|
|
2291
|
+
const p = rig.position || {};
|
|
2292
|
+
const q = rig.orientation || {};
|
|
2293
|
+
o.position = { x: p.x || 0, y: p.y || 0, z: p.z || 0 };
|
|
2294
|
+
o.orientation = { x: q.x || 0, y: q.y || 0, z: q.z || 0, w: q.w === undefined ? 1 : q.w };
|
|
2295
|
+
o.convergenceDiopters = rig.convergenceDiopters;
|
|
2296
|
+
o.metersToVirtual = rig.metersToVirtual;
|
|
2297
|
+
return o;
|
|
2298
|
+
}
|
|
2299
|
+
|
|
2132
2300
|
/** Put an engine node at a rigid display-space pose (position + rotation; no scale). */
|
|
2133
2301
|
function placeNode(node, m) {
|
|
2134
2302
|
node.setLocalPosition(m[12], m[13], m[14]);
|
|
@@ -2695,16 +2863,28 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
|
|
|
2695
2863
|
|
|
2696
2864
|
// ── diagnostics (diag / ?dxrdiag — ./inline3d-splat-diag.js) ──
|
|
2697
2865
|
const diagCfg = resolveDiag(opts.diag);
|
|
2698
|
-
if (diagCfg.unknown.length) console.warn(`[inline3d/splat] diag: unknown switch(es) ${diagCfg.unknown.join(', ')} — known:
|
|
2866
|
+
if (diagCfg.unknown.length) console.warn(`[inline3d/splat] diag: unknown switch(es) ${diagCfg.unknown.join(', ')} — known: ${DIAG_SWITCHES.join(', ')}.`);
|
|
2699
2867
|
const diag = diagCfg.on ? new DiagRecorder({ switches: diagCfg.switches }) : null;
|
|
2700
2868
|
/** norig: armed by the first setSource — from then on the declared rig is kept. */
|
|
2701
2869
|
let rigLockArmed = false;
|
|
2702
2870
|
if (diag) {
|
|
2703
2871
|
diag.imageState = () => viewer.diagImageState();
|
|
2704
2872
|
diag.observeLongTasks();
|
|
2873
|
+
diag.observeLongFrames();
|
|
2705
2874
|
diag.log(`on — switches [${[...diag.switches].join(', ') || 'none'}]; dump: copy(__dxrDiag.dump())`);
|
|
2706
2875
|
}
|
|
2707
2876
|
let removed = false;
|
|
2877
|
+
// handle.setLayerRig: the per-rig cameras and where they read the declared rig from.
|
|
2878
|
+
// Kill switch `nolayerrig`: requests are recorded but every layer stays on the eye camera.
|
|
2879
|
+
let warnedLayerRigPath = false;
|
|
2880
|
+
function layerRigs() {
|
|
2881
|
+
if (!viewer.layerRigs) {
|
|
2882
|
+
viewer.layerRigs = new LayerRigCameras(viewer);
|
|
2883
|
+
viewer.layerRigs.disabled = !!diag?.has('nolayerrig');
|
|
2884
|
+
viewer.layerRigSource = () => out.viewRig || null;
|
|
2885
|
+
}
|
|
2886
|
+
return viewer.layerRigs;
|
|
2887
|
+
}
|
|
2708
2888
|
/** prepareSource: prepared handle → { loaded, state: 'ready' | 'used' | 'disposed', dispose }. */
|
|
2709
2889
|
const preparedAssets = new WeakMap();
|
|
2710
2890
|
const livePrepared = new Set();
|
|
@@ -2785,6 +2965,42 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
|
|
|
2785
2965
|
prepareSource,
|
|
2786
2966
|
setRig,
|
|
2787
2967
|
setVideo,
|
|
2968
|
+
/**
|
|
2969
|
+
* Draw a layer of this tile's engine through the DISPLAY rig (round, physical-depth stage
|
|
2970
|
+
* objects) while the splat and the declared view rig stay on the photo's camera rig — or back
|
|
2971
|
+
* on the camera rig ('camera'). No second full pass: one extra camera over the named layers,
|
|
2972
|
+
* into the same target, in composition order. docs/playcanvas-adapter.md §setLayerRig and
|
|
2973
|
+
* docs/proposals/layer-display-rig.md (the exact mapping, and why it is not Kooima).
|
|
2974
|
+
* `viewerDistance` (m) / `gain` set the rounding for the whole tile (last call wins).
|
|
2975
|
+
*/
|
|
2976
|
+
setLayerRig(layer, rig, o = {}) {
|
|
2977
|
+
const r = validateLayerRig(layer, rig, o);
|
|
2978
|
+
if (viewer._viewPath === 'cameras' && !warnedLayerRigPath) {
|
|
2979
|
+
warnedLayerRigPath = true;
|
|
2980
|
+
console.warn('[inline3d/splat] setLayerRig needs the engine RenderView path; on the N-camera fallback the layer stays on the photo rig.');
|
|
2981
|
+
}
|
|
2982
|
+
layerRigs().set(layer, r.rig, { viewerDistance: r.viewerDistance, gain: r.gain });
|
|
2983
|
+
return out;
|
|
2984
|
+
},
|
|
2985
|
+
/** The layers on the display rig (as the page named them), and what the last frame did. */
|
|
2986
|
+
layerRigState() {
|
|
2987
|
+
const lr = viewer.layerRigs;
|
|
2988
|
+
return {
|
|
2989
|
+
display: lr ? [...lr.requests.keys()] : [],
|
|
2990
|
+
disabled: !!lr?.disabled,
|
|
2991
|
+
rounded: !!lr?.last.rounded,
|
|
2992
|
+
gain: lr?.last.gain ?? null,
|
|
2993
|
+
};
|
|
2994
|
+
},
|
|
2995
|
+
/**
|
|
2996
|
+
* An unlit material showing the left half of `texture` to left-eye views and the right half to
|
|
2997
|
+
* right-eye views (format 'sbs' | 'tb' | 'mono'), on any mesh — mono / 2D: the left half. Needs
|
|
2998
|
+
* the engine (after `ready`). docs/playcanvas-adapter.md §makeSbsMaterial.
|
|
2999
|
+
*/
|
|
3000
|
+
makeSbsMaterial(texture, o = {}) {
|
|
3001
|
+
if (!viewer.app || !viewer.pc) throw new Error('@displayxr/inline3d/splat: makeSbsMaterial() needs the engine — call it after `await handle.ready`.');
|
|
3002
|
+
return makeSbsMaterial(viewer.pc, texture, o);
|
|
3003
|
+
},
|
|
2788
3004
|
/**
|
|
2789
3005
|
* Play a transition effect (inflate, deflate, sweep, dissolve, fade, pulse, custom) — see
|
|
2790
3006
|
* docs/splat-effects.md. Validated now; runs once the first asset is on screen. Resolves
|
|
@@ -2940,6 +3156,14 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
|
|
|
2940
3156
|
delete out._resolveFirstWoven;
|
|
2941
3157
|
}
|
|
2942
3158
|
|
|
3159
|
+
// addSplat's `displayRigLayers` sugar: [layer, …] or { layers: [...], viewerDistance, gain }.
|
|
3160
|
+
if (opts.displayRigLayers !== undefined) {
|
|
3161
|
+
const d = opts.displayRigLayers;
|
|
3162
|
+
const list = Array.isArray(d) ? d : d && Array.isArray(d.layers) ? d.layers : null;
|
|
3163
|
+
if (!list) throw new TypeError('@displayxr/inline3d/splat: displayRigLayers — expected an array of layers, or { layers, viewerDistance?, gain? }.');
|
|
3164
|
+
const o = Array.isArray(d) ? {} : { ...(d.viewerDistance !== undefined ? { viewerDistance: d.viewerDistance } : {}), ...(d.gain !== undefined ? { gain: d.gain } : {}) };
|
|
3165
|
+
for (const l of list) out.setLayerRig(l, 'display', o);
|
|
3166
|
+
}
|
|
2943
3167
|
// Replay what the page did before this module arrived — exclude() above all, which a product
|
|
2944
3168
|
// page calls on the very next line after addSplat.
|
|
2945
3169
|
for (const [name, args] of pending) {
|
|
@@ -3156,11 +3380,91 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
|
|
|
3156
3380
|
return best;
|
|
3157
3381
|
}
|
|
3158
3382
|
const full = c.res?.centers;
|
|
3159
|
-
if (full && full.length) return
|
|
3383
|
+
if (full && full.length) return pickFull(c, full, ray);
|
|
3160
3384
|
// The engine released its centres: fall back to the strided set kept at load.
|
|
3161
3385
|
return c.pickCentres ? nearestCentreToRay(c.pickCentres, ray.o, ray.d) : null;
|
|
3162
3386
|
}
|
|
3163
3387
|
|
|
3388
|
+
/**
|
|
3389
|
+
* The eye of the view on screen and an orthonormal frame at it, in MODEL space, plus the
|
|
3390
|
+
* direction extent of its frustum (gnomonic, per axis): what a pick index is built for. Every
|
|
3391
|
+
* pick ray of this view passes through `eye`. null when the view is not a plain perspective one.
|
|
3392
|
+
*/
|
|
3393
|
+
function pickFrame() {
|
|
3394
|
+
const v = viewer.currentView();
|
|
3395
|
+
const invP = mat4Invert(v.proj);
|
|
3396
|
+
if (!invP) return null;
|
|
3397
|
+
const T = mat4Mul(viewer.rigMatrix(), v.pose);
|
|
3398
|
+
const at = (x, y, z) => contentToModel(transformPoint(T, x, y, z));
|
|
3399
|
+
const eye = at(0, 0, 0);
|
|
3400
|
+
const sub = (p) => [p[0] - eye[0], p[1] - eye[1], p[2] - eye[2]];
|
|
3401
|
+
const dot = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
|
|
3402
|
+
const norm = (a) => {
|
|
3403
|
+
const l = Math.hypot(a[0], a[1], a[2]);
|
|
3404
|
+
return l > 0 ? [a[0] / l, a[1] / l, a[2] / l] : null;
|
|
3405
|
+
};
|
|
3406
|
+
const f = norm(sub(at(0, 0, -1)));
|
|
3407
|
+
if (!f) return null;
|
|
3408
|
+
let r = sub(at(1, 0, 0));
|
|
3409
|
+
const rf = dot(r, f);
|
|
3410
|
+
r = norm([r[0] - rf * f[0], r[1] - rf * f[1], r[2] - rf * f[2]]);
|
|
3411
|
+
if (!r) return null;
|
|
3412
|
+
const up = [f[1] * r[2] - f[2] * r[1], f[2] * r[0] - f[0] * r[2], f[0] * r[1] - f[1] * r[0]];
|
|
3413
|
+
let U = 0;
|
|
3414
|
+
let V = 0;
|
|
3415
|
+
for (const [x, y] of [[-1, -1], [1, -1], [-1, 1], [1, 1]]) {
|
|
3416
|
+
const d = sub(at(...transformPoint(invP, x, y, 1)));
|
|
3417
|
+
const z = dot(d, f);
|
|
3418
|
+
if (!(z > 0)) return null;
|
|
3419
|
+
U = Math.max(U, Math.abs(dot(d, r) / z));
|
|
3420
|
+
V = Math.max(V, Math.abs(dot(d, up) / z));
|
|
3421
|
+
}
|
|
3422
|
+
if (!(U > 0 && V > 0)) return null;
|
|
3423
|
+
// a margin past the frustum: a pick at its edge reaches a little outside it
|
|
3424
|
+
U = U * 1.1 + 0.02;
|
|
3425
|
+
V = V * 1.1 + 0.02;
|
|
3426
|
+
return { eye, f, r, up, U, V, key: [...eye, ...f, ...r, U, V].join(',') };
|
|
3427
|
+
}
|
|
3428
|
+
|
|
3429
|
+
/**
|
|
3430
|
+
* A pick over the full centre set. The first pick from an eye position is the plain full scan
|
|
3431
|
+
* (a page picking once per frame pays what it always did); a SECOND pick from the same eye
|
|
3432
|
+
* builds a pick index for it (buildPickIndex: about one scan) and every further pick from there
|
|
3433
|
+
* reads a few cells — a burst of N picks costs ~2 scans, not N. Same answer as the full scan
|
|
3434
|
+
* (queryPickIndex falls back to it when it cannot answer exactly). `?dxrdiag=oldpick`: always
|
|
3435
|
+
* the full scan (the 1.21.1 path), for A/B.
|
|
3436
|
+
*/
|
|
3437
|
+
function pickFull(c, full, ray) {
|
|
3438
|
+
const t0 = diag ? performance.now() : 0;
|
|
3439
|
+
let how = 'scan';
|
|
3440
|
+
let hit;
|
|
3441
|
+
if (!diag?.has('oldpick')) {
|
|
3442
|
+
const fr = pickFrame();
|
|
3443
|
+
if (fr) {
|
|
3444
|
+
const same = (p) => p && p.key === fr.key && p.xyz === full && p.alpha8 === c.alpha8;
|
|
3445
|
+
let ix = same(c.pickIx) ? c.pickIx.ix : null;
|
|
3446
|
+
if (!ix && same(c.pickPrev)) {
|
|
3447
|
+
const count = Math.floor(full.length / 3);
|
|
3448
|
+
ix = buildPickIndex(full, count, c.alpha8, fr.eye, fr.f, fr.r, fr.up, fr.U, fr.V);
|
|
3449
|
+
c.pickIx = ix ? { key: fr.key, xyz: full, alpha8: c.alpha8, ix } : null;
|
|
3450
|
+
how = 'build';
|
|
3451
|
+
}
|
|
3452
|
+
c.pickPrev = { key: fr.key, xyz: full, alpha8: c.alpha8 };
|
|
3453
|
+
if (ix) {
|
|
3454
|
+
hit = queryPickIndex(ix, full, ray.o, ray.d);
|
|
3455
|
+
if (hit !== undefined && how === 'scan') how = 'index';
|
|
3456
|
+
}
|
|
3457
|
+
}
|
|
3458
|
+
}
|
|
3459
|
+
if (hit === undefined) {
|
|
3460
|
+
hit = nearestCentreToRay(full, ray.o, ray.d, undefined, c.alpha8);
|
|
3461
|
+
if (how === 'build') how = 'build+scan';
|
|
3462
|
+
else if (how === 'index') how = 'scan';
|
|
3463
|
+
}
|
|
3464
|
+
if (diag) diag.pick(performance.now() - t0, how);
|
|
3465
|
+
return hit;
|
|
3466
|
+
}
|
|
3467
|
+
|
|
3164
3468
|
function bindFocusInput() {
|
|
3165
3469
|
// controls:'page' binds no input at all: gestures are the page's (pick + setFocus remain).
|
|
3166
3470
|
if (pageMode || focusInput === false || unbindFocusInput) return;
|
|
@@ -3601,6 +3905,9 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
|
|
|
3601
3905
|
});
|
|
3602
3906
|
if (!app || removed) return null;
|
|
3603
3907
|
pcModule = pc;
|
|
3908
|
+
// diag: count the GL calls that can block (compile, link, status queries, readbacks, syncs),
|
|
3909
|
+
// from before the first asset's compile on
|
|
3910
|
+
if (diag) diag.instrumentGl(app.graphicsDevice?.gl);
|
|
3604
3911
|
/**
|
|
3605
3912
|
* ADVANCED, not covered by the semver promise: the engine objects behind this window.
|
|
3606
3913
|
* `app` is the tile's `pc.AppBase`; `root` the content root, in the splat's content space
|
|
@@ -4217,10 +4524,13 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
|
|
|
4217
4524
|
const finish = () => {
|
|
4218
4525
|
if (finished) return;
|
|
4219
4526
|
finished = true;
|
|
4527
|
+
const ts = performance.now();
|
|
4220
4528
|
for (const f of finishers) f();
|
|
4529
|
+
perfSpan('settle:teardown', ts);
|
|
4221
4530
|
if (gen === sourceGen) diag?.setPhase('settle');
|
|
4222
4531
|
if (pendingSwap?.finish === finish) pendingSwap = null;
|
|
4223
4532
|
settle();
|
|
4533
|
+
if (gen === sourceGen) diag?.settled(performance.now() - ts);
|
|
4224
4534
|
};
|
|
4225
4535
|
pendingSwap = { finish };
|
|
4226
4536
|
|
|
@@ -4429,12 +4739,15 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
|
|
|
4429
4739
|
const finish = () => {
|
|
4430
4740
|
if (finished) return;
|
|
4431
4741
|
finished = true;
|
|
4742
|
+
const ts = performance.now();
|
|
4432
4743
|
for (const f of finishers) f();
|
|
4744
|
+
perfSpan('settle:teardown', ts);
|
|
4433
4745
|
for (const w of [...wakers]) w();
|
|
4434
4746
|
wakers.clear();
|
|
4435
4747
|
viewer._transitionState = null;
|
|
4436
4748
|
if (gen === sourceGen) diag?.setPhase('settle');
|
|
4437
4749
|
if (pendingSwap?.finish === finish) pendingSwap = null;
|
|
4750
|
+
if (gen === sourceGen) diag?.settled(performance.now() - ts);
|
|
4438
4751
|
};
|
|
4439
4752
|
const ac = typeof AbortController === 'function' ? new AbortController() : null;
|
|
4440
4753
|
finishers.push(() => ac?.abort());
|
|
@@ -303,3 +303,100 @@ export class VideoPlane {
|
|
|
303
303
|
this.mesh.destroy?.();
|
|
304
304
|
}
|
|
305
305
|
}
|
|
306
|
+
|
|
307
|
+
// ── handle.makeSbsMaterial(): per-eye stereo on ANY quad (docs/proposals/layer-display-rig.md) ──
|
|
308
|
+
//
|
|
309
|
+
// setVideo's eye pick, lifted out of the full-screen plane: the eye viewports sit side by side in
|
|
310
|
+
// the buffer, so `gl_FragCoord.x >= split` is the right eye. The viewer publishes that split every
|
|
311
|
+
// draw as a SCENE-WIDE uniform (EYE_SPLIT_UNIFORM, 1e9 in mono / the 2D tier / a 1-view mode), so a
|
|
312
|
+
// material only has to declare it — the SDK never has to know which materials are stereo.
|
|
313
|
+
// PlayCanvas's own `view_index` is set per RenderView on the single-camera path too, but it is 0
|
|
314
|
+
// for every camera on the N-camera fallback path; the split is right on both.
|
|
315
|
+
|
|
316
|
+
/** The scene-wide uniform the viewer sets every draw: first right-eye pixel column (1e9 = mono). */
|
|
317
|
+
export const EYE_SPLIT_UNIFORM = 'dxr_eye_split';
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* GLSL for a custom shader that wants the same pick: declare the uniform, call dxrEyeRegion() with
|
|
321
|
+
* the left and right regions ([s0, t0, ds, dt], t = image rows from the top).
|
|
322
|
+
*/
|
|
323
|
+
export const SBS_EYE_GLSL = `
|
|
324
|
+
uniform float ${EYE_SPLIT_UNIFORM};
|
|
325
|
+
vec4 dxrEyeRegion(vec4 left, vec4 right) { return gl_FragCoord.x >= ${EYE_SPLIT_UNIFORM} ? right : left; }`;
|
|
326
|
+
|
|
327
|
+
const SBS_OPTION_KEYS = new Set(['format', 'opacity', 'flipY', 'depthTest', 'depthWrite', 'cull', 'name']);
|
|
328
|
+
|
|
329
|
+
/** makeSbsMaterial's options, validated. */
|
|
330
|
+
export function validateSbsOptions(texture, o = {}) {
|
|
331
|
+
if (!texture || typeof texture !== 'object') throw new TypeError('@displayxr/inline3d/splat: makeSbsMaterial(texture) — expected a pc.Texture.');
|
|
332
|
+
if (o === null || typeof o !== 'object') throw new TypeError('@displayxr/inline3d/splat: makeSbsMaterial options must be an object.');
|
|
333
|
+
const unknown = Object.keys(o).filter((k) => !SBS_OPTION_KEYS.has(k));
|
|
334
|
+
if (unknown.length) throw new Error(`@displayxr/inline3d/splat: makeSbsMaterial — unknown option(s) ${unknown.join(', ')}.`);
|
|
335
|
+
const format = o.format === undefined ? 'sbs' : o.format;
|
|
336
|
+
if (!VIDEO_FORMATS.includes(format)) throw new Error(`@displayxr/inline3d/splat: makeSbsMaterial — format "${format}", expected ${VIDEO_FORMATS.join(' | ')}.`);
|
|
337
|
+
const opacity = o.opacity === undefined ? 1 : o.opacity;
|
|
338
|
+
if (!Number.isFinite(opacity) || opacity < 0 || opacity > 1) throw new Error(`@displayxr/inline3d/splat: makeSbsMaterial — bad opacity: ${o.opacity}.`);
|
|
339
|
+
return { format, opacity, flipY: o.flipY === true, depthTest: o.depthTest !== false, depthWrite: o.depthWrite !== false, cull: o.cull === true, name: o.name };
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const SBS_VERT = `
|
|
343
|
+
attribute vec3 vertex_position;
|
|
344
|
+
attribute vec2 vertex_texCoord0;
|
|
345
|
+
uniform mat4 matrix_model;
|
|
346
|
+
uniform mat4 matrix_viewProjection;
|
|
347
|
+
varying vec2 vUv;
|
|
348
|
+
void main() {
|
|
349
|
+
vUv = vertex_texCoord0;
|
|
350
|
+
gl_Position = matrix_viewProjection * matrix_model * vec4(vertex_position, 1.0);
|
|
351
|
+
}`;
|
|
352
|
+
const SBS_FRAG = `
|
|
353
|
+
varying vec2 vUv;
|
|
354
|
+
uniform sampler2D dxrSbsTex;
|
|
355
|
+
uniform vec4 dxrSbsL; // left eye's region: s0, t0, ds, dt (t = image rows, top = 0)
|
|
356
|
+
uniform vec4 dxrSbsR; // right eye's
|
|
357
|
+
uniform vec2 dxrSbsTexel; // half a texel, per axis
|
|
358
|
+
uniform float dxrSbsOpacity;
|
|
359
|
+
uniform float dxrSbsFlipY; // 1: the texture's row 0 is the image's BOTTOM
|
|
360
|
+
${SBS_EYE_GLSL}
|
|
361
|
+
void main() {
|
|
362
|
+
vec4 r = dxrEyeRegion(dxrSbsL, dxrSbsR);
|
|
363
|
+
vec2 st = r.xy + vec2(vUv.x, 1.0 - vUv.y) * r.zw;
|
|
364
|
+
st = clamp(st, r.xy + dxrSbsTexel, r.xy + r.zw - dxrSbsTexel);
|
|
365
|
+
if (dxrSbsFlipY > 0.5) st.y = 1.0 - st.y;
|
|
366
|
+
gl_FragColor = vec4(texture2D(dxrSbsTex, st).rgb, dxrSbsOpacity);
|
|
367
|
+
}`;
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* An unlit material that shows the LEFT half of `texture` to left-eye views and the RIGHT half to
|
|
371
|
+
* right-eye views (or top/bottom, or the same picture: `format`), on whatever mesh it is put on.
|
|
372
|
+
* The mesh's geometry is untouched — put the quad at the screen plane and the clip's own disparity
|
|
373
|
+
* is the only depth. Mono: every fragment samples the left region at full resolution. The texture
|
|
374
|
+
* is the page's (a `<video>`'s frames uploaded by the page, an image); nothing is decoded here.
|
|
375
|
+
*/
|
|
376
|
+
export function makeSbsMaterial(pc, texture, o = {}) {
|
|
377
|
+
const opt = validateSbsOptions(texture, o);
|
|
378
|
+
const mat = new pc.ShaderMaterial({
|
|
379
|
+
uniqueName: 'inline3dSbsQuad',
|
|
380
|
+
attributes: { vertex_position: pc.SEMANTIC_POSITION, vertex_texCoord0: pc.SEMANTIC_TEXCOORD0 },
|
|
381
|
+
vertexGLSL: SBS_VERT,
|
|
382
|
+
fragmentGLSL: SBS_FRAG,
|
|
383
|
+
});
|
|
384
|
+
if (opt.name) mat.name = opt.name;
|
|
385
|
+
mat.cull = opt.cull ? pc.CULLFACE_BACK : pc.CULLFACE_NONE;
|
|
386
|
+
mat.depthTest = opt.depthTest;
|
|
387
|
+
mat.depthWrite = opt.depthWrite;
|
|
388
|
+
if (opt.opacity < 1 && pc.BlendState && pc.BLENDMODE_SRC_ALPHA !== undefined) {
|
|
389
|
+
mat.blendState = new pc.BlendState(true, pc.BLENDEQUATION_ADD, pc.BLENDMODE_SRC_ALPHA, pc.BLENDMODE_ONE_MINUS_SRC_ALPHA);
|
|
390
|
+
}
|
|
391
|
+
const r = eyeRegions(opt.format);
|
|
392
|
+
mat.setParameter('dxrSbsL', r.L);
|
|
393
|
+
mat.setParameter('dxrSbsR', r.R);
|
|
394
|
+
mat.setParameter('dxrSbsOpacity', opt.opacity);
|
|
395
|
+
mat.setParameter('dxrSbsFlipY', opt.flipY ? 1 : 0);
|
|
396
|
+
mat.setParameter('dxrSbsTex', texture);
|
|
397
|
+
const w = Math.max(1, texture.width || 1);
|
|
398
|
+
const h = Math.max(1, texture.height || 1);
|
|
399
|
+
mat.setParameter('dxrSbsTexel', [0.5 / w, 0.5 / h]);
|
|
400
|
+
mat.update();
|
|
401
|
+
return mat;
|
|
402
|
+
}
|
package/js/inline3d-splat.js
CHANGED
|
@@ -381,6 +381,14 @@ export function addSplat(wall, canvas, src, opts = {}) {
|
|
|
381
381
|
"(addSplat(…, { engine:'playcanvas' })); with Spark, use wall.addVideo on its own canvas.",
|
|
382
382
|
);
|
|
383
383
|
},
|
|
384
|
+
/** Not on this backend: per-layer rigs are a PlayCanvas-backend feature. */
|
|
385
|
+
setLayerRig() {
|
|
386
|
+
throw new Error("@displayxr/inline3d/splat: setLayerRig() is implemented on the PlayCanvas backend only (addSplat(…, { engine:'playcanvas' })).");
|
|
387
|
+
},
|
|
388
|
+
/** Not on this backend: per-eye SBS materials are a PlayCanvas-backend feature. */
|
|
389
|
+
makeSbsMaterial() {
|
|
390
|
+
throw new Error("@displayxr/inline3d/splat: makeSbsMaterial() is implemented on the PlayCanvas backend only (addSplat(…, { engine:'playcanvas' })).");
|
|
391
|
+
},
|
|
384
392
|
/** Not on this backend: the live rig switch is a PlayCanvas-backend feature. */
|
|
385
393
|
setRig() {
|
|
386
394
|
throw new Error(
|
|
@@ -759,6 +767,16 @@ function addSplatDeferred(wall, canvas, src, opts) {
|
|
|
759
767
|
return out;
|
|
760
768
|
},
|
|
761
769
|
stopEffect: queue('stopEffect'),
|
|
770
|
+
// A layer-rig request before the adapter has loaded: queued, replayed in order once it has
|
|
771
|
+
// (it needs no asset, only the engine's layers — which the adapter resolves lazily).
|
|
772
|
+
setLayerRig: (...args) => {
|
|
773
|
+
pending.push(['setLayerRig', args]);
|
|
774
|
+
return out;
|
|
775
|
+
},
|
|
776
|
+
layerRigState: () => ({ display: [], disabled: false, rounded: false, gain: null }),
|
|
777
|
+
makeSbsMaterial: () => {
|
|
778
|
+
throw new Error('@displayxr/inline3d/splat: makeSbsMaterial() needs the engine — call it after `await handle.ready`.');
|
|
779
|
+
},
|
|
762
780
|
effects: () => [],
|
|
763
781
|
getFocus: () => null,
|
|
764
782
|
// A plain data slot the adapter reads at call time, so a callback assigned on the very next
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@displayxr/inline3d",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.23.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",
|
|
@@ -54,6 +54,7 @@
|
|
|
54
54
|
"js/inline3d-splat-live.js",
|
|
55
55
|
"js/inline3d-splat-diag.js",
|
|
56
56
|
"js/inline3d-splat-video.js",
|
|
57
|
+
"js/inline3d-splat-layer-rig.js",
|
|
57
58
|
"js/inline3d-model.js",
|
|
58
59
|
"js/inline3d-model-entry.js",
|
|
59
60
|
"js/inline3d-model-playcanvas.js",
|