@displayxr/inline3d 1.43.0 → 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.
@@ -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
- if (drawn) drawPairArrow(g, geo.outW, geo.outH, u, v, disparity, hf * geo.outH);
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]) {