@displayxr/inline3d 1.43.1 → 1.45.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.
@@ -19,16 +19,29 @@
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,
26
28
  CursorPointer,
27
29
  CURSOR_DEFAULT_TUNING,
28
- cursorArrowMesh,
29
30
  cursorArrowFootprint,
30
31
  cursorDefaultHeight,
32
+ cursorPlacementSoften,
33
+ cursorArrowMesh,
34
+ cursorSoftArrowBox,
35
+ cursorSoftArrowGLSL,
36
+ CursorShapeSelector,
37
+ cursorShapeTexture,
38
+ cursorSpriteBox,
39
+ cursorSpriteGLSL,
40
+ cursorSpriteRect,
31
41
  cursorViewRay,
42
+ resolveCursorSoften,
43
+ CURSOR_FILL_COLOR,
44
+ CURSOR_OUTLINE_COLOR,
32
45
  } from './inline3d-cursor-depth.js';
33
46
 
34
47
  /** Pick-buffer width in pixels (height follows the eye's aspect). */
@@ -42,9 +55,9 @@ export class PlayCanvasDepthCursor {
42
55
  /**
43
56
  * @param {object} pc the PlayCanvas namespace the adapter renders with.
44
57
  * @param {object} viewer the PlayCanvasSplatViewer (app, rigNode, eye are read lazily).
45
- * @param {{canvas: HTMLElement, height?: number, margin?: number, anchor?: string, pointerScope?: string}} opts
58
+ * @param {{canvas: HTMLElement, height?: number, margin?: number, anchor?: string, pointerScope?: string, soften?: string|object, shape?: string, shapeFor?: Function}} opts
46
59
  */
47
- constructor(pc, viewer, { canvas, height, margin, anchor = 'hybrid', pointerScope = 'canvas' }) {
60
+ constructor(pc, viewer, { canvas, height, margin, anchor = 'hybrid', pointerScope = 'canvas', soften, shape = 'auto', shapeFor = null }) {
48
61
  this.pc = pc;
49
62
  this.viewer = viewer;
50
63
  /** Sprite height (canvas heights) when set; null = the OS arrow's size, from the canvas's on-page height. */
@@ -54,16 +67,29 @@ export class PlayCanvasDepthCursor {
54
67
  this.placer = new CursorDepthPlacer(tuning, { anchor });
55
68
  /** The last placement (diagnostics). */
56
69
  this.placement = { active: false };
70
+ /** Crosstalk softening parameters (resolveCursorSoften). */
71
+ this.soften = resolveCursorSoften(soften, '@displayxr/inline3d cursor');
72
+ /** The last frame's softening (diagnostics), null while nothing is drawn. */
73
+ this.softening = null;
57
74
  /** The last picked world point under the footprint, or null (nothing under it). */
58
75
  this.hit = null;
59
76
  this._picker = null;
60
77
  this._pickCam = null;
78
+ this._picker2 = null; // the display-rig pass (handle.setLayerRig), made on first use
79
+ this._pickCam2 = null;
61
80
  this._inFlight = false;
62
81
  this._pickId = 0;
63
82
  this._pickStart = 0;
64
83
  this._frame = 0;
65
84
  this._disposed = false;
66
85
  this._sprite = null; // { mi, node, mat } — built on first use
86
+ /** Which sprite (CSS mirror / fixed / shapeFor); `.current` = the one drawn last. */
87
+ this.shapes = new CursorShapeSelector(this.pointer, { shape, shapeFor });
88
+ }
89
+
90
+ /** The sprite drawn last: a CURSOR_SHAPES name, 'none' or 'image'. */
91
+ get shape() {
92
+ return this.shapes.current;
67
93
  }
68
94
 
69
95
  /**
@@ -99,9 +125,16 @@ export class PlayCanvasDepthCursor {
99
125
  this.placement = p;
100
126
  this.pointer.hideCss(!!p.active);
101
127
  const sprite = this._sprite || (p.active ? this._makeSprite(app) : null);
128
+ this.softening = null;
102
129
  if (!sprite) return;
103
130
  sprite.mi.visible = !!p.active;
104
131
  if (!p.active) return;
132
+ const [pw, ph] = this.pointer.panelSize();
133
+ const s = (this.softening = cursorPlacementSoften(p, views, pw, ph, h, this.soften));
134
+ const chosen = this.shapes.pick({ u: uv[0], v: uv[1], point: this.hit }, t);
135
+ if (chosen.kind === 'none') sprite.mi.visible = false; // the page hides its cursor: so do we (the OS one stays hidden)
136
+ else if (chosen.kind === 'shape' && chosen.name === 'arrow') this._useSoft(app, sprite, s);
137
+ else this._useSprite(app, sprite, s, chosen);
105
138
  // The unit arrow in the sprite's XY plane, tip at its origin: basis (rotation), height (scale), position = the hotspot.
106
139
  const { x, y, z } = p.basis;
107
140
  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 +146,7 @@ export class PlayCanvasDepthCursor {
113
146
  /** Mono / no pointer / no views: no sprite, the CSS cursor back. */
114
147
  inactive() {
115
148
  if (this.placement.active) this.placement = { active: false };
149
+ this.softening = null;
116
150
  this.placer.update(null); // forget the hybrid anchor
117
151
  if (this._sprite) this._sprite.mi.visible = false;
118
152
  this.pointer.hideCss(false);
@@ -161,28 +195,198 @@ export class PlayCanvasDepthCursor {
161
195
  mi.pick = false;
162
196
  mi.drawOrder = 1e9;
163
197
  app.scene.layers.getLayerById(pc.LAYERID_UI).addMeshInstances([mi]);
164
- this._sprite = { mi, node, mat };
198
+ this._sprite = { mi, node, mat, mesh, soft: null, tex: null, textures: new Map() };
165
199
  return this._sprite;
166
200
  }
167
201
 
202
+ // Crisp: the arrow mesh and material as built above. Softened: a quad over the padded arrow box,
203
+ // its shader the runtime's 13-tap blur of the arrow, times the opacity.
204
+ _useSoft(app, sprite, s) {
205
+ const mi = sprite.mi;
206
+ if (!s.soft) {
207
+ if (mi.mesh !== sprite.mesh) mi.mesh = sprite.mesh;
208
+ if (mi.material !== sprite.mat) mi.material = sprite.mat;
209
+ return;
210
+ }
211
+ const soft = sprite.soft || (sprite.soft = this._makeSoft(app));
212
+ soft.mat.setParameter('inline3dCursorBox', cursorSoftArrowBox(s.radius, s.aa));
213
+ soft.mat.setParameter('inline3dCursorSoft', [s.radius, s.alpha, s.aa, 0]);
214
+ if (mi.mesh !== soft.mesh) mi.mesh = soft.mesh;
215
+ if (mi.material !== soft.mat) mi.material = soft.mat;
216
+ }
217
+
218
+ _makeSoft(app) {
219
+ const pc = this.pc;
220
+ const mesh = new pc.Mesh(app.graphicsDevice);
221
+ // A unit quad; the vertex shader spreads it over the padded arrow box (arrow units, y down).
222
+ mesh.setPositions(new Float32Array([0, 0, 0, 1, 0, 0, 0, 1, 0, 1, 0, 0, 1, 1, 0, 0, 1, 0]));
223
+ mesh.update();
224
+ const mat = new pc.ShaderMaterial({
225
+ uniqueName: 'inline3dDepthCursorSoft',
226
+ attributes: { vertex_position: pc.SEMANTIC_POSITION },
227
+ vertexGLSL: `
228
+ attribute vec3 vertex_position;
229
+ uniform mat4 matrix_model;
230
+ uniform mat4 matrix_viewProjection;
231
+ uniform vec4 inline3dCursorBox;
232
+ varying vec2 vArrow;
233
+ void main() {
234
+ vArrow = vec2(mix(inline3dCursorBox.x, inline3dCursorBox.z, vertex_position.x), mix(inline3dCursorBox.y, inline3dCursorBox.w, vertex_position.y));
235
+ gl_Position = matrix_viewProjection * matrix_model * vec4(vArrow.x, -vArrow.y, 0.0, 1.0);
236
+ }`,
237
+ fragmentGLSL: `
238
+ uniform vec4 inline3dCursorSoft; // radius, opacity, aa (arrow units)
239
+ uniform vec3 inline3dCursorFill;
240
+ uniform vec3 inline3dCursorOutline;
241
+ varying vec2 vArrow;
242
+ ${cursorSoftArrowGLSL()}
243
+ void main() {
244
+ vec4 c = inline3dSoftArrow(vArrow, inline3dCursorSoft.x, inline3dCursorSoft.z, inline3dCursorFill, inline3dCursorOutline);
245
+ gl_FragColor = vec4(c.rgb, c.a * inline3dCursorSoft.y);
246
+ }`,
247
+ });
248
+ mat.setParameter('inline3dCursorFill', CURSOR_FILL_COLOR.slice(0, 3));
249
+ mat.setParameter('inline3dCursorOutline', CURSOR_OUTLINE_COLOR.slice(0, 3));
250
+ mat.blendState = new pc.BlendState(
251
+ true,
252
+ pc.BLENDEQUATION_ADD,
253
+ pc.BLENDMODE_SRC_ALPHA,
254
+ pc.BLENDMODE_ONE_MINUS_SRC_ALPHA,
255
+ pc.BLENDEQUATION_ADD,
256
+ pc.BLENDMODE_ONE,
257
+ pc.BLENDMODE_ONE_MINUS_SRC_ALPHA,
258
+ );
259
+ mat.depthTest = false;
260
+ mat.depthWrite = false;
261
+ mat.cull = pc.CULLFACE_NONE;
262
+ mat.update();
263
+ return { mesh, mat };
264
+ }
265
+
266
+ // Every sprite but the arrow: a texture (a keyword shape rasterised once, or a url() image) on
267
+ // one soft quad — the same 13-tap blur and opacity, crisp at radius 0. One program for all.
268
+ _useSprite(app, sprite, s, chosen) {
269
+ const mi = sprite.mi;
270
+ const q = sprite.tex || (sprite.tex = this._makeTex(app));
271
+ const radius = s.soft ? s.radius : 0;
272
+ q.mat.setParameter('inline3dCursorTex', this._texture(app, sprite, chosen));
273
+ q.mat.setParameter('inline3dCursorBox', cursorSpriteBox(chosen, radius, s.aa));
274
+ q.mat.setParameter('inline3dCursorRect', cursorSpriteRect(chosen));
275
+ q.mat.setParameter('inline3dCursorSoft', [radius, s.soft ? s.alpha : 1, 0, 0]);
276
+ if (mi.mesh !== q.mesh) mi.mesh = q.mesh;
277
+ if (mi.material !== q.mat) mi.material = q.mat;
278
+ }
279
+
280
+ _texture(app, sprite, chosen) {
281
+ let t = sprite.textures.get(chosen.key);
282
+ if (t) return t;
283
+ const pc = this.pc;
284
+ const opts = {
285
+ name: `inline3d-cursor-${chosen.kind}`,
286
+ format: pc.PIXELFORMAT_RGBA8,
287
+ mipmaps: false,
288
+ minFilter: pc.FILTER_LINEAR,
289
+ magFilter: pc.FILTER_LINEAR,
290
+ addressU: pc.ADDRESS_CLAMP_TO_EDGE,
291
+ addressV: pc.ADDRESS_CLAMP_TO_EDGE,
292
+ flipY: false, // row 0 = the sprite's top, as the shader reads it
293
+ };
294
+ if (chosen.kind === 'image') {
295
+ const img = chosen.entry.image;
296
+ t = new pc.Texture(app.graphicsDevice, { ...opts, width: img.naturalWidth || img.width, height: img.naturalHeight || img.height });
297
+ t.setSource(img);
298
+ } else {
299
+ const d = cursorShapeTexture(chosen.name);
300
+ t = new pc.Texture(app.graphicsDevice, { ...opts, width: d.width, height: d.height });
301
+ t.lock().set(d.data);
302
+ t.unlock();
303
+ }
304
+ if (sprite.textures.size > 32) {
305
+ for (const old of sprite.textures.values()) old.destroy?.();
306
+ sprite.textures.clear();
307
+ }
308
+ sprite.textures.set(chosen.key, t);
309
+ return t;
310
+ }
311
+
312
+ _makeTex(app) {
313
+ const pc = this.pc;
314
+ const mesh = new pc.Mesh(app.graphicsDevice);
315
+ mesh.setPositions(new Float32Array([0, 0, 0, 1, 0, 0, 0, 1, 0, 1, 0, 0, 1, 1, 0, 0, 1, 0]));
316
+ mesh.update();
317
+ const mat = new pc.ShaderMaterial({
318
+ uniqueName: 'inline3dDepthCursorSprite',
319
+ attributes: { vertex_position: pc.SEMANTIC_POSITION },
320
+ vertexGLSL: `
321
+ attribute vec3 vertex_position;
322
+ uniform mat4 matrix_model;
323
+ uniform mat4 matrix_viewProjection;
324
+ uniform vec4 inline3dCursorBox;
325
+ varying vec2 vSprite;
326
+ void main() {
327
+ vSprite = vec2(mix(inline3dCursorBox.x, inline3dCursorBox.z, vertex_position.x), mix(inline3dCursorBox.y, inline3dCursorBox.w, vertex_position.y));
328
+ gl_Position = matrix_viewProjection * matrix_model * vec4(vSprite.x, -vSprite.y, 0.0, 1.0);
329
+ }`,
330
+ fragmentGLSL: `
331
+ uniform vec4 inline3dCursorRect; // the area the texture covers (sprite units, y down)
332
+ uniform vec4 inline3dCursorSoft; // radius (sprite units), opacity
333
+ uniform sampler2D inline3dCursorTex;
334
+ varying vec2 vSprite;
335
+ ${cursorSpriteGLSL()}
336
+ void main() {
337
+ vec4 c = inline3dSoftSprite(inline3dCursorTex, vSprite, inline3dCursorRect, inline3dCursorSoft.x);
338
+ gl_FragColor = vec4(c.rgb, c.a * inline3dCursorSoft.y);
339
+ }`,
340
+ });
341
+ mat.blendState = new pc.BlendState(
342
+ true,
343
+ pc.BLENDEQUATION_ADD,
344
+ pc.BLENDMODE_SRC_ALPHA,
345
+ pc.BLENDMODE_ONE_MINUS_SRC_ALPHA,
346
+ pc.BLENDEQUATION_ADD,
347
+ pc.BLENDMODE_ONE,
348
+ pc.BLENDMODE_ONE_MINUS_SRC_ALPHA,
349
+ );
350
+ mat.depthTest = false;
351
+ mat.depthWrite = false;
352
+ mat.cull = pc.CULLFACE_NONE;
353
+ mat.update();
354
+ return { mesh, mat };
355
+ }
356
+
168
357
  dispose() {
169
358
  this._disposed = true;
170
359
  this.pointer.dispose();
360
+ this.shapes.dispose();
171
361
  try {
172
362
  if (this._sprite) {
173
363
  this.viewer.app?.scene?.layers?.getLayerById(this.pc.LAYERID_UI)?.removeMeshInstances?.([this._sprite.mi]);
174
364
  this._sprite.node.destroy?.();
365
+ this._sprite.soft?.mat.destroy?.();
366
+ this._sprite.soft?.mesh.destroy?.();
367
+ this._sprite.tex?.mat.destroy?.();
368
+ this._sprite.tex?.mesh.destroy?.();
369
+ for (const t of this._sprite.textures.values()) t.destroy?.();
175
370
  }
176
371
  this._picker?.destroy?.();
177
372
  this._pickCam?.destroy?.();
373
+ this._picker2?.destroy?.();
374
+ this._pickCam2?.destroy?.();
178
375
  } catch {
179
376
  // the app may already be gone with its device
180
377
  }
181
378
  this._picker = null;
182
379
  this._pickCam = null;
380
+ this._picker2 = null;
381
+ this._pickCam2 = null;
183
382
  }
184
383
 
185
- // Render the pick buffer from the first eye and read the footprint's world points.
384
+ // Render the pick buffer from the first eye and read the footprint's world points. Layers moved
385
+ // to the display rig (handle.setLayerRig) are drawn by the layer rig's own cameras, not the eye:
386
+ // with the rig ROUNDED (a camera rig, gain != 1) they take a second pass through the round
387
+ // views, and each of their hits becomes the world point the PHOTO views see at the same pixels
388
+ // in both eyes, so the placer (which solves against the photo views) puts the arrow where the
389
+ // stage appears. Otherwise they draw through the photo views and simply join the eye pass.
186
390
  _pick(app, rig, e, uv, height) {
187
391
  const pc = this.pc;
188
392
  const aspect = e.width > 0 && e.height > 0 ? e.width / e.height : 1;
@@ -190,51 +394,27 @@ export class PlayCanvasDepthCursor {
190
394
  const H = Math.max(1, Math.round(W / aspect));
191
395
  if (!this._picker) {
192
396
  this._picker = new pc.Picker(app, W, H, true);
193
- const cam = new pc.Entity('inline3d-cursor-pick', app);
194
- // frustumCulling off: this camera never renders on its own (disabled — the picker drives
195
- // it), so nothing keeps its culling frustum current, and a stale one culls everything.
196
- cam.addComponent('camera', { clearColor: new pc.Color(0, 0, 0, 0), frustumCulling: false });
197
- cam.camera.enabled = false;
198
- cam._dxrProj = new Float64Array(16);
199
- cam.camera.calculateProjection = (out) => out.set(cam._dxrProj);
200
- rig.addChild(cam);
201
- this._pickCam = cam;
397
+ this._pickCam = this._makePickCam(app, rig, 'inline3d-cursor-pick');
202
398
  }
203
399
  const cam = this._pickCam;
204
- cam._dxrProj.set(e.proj);
205
400
  // The picker linearises depth with the COMPONENT's near/far, and reconstructs through the
206
401
  // projection: keep the two in agreement (GL-style projection: m10 = -(f+n)/(f-n), m14 = -2fn/(f-n)).
207
402
  const m10 = e.proj[10], m14 = e.proj[14];
208
403
  const near = m14 / (m10 - 1);
209
404
  const far = m14 / (m10 + 1);
210
- if (near > 0 && far > near) {
211
- cam.camera.nearClip = near;
212
- cam.camera.farClip = far;
213
- }
214
- if (cam.camera.camera) cam.camera.camera._projMatDirty = true;
215
- const q = quatFromMatrix(e.pose);
216
- cam.setLocalPosition(e.pose[12], e.pose[13], e.pose[14]);
217
- cam.setLocalRotation(q[0], q[1], q[2], q[3]);
405
+ placePickCam(cam, e.proj, e.pose, near, far);
218
406
  const eye = this.viewer.eye?.camera;
219
- const skip = new Set([pc.LAYERID_UI, pc.LAYERID_IMMEDIATE, pc.LAYERID_SKYBOX]);
220
- const ids = (eye && Array.isArray(eye.layers) ? eye.layers : cam.camera.layers).filter((id) => !skip.has(id));
221
- const layers = ids.map((id) => app.scene.layers.getLayerById(id)).filter(Boolean);
407
+ const plan = cursorPickPlan(
408
+ eye && Array.isArray(eye.layers) ? eye.layers : cam.camera.layers,
409
+ this.viewer.layerRigs,
410
+ [pc.LAYERID_UI, pc.LAYERID_IMMEDIATE, pc.LAYERID_SKYBOX],
411
+ );
412
+ const layers = plan.eye.map((id) => app.scene.layers.getLayerById(id)).filter(Boolean);
413
+ setCamLayers(cam, plan.eye);
222
414
 
223
415
  const picker = this._picker;
224
416
  picker.resize(W, H);
225
- try {
226
- // Enabled only for the synchronous pick pass: a disabled camera is not in its layers'
227
- // camera lists, so the picker would render nothing; enabled outside it, the camera would
228
- // render a frame of its own on every app.tick.
229
- cam.camera.enabled = true;
230
- picker.prepare(cam.camera, app.scene, layers);
231
- } catch (err) {
232
- console.warn('[inline3d] cursor: depth: the PlayCanvas picker failed; cursor stays on the glass.', err);
233
- this.hit = null;
234
- return;
235
- } finally {
236
- cam.camera.enabled = false;
237
- }
417
+ if (!this._prepare(picker, cam, app, layers)) return;
238
418
  // The picker stores LINEAR depth: (viewDist - near) / (far - near), viewDist = 1/gl_FragCoord.w.
239
419
  // Rebuild the point ourselves along the exact off-axis ray (getWorldPointAsync would unproject
240
420
  // with the component's symmetric projection, not this one). PlayCanvas's view matrix is built
@@ -244,33 +424,31 @@ export class PlayCanvasDepthCursor {
244
424
  const Mw = view.transformMatrix;
245
425
  const f = [-Mw[8], -Mw[9], -Mw[10]];
246
426
  const zNear = cam.camera.nearClip, zFar = cam.camera.farClip;
247
- const samples = cursorArrowFootprint(uv[0], uv[1], height, this.pointer.aspect())
248
- .filter(([u, v]) => u >= 0 && u <= 1 && v >= 0 && v <= 1)
249
- .map(([u, v]) =>
250
- picker.getPointDepthAsync(Math.min(W - 1, Math.floor(u * W)), Math.min(H - 1, Math.floor(v * H))).then((lin) => {
251
- if (lin === null || !(lin >= 0 && lin < 1)) return null;
252
- const z = zNear + lin * (zFar - zNear);
253
- const ray = cursorViewRay(view, u, v);
254
- 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 };
255
- }),
256
- );
427
+ const footprint = cursorArrowFootprint(uv[0], uv[1], height, this.pointer.aspect()).filter(([u, v]) => u >= 0 && u <= 1 && v >= 0 && v <= 1);
428
+ const read = (pk, u, v, toWorld) =>
429
+ pk.getPointDepthAsync(Math.min(W - 1, Math.floor(u * W)), Math.min(H - 1, Math.floor(v * H))).then((lin) => {
430
+ if (lin === null || !(lin >= 0 && lin < 1)) return null;
431
+ return toWorld(u, v, zNear + lin * (zFar - zNear));
432
+ });
433
+ const samples = footprint.map(([u, v]) =>
434
+ read(picker, u, v, (uu, vv, z) => {
435
+ const ray = cursorViewRay(view, uu, vv);
436
+ 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 };
437
+ }),
438
+ );
439
+ // The display run, rounded: a second pass, only while such a layer exists (zero cost otherwise).
440
+ if (plan.display.length) {
441
+ const lr = this.viewer.layerRigs;
442
+ const pass = this._pickDisplay(app, rig, e, lr, plan.display, near, far, W, H);
443
+ if (pass) for (const [u, v] of footprint) samples.push(read(pass.picker, u, v, pass.toWorld));
444
+ }
257
445
  this._inFlight = true;
258
446
  const id = ++this._pickId;
259
447
  this._pickStart = globalThis.performance ? globalThis.performance.now() : Date.now();
260
448
  Promise.all(samples)
261
449
  .then((pts) => {
262
450
  if (this._disposed || id !== this._pickId) return;
263
- let best = Infinity;
264
- let nearest = null;
265
- for (const p of pts) {
266
- if (!p) continue;
267
- const d = p.x * f[0] + p.y * f[1] + p.z * f[2];
268
- if (d < best) {
269
- best = d;
270
- nearest = [p.x, p.y, p.z];
271
- }
272
- }
273
- this.hit = nearest;
451
+ this.hit = nearestPickPoint(pts, f);
274
452
  })
275
453
  .catch(() => {
276
454
  if (id === this._pickId) this.hit = null;
@@ -279,9 +457,277 @@ export class PlayCanvasDepthCursor {
279
457
  if (id === this._pickId) this._inFlight = false;
280
458
  });
281
459
  }
460
+
461
+ // The pick camera: a child of the rig node, DISABLED except around prepare() (a disabled camera
462
+ // is in no layer's camera list, so the picker would render nothing; an enabled one would render
463
+ // a frame of its own on every app.tick), with a projection override.
464
+ _makePickCam(app, rig, name) {
465
+ const pc = this.pc;
466
+ const cam = new pc.Entity(name, app);
467
+ // frustumCulling off: this camera never renders on its own, so nothing keeps its culling
468
+ // frustum current, and a stale one culls everything.
469
+ cam.addComponent('camera', { clearColor: new pc.Color(0, 0, 0, 0), frustumCulling: false });
470
+ cam.camera.enabled = false;
471
+ cam._dxrProj = new Float64Array(16);
472
+ cam.camera.calculateProjection = (out) => out.set(cam._dxrProj);
473
+ rig.addChild(cam);
474
+ return cam;
475
+ }
476
+
477
+ // One synchronous pick pass. The depth picker draws into TWO colour targets (pick id + pick
478
+ // depth). The engine's lit and gsplat shaders write both under DEPTH_PICK_PASS; an app's own
479
+ // pc.ShaderMaterial usually writes only the first, and WebGL rejects every such draw with
480
+ // GL_INVALID_OPERATION ("Active draw buffers with missing fragment shader outputs") — one per
481
+ // mesh per pick, so a page with a custom-shader backdrop logged hundreds a second. Those draws
482
+ // never reached the pick buffer anyway: leave them out of the pass, exactly as if they were not
483
+ // pickable. (A ShaderMaterial opts in with CURSOR_DEPTH_PICK_GLSL / _WGSL.)
484
+ _prepare(picker, cam, app, layers) {
485
+ const muted = muteDepthlessPicks(this.pc, layers, app.graphicsDevice);
486
+ try {
487
+ cam.camera.enabled = true;
488
+ picker.prepare(cam.camera, app.scene, layers);
489
+ return true;
490
+ } catch (err) {
491
+ console.warn('[inline3d] cursor: depth: the PlayCanvas picker failed; cursor stays on the glass.', err);
492
+ this.hit = null;
493
+ return false;
494
+ } finally {
495
+ cam.camera.enabled = false;
496
+ for (const mi of muted) mi.pick = true;
497
+ }
498
+ }
499
+
500
+ // The display-rig pass: its own picker + camera, drawn with exactly what the layer rig's display
501
+ // camera draws, proj · view_round · rig⁻¹ (PlayCanvas composes RenderViews with the rig node's
502
+ // world transform, scale included). The camera's own view is undone in the projection override,
503
+ // as the layer rig's N-camera path does. Null (no pass) when the rig has no round views this frame.
504
+ _pickDisplay(app, rig, e, lr, ids, near, far, W, H) {
505
+ const round = lr?.lastRound;
506
+ const poses = lr?.lastEntries;
507
+ if (!round || !poses || round.length < 2 || poses.length !== round.length) return null;
508
+ const pc = this.pc;
509
+ if (!this._picker2) {
510
+ this._picker2 = new pc.Picker(app, W, H, true);
511
+ this._pickCam2 = this._makePickCam(app, rig, 'inline3d-cursor-pick-display');
512
+ }
513
+ const cam = this._pickCam2;
514
+ placePickCam(cam, e.proj, e.pose, near, far);
515
+ const R = rig.getWorldTransform().data;
516
+ const Rinv = invert4(R);
517
+ if (!Rinv) return null;
518
+ cam._dxrProj.set(mul4(e.proj, mul4(round[0].view, mul4(Rinv, unscaled(cam.getWorldTransform().data)))));
519
+ if (cam.camera.camera) cam.camera.camera._projMatDirty = true;
520
+ setCamLayers(cam, ids);
521
+ const layers = ids.map((id) => app.scene.layers.getLayerById(id)).filter(Boolean);
522
+ const picker = this._picker2;
523
+ picker.resize(W, H);
524
+ if (!this._prepare(picker, cam, app, layers)) return null;
525
+ const solve = displayRigPickSolver(e.proj, round, poses.map((p) => p.pose), R);
526
+ if (!solve) return null;
527
+ return { picker, toWorld: (u, v, dist) => solve(u, v, dist) };
528
+ }
529
+ }
530
+
531
+ /**
532
+ * Which layers each pick pass draws. `eyeLayers` the eye camera's list; `layerRigs` the viewer's
533
+ * LayerRigCameras (or null); `skip` the ids never picked (UI, immediate, skybox).
534
+ * eye — the eye's layers, plus the layer rig's post run and (when the rig is not rounded this
535
+ * frame) its display run: all drawn through the photo views.
536
+ * display — the display run, when it is rounded: picked through the round views.
537
+ * @returns {{ eye: number[], display: number[] }}
538
+ */
539
+ export function cursorPickPlan(eyeLayers, layerRigs, skip = []) {
540
+ const no = new Set(skip);
541
+ const eye = (eyeLayers || []).filter((id) => !no.has(id));
542
+ const moved = layerRigs && layerRigs.active !== false ? layerRigs.moved : null;
543
+ if (!moved || (!moved.display?.length && !moved.post?.length)) return { eye, display: [] };
544
+ const rounded = !!(layerRigs.lastRound && layerRigs.lastRound.length >= 2);
545
+ const add = (list, ids) => {
546
+ for (const id of ids || []) if (!no.has(id) && !list.includes(id)) list.push(id);
547
+ };
548
+ const display = [];
549
+ if (rounded) add(display, moved.display);
550
+ else add(eye, moved.display);
551
+ add(eye, moved.post);
552
+ return { eye, display: display.filter((id) => !eye.includes(id)) };
553
+ }
554
+
555
+ /**
556
+ * The picked point nearest the viewer: the smallest distance along the eye's forward `f` (world),
557
+ * i.e. the most crossed disparity. `pts` are {x, y, z} or [x, y, z] (null = nothing there).
558
+ * @returns {number[]|null} [x, y, z], or null when nothing was hit.
559
+ */
560
+ export function nearestPickPoint(pts, f) {
561
+ let best = Infinity;
562
+ let nearest = null;
563
+ for (const p of pts || []) {
564
+ if (!p) continue;
565
+ const x = Array.isArray(p) ? p[0] : p.x, y = Array.isArray(p) ? p[1] : p.y, z = Array.isArray(p) ? p[2] : p.z;
566
+ if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(z)) continue;
567
+ const d = x * f[0] + y * f[1] + z * f[2];
568
+ if (d < best) {
569
+ best = d;
570
+ nearest = [x, y, z];
571
+ }
572
+ }
573
+ return nearest;
574
+ }
575
+
576
+ /**
577
+ * Turn a display-rig pick into the WORLD point the PHOTO views show at the same place.
578
+ *
579
+ * The pick was drawn from round eye 0 (`rounds[i].view` maps rig space to round eye i); a sample at
580
+ * canvas (u, v) with view distance `dist` is the round-space point X. Each round eye i shows X at
581
+ * the pixel where the PHOTO eye i (pose `poses[i]`, rig space) sees P_i · view_round_i · X, so the
582
+ * point the photo views put at both eyes' pixels is where the photo rays of the first and last eye
583
+ * through those points meet. Taken ON eye 0's ray (the pixel that was sampled stays exact) at its
584
+ * closest approach to the last eye's (exact whenever the rays meet: eyes level with each other).
585
+ * Mapped to world by the rig node's transform `R`. With no rounding (round view = photo view) it
586
+ * is X itself.
587
+ * @returns {((u: number, v: number, dist: number) => number[]|null)|null}
588
+ */
589
+ export function displayRigPickSolver(proj, rounds, poses, R) {
590
+ const L = rounds.length - 1;
591
+ if (L < 1 || poses.length <= L) return null;
592
+ const T0 = mul4(poses[0], mul4(rounds[0].view, rounds[0].viewInv));
593
+ const TL = mul4(poses[L], mul4(rounds[L].view, rounds[0].viewInv));
594
+ const E0 = [poses[0][12], poses[0][13], poses[0][14]];
595
+ const EL = [poses[L][12], poses[L][13], poses[L][14]];
596
+ return (u, v, dist) => {
597
+ if (!(dist > 0)) return null;
598
+ // Round eye 0's view space: the direction through NDC (x, y) is ((x + c)/a, (y + d)/b, -1).
599
+ const x = 2 * u - 1, y = 1 - 2 * v;
600
+ const Xe = [((x + proj[8]) / proj[0]) * dist, ((y + proj[9]) / proj[5]) * dist, -dist];
601
+ const Q0 = apply(T0, Xe), QL = apply(TL, Xe);
602
+ const P = closestApproach(E0, sub3(Q0, E0), EL, sub3(QL, EL)) || Q0;
603
+ const w = apply(R, P);
604
+ return Number.isFinite(w[0]) && Number.isFinite(w[1]) && Number.isFinite(w[2]) ? w : null;
605
+ };
606
+ }
607
+
608
+ function sub3(a, b) {
609
+ return [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
610
+ }
611
+
612
+ /** Column-major 4x4 × point (w = 1, affine). */
613
+ function apply(m, p) {
614
+ return [0, 1, 2].map((r) => m[r] * p[0] + m[4 + r] * p[1] + m[8 + r] * p[2] + m[12 + r]);
615
+ }
616
+
617
+ /** The point of p1 + s·d1 closest to the line p2 + r·d2; null when (near) parallel. */
618
+ function closestApproach(p1, d1, p2, d2) {
619
+ const dot = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
620
+ const w0 = sub3(p1, p2);
621
+ const aa = dot(d1, d1), bb = dot(d1, d2), cc = dot(d2, d2), dd = dot(d1, w0), ee = dot(d2, w0);
622
+ const den = aa * cc - bb * bb;
623
+ if (!(den > 1e-12 * aa * cc)) return null;
624
+ const s = (bb * ee - cc * dd) / den;
625
+ return [0, 1, 2].map((i) => p1[i] + s * d1[i]);
626
+ }
627
+
628
+ /** Point a pick camera along a view: its projection override, near/far, and rig-space pose. */
629
+ function placePickCam(cam, proj, pose, near, far) {
630
+ cam._dxrProj.set(proj);
631
+ if (near > 0 && far > near) {
632
+ cam.camera.nearClip = near;
633
+ cam.camera.farClip = far;
634
+ }
635
+ if (cam.camera.camera) cam.camera.camera._projMatDirty = true;
636
+ const q = quatFromMatrix(pose);
637
+ cam.setLocalPosition(pose[12], pose[13], pose[14]);
638
+ cam.setLocalRotation(q[0], q[1], q[2], q[3]);
639
+ }
640
+
641
+ /** The picker draws only sublayers whose camera list holds the camera: give it these layers (on change only). */
642
+ function setCamLayers(cam, ids) {
643
+ const key = ids.join(',');
644
+ if (cam._dxrLayersKey === key) return;
645
+ cam._dxrLayersKey = key;
646
+ cam.camera.layers = ids.slice();
647
+ }
648
+
649
+ /** General column-major 4x4 inverse (cofactors); null when singular. */
650
+ function invert4(m) {
651
+ const a = m, o = new Array(16);
652
+ o[0] = a[5]*a[10]*a[15]-a[5]*a[11]*a[14]-a[9]*a[6]*a[15]+a[9]*a[7]*a[14]+a[13]*a[6]*a[11]-a[13]*a[7]*a[10];
653
+ o[4] = -a[4]*a[10]*a[15]+a[4]*a[11]*a[14]+a[8]*a[6]*a[15]-a[8]*a[7]*a[14]-a[12]*a[6]*a[11]+a[12]*a[7]*a[10];
654
+ o[8] = a[4]*a[9]*a[15]-a[4]*a[11]*a[13]-a[8]*a[5]*a[15]+a[8]*a[7]*a[13]+a[12]*a[5]*a[11]-a[12]*a[7]*a[9];
655
+ o[12] = -a[4]*a[9]*a[14]+a[4]*a[10]*a[13]+a[8]*a[5]*a[14]-a[8]*a[6]*a[13]-a[12]*a[5]*a[10]+a[12]*a[6]*a[9];
656
+ o[1] = -a[1]*a[10]*a[15]+a[1]*a[11]*a[14]+a[9]*a[2]*a[15]-a[9]*a[3]*a[14]-a[13]*a[2]*a[11]+a[13]*a[3]*a[10];
657
+ o[5] = a[0]*a[10]*a[15]-a[0]*a[11]*a[14]-a[8]*a[2]*a[15]+a[8]*a[3]*a[14]+a[12]*a[2]*a[11]-a[12]*a[3]*a[10];
658
+ o[9] = -a[0]*a[9]*a[15]+a[0]*a[11]*a[13]+a[8]*a[1]*a[15]-a[8]*a[3]*a[13]-a[12]*a[1]*a[11]+a[12]*a[3]*a[9];
659
+ o[13] = a[0]*a[9]*a[14]-a[0]*a[10]*a[13]-a[8]*a[1]*a[14]+a[8]*a[2]*a[13]+a[12]*a[1]*a[10]-a[12]*a[2]*a[9];
660
+ o[2] = a[1]*a[6]*a[15]-a[1]*a[7]*a[14]-a[5]*a[2]*a[15]+a[5]*a[3]*a[14]+a[13]*a[2]*a[7]-a[13]*a[3]*a[6];
661
+ o[6] = -a[0]*a[6]*a[15]+a[0]*a[7]*a[14]+a[4]*a[2]*a[15]-a[4]*a[3]*a[14]-a[12]*a[2]*a[7]+a[12]*a[3]*a[6];
662
+ o[10] = a[0]*a[5]*a[15]-a[0]*a[7]*a[13]-a[4]*a[1]*a[15]+a[4]*a[3]*a[13]+a[12]*a[1]*a[7]-a[12]*a[3]*a[5];
663
+ o[14] = -a[0]*a[5]*a[14]+a[0]*a[6]*a[13]+a[4]*a[1]*a[14]-a[4]*a[2]*a[13]-a[12]*a[1]*a[6]+a[12]*a[2]*a[5];
664
+ o[3] = -a[1]*a[6]*a[11]+a[1]*a[7]*a[10]+a[5]*a[2]*a[11]-a[5]*a[3]*a[10]-a[9]*a[2]*a[7]+a[9]*a[3]*a[6];
665
+ o[7] = a[0]*a[6]*a[11]-a[0]*a[7]*a[10]-a[4]*a[2]*a[11]+a[4]*a[3]*a[10]+a[8]*a[2]*a[7]-a[8]*a[3]*a[6];
666
+ o[11] = -a[0]*a[5]*a[11]+a[0]*a[7]*a[9]+a[4]*a[1]*a[11]-a[4]*a[3]*a[9]-a[8]*a[1]*a[7]+a[8]*a[3]*a[5];
667
+ o[15] = a[0]*a[5]*a[10]-a[0]*a[6]*a[9]-a[4]*a[1]*a[10]+a[4]*a[2]*a[9]+a[8]*a[1]*a[6]-a[8]*a[2]*a[5];
668
+ const det = a[0]*o[0]+a[1]*o[4]+a[2]*o[8]+a[3]*o[12];
669
+ if (!det || !Number.isFinite(det)) return null;
670
+ for (let i = 0; i < 16; i++) o[i] /= det;
671
+ return o;
282
672
  }
283
673
 
284
674
  /** A copy of a column-major transform with unit rotation columns (scale removed). */
675
+ // Does this material's pick shader write the depth-pick output (the second colour target)? Engine
676
+ // materials do; a pc.ShaderMaterial does when its fragment code, with its #includes followed
677
+ // through the material's and the engine's chunk maps, handles DEPTH_PICK_PASS — the engine's own
678
+ // gsplat material is a ShaderMaterial whose whole fragment is `#include "gsplatPS"`.
679
+ const writesPickDepth = new WeakMap();
680
+ const PICK_DEPTH_RE = /DEPTH_PICK_PASS|pcFragColor1/;
681
+ function materialWritesPickDepth(pc, mat, device) {
682
+ if (!mat || !pc.ShaderMaterial || !(mat instanceof pc.ShaderMaterial)) return true;
683
+ let v = writesPickDepth.get(mat);
684
+ if (v !== undefined) return v;
685
+ v = true; // can't tell: leave it in the pass, as before
686
+ try {
687
+ const wgsl = !!device?.isWebGPU;
688
+ const d = mat.shaderDesc || {};
689
+ const root = wgsl ? d.fragmentWGSL : d.fragmentGLSL ?? d.fragmentCode;
690
+ if (typeof root === 'string') {
691
+ const lang = wgsl ? pc.SHADERLANGUAGE_WGSL : pc.SHADERLANGUAGE_GLSL;
692
+ const own = mat.hasShaderChunks ? (wgsl ? mat.shaderChunks.wgsl : mat.shaderChunks.glsl) : null;
693
+ const engine = pc.ShaderChunks?.get ? pc.ShaderChunks.get(device, lang) : null;
694
+ const chunk = (name) => own?.get?.(name) ?? engine?.get?.(name);
695
+ const seen = new Set();
696
+ const scan = (code, depth) => {
697
+ if (PICK_DEPTH_RE.test(code)) return true;
698
+ if (depth > 8) return false;
699
+ for (const [, name] of code.matchAll(/#include\s+"(\w+)"/g)) {
700
+ if (seen.has(name)) continue;
701
+ seen.add(name);
702
+ const c = chunk(name);
703
+ if (typeof c === 'string' && scan(c, depth + 1)) return true;
704
+ }
705
+ return false;
706
+ };
707
+ v = scan(root, 0);
708
+ }
709
+ } catch {
710
+ v = true;
711
+ }
712
+ writesPickDepth.set(mat, v);
713
+ return v;
714
+ }
715
+
716
+ // Clear `pick` on the layers' mesh instances whose shader cannot write pick depth; returns them so
717
+ // the caller restores the flag right after the (synchronous) pick pass.
718
+ function muteDepthlessPicks(pc, layers, device) {
719
+ const muted = [];
720
+ for (const layer of layers) {
721
+ for (const mi of layer.meshInstances || []) {
722
+ if (mi.pick && !materialWritesPickDepth(pc, mi.material, device)) {
723
+ mi.pick = false;
724
+ muted.push(mi);
725
+ }
726
+ }
727
+ }
728
+ return muted;
729
+ }
730
+
285
731
  function unscaled(m) {
286
732
  const o = Array.from(m);
287
733
  for (const c of [0, 4, 8]) {