@displayxr/inline3d 1.43.1 → 1.44.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 +63 -0
- package/THIRD_PARTY_NOTICES.md +26 -2
- package/camera.d.ts +3 -0
- package/cursor-depth.d.ts +124 -1
- package/dist/call.js +4 -4
- package/js/camera/pair-cursor.js +74 -5
- package/js/inline3d-cursor-bvh.js +147 -0
- package/js/inline3d-cursor-depth-playcanvas.js +150 -4
- package/js/inline3d-cursor-depth.js +407 -24
- package/js/inline3d-cursor-option.js +121 -6
- package/js/inline3d-cursor-perf.js +199 -0
- package/js/inline3d-model.js +30 -1
- package/js/inline3d-playcanvas-engine.js +1 -0
- package/js/inline3d-three.js +149 -27
- package/js/vendor/three-mesh-bvh.js +3 -0
- package/model.d.ts +1 -1
- package/package.json +6 -1
- package/splat.d.ts +1 -1
- package/three.d.ts +33 -4
package/js/camera/pair-cursor.js
CHANGED
|
@@ -15,7 +15,11 @@
|
|
|
15
15
|
// disparity is past the draw threshold (-0.005 baselines, and >= 0.5 px; cursorShouldDraw), the
|
|
16
16
|
// OS cursor hidden exactly then;
|
|
17
17
|
// - the same filter (fast rise, slow sink) and the same arrow (CURSOR_ARROW, tip = hotspot, the
|
|
18
|
-
// OS arrow's 19 CSS px)
|
|
18
|
+
// OS arrow's 19 CSS px);
|
|
19
|
+
// - the same crosstalk softening (Amendment 2 §B.7, cursorSoften): a lifted arrow is blurred by
|
|
20
|
+
// 0.1 × its ghost offset (here: its disparity, the two halves being the two views) and faded
|
|
21
|
+
// toward 0.5, both zero on the glass. Rasterised into the buffer from the core's 13-tap kernel
|
|
22
|
+
// (cursorSoftArrowRaster) — inside the woven pixels, never a CSS filter or CSS opacity.
|
|
19
23
|
// What is specific to a pair: it has no 3D views, so it works in tile PIXEL disparity — OUTPUT
|
|
20
24
|
// pixels of one eye of the woven buffer, + = crossed (in front of the glass), the sign the rest of
|
|
21
25
|
// camera/ uses — and converts to the core's eye-baseline units through the panel's pixel pitch:
|
|
@@ -30,7 +34,10 @@ import {
|
|
|
30
34
|
cursorDefaultHeight,
|
|
31
35
|
cursorFilterStep,
|
|
32
36
|
cursorShouldDraw,
|
|
37
|
+
cursorSoften,
|
|
38
|
+
cursorSoftArrowRaster,
|
|
33
39
|
cursorTarget,
|
|
40
|
+
resolveCursorSoften,
|
|
34
41
|
CURSOR_ARROW,
|
|
35
42
|
CURSOR_ARROW_CSS_PX,
|
|
36
43
|
CURSOR_DEFAULT_TUNING,
|
|
@@ -251,6 +258,53 @@ export function drawPairArrow(g, outW, outH, u, v, disparityPx, sizePx) {
|
|
|
251
258
|
}
|
|
252
259
|
}
|
|
253
260
|
|
|
261
|
+
// A scratch 2D canvas for the softened arrow (drawImage blends and honours the clip; putImageData
|
|
262
|
+
// would do neither). Null where no canvas can be made — then the crisp arrow is drawn.
|
|
263
|
+
let _scratch = null;
|
|
264
|
+
function scratchCanvas(w, h) {
|
|
265
|
+
if (!_scratch || (typeof OffscreenCanvas === 'function' && !(_scratch instanceof OffscreenCanvas))) {
|
|
266
|
+
try {
|
|
267
|
+
if (typeof OffscreenCanvas === 'function') _scratch = new OffscreenCanvas(1, 1);
|
|
268
|
+
else if (globalThis.document && document.createElement) _scratch = document.createElement('canvas');
|
|
269
|
+
} catch {
|
|
270
|
+
_scratch = null;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
if (!_scratch || typeof _scratch.getContext !== 'function') return null;
|
|
274
|
+
if (_scratch.width < w) _scratch.width = w;
|
|
275
|
+
if (_scratch.height < h) _scratch.height = h;
|
|
276
|
+
return _scratch.getContext('2d');
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* The SOFTENED arrow into both halves (ADR-046 Amendment 2 §B.7): as drawPairArrow — same
|
|
281
|
+
* placement, same tip, same clip to each half — but rasterised from the core's 13-tap blur of the
|
|
282
|
+
* arrow with `radius` (arrow heights) and `alpha` (opacity). Returns false (nothing drawn) when no
|
|
283
|
+
* scratch canvas is available, so the caller can fall back to the crisp arrow.
|
|
284
|
+
*/
|
|
285
|
+
export function drawPairArrowSoft(g, outW, outH, u, v, disparityPx, sizePx, radius, alpha) {
|
|
286
|
+
const cy = v * outH;
|
|
287
|
+
const fill = arrowFill(sizePx);
|
|
288
|
+
const polys = [CURSOR_ARROW, fill];
|
|
289
|
+
const rasters = [0, 1].map((eye) => {
|
|
290
|
+
const cx = eye * outW + u * outW + (eye === 0 ? disparityPx / 2 : -disparityPx / 2);
|
|
291
|
+
return cursorSoftArrowRaster(cx, cy, sizePx, radius, alpha, [1, 1, 1, 1], [0, 0, 0, 1], polys);
|
|
292
|
+
});
|
|
293
|
+
const sg = scratchCanvas(Math.max(rasters[0].width, rasters[1].width), Math.max(rasters[0].height, rasters[1].height));
|
|
294
|
+
if (!sg || typeof sg.putImageData !== 'function' || typeof ImageData !== 'function') return false;
|
|
295
|
+
for (const eye of [0, 1]) {
|
|
296
|
+
const r = rasters[eye];
|
|
297
|
+
sg.putImageData(new ImageData(r.data, r.width, r.height), 0, 0);
|
|
298
|
+
g.save();
|
|
299
|
+
g.beginPath();
|
|
300
|
+
g.rect(eye * outW, 0, outW, outH);
|
|
301
|
+
g.clip();
|
|
302
|
+
g.drawImage(sg.canvas, 0, 0, r.width, r.height, r.x0, r.y0, r.width, r.height);
|
|
303
|
+
g.restore();
|
|
304
|
+
}
|
|
305
|
+
return true;
|
|
306
|
+
}
|
|
307
|
+
|
|
254
308
|
/**
|
|
255
309
|
* One cursor over one woven pair. The owner calls `draw()` after painting the pair each frame
|
|
256
310
|
* (`idle()` on any frame it does not paint a woven pair), and hands it the latest grayscale copy
|
|
@@ -258,8 +312,10 @@ export function drawPairArrow(g, outW, outH, u, v, disparityPx, sizePx) {
|
|
|
258
312
|
*/
|
|
259
313
|
export class PairCursor {
|
|
260
314
|
/** @param {HTMLElement} canvas the tile — the pointer counts over its box */
|
|
261
|
-
constructor(canvas, { height, pointerScope = 'window' } = {}) {
|
|
315
|
+
constructor(canvas, { height, pointerScope = 'window', soften } = {}) {
|
|
262
316
|
this.pointer = new CursorPointer(canvas, { scope: pointerScope });
|
|
317
|
+
/** Crosstalk softening parameters (resolveCursorSoften). */
|
|
318
|
+
this.soften = resolveCursorSoften(soften, '@displayxr/inline3d pair cursor');
|
|
263
319
|
// A fraction of the tile height when given; by default the OS arrow's size (CSS px → tile).
|
|
264
320
|
this.height = height > 0 ? height : null;
|
|
265
321
|
this.filter = {};
|
|
@@ -271,7 +327,7 @@ export class PairCursor {
|
|
|
271
327
|
this.metersPerCssPx = null;
|
|
272
328
|
/** The eye separation (metres) the baseline units convert with: displayMetrics' (a default today). */
|
|
273
329
|
this.eyeSeparationM = DISPLAY_METRICS_DEFAULTS.eyeSeparationM;
|
|
274
|
-
/** The last frame's placement, for diagnostics: `{ contentPx, targetPx, disparityPx, filteredPx, drawn, pxPerBaseline }` (output px, + = in front) or null. */
|
|
330
|
+
/** The last frame's placement, for diagnostics: `{ contentPx, targetPx, disparityPx, filteredPx, drawn, pxPerBaseline, soften }` (output px, + = in front) or null. */
|
|
275
331
|
this.last = null;
|
|
276
332
|
}
|
|
277
333
|
|
|
@@ -308,9 +364,22 @@ export class PairCursor {
|
|
|
308
364
|
const d = cursorFilterStep(this.filter, CURSOR_DEFAULT_TUNING, target, nowSec);
|
|
309
365
|
const drawn = cursorShouldDraw(d, pxPerBaseline);
|
|
310
366
|
const disparity = -d * pxPerBaseline; // output px, + = in front
|
|
311
|
-
|
|
367
|
+
let soften = null;
|
|
368
|
+
if (drawn) {
|
|
369
|
+
// Softening in PANEL px: one output px of an eye spans (tile CSS width × dpr) / outW of them.
|
|
370
|
+
// The ghost offset of a pair IS its disparity (the two halves are the two views).
|
|
371
|
+
const sizePx = hf * geo.outH;
|
|
372
|
+
const dpr = globalThis.devicePixelRatio > 0 ? globalThis.devicePixelRatio : 1;
|
|
373
|
+
const k = r && r.width > 0 ? (r.width * dpr) / geo.outW : 1;
|
|
374
|
+
const spritePx = r && r.height > 0 ? hf * r.height * dpr : sizePx;
|
|
375
|
+
const s = cursorSoften(d, disparity * k, spritePx, this.soften);
|
|
376
|
+
soften = { blurPx: s.blurPx, alpha: s.alpha, ghostPx: Math.abs(disparity * k), spritePx, radius: spritePx > 0 ? s.blurPx / spritePx : 0, soft: s.blurPx > 0 || s.alpha < 1 };
|
|
377
|
+
if (!soften.soft || !drawPairArrowSoft(g, geo.outW, geo.outH, u, v, disparity, sizePx, soften.radius, s.alpha)) {
|
|
378
|
+
drawPairArrow(g, geo.outW, geo.outH, u, v, disparity, sizePx);
|
|
379
|
+
}
|
|
380
|
+
}
|
|
312
381
|
this.pointer.hideCss(drawn); // the OS arrow IS the cursor on the plane
|
|
313
|
-
this.last = { contentPx: this._content, targetPx: -target * pxPerBaseline || 0, disparityPx: drawn ? disparity : 0, filteredPx: disparity || 0, drawn, pxPerBaseline };
|
|
382
|
+
this.last = { contentPx: this._content, targetPx: -target * pxPerBaseline || 0, disparityPx: drawn ? disparity : 0, filteredPx: disparity || 0, drawn, pxPerBaseline, soften };
|
|
314
383
|
return this.last;
|
|
315
384
|
}
|
|
316
385
|
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
// inline3d-cursor-bvh.js — the depth cursor's pick, BVH-accelerated, for three.js scenes.
|
|
2
|
+
//
|
|
3
|
+
// Loaded ONLY when a page opts into `cursor: 'depth'` (./model imports it dynamically on opt-in;
|
|
4
|
+
// ADR-046 §0: zero cost, no download otherwise). It brings the vendored MeshBVH slice of
|
|
5
|
+
// three-mesh-bvh (js/vendor/three-mesh-bvh.js, MIT), which imports the page's own `three`.
|
|
6
|
+
//
|
|
7
|
+
// Same answer as three's Raycaster.intersectObjects(targets, true)[0] — the nearest hit, the same
|
|
8
|
+
// layer test, the same traversal (an object's raycast returning false stops its subtree) — but each
|
|
9
|
+
// plain Mesh is tested against a bounds tree instead of every triangle. Nothing on the page is
|
|
10
|
+
// touched: no prototype patching, no `geometry.boundsTree`, no index reordering (`indirect: true`);
|
|
11
|
+
// the trees live in a WeakMap keyed by geometry.
|
|
12
|
+
//
|
|
13
|
+
// - Built lazily, the first time a ray reaches a mesh's bounding sphere, within a small time
|
|
14
|
+
// budget a frame (one big mesh may exceed it once; it cannot be split). A mesh not built yet is
|
|
15
|
+
// raycast the plain way meanwhile, so the answer never waits for a build.
|
|
16
|
+
// - CPU-animated vertices (the position attribute's version moves): the tree is refitted.
|
|
17
|
+
// A replaced attribute, a changed vertex count or index: rebuilt.
|
|
18
|
+
// - Skinned, morphing, instanced or batched meshes, a partial drawRange, and anything that is not
|
|
19
|
+
// a Mesh (points, lines, sprites, a splat's own raycast): three's own raycast, as before —
|
|
20
|
+
// its answer follows the deformation; a static tree would not.
|
|
21
|
+
|
|
22
|
+
import { MeshBVH } from './vendor/three-mesh-bvh.js';
|
|
23
|
+
|
|
24
|
+
/** Build time allowed per ~frame (ms) before further builds wait for the next one. */
|
|
25
|
+
const BUILD_BUDGET_MS = 4;
|
|
26
|
+
const FRAME_MS = 16;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* A hit test for ./three's DepthCursor over `targets` (searched recursively): the nearest hit along
|
|
30
|
+
* a WORLD ray, as a world point, or null.
|
|
31
|
+
* @param {object} THREE the page's three.js namespace.
|
|
32
|
+
* @param {object[]} targets Object3Ds that count as content.
|
|
33
|
+
* @returns {((origin: number[], direction: number[]) => number[]|null) & {info: () => object}}
|
|
34
|
+
*/
|
|
35
|
+
export function bvhHitTest(THREE, targets, { now = () => (globalThis.performance ? globalThis.performance.now() : Date.now()) } = {}) {
|
|
36
|
+
const raycaster = new THREE.Raycaster();
|
|
37
|
+
raycaster.firstHitOnly = true; // MeshBVH.raycastObject3D: the nearest hit only
|
|
38
|
+
const o = new THREE.Vector3();
|
|
39
|
+
const d = new THREE.Vector3();
|
|
40
|
+
const sphere = new THREE.Sphere();
|
|
41
|
+
const trees = new WeakMap(); // geometry → { bvh, pos, idx, count, posVersion, idxVersion } | false (cannot)
|
|
42
|
+
const hits = [];
|
|
43
|
+
const info = { mode: 'bvh', built: 0, refits: 0, failed: 0, buildMs: 0, plainCasts: 0 };
|
|
44
|
+
let windowStart = -Infinity;
|
|
45
|
+
let spent = 0;
|
|
46
|
+
const best = [0, 0, 0];
|
|
47
|
+
let bestDist = Infinity;
|
|
48
|
+
|
|
49
|
+
function treeFor(mesh) {
|
|
50
|
+
if (mesh.isSkinnedMesh || mesh.isInstancedMesh || mesh.isBatchedMesh) return null;
|
|
51
|
+
const g = mesh.geometry;
|
|
52
|
+
if (!g || !g.attributes) return null;
|
|
53
|
+
const pos = g.attributes.position;
|
|
54
|
+
if (!pos) return null;
|
|
55
|
+
if (g.morphAttributes && g.morphAttributes.position && g.morphAttributes.position.length) return null;
|
|
56
|
+
const idx = g.index;
|
|
57
|
+
const full = idx ? idx.count : pos.count;
|
|
58
|
+
const dr = g.drawRange;
|
|
59
|
+
if (dr && (dr.start > 0 || (dr.count !== Infinity && dr.count < full))) return null;
|
|
60
|
+
let t = trees.get(g);
|
|
61
|
+
if (t === false) return null;
|
|
62
|
+
if (t) {
|
|
63
|
+
if (t.pos !== pos || t.idx !== idx || t.count !== pos.count || (idx && idx.version !== t.idxVersion)) {
|
|
64
|
+
trees.delete(g); // replaced or resized: rebuild below
|
|
65
|
+
t = undefined;
|
|
66
|
+
} else {
|
|
67
|
+
if (pos.version !== t.posVersion) {
|
|
68
|
+
t.bvh.refit(); // vertices animated on the CPU
|
|
69
|
+
t.posVersion = pos.version;
|
|
70
|
+
info.refits++;
|
|
71
|
+
}
|
|
72
|
+
return t;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
const t0 = now();
|
|
76
|
+
if (t0 - windowStart >= FRAME_MS) {
|
|
77
|
+
windowStart = t0;
|
|
78
|
+
spent = 0;
|
|
79
|
+
}
|
|
80
|
+
if (spent >= BUILD_BUDGET_MS) return null; // plain raycast this time; built on a later frame
|
|
81
|
+
try {
|
|
82
|
+
const bvh = new MeshBVH(g, { indirect: true, setBoundingBox: false, verbose: false });
|
|
83
|
+
t = { bvh, pos, idx, count: pos.count, posVersion: pos.version, idxVersion: idx ? idx.version : 0 };
|
|
84
|
+
trees.set(g, t);
|
|
85
|
+
info.built++;
|
|
86
|
+
} catch (err) {
|
|
87
|
+
trees.set(g, false); // e.g. a three.js older than the slice supports: plain raycast for good
|
|
88
|
+
info.failed++;
|
|
89
|
+
t = null;
|
|
90
|
+
}
|
|
91
|
+
const dt = now() - t0;
|
|
92
|
+
spent += dt;
|
|
93
|
+
info.buildMs += dt;
|
|
94
|
+
return t;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function castOne(obj) {
|
|
98
|
+
hits.length = 0;
|
|
99
|
+
let result;
|
|
100
|
+
const t = obj.isMesh ? treeFor(obj) : null;
|
|
101
|
+
if (t) {
|
|
102
|
+
const g = obj.geometry;
|
|
103
|
+
if (g.boundingSphere === null) g.computeBoundingSphere(); // as Mesh.raycast does
|
|
104
|
+
sphere.copy(g.boundingSphere).applyMatrix4(obj.matrixWorld);
|
|
105
|
+
if (!raycaster.ray.intersectsSphere(sphere)) return undefined;
|
|
106
|
+
t.bvh.raycastObject3D(obj, raycaster, hits);
|
|
107
|
+
} else {
|
|
108
|
+
if (obj.isMesh || obj.isPoints || obj.isLine || obj.isSprite) info.plainCasts++; // drawables only, not groups
|
|
109
|
+
result = obj.raycast(raycaster, hits);
|
|
110
|
+
}
|
|
111
|
+
for (let i = 0; i < hits.length; i++) {
|
|
112
|
+
const h = hits[i];
|
|
113
|
+
if (h.distance < bestDist) {
|
|
114
|
+
bestDist = h.distance;
|
|
115
|
+
best[0] = h.point.x;
|
|
116
|
+
best[1] = h.point.y;
|
|
117
|
+
best[2] = h.point.z;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return result;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Raycaster.intersectObject's traversal: the layer test, and `false` from raycast stops the subtree.
|
|
124
|
+
function visit(obj) {
|
|
125
|
+
let propagate = true;
|
|
126
|
+
if (obj.layers.test(raycaster.layers)) {
|
|
127
|
+
if (castOne(obj) === false) propagate = false;
|
|
128
|
+
}
|
|
129
|
+
if (propagate) {
|
|
130
|
+
const c = obj.children;
|
|
131
|
+
for (let i = 0; i < c.length; i++) visit(c[i]);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const hitTest = (origin, direction) => {
|
|
136
|
+
o.set(origin[0], origin[1], origin[2]);
|
|
137
|
+
d.set(direction[0], direction[1], direction[2]).normalize();
|
|
138
|
+
raycaster.set(o, d);
|
|
139
|
+
bestDist = Infinity;
|
|
140
|
+
for (let i = 0; i < targets.length; i++) visit(targets[i]);
|
|
141
|
+
hits.length = 0;
|
|
142
|
+
return bestDist < Infinity ? [best[0], best[1], best[2]] : null;
|
|
143
|
+
};
|
|
144
|
+
/** Diagnostics: `{ mode: 'bvh', built, refits, failed, buildMs, plainCasts }` (cumulative). */
|
|
145
|
+
hitTest.info = () => ({ ...info });
|
|
146
|
+
return hitTest;
|
|
147
|
+
}
|
|
@@ -19,7 +19,9 @@
|
|
|
19
19
|
// glass — as the classic arrow (the shared cursorArrowMesh: dark outline, white fill, tip on the
|
|
20
20
|
// hotspot) in the UI layer with depth test off, so every eye camera draws it last and nothing
|
|
21
21
|
// occludes it; the picker skips the UI layer, so the cursor never picks itself. On the plane
|
|
22
|
-
// nothing is drawn and the CSS cursor is the cursor.
|
|
22
|
+
// nothing is drawn and the CSS cursor is the cursor. A LIFTED arrow is softened against crosstalk
|
|
23
|
+
// (ADR-046 Amendment 2 §B.7): the mesh instance swaps to a quad whose shader blurs and fades the
|
|
24
|
+
// arrow (cursorSoftArrowGLSL); a (0 px, 1.0) frame draws the crisp mesh exactly as before.
|
|
23
25
|
|
|
24
26
|
import {
|
|
25
27
|
CursorDepthPlacer,
|
|
@@ -28,7 +30,13 @@ import {
|
|
|
28
30
|
cursorArrowMesh,
|
|
29
31
|
cursorArrowFootprint,
|
|
30
32
|
cursorDefaultHeight,
|
|
33
|
+
cursorPlacementSoften,
|
|
34
|
+
cursorSoftArrowBox,
|
|
35
|
+
cursorSoftArrowGLSL,
|
|
31
36
|
cursorViewRay,
|
|
37
|
+
resolveCursorSoften,
|
|
38
|
+
CURSOR_FILL_COLOR,
|
|
39
|
+
CURSOR_OUTLINE_COLOR,
|
|
32
40
|
} from './inline3d-cursor-depth.js';
|
|
33
41
|
|
|
34
42
|
/** Pick-buffer width in pixels (height follows the eye's aspect). */
|
|
@@ -42,9 +50,9 @@ export class PlayCanvasDepthCursor {
|
|
|
42
50
|
/**
|
|
43
51
|
* @param {object} pc the PlayCanvas namespace the adapter renders with.
|
|
44
52
|
* @param {object} viewer the PlayCanvasSplatViewer (app, rigNode, eye are read lazily).
|
|
45
|
-
* @param {{canvas: HTMLElement, height?: number, margin?: number, anchor?: string, pointerScope?: string}} opts
|
|
53
|
+
* @param {{canvas: HTMLElement, height?: number, margin?: number, anchor?: string, pointerScope?: string, soften?: string|object}} opts
|
|
46
54
|
*/
|
|
47
|
-
constructor(pc, viewer, { canvas, height, margin, anchor = 'hybrid', pointerScope = 'canvas' }) {
|
|
55
|
+
constructor(pc, viewer, { canvas, height, margin, anchor = 'hybrid', pointerScope = 'canvas', soften }) {
|
|
48
56
|
this.pc = pc;
|
|
49
57
|
this.viewer = viewer;
|
|
50
58
|
/** Sprite height (canvas heights) when set; null = the OS arrow's size, from the canvas's on-page height. */
|
|
@@ -54,6 +62,10 @@ export class PlayCanvasDepthCursor {
|
|
|
54
62
|
this.placer = new CursorDepthPlacer(tuning, { anchor });
|
|
55
63
|
/** The last placement (diagnostics). */
|
|
56
64
|
this.placement = { active: false };
|
|
65
|
+
/** Crosstalk softening parameters (resolveCursorSoften). */
|
|
66
|
+
this.soften = resolveCursorSoften(soften, '@displayxr/inline3d cursor');
|
|
67
|
+
/** The last frame's softening (diagnostics), null while nothing is drawn. */
|
|
68
|
+
this.softening = null;
|
|
57
69
|
/** The last picked world point under the footprint, or null (nothing under it). */
|
|
58
70
|
this.hit = null;
|
|
59
71
|
this._picker = null;
|
|
@@ -99,9 +111,12 @@ export class PlayCanvasDepthCursor {
|
|
|
99
111
|
this.placement = p;
|
|
100
112
|
this.pointer.hideCss(!!p.active);
|
|
101
113
|
const sprite = this._sprite || (p.active ? this._makeSprite(app) : null);
|
|
114
|
+
this.softening = null;
|
|
102
115
|
if (!sprite) return;
|
|
103
116
|
sprite.mi.visible = !!p.active;
|
|
104
117
|
if (!p.active) return;
|
|
118
|
+
const [pw, ph] = this.pointer.panelSize();
|
|
119
|
+
this._useSoft(app, sprite, (this.softening = cursorPlacementSoften(p, views, pw, ph, h, this.soften)));
|
|
105
120
|
// The unit arrow in the sprite's XY plane, tip at its origin: basis (rotation), height (scale), position = the hotspot.
|
|
106
121
|
const { x, y, z } = p.basis;
|
|
107
122
|
const q = quatFromMatrix([x[0], x[1], x[2], 0, y[0], y[1], y[2], 0, z[0], z[1], z[2], 0, 0, 0, 0, 1]);
|
|
@@ -113,6 +128,7 @@ export class PlayCanvasDepthCursor {
|
|
|
113
128
|
/** Mono / no pointer / no views: no sprite, the CSS cursor back. */
|
|
114
129
|
inactive() {
|
|
115
130
|
if (this.placement.active) this.placement = { active: false };
|
|
131
|
+
this.softening = null;
|
|
116
132
|
this.placer.update(null); // forget the hybrid anchor
|
|
117
133
|
if (this._sprite) this._sprite.mi.visible = false;
|
|
118
134
|
this.pointer.hideCss(false);
|
|
@@ -161,10 +177,74 @@ export class PlayCanvasDepthCursor {
|
|
|
161
177
|
mi.pick = false;
|
|
162
178
|
mi.drawOrder = 1e9;
|
|
163
179
|
app.scene.layers.getLayerById(pc.LAYERID_UI).addMeshInstances([mi]);
|
|
164
|
-
this._sprite = { mi, node, mat };
|
|
180
|
+
this._sprite = { mi, node, mat, mesh, soft: null };
|
|
165
181
|
return this._sprite;
|
|
166
182
|
}
|
|
167
183
|
|
|
184
|
+
// Crisp: the arrow mesh and material as built above. Softened: a quad over the padded arrow box,
|
|
185
|
+
// its shader the runtime's 13-tap blur of the arrow, times the opacity.
|
|
186
|
+
_useSoft(app, sprite, s) {
|
|
187
|
+
const mi = sprite.mi;
|
|
188
|
+
if (!s.soft) {
|
|
189
|
+
if (mi.mesh !== sprite.mesh) mi.mesh = sprite.mesh;
|
|
190
|
+
if (mi.material !== sprite.mat) mi.material = sprite.mat;
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
const soft = sprite.soft || (sprite.soft = this._makeSoft(app));
|
|
194
|
+
soft.mat.setParameter('inline3dCursorBox', cursorSoftArrowBox(s.radius, s.aa));
|
|
195
|
+
soft.mat.setParameter('inline3dCursorSoft', [s.radius, s.alpha, s.aa, 0]);
|
|
196
|
+
if (mi.mesh !== soft.mesh) mi.mesh = soft.mesh;
|
|
197
|
+
if (mi.material !== soft.mat) mi.material = soft.mat;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
_makeSoft(app) {
|
|
201
|
+
const pc = this.pc;
|
|
202
|
+
const mesh = new pc.Mesh(app.graphicsDevice);
|
|
203
|
+
// A unit quad; the vertex shader spreads it over the padded arrow box (arrow units, y down).
|
|
204
|
+
mesh.setPositions(new Float32Array([0, 0, 0, 1, 0, 0, 0, 1, 0, 1, 0, 0, 1, 1, 0, 0, 1, 0]));
|
|
205
|
+
mesh.update();
|
|
206
|
+
const mat = new pc.ShaderMaterial({
|
|
207
|
+
uniqueName: 'inline3dDepthCursorSoft',
|
|
208
|
+
attributes: { vertex_position: pc.SEMANTIC_POSITION },
|
|
209
|
+
vertexGLSL: `
|
|
210
|
+
attribute vec3 vertex_position;
|
|
211
|
+
uniform mat4 matrix_model;
|
|
212
|
+
uniform mat4 matrix_viewProjection;
|
|
213
|
+
uniform vec4 inline3dCursorBox;
|
|
214
|
+
varying vec2 vArrow;
|
|
215
|
+
void main() {
|
|
216
|
+
vArrow = vec2(mix(inline3dCursorBox.x, inline3dCursorBox.z, vertex_position.x), mix(inline3dCursorBox.y, inline3dCursorBox.w, vertex_position.y));
|
|
217
|
+
gl_Position = matrix_viewProjection * matrix_model * vec4(vArrow.x, -vArrow.y, 0.0, 1.0);
|
|
218
|
+
}`,
|
|
219
|
+
fragmentGLSL: `
|
|
220
|
+
uniform vec4 inline3dCursorSoft; // radius, opacity, aa (arrow units)
|
|
221
|
+
uniform vec3 inline3dCursorFill;
|
|
222
|
+
uniform vec3 inline3dCursorOutline;
|
|
223
|
+
varying vec2 vArrow;
|
|
224
|
+
${cursorSoftArrowGLSL()}
|
|
225
|
+
void main() {
|
|
226
|
+
vec4 c = inline3dSoftArrow(vArrow, inline3dCursorSoft.x, inline3dCursorSoft.z, inline3dCursorFill, inline3dCursorOutline);
|
|
227
|
+
gl_FragColor = vec4(c.rgb, c.a * inline3dCursorSoft.y);
|
|
228
|
+
}`,
|
|
229
|
+
});
|
|
230
|
+
mat.setParameter('inline3dCursorFill', CURSOR_FILL_COLOR.slice(0, 3));
|
|
231
|
+
mat.setParameter('inline3dCursorOutline', CURSOR_OUTLINE_COLOR.slice(0, 3));
|
|
232
|
+
mat.blendState = new pc.BlendState(
|
|
233
|
+
true,
|
|
234
|
+
pc.BLENDEQUATION_ADD,
|
|
235
|
+
pc.BLENDMODE_SRC_ALPHA,
|
|
236
|
+
pc.BLENDMODE_ONE_MINUS_SRC_ALPHA,
|
|
237
|
+
pc.BLENDEQUATION_ADD,
|
|
238
|
+
pc.BLENDMODE_ONE,
|
|
239
|
+
pc.BLENDMODE_ONE_MINUS_SRC_ALPHA,
|
|
240
|
+
);
|
|
241
|
+
mat.depthTest = false;
|
|
242
|
+
mat.depthWrite = false;
|
|
243
|
+
mat.cull = pc.CULLFACE_NONE;
|
|
244
|
+
mat.update();
|
|
245
|
+
return { mesh, mat };
|
|
246
|
+
}
|
|
247
|
+
|
|
168
248
|
dispose() {
|
|
169
249
|
this._disposed = true;
|
|
170
250
|
this.pointer.dispose();
|
|
@@ -172,6 +252,8 @@ export class PlayCanvasDepthCursor {
|
|
|
172
252
|
if (this._sprite) {
|
|
173
253
|
this.viewer.app?.scene?.layers?.getLayerById(this.pc.LAYERID_UI)?.removeMeshInstances?.([this._sprite.mi]);
|
|
174
254
|
this._sprite.node.destroy?.();
|
|
255
|
+
this._sprite.soft?.mat.destroy?.();
|
|
256
|
+
this._sprite.soft?.mesh.destroy?.();
|
|
175
257
|
}
|
|
176
258
|
this._picker?.destroy?.();
|
|
177
259
|
this._pickCam?.destroy?.();
|
|
@@ -222,6 +304,13 @@ export class PlayCanvasDepthCursor {
|
|
|
222
304
|
|
|
223
305
|
const picker = this._picker;
|
|
224
306
|
picker.resize(W, H);
|
|
307
|
+
// The depth picker draws into TWO colour targets (pick id + pick depth). The engine's lit and
|
|
308
|
+
// gsplat shaders write both under DEPTH_PICK_PASS; an app's own pc.ShaderMaterial usually
|
|
309
|
+
// writes only the first, and WebGL rejects every such draw with GL_INVALID_OPERATION ("Active
|
|
310
|
+
// draw buffers with missing fragment shader outputs") — one per mesh per pick, so a page with
|
|
311
|
+
// a custom-shader backdrop logged hundreds a second. Those draws never reached the pick buffer
|
|
312
|
+
// anyway: leave them out of the pass, exactly as if they were not pickable.
|
|
313
|
+
const muted = muteDepthlessPicks(pc, layers, app.graphicsDevice);
|
|
225
314
|
try {
|
|
226
315
|
// Enabled only for the synchronous pick pass: a disabled camera is not in its layers'
|
|
227
316
|
// camera lists, so the picker would render nothing; enabled outside it, the camera would
|
|
@@ -234,6 +323,7 @@ export class PlayCanvasDepthCursor {
|
|
|
234
323
|
return;
|
|
235
324
|
} finally {
|
|
236
325
|
cam.camera.enabled = false;
|
|
326
|
+
for (const mi of muted) mi.pick = true;
|
|
237
327
|
}
|
|
238
328
|
// The picker stores LINEAR depth: (viewDist - near) / (far - near), viewDist = 1/gl_FragCoord.w.
|
|
239
329
|
// Rebuild the point ourselves along the exact off-axis ray (getWorldPointAsync would unproject
|
|
@@ -282,6 +372,62 @@ export class PlayCanvasDepthCursor {
|
|
|
282
372
|
}
|
|
283
373
|
|
|
284
374
|
/** A copy of a column-major transform with unit rotation columns (scale removed). */
|
|
375
|
+
// Does this material's pick shader write the depth-pick output (the second colour target)? Engine
|
|
376
|
+
// materials do; a pc.ShaderMaterial does when its fragment code, with its #includes followed
|
|
377
|
+
// through the material's and the engine's chunk maps, handles DEPTH_PICK_PASS — the engine's own
|
|
378
|
+
// gsplat material is a ShaderMaterial whose whole fragment is `#include "gsplatPS"`.
|
|
379
|
+
const writesPickDepth = new WeakMap();
|
|
380
|
+
const PICK_DEPTH_RE = /DEPTH_PICK_PASS|pcFragColor1/;
|
|
381
|
+
function materialWritesPickDepth(pc, mat, device) {
|
|
382
|
+
if (!mat || !pc.ShaderMaterial || !(mat instanceof pc.ShaderMaterial)) return true;
|
|
383
|
+
let v = writesPickDepth.get(mat);
|
|
384
|
+
if (v !== undefined) return v;
|
|
385
|
+
v = true; // can't tell: leave it in the pass, as before
|
|
386
|
+
try {
|
|
387
|
+
const wgsl = !!device?.isWebGPU;
|
|
388
|
+
const d = mat.shaderDesc || {};
|
|
389
|
+
const root = wgsl ? d.fragmentWGSL : d.fragmentGLSL ?? d.fragmentCode;
|
|
390
|
+
if (typeof root === 'string') {
|
|
391
|
+
const lang = wgsl ? pc.SHADERLANGUAGE_WGSL : pc.SHADERLANGUAGE_GLSL;
|
|
392
|
+
const own = mat.hasShaderChunks ? (wgsl ? mat.shaderChunks.wgsl : mat.shaderChunks.glsl) : null;
|
|
393
|
+
const engine = pc.ShaderChunks?.get ? pc.ShaderChunks.get(device, lang) : null;
|
|
394
|
+
const chunk = (name) => own?.get?.(name) ?? engine?.get?.(name);
|
|
395
|
+
const seen = new Set();
|
|
396
|
+
const scan = (code, depth) => {
|
|
397
|
+
if (PICK_DEPTH_RE.test(code)) return true;
|
|
398
|
+
if (depth > 8) return false;
|
|
399
|
+
for (const [, name] of code.matchAll(/#include\s+"(\w+)"/g)) {
|
|
400
|
+
if (seen.has(name)) continue;
|
|
401
|
+
seen.add(name);
|
|
402
|
+
const c = chunk(name);
|
|
403
|
+
if (typeof c === 'string' && scan(c, depth + 1)) return true;
|
|
404
|
+
}
|
|
405
|
+
return false;
|
|
406
|
+
};
|
|
407
|
+
v = scan(root, 0);
|
|
408
|
+
}
|
|
409
|
+
} catch {
|
|
410
|
+
v = true;
|
|
411
|
+
}
|
|
412
|
+
writesPickDepth.set(mat, v);
|
|
413
|
+
return v;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// Clear `pick` on the layers' mesh instances whose shader cannot write pick depth; returns them so
|
|
417
|
+
// the caller restores the flag right after the (synchronous) pick pass.
|
|
418
|
+
function muteDepthlessPicks(pc, layers, device) {
|
|
419
|
+
const muted = [];
|
|
420
|
+
for (const layer of layers) {
|
|
421
|
+
for (const mi of layer.meshInstances || []) {
|
|
422
|
+
if (mi.pick && !materialWritesPickDepth(pc, mi.material, device)) {
|
|
423
|
+
mi.pick = false;
|
|
424
|
+
muted.push(mi);
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
return muted;
|
|
429
|
+
}
|
|
430
|
+
|
|
285
431
|
function unscaled(m) {
|
|
286
432
|
const o = Array.from(m);
|
|
287
433
|
for (const c of [0, 4, 8]) {
|