@displayxr/inline3d 1.34.0 → 1.36.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 +57 -0
- package/camera.d.ts +23 -0
- package/cursor-depth.d.ts +76 -0
- package/dist/call.js +4 -4
- package/js/inline3d-call.js +25 -2
- package/js/inline3d-camera.js +105 -8
- package/js/inline3d-cursor-depth-playcanvas.js +281 -0
- package/js/inline3d-cursor-depth.js +311 -0
- package/js/inline3d-model-playcanvas.js +1 -0
- package/js/inline3d-model.js +2 -1
- package/js/inline3d-playcanvas-engine.js +3 -0
- package/js/inline3d-splat-playcanvas.js +19 -0
- package/js/inline3d-splat.js +4 -1
- package/js/inline3d-three.js +201 -0
- package/js/inline3d-viewer.js +24 -0
- package/model.d.ts +10 -0
- package/package.json +9 -1
- package/splat.d.ts +10 -0
- package/three.d.ts +49 -0
package/js/inline3d-call.js
CHANGED
|
@@ -1127,6 +1127,9 @@ class Call {
|
|
|
1127
1127
|
hello: this.hello(),
|
|
1128
1128
|
transport: this.transport,
|
|
1129
1129
|
peers: [...this.tiles.values()].map((tile) => tile.diag()),
|
|
1130
|
+
// The self view's ACTUAL weave state (web#131): route, woven, the fallback reason and the
|
|
1131
|
+
// tile's firstWoven result — null without a self view.
|
|
1132
|
+
self: this.self ? this.self.diag() : null,
|
|
1130
1133
|
mono3D: { ...this._mono3DInfo(), lift: this.liftApi, pool: this.liftPool ? this.liftPool.list() : [] },
|
|
1131
1134
|
speaker: this.speakerId,
|
|
1132
1135
|
depth: this.depth,
|
|
@@ -1866,14 +1869,34 @@ class SelfTile {
|
|
|
1866
1869
|
else {
|
|
1867
1870
|
this.detach();
|
|
1868
1871
|
this.cam = cam;
|
|
1869
|
-
|
|
1872
|
+
// onRouteChange: the view drops to its flat left eye by itself when the wall says the tile
|
|
1873
|
+
// will not weave (firstWoven → woven:false, web#131), and the badge must follow it.
|
|
1874
|
+
this.view = addCameraView(wall, this.canvas, cam, {
|
|
1875
|
+
mirror: true,
|
|
1876
|
+
autoConverge: false,
|
|
1877
|
+
aspect: this.call.o.tileAspect,
|
|
1878
|
+
onRouteChange: (route, st) => {
|
|
1879
|
+
if (this.cam !== cam) return; // a view this tile already replaced
|
|
1880
|
+
this.call.log('self-route', { route, woven: st.woven, reason: st.reason });
|
|
1881
|
+
this._badge();
|
|
1882
|
+
},
|
|
1883
|
+
});
|
|
1870
1884
|
}
|
|
1871
1885
|
this._badge();
|
|
1872
1886
|
}
|
|
1873
1887
|
|
|
1888
|
+
/** 3D only while the view is on the woven route — a layer the wall failed has already taken it flat. */
|
|
1874
1889
|
_badge() {
|
|
1875
1890
|
const t = this.call.t;
|
|
1876
|
-
|
|
1891
|
+
const woven = !!(this.view && this.view.woven);
|
|
1892
|
+
this.badge.replaceChildren(el('b', { text: `${t('you')} · ${woven ? t('badge3D') : t('badge2D')}` }));
|
|
1893
|
+
this.badge.dataset.route = this.route || '';
|
|
1894
|
+
}
|
|
1895
|
+
|
|
1896
|
+
/** For `call.diagnostics().self`: what the self view actually shows. */
|
|
1897
|
+
diag() {
|
|
1898
|
+
if (!this.view) return { route: null, woven: false, reason: 'no-camera', firstWoven: null, layerRetries: 0 };
|
|
1899
|
+
return this.view.weaveState();
|
|
1877
1900
|
}
|
|
1878
1901
|
|
|
1879
1902
|
onWallLost() {
|
package/js/inline3d-camera.js
CHANGED
|
@@ -51,6 +51,11 @@ export const CAMERA_SDK = 'inline3d-camera/1';
|
|
|
51
51
|
const AUTO_CONV_INTERVAL_MS = 200;
|
|
52
52
|
const AUTO_CONV_EYE_WIDTH = 240;
|
|
53
53
|
const FRAME_WAIT_MS = 4000;
|
|
54
|
+
// A self view whose layer the wall could not build (`firstWoven` → `woven:false, 'layer-failed'`)
|
|
55
|
+
// goes flat and re-registers a few times with backoff — the remote tile's rule (web#131).
|
|
56
|
+
const LAYER_RETRIES = 4;
|
|
57
|
+
const LAYER_RETRY_BASE_MS = 1500;
|
|
58
|
+
const LAYER_RETRY_MAX_MS = 20000;
|
|
54
59
|
const RECORD_MIME_CANDIDATES = ['video/webm;codecs=vp9', 'video/webm;codecs=vp8', 'video/webm', 'video/mp4'];
|
|
55
60
|
|
|
56
61
|
const hasDoc = () => typeof document !== 'undefined' && document && typeof document.createElement === 'function';
|
|
@@ -471,7 +476,12 @@ function sizeFlat(canvas) {
|
|
|
471
476
|
* @param {object|null} wall a `createInline3D()` / `sharedInline3D()` result (may be unsupported) or null
|
|
472
477
|
* @param {HTMLCanvasElement} canvas the tile — its CSS box is the shape the viewer sees
|
|
473
478
|
* @param {StereoCamera} cam
|
|
474
|
-
*
|
|
479
|
+
* On a 3D wall the view follows its tile's `firstWoven`: a window the wall will not weave
|
|
480
|
+
* (`'layer-failed'`, `'session-ended'`) drops the view to the flat left eye — never the packed
|
|
481
|
+
* pair — with `fallbackReason` saying why, and `onRouteChange(route, state)` tells the page (a
|
|
482
|
+
* badge reads `route`). A refused layer is retried a few times; `_reroute(true, wall)` recovers.
|
|
483
|
+
*
|
|
484
|
+
* @param {{ mirror?: boolean, autoConverge?: boolean, depth?: number, aspect?: number, onRouteChange?: Function }} [opts]
|
|
475
485
|
*/
|
|
476
486
|
export function addCameraView(wall, canvas, cam, opts = {}) {
|
|
477
487
|
if (!canvas || typeof canvas.getContext !== 'function') throw new TypeError(`${TAG} addCameraView(wall, canvas, cam, opts) needs a canvas`);
|
|
@@ -494,6 +504,15 @@ class CameraView {
|
|
|
494
504
|
this.setDepth(o.depth);
|
|
495
505
|
this.route = null;
|
|
496
506
|
this.handle = null;
|
|
507
|
+
// Why a pair on a 3D-capable wall is shown FLAT (web#131): the wall said this tile's window
|
|
508
|
+
// will not weave (`firstWoven` → woven:false — 'layer-failed', 'session-ended'). Null while
|
|
509
|
+
// nothing has failed. A packed pair is never the visible fallback: the view paints one eye.
|
|
510
|
+
this.fallbackReason = null;
|
|
511
|
+
// The current registration's settled `firstWoven`, null while pending / not registered.
|
|
512
|
+
this._fw = null;
|
|
513
|
+
this._onRouteChange = typeof o.onRouteChange === 'function' ? o.onRouteChange : null;
|
|
514
|
+
this._layerFails = 0;
|
|
515
|
+
this._layerTimer = 0;
|
|
497
516
|
this.buffer = null; // the SBS canvas the wall repaints the tile from (woven route)
|
|
498
517
|
this.removed = false;
|
|
499
518
|
this._raf = 0;
|
|
@@ -507,11 +526,30 @@ class CameraView {
|
|
|
507
526
|
if (this._loop) this._start();
|
|
508
527
|
}
|
|
509
528
|
|
|
510
|
-
/**
|
|
529
|
+
/**
|
|
530
|
+
* True while the view is on the woven route: registered on a 3D wall, and the wall has not said
|
|
531
|
+
* the tile will not weave (a failed layer drops the view to `'flat-left'`). Route is
|
|
532
|
+
* `'woven-sbs'` (3D on the panel) | `'flat-left'` (a pair, shown flat) | `'flat'` (mono).
|
|
533
|
+
*/
|
|
511
534
|
get woven() {
|
|
512
535
|
return this.route === 'woven-sbs';
|
|
513
536
|
}
|
|
514
537
|
|
|
538
|
+
/**
|
|
539
|
+
* What is actually on the panel, for diagnostics: `{ route, woven, reason, firstWoven,
|
|
540
|
+
* layerRetries }`. `firstWoven` is the current registration's settled result, `'pending'`
|
|
541
|
+
* before it settles, null off the woven route.
|
|
542
|
+
*/
|
|
543
|
+
weaveState() {
|
|
544
|
+
return {
|
|
545
|
+
route: this.route,
|
|
546
|
+
woven: this.woven,
|
|
547
|
+
reason: this.fallbackReason,
|
|
548
|
+
firstWoven: this.route === 'woven-sbs' ? (this._fw ? { ...this._fw } : 'pending') : null,
|
|
549
|
+
layerRetries: this._layerFails,
|
|
550
|
+
};
|
|
551
|
+
}
|
|
552
|
+
|
|
515
553
|
/** The per-eye convergence shift currently painted, source px. */
|
|
516
554
|
get convergencePx() {
|
|
517
555
|
return this.conv.current;
|
|
@@ -543,20 +581,77 @@ class CameraView {
|
|
|
543
581
|
return this.autoConverge;
|
|
544
582
|
}
|
|
545
583
|
|
|
546
|
-
/**
|
|
584
|
+
/**
|
|
585
|
+
* Re-register on the wall (the call does this when the weave goes live, #172, or a session is
|
|
586
|
+
* recovered). A FORCED reroute also clears a layer fallback (web#131): a wall that came back
|
|
587
|
+
* gets a fresh registration. An unforced one keeps the view flat while a fallback stands.
|
|
588
|
+
*/
|
|
547
589
|
_reroute(force, wall) {
|
|
548
590
|
if (wall !== undefined) this.wall = wall;
|
|
549
591
|
if (this.removed) return;
|
|
592
|
+
if (force && this.fallbackReason) {
|
|
593
|
+
this.fallbackReason = null;
|
|
594
|
+
clearTimeout(this._layerTimer);
|
|
595
|
+
this._layerTimer = 0;
|
|
596
|
+
}
|
|
550
597
|
const cam = this.cam;
|
|
551
598
|
const live = cam.state === 'live' && cam.video;
|
|
552
|
-
const
|
|
599
|
+
const wovenOk = cam.format === 'sbs' && live && this.wall && this.wall.supported && !this.fallbackReason;
|
|
600
|
+
const route = wovenOk ? 'woven-sbs' : cam.format === 'sbs' ? 'flat-left' : 'flat';
|
|
553
601
|
if (!force && route === this.route) return;
|
|
602
|
+
const prev = this.route;
|
|
554
603
|
this._unregister();
|
|
555
604
|
this.route = route;
|
|
556
|
-
if (route === 'woven-sbs')
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
605
|
+
if (route === 'woven-sbs') this._registerWoven();
|
|
606
|
+
if (route !== prev) this._routeChanged();
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
_registerWoven() {
|
|
610
|
+
if (!this.buffer) this.buffer = document.createElement('canvas');
|
|
611
|
+
this._paintWoven(); // the first frame exists before the layer does
|
|
612
|
+
const handle = this.wall.addImage(this.canvas, this.buffer);
|
|
613
|
+
this.handle = handle;
|
|
614
|
+
this._fw = null;
|
|
615
|
+
const fw = handle && handle.firstWoven;
|
|
616
|
+
if (!fw || typeof fw.then !== 'function') return;
|
|
617
|
+
fw.then((res) => {
|
|
618
|
+
// A result for a registration we already left (removed, re-registered) says nothing now.
|
|
619
|
+
if (this.handle !== handle || this.removed || !res) return;
|
|
620
|
+
this._fw = { woven: !!res.woven, confirmed: !!res.confirmed, reason: res.reason || null, ms: res.ms };
|
|
621
|
+
this.cam.log?.('view-first-woven', { woven: !!res.woven, reason: res.reason || null });
|
|
622
|
+
if (res.woven) {
|
|
623
|
+
this._layerFails = 0;
|
|
624
|
+
this._routeChanged(); // the state settled; the route did not move
|
|
625
|
+
} else if (res.reason !== 'removed') this._layerLost(res.reason || 'layer-failed');
|
|
626
|
+
});
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
/**
|
|
630
|
+
* The wall said this window will not weave (web#131): the canvas must never be left showing the
|
|
631
|
+
* packed pair under a 3D badge. Go flat (one eye, mirrored) and say why. A layer the browser
|
|
632
|
+
* refused is retried a few times with backoff while the wall stays up (the remote tile's rule);
|
|
633
|
+
* a session that ended waits for `_reroute(true, wall)` with the new wall.
|
|
634
|
+
*/
|
|
635
|
+
_layerLost(reason) {
|
|
636
|
+
this.fallbackReason = reason;
|
|
637
|
+
this._reroute(false);
|
|
638
|
+
if (reason !== 'layer-failed' || this._layerFails >= LAYER_RETRIES) return;
|
|
639
|
+
const delay = Math.min(LAYER_RETRY_MAX_MS, LAYER_RETRY_BASE_MS * Math.pow(2, this._layerFails++));
|
|
640
|
+
this.cam.log?.('view-layer-retry', { attempt: this._layerFails, inMs: delay });
|
|
641
|
+
clearTimeout(this._layerTimer);
|
|
642
|
+
this._layerTimer = setTimeout(() => {
|
|
643
|
+
this._layerTimer = 0;
|
|
644
|
+
if (this.removed || this.fallbackReason !== reason) return;
|
|
645
|
+
this._reroute(true);
|
|
646
|
+
}, delay);
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
_routeChanged() {
|
|
650
|
+
if (!this._onRouteChange) return;
|
|
651
|
+
try {
|
|
652
|
+
this._onRouteChange(this.route, this.weaveState());
|
|
653
|
+
} catch (err) {
|
|
654
|
+
console.error(`${TAG} onRouteChange threw`, err);
|
|
560
655
|
}
|
|
561
656
|
}
|
|
562
657
|
|
|
@@ -672,6 +767,8 @@ class CameraView {
|
|
|
672
767
|
this.removed = true;
|
|
673
768
|
if (this._raf) cancelAnimationFrame(this._raf);
|
|
674
769
|
this._raf = 0;
|
|
770
|
+
clearTimeout(this._layerTimer);
|
|
771
|
+
this._layerTimer = 0;
|
|
675
772
|
this._unregister();
|
|
676
773
|
this.cam._views?.delete(this);
|
|
677
774
|
}
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
// inline3d-cursor-depth-playcanvas.js — the depth-aware cursor on the PlayCanvas backend (./model and
|
|
2
|
+
// ./splat, `cursor: 'depth'`).
|
|
3
|
+
//
|
|
4
|
+
// Internal, and LOADED ONLY ON OPT-IN: the adapters import this module dynamically when a page
|
|
5
|
+
// passes `cursor: 'depth'`, so a page that does not ask pays nothing — not even the download.
|
|
6
|
+
// The adapter's per-frame hook is an optional call (`viewer.cursorDepth?.frame(…)`) that is a
|
|
7
|
+
// no-op without it.
|
|
8
|
+
//
|
|
9
|
+
// Hit test: PlayCanvas's own depth PICKER, from the first eye, at low resolution. One path for
|
|
10
|
+
// meshes AND gaussian splats (the picker renders both into its depth target), and it reads what
|
|
11
|
+
// is actually drawn — no splat raycasting, no CPU triangle walk. It costs one extra low-res
|
|
12
|
+
// scene pass, run every other frame and only while the pointer is over the canvas. Results come
|
|
13
|
+
// back asynchronously (a frame or two late); the placement filter absorbs that, exactly like the
|
|
14
|
+
// runtime's one-frame-late app hint.
|
|
15
|
+
//
|
|
16
|
+
// Placement: the same maths as everywhere else (./inline3d-cursor-depth.js, a port of the
|
|
17
|
+
// runtime's u_cursor_depth, ADR-046), solved in WORLD space from this frame's views carried
|
|
18
|
+
// through the rig node. Drawn as immediate-mode lines with depth test off, so every eye camera
|
|
19
|
+
// draws it and nothing occludes it.
|
|
20
|
+
|
|
21
|
+
import {
|
|
22
|
+
CursorDepthPlacer,
|
|
23
|
+
CursorPointer,
|
|
24
|
+
CURSOR_DEFAULT_HEIGHT,
|
|
25
|
+
cursorCrosshairPoints,
|
|
26
|
+
cursorFootprint,
|
|
27
|
+
cursorViewRay,
|
|
28
|
+
} from './inline3d-cursor-depth.js';
|
|
29
|
+
|
|
30
|
+
/** Pick-buffer width in pixels (height follows the eye's aspect). */
|
|
31
|
+
const PICK_WIDTH = 160;
|
|
32
|
+
/** Pick every Nth frame. */
|
|
33
|
+
const PICK_EVERY = 2;
|
|
34
|
+
/** A pick that has not answered in this long is abandoned (and the cursor falls to the glass). */
|
|
35
|
+
const PICK_TIMEOUT_MS = 500;
|
|
36
|
+
|
|
37
|
+
export class PlayCanvasDepthCursor {
|
|
38
|
+
/**
|
|
39
|
+
* @param {object} pc the PlayCanvas namespace the adapter renders with.
|
|
40
|
+
* @param {object} viewer the PlayCanvasSplatViewer (app, rigNode, eye are read lazily).
|
|
41
|
+
* @param {{canvas: HTMLElement, height?: number, color?: number[]}} opts
|
|
42
|
+
*/
|
|
43
|
+
constructor(pc, viewer, { canvas, height = CURSOR_DEFAULT_HEIGHT, color = [1, 0.83, 0.1, 1] }) {
|
|
44
|
+
this.pc = pc;
|
|
45
|
+
this.viewer = viewer;
|
|
46
|
+
this.height = height;
|
|
47
|
+
this.color = color;
|
|
48
|
+
this.pointer = new CursorPointer(canvas);
|
|
49
|
+
this.placer = new CursorDepthPlacer();
|
|
50
|
+
/** The last placement (diagnostics). */
|
|
51
|
+
this.placement = { active: false };
|
|
52
|
+
/** The last picked world point under the footprint, or null (nothing under it). */
|
|
53
|
+
this.hit = null;
|
|
54
|
+
this._picker = null;
|
|
55
|
+
this._pickCam = null;
|
|
56
|
+
this._inFlight = false;
|
|
57
|
+
this._pickId = 0;
|
|
58
|
+
this._pickStart = 0;
|
|
59
|
+
this._frame = 0;
|
|
60
|
+
this._disposed = false;
|
|
61
|
+
this._points = [];
|
|
62
|
+
this._colors = [];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* One 3D frame, before app.tick: schedule a pick, place the sprite, queue its lines.
|
|
67
|
+
* @param {Array<{proj: ArrayLike<number>, pose: ArrayLike<number>, width: number, height: number}>} entries
|
|
68
|
+
* this frame's views, poses in RIG space (the eye cameras' parent).
|
|
69
|
+
*/
|
|
70
|
+
frame(entries) {
|
|
71
|
+
const app = this.viewer.app;
|
|
72
|
+
const rig = this.viewer.rigNode;
|
|
73
|
+
const uv = this.pointer.uv;
|
|
74
|
+
if (this._disposed || !app || !rig || !uv || !entries || entries.length < 2) {
|
|
75
|
+
this.inactive();
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
const R = rig.getWorldTransform().data;
|
|
79
|
+
const views = entries.map((e) => ({ projectionMatrix: e.proj, transformMatrix: mul4(R, e.pose) }));
|
|
80
|
+
const t = globalThis.performance ? globalThis.performance.now() : Date.now();
|
|
81
|
+
if (this._inFlight && t - this._pickStart > PICK_TIMEOUT_MS) {
|
|
82
|
+
// A read that never settles must not freeze the cursor on a stale hit.
|
|
83
|
+
this._inFlight = false;
|
|
84
|
+
this._pickId++;
|
|
85
|
+
this.hit = null;
|
|
86
|
+
if (!this._warnedTimeout) {
|
|
87
|
+
this._warnedTimeout = true;
|
|
88
|
+
console.warn('[inline3d] cursor: depth: a PlayCanvas pick did not answer in time; retrying (cursor on the glass meanwhile).');
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
if (this._frame++ % PICK_EVERY === 0 && !this._inFlight) this._pick(app, rig, entries[0], uv);
|
|
92
|
+
const now = (globalThis.performance ? globalThis.performance.now() : Date.now()) / 1000;
|
|
93
|
+
const p = this.placer.update(views, { u: uv[0], v: uv[1], nearestPoint: this.hit, cursorHeight: this.height }, now);
|
|
94
|
+
this.placement = p;
|
|
95
|
+
this.pointer.hideCss(!!p.active);
|
|
96
|
+
if (!p.active) return;
|
|
97
|
+
const pts = cursorCrosshairPoints(p, this._points);
|
|
98
|
+
const cols = this._colors;
|
|
99
|
+
if (cols.length !== (pts.length / 3) * 4) {
|
|
100
|
+
cols.length = 0;
|
|
101
|
+
for (let i = 0; i < pts.length / 3; i++) cols.push(...this.color);
|
|
102
|
+
}
|
|
103
|
+
app.drawLineArrays(pts, cols, false);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Mono / no pointer / no views: no sprite, the CSS cursor back. */
|
|
107
|
+
inactive() {
|
|
108
|
+
if (this.placement.active) this.placement = { active: false };
|
|
109
|
+
this.pointer.hideCss(false);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
dispose() {
|
|
113
|
+
this._disposed = true;
|
|
114
|
+
this.pointer.dispose();
|
|
115
|
+
try {
|
|
116
|
+
this._picker?.destroy?.();
|
|
117
|
+
this._pickCam?.destroy?.();
|
|
118
|
+
} catch {
|
|
119
|
+
// the app may already be gone with its device
|
|
120
|
+
}
|
|
121
|
+
this._picker = null;
|
|
122
|
+
this._pickCam = null;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Render the pick buffer from the first eye and read the footprint's world points.
|
|
126
|
+
_pick(app, rig, e, uv) {
|
|
127
|
+
const pc = this.pc;
|
|
128
|
+
const aspect = e.width > 0 && e.height > 0 ? e.width / e.height : 1;
|
|
129
|
+
const W = PICK_WIDTH;
|
|
130
|
+
const H = Math.max(1, Math.round(W / aspect));
|
|
131
|
+
if (!this._picker) {
|
|
132
|
+
this._picker = new pc.Picker(app, W, H, true);
|
|
133
|
+
const cam = new pc.Entity('inline3d-cursor-pick', app);
|
|
134
|
+
// frustumCulling off: this camera never renders on its own (disabled — the picker drives
|
|
135
|
+
// it), so nothing keeps its culling frustum current, and a stale one culls everything.
|
|
136
|
+
cam.addComponent('camera', { clearColor: new pc.Color(0, 0, 0, 0), frustumCulling: false });
|
|
137
|
+
cam.camera.enabled = false;
|
|
138
|
+
cam._dxrProj = new Float64Array(16);
|
|
139
|
+
cam.camera.calculateProjection = (out) => out.set(cam._dxrProj);
|
|
140
|
+
rig.addChild(cam);
|
|
141
|
+
this._pickCam = cam;
|
|
142
|
+
}
|
|
143
|
+
const cam = this._pickCam;
|
|
144
|
+
cam._dxrProj.set(e.proj);
|
|
145
|
+
// The picker linearises depth with the COMPONENT's near/far, and reconstructs through the
|
|
146
|
+
// projection: keep the two in agreement (GL-style projection: m10 = -(f+n)/(f-n), m14 = -2fn/(f-n)).
|
|
147
|
+
const m10 = e.proj[10], m14 = e.proj[14];
|
|
148
|
+
const near = m14 / (m10 - 1);
|
|
149
|
+
const far = m14 / (m10 + 1);
|
|
150
|
+
if (near > 0 && far > near) {
|
|
151
|
+
cam.camera.nearClip = near;
|
|
152
|
+
cam.camera.farClip = far;
|
|
153
|
+
}
|
|
154
|
+
if (cam.camera.camera) cam.camera.camera._projMatDirty = true;
|
|
155
|
+
const q = quatFromMatrix(e.pose);
|
|
156
|
+
cam.setLocalPosition(e.pose[12], e.pose[13], e.pose[14]);
|
|
157
|
+
cam.setLocalRotation(q[0], q[1], q[2], q[3]);
|
|
158
|
+
const eye = this.viewer.eye?.camera;
|
|
159
|
+
const skip = new Set([pc.LAYERID_UI, pc.LAYERID_IMMEDIATE, pc.LAYERID_SKYBOX]);
|
|
160
|
+
const ids = (eye && Array.isArray(eye.layers) ? eye.layers : cam.camera.layers).filter((id) => !skip.has(id));
|
|
161
|
+
const layers = ids.map((id) => app.scene.layers.getLayerById(id)).filter(Boolean);
|
|
162
|
+
|
|
163
|
+
const picker = this._picker;
|
|
164
|
+
picker.resize(W, H);
|
|
165
|
+
try {
|
|
166
|
+
// Enabled only for the synchronous pick pass: a disabled camera is not in its layers'
|
|
167
|
+
// camera lists, so the picker would render nothing; enabled outside it, the camera would
|
|
168
|
+
// render a frame of its own on every app.tick.
|
|
169
|
+
cam.camera.enabled = true;
|
|
170
|
+
picker.prepare(cam.camera, app.scene, layers);
|
|
171
|
+
} catch (err) {
|
|
172
|
+
console.warn('[inline3d] cursor: depth: the PlayCanvas picker failed; cursor stays on the glass.', err);
|
|
173
|
+
this.hit = null;
|
|
174
|
+
return;
|
|
175
|
+
} finally {
|
|
176
|
+
cam.camera.enabled = false;
|
|
177
|
+
}
|
|
178
|
+
// The picker stores LINEAR depth: (viewDist - near) / (far - near), viewDist = 1/gl_FragCoord.w.
|
|
179
|
+
// Rebuild the point ourselves along the exact off-axis ray (getWorldPointAsync would unproject
|
|
180
|
+
// with the component's symmetric projection, not this one). PlayCanvas's view matrix is built
|
|
181
|
+
// from the camera's world POSITION and ROTATION only — the rig node's scale is not in it — so
|
|
182
|
+
// view distances are world units and the ray must use the UNSCALED world rotation.
|
|
183
|
+
const view = { projectionMatrix: e.proj, transformMatrix: unscaled(cam.getWorldTransform().data) };
|
|
184
|
+
const Mw = view.transformMatrix;
|
|
185
|
+
const f = [-Mw[8], -Mw[9], -Mw[10]];
|
|
186
|
+
const zNear = cam.camera.nearClip, zFar = cam.camera.farClip;
|
|
187
|
+
const samples = cursorFootprint(uv[0], uv[1], this.height, this.pointer.aspect())
|
|
188
|
+
.filter(([u, v]) => u >= 0 && u <= 1 && v >= 0 && v <= 1)
|
|
189
|
+
.map(([u, v]) =>
|
|
190
|
+
picker.getPointDepthAsync(Math.min(W - 1, Math.floor(u * W)), Math.min(H - 1, Math.floor(v * H))).then((lin) => {
|
|
191
|
+
if (lin === null || !(lin >= 0 && lin < 1)) return null;
|
|
192
|
+
const z = zNear + lin * (zFar - zNear);
|
|
193
|
+
const ray = cursorViewRay(view, u, v);
|
|
194
|
+
return { x: ray.origin[0] + ray.direction[0] * z, y: ray.origin[1] + ray.direction[1] * z, z: ray.origin[2] + ray.direction[2] * z };
|
|
195
|
+
}),
|
|
196
|
+
);
|
|
197
|
+
this._inFlight = true;
|
|
198
|
+
const id = ++this._pickId;
|
|
199
|
+
this._pickStart = globalThis.performance ? globalThis.performance.now() : Date.now();
|
|
200
|
+
Promise.all(samples)
|
|
201
|
+
.then((pts) => {
|
|
202
|
+
if (this._disposed || id !== this._pickId) return;
|
|
203
|
+
let best = Infinity;
|
|
204
|
+
let nearest = null;
|
|
205
|
+
for (const p of pts) {
|
|
206
|
+
if (!p) continue;
|
|
207
|
+
const d = p.x * f[0] + p.y * f[1] + p.z * f[2];
|
|
208
|
+
if (d < best) {
|
|
209
|
+
best = d;
|
|
210
|
+
nearest = [p.x, p.y, p.z];
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
this.hit = nearest;
|
|
214
|
+
})
|
|
215
|
+
.catch(() => {
|
|
216
|
+
if (id === this._pickId) this.hit = null;
|
|
217
|
+
})
|
|
218
|
+
.finally(() => {
|
|
219
|
+
if (id === this._pickId) this._inFlight = false;
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** A copy of a column-major transform with unit rotation columns (scale removed). */
|
|
225
|
+
function unscaled(m) {
|
|
226
|
+
const o = Array.from(m);
|
|
227
|
+
for (const c of [0, 4, 8]) {
|
|
228
|
+
const l = Math.hypot(o[c], o[c + 1], o[c + 2]) || 1;
|
|
229
|
+
o[c] /= l;
|
|
230
|
+
o[c + 1] /= l;
|
|
231
|
+
o[c + 2] /= l;
|
|
232
|
+
}
|
|
233
|
+
return o;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** Column-major 4x4 multiply, a × b. */
|
|
237
|
+
function mul4(a, b) {
|
|
238
|
+
const o = new Array(16);
|
|
239
|
+
for (let c = 0; c < 4; c++) {
|
|
240
|
+
for (let r = 0; r < 4; r++) {
|
|
241
|
+
let s = 0;
|
|
242
|
+
for (let k = 0; k < 4; k++) s += a[k * 4 + r] * b[c * 4 + k];
|
|
243
|
+
o[c * 4 + r] = s;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
return o;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** Unit quaternion [x, y, z, w] from a column-major rigid transform's rotation. */
|
|
250
|
+
function quatFromMatrix(m) {
|
|
251
|
+
const m00 = m[0], m11 = m[5], m22 = m[10];
|
|
252
|
+
const tr = m00 + m11 + m22;
|
|
253
|
+
let x, y, z, w;
|
|
254
|
+
if (tr > 0) {
|
|
255
|
+
const s = Math.sqrt(tr + 1) * 2;
|
|
256
|
+
w = 0.25 * s;
|
|
257
|
+
x = (m[6] - m[9]) / s;
|
|
258
|
+
y = (m[8] - m[2]) / s;
|
|
259
|
+
z = (m[1] - m[4]) / s;
|
|
260
|
+
} else if (m00 > m11 && m00 > m22) {
|
|
261
|
+
const s = Math.sqrt(1 + m00 - m11 - m22) * 2;
|
|
262
|
+
w = (m[6] - m[9]) / s;
|
|
263
|
+
x = 0.25 * s;
|
|
264
|
+
y = (m[4] + m[1]) / s;
|
|
265
|
+
z = (m[8] + m[2]) / s;
|
|
266
|
+
} else if (m11 > m22) {
|
|
267
|
+
const s = Math.sqrt(1 + m11 - m00 - m22) * 2;
|
|
268
|
+
w = (m[8] - m[2]) / s;
|
|
269
|
+
x = (m[4] + m[1]) / s;
|
|
270
|
+
y = 0.25 * s;
|
|
271
|
+
z = (m[9] + m[6]) / s;
|
|
272
|
+
} else {
|
|
273
|
+
const s = Math.sqrt(1 + m22 - m00 - m11) * 2;
|
|
274
|
+
w = (m[1] - m[4]) / s;
|
|
275
|
+
x = (m[8] + m[2]) / s;
|
|
276
|
+
y = (m[9] + m[6]) / s;
|
|
277
|
+
z = 0.25 * s;
|
|
278
|
+
}
|
|
279
|
+
const l = Math.hypot(x, y, z, w) || 1;
|
|
280
|
+
return [x / l, y / l, z / l, w / l];
|
|
281
|
+
}
|