@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
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
// inline3d-cursor-depth.js — the depth-aware cursor's placement maths, dependency-free.
|
|
2
|
+
//
|
|
3
|
+
// The OS cursor is flat: it is drawn at zero disparity, on the glass. Hover it over content that
|
|
4
|
+
// pops OUT of the glass and it is drawn on top of pixels whose disparity says they are in front
|
|
5
|
+
// of it — a depth violation, and it reads as a broken image. The fix is a cursor SPRITE drawn at
|
|
6
|
+
// the depth of the nearest content under it.
|
|
7
|
+
//
|
|
8
|
+
// This is a line-for-line port of the runtime's `u_cursor_depth` (displayxr-runtime ADR-046,
|
|
9
|
+
// extension XR_DXR_cursor_depth), so a web page and a native app place their cursors
|
|
10
|
+
// identically. The test file pins the same numbers as the runtime's C tests.
|
|
11
|
+
//
|
|
12
|
+
// Division of labour, as in the runtime: the PAGE knows its content, so it supplies the nearest
|
|
13
|
+
// content point under the cursor (a raycast, a depth readback, a depth map); this module decides
|
|
14
|
+
// where the sprite goes, how big it is, and how it moves over time. ./three's `DepthCursor` wires
|
|
15
|
+
// both for a three.js scene. Nothing here runs unless a page calls it — opt-in, zero cost
|
|
16
|
+
// otherwise.
|
|
17
|
+
//
|
|
18
|
+
// The geometry is solved from the VIEWS the page is about to render (projection + transform
|
|
19
|
+
// matrices, column-major, as an XRView carries them), never from rig internals, so it is
|
|
20
|
+
// correct for display rigs, camera rigs, any scale, and any view count >= 2:
|
|
21
|
+
//
|
|
22
|
+
// - S, the canvas point under the cursor, is where the two outermost view rays through (u, v)
|
|
23
|
+
// meet (every off-axis frustum frames the same canvas);
|
|
24
|
+
// - E, the cyclopean eye, is the midpoint of those two views;
|
|
25
|
+
// - a point's depth is t = its distance in front of E along the display normal over S's
|
|
26
|
+
// (t = 1 on the canvas, t < 1 in front of it);
|
|
27
|
+
// - the policy works in DISPARITY d = 1 - 1/t, in eye-baseline units (on-screen disparity =
|
|
28
|
+
// baseline × d): 0 on the canvas, < 0 in front. The eye compares disparities, so the margin,
|
|
29
|
+
// the clamp and the slew rates live there;
|
|
30
|
+
// - the sprite goes at C = E + t (S - E) — on the cyclopean ray, so it never slides sideways
|
|
31
|
+
// as it rises — with its height scaled by t so its apparent size is constant.
|
|
32
|
+
|
|
33
|
+
/** Default sprite height, as a fraction of the canvas height. */
|
|
34
|
+
export const CURSOR_DEFAULT_HEIGHT = 0.03;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The runtime's defaults (u_cursor_depth_tuning_defaults), in the same units.
|
|
38
|
+
* Disparities are in eye-baseline units; times in seconds.
|
|
39
|
+
*/
|
|
40
|
+
export const CURSOR_DEFAULT_TUNING = Object.freeze({
|
|
41
|
+
/** How far in front of the content the cursor floats (~2 mm on screen at 65 mm IPD). */
|
|
42
|
+
margin: 0.03,
|
|
43
|
+
/** Comfort clamp: t >= 0.625, never more than ~3/8 of the way to the eye. */
|
|
44
|
+
minDisparity: -0.6,
|
|
45
|
+
maxDisparity: 0.6,
|
|
46
|
+
/** Rising toward the viewer is fast — never lag behind content that comes forward. */
|
|
47
|
+
riseTau: 0.03,
|
|
48
|
+
/** Sinking away is slow — no flicker as the footprint crosses an edge. */
|
|
49
|
+
sinkTau: 0.25,
|
|
50
|
+
/** A gap longer than this re-primes (snaps) the filter. */
|
|
51
|
+
stale: 0.5,
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
const sub = (a, b) => [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
|
|
55
|
+
const dot = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
|
|
56
|
+
const addScaled = (a, b, s) => [a[0] + b[0] * s, a[1] + b[1] * s, a[2] + b[2] * s];
|
|
57
|
+
const finite3 = (a) => Number.isFinite(a[0]) && Number.isFinite(a[1]) && Number.isFinite(a[2]);
|
|
58
|
+
const rotate = (m, v) => [
|
|
59
|
+
m[0] * v[0] + m[4] * v[1] + m[8] * v[2],
|
|
60
|
+
m[1] * v[0] + m[5] * v[1] + m[9] * v[2],
|
|
61
|
+
m[2] * v[0] + m[6] * v[1] + m[10] * v[2],
|
|
62
|
+
];
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The ray from a view through canvas point (u, v) — origin and (unnormalised) direction in the
|
|
66
|
+
* space of the view's transform. (u, v) is canvas-normalised: [0,1]², origin top-left, v down.
|
|
67
|
+
*
|
|
68
|
+
* Use this for the page's own hit test, so the rays it casts are exactly the rays the placement
|
|
69
|
+
* is solved from.
|
|
70
|
+
*
|
|
71
|
+
* @param {{projectionMatrix: ArrayLike<number>, transformMatrix: ArrayLike<number>}} view
|
|
72
|
+
* @param {number} u
|
|
73
|
+
* @param {number} v
|
|
74
|
+
* @returns {{origin: number[], direction: number[]}}
|
|
75
|
+
*/
|
|
76
|
+
export function cursorViewRay(view, u, v) {
|
|
77
|
+
const p = view.projectionMatrix;
|
|
78
|
+
const m = view.transformMatrix;
|
|
79
|
+
// Any perspective projection maps view-space (X, Y, Z) to NDC x = (a X + c Z) / -Z, so the
|
|
80
|
+
// view-space direction through NDC (x, y) is ((x + c) / a, (y + d) / b, -1).
|
|
81
|
+
const x = 2 * u - 1;
|
|
82
|
+
const y = 1 - 2 * v;
|
|
83
|
+
const d = [(x + p[8]) / p[0], (y + p[9]) / p[5], -1];
|
|
84
|
+
return { origin: [m[12], m[13], m[14]], direction: rotate(m, d) };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Solve the cursor's line of sight from two views (normally the first and the last).
|
|
89
|
+
* @returns {object|null} the geometry, or null when degenerate — coincident views (2D), parallel
|
|
90
|
+
* rays, (u, v) off the canvas, non-finite input.
|
|
91
|
+
*/
|
|
92
|
+
export function solveCursorGeometry(viewA, viewB, u, v) {
|
|
93
|
+
if (!(u >= 0 && u <= 1 && v >= 0 && v <= 1)) return null;
|
|
94
|
+
const ra = cursorViewRay(viewA, u, v);
|
|
95
|
+
const rb = cursorViewRay(viewB, u, v);
|
|
96
|
+
const p1 = ra.origin, d1 = ra.direction, p2 = rb.origin, d2 = rb.direction;
|
|
97
|
+
if (!finite3(p1) || !finite3(p2) || !finite3(d1) || !finite3(d2)) return null;
|
|
98
|
+
|
|
99
|
+
// Closest points of p1 + s d1 and p2 + r d2; the midpoint absorbs float noise.
|
|
100
|
+
const w0 = sub(p1, p2);
|
|
101
|
+
const aa = dot(d1, d1), bb = dot(d1, d2), cc = dot(d2, d2);
|
|
102
|
+
const dd = dot(d1, w0), ee = dot(d2, w0);
|
|
103
|
+
const den = aa * cc - bb * bb;
|
|
104
|
+
if (dot(w0, w0) <= 0 || !(den > 1e-12 * aa * cc)) return null;
|
|
105
|
+
const s = (bb * ee - cc * dd) / den;
|
|
106
|
+
const r = (aa * ee - bb * dd) / den;
|
|
107
|
+
if (!(s > 0 && r > 0)) return null;
|
|
108
|
+
const q1 = addScaled(p1, d1, s), q2 = addScaled(p2, d2, r);
|
|
109
|
+
const canvasPoint = [(q1[0] + q2[0]) / 2, (q1[1] + q2[1]) / 2, (q1[2] + q2[2]) / 2];
|
|
110
|
+
const eye = [(p1[0] + p2[0]) / 2, (p1[1] + p2[1]) / 2, (p1[2] + p2[2]) / 2];
|
|
111
|
+
|
|
112
|
+
const ma = viewA.transformMatrix;
|
|
113
|
+
const fwd = rotate(ma, [0, 0, -1]);
|
|
114
|
+
const fl = Math.hypot(fwd[0], fwd[1], fwd[2]);
|
|
115
|
+
const forward = [fwd[0] / fl, fwd[1] / fl, fwd[2] / fl];
|
|
116
|
+
const eyeToCanvas = dot(sub(canvasPoint, eye), forward);
|
|
117
|
+
// Canvas height from view A's frustum: tan(up) - tan(down) = 2 / b.
|
|
118
|
+
const canvasHeight = dot(sub(canvasPoint, p1), forward) * (2 / viewA.projectionMatrix[5]);
|
|
119
|
+
if (!(eyeToCanvas > 0) || !(canvasHeight > 0) || !Number.isFinite(canvasHeight)) return null;
|
|
120
|
+
|
|
121
|
+
return { eye, canvasPoint, forward, eyeToCanvas, canvasHeight, basis: orthonormalBasis(ma) };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** The view's rotation as unit X/Y/Z columns (a transform with parent scale still works). */
|
|
125
|
+
function orthonormalBasis(m) {
|
|
126
|
+
const col = (i) => {
|
|
127
|
+
const c = [m[i], m[i + 1], m[i + 2]];
|
|
128
|
+
const l = Math.hypot(c[0], c[1], c[2]) || 1;
|
|
129
|
+
return [c[0] / l, c[1] / l, c[2] / l];
|
|
130
|
+
};
|
|
131
|
+
return { x: col(0), y: col(4), z: col(8) };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Disparity of a point (eye-baseline units; 0 on the canvas, < 0 in front).
|
|
136
|
+
* @returns {number|null} null when the point is not in front of the eye.
|
|
137
|
+
*/
|
|
138
|
+
export function cursorPointDisparity(g, point) {
|
|
139
|
+
if (!finite3(point)) return null;
|
|
140
|
+
const t = dot(sub(point, g.eye), g.forward) / g.eyeToCanvas;
|
|
141
|
+
if (!(t > 0) || !Number.isFinite(t)) return null;
|
|
142
|
+
return 1 - 1 / t;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Target disparity: content minus the margin, clamped; 0 (the canvas) with nothing under it. */
|
|
146
|
+
export function cursorTarget(tuning, hasContent, contentDisparity) {
|
|
147
|
+
if (!hasContent || !Number.isFinite(contentDisparity)) return 0;
|
|
148
|
+
return Math.min(tuning.maxDisparity, Math.max(tuning.minDisparity, contentDisparity - tuning.margin));
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Advance the filter toward `target`. `filter` is a plain object, `{}` to start (the first step
|
|
153
|
+
* snaps). A call at a time not after the previous one does not advance it.
|
|
154
|
+
* @param {number} nowSec seconds (e.g. rAF timestamp / 1000).
|
|
155
|
+
*/
|
|
156
|
+
export function cursorFilterStep(filter, tuning, target, nowSec) {
|
|
157
|
+
if (!filter.primed || nowSec < filter.last || nowSec - filter.last > tuning.stale) {
|
|
158
|
+
filter.primed = true;
|
|
159
|
+
filter.disparity = target;
|
|
160
|
+
filter.last = nowSec;
|
|
161
|
+
return target;
|
|
162
|
+
}
|
|
163
|
+
if (nowSec === filter.last) return filter.disparity;
|
|
164
|
+
const dt = nowSec - filter.last;
|
|
165
|
+
const tau = target < filter.disparity ? tuning.riseTau : tuning.sinkTau;
|
|
166
|
+
const alpha = tau > 0 ? 1 - Math.exp(-dt / tau) : 1;
|
|
167
|
+
filter.disparity += alpha * (target - filter.disparity);
|
|
168
|
+
filter.last = nowSec;
|
|
169
|
+
return filter.disparity;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Where to draw a sprite at `disparity`, and how tall, for `heightFraction` of the canvas. */
|
|
173
|
+
export function placeCursor(g, disparity, heightFraction) {
|
|
174
|
+
const t = 1 / (1 - disparity);
|
|
175
|
+
const hf = heightFraction > 0 && Number.isFinite(heightFraction) ? heightFraction : CURSOR_DEFAULT_HEIGHT;
|
|
176
|
+
return { position: addScaled(g.eye, sub(g.canvasPoint, g.eye), t), height: hf * g.canvasHeight * t };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* The whole pipeline with its state: one per cursor.
|
|
181
|
+
*
|
|
182
|
+
* const placer = new CursorDepthPlacer();
|
|
183
|
+
* // per frame, with the views you are about to render:
|
|
184
|
+
* const p = placer.update(views, { u, v, nearestPoint, cursorHeight: 0.03 }, now / 1000);
|
|
185
|
+
* if (p.active) drawSprite(p.position, p.basis, p.height);
|
|
186
|
+
*
|
|
187
|
+
* `views` are `{projectionMatrix, transformMatrix}` (or XRViews — `transform.matrix` is read
|
|
188
|
+
* when `transformMatrix` is absent). `nearestPoint` is the content point under the cursor
|
|
189
|
+
* FOOTPRINT nearest the viewer, in the views' space, or null for "nothing under it".
|
|
190
|
+
*/
|
|
191
|
+
export class CursorDepthPlacer {
|
|
192
|
+
constructor(tuning = CURSOR_DEFAULT_TUNING) {
|
|
193
|
+
this.tuning = tuning;
|
|
194
|
+
this.filter = {};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
update(views, hint, nowSec) {
|
|
198
|
+
const inactive = { active: false, position: null, basis: null, height: 0, disparity: 0, targetDisparity: 0 };
|
|
199
|
+
if (!views || views.length < 2 || !hint) return inactive;
|
|
200
|
+
const a = asView(views[0]);
|
|
201
|
+
const b = asView(views[views.length - 1]);
|
|
202
|
+
const g = solveCursorGeometry(a, b, hint.u, hint.v);
|
|
203
|
+
if (!g) return inactive;
|
|
204
|
+
const content = hint.nearestPoint ? cursorPointDisparity(g, hint.nearestPoint) : null;
|
|
205
|
+
const target = cursorTarget(this.tuning, content !== null, content);
|
|
206
|
+
const disparity = cursorFilterStep(this.filter, this.tuning, target, nowSec);
|
|
207
|
+
const { position, height } = placeCursor(g, disparity, hint.cursorHeight);
|
|
208
|
+
return { active: true, position, basis: g.basis, height, disparity, targetDisparity: target, geometry: g };
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function asView(v) {
|
|
213
|
+
return v.transformMatrix ? v : { projectionMatrix: v.projectionMatrix, transformMatrix: v.transform.matrix };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* The pointer half of a depth cursor, shared by every backend: tracks the pointer over the
|
|
218
|
+
* canvas (canvas-normalised, v down) and hides the CSS cursor exactly while a sprite replaces it.
|
|
219
|
+
* Constructing one adds two listeners; nothing else runs until a backend asks for `uv`.
|
|
220
|
+
*/
|
|
221
|
+
export class CursorPointer {
|
|
222
|
+
/** @param {HTMLElement} canvas */
|
|
223
|
+
constructor(canvas) {
|
|
224
|
+
this.canvas = canvas;
|
|
225
|
+
/** `[u, v]` while the pointer is over the canvas, else null. */
|
|
226
|
+
this.uv = null;
|
|
227
|
+
this._hidden = false;
|
|
228
|
+
this._prev = '';
|
|
229
|
+
this._onMove = (e) => {
|
|
230
|
+
const r = canvas.getBoundingClientRect();
|
|
231
|
+
this.uv = r.width > 0 && r.height > 0 ? [(e.clientX - r.left) / r.width, (e.clientY - r.top) / r.height] : null;
|
|
232
|
+
};
|
|
233
|
+
this._onLeave = () => {
|
|
234
|
+
this.uv = null;
|
|
235
|
+
};
|
|
236
|
+
canvas.addEventListener('pointermove', this._onMove);
|
|
237
|
+
canvas.addEventListener('pointerleave', this._onLeave);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** Set the pointer directly (canvas-normalised, v down), or null — for scripted input. */
|
|
241
|
+
set(u, v) {
|
|
242
|
+
this.uv = u === null || u === undefined ? null : [u, v];
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** The canvas aspect (width / height), for a round footprint. */
|
|
246
|
+
aspect() {
|
|
247
|
+
const r = this.canvas.getBoundingClientRect();
|
|
248
|
+
return r.height > 0 ? r.width / r.height : 1;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** Hide (true) or restore (false) the CSS cursor; idempotent. */
|
|
252
|
+
hideCss(hide) {
|
|
253
|
+
if (hide === this._hidden) return;
|
|
254
|
+
if (hide) {
|
|
255
|
+
this._prev = this.canvas.style.cursor || '';
|
|
256
|
+
this.canvas.style.cursor = 'none';
|
|
257
|
+
} else {
|
|
258
|
+
this.canvas.style.cursor = this._prev;
|
|
259
|
+
}
|
|
260
|
+
this._hidden = hide;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
dispose() {
|
|
264
|
+
this.canvas.removeEventListener('pointermove', this._onMove);
|
|
265
|
+
this.canvas.removeEventListener('pointerleave', this._onLeave);
|
|
266
|
+
this.hideCss(false);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* The footprint ring every backend samples: the hotspot plus 8 points on a circle of radius
|
|
272
|
+
* 0.75 × the sprite height (canvas heights), so the sprite and a margin are covered.
|
|
273
|
+
* @returns {Array<[number, number]>} canvas-normalised points, the hotspot first.
|
|
274
|
+
*/
|
|
275
|
+
export function cursorFootprint(u, v, height, aspect) {
|
|
276
|
+
const r = 0.75 * height;
|
|
277
|
+
const out = [[u, v]];
|
|
278
|
+
for (let k = 0; k < 8; k++) {
|
|
279
|
+
const a = (k * Math.PI) / 4;
|
|
280
|
+
out.push([u + (r * Math.cos(a)) / aspect, v + r * Math.sin(a)]);
|
|
281
|
+
}
|
|
282
|
+
return out;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* The crosshair every backend draws: 8 segments in the sprite's own XY plane, unit = sprite
|
|
287
|
+
* height (a "+" with a gap and a small square at the hotspot), as [x0, y0, x1, y1] tuples.
|
|
288
|
+
*/
|
|
289
|
+
export const CURSOR_CROSSHAIR = Object.freeze([
|
|
290
|
+
[-0.5, 0, -0.15, 0], [0.15, 0, 0.5, 0], [0, -0.5, 0, -0.15], [0, 0.15, 0, 0.5],
|
|
291
|
+
[-0.15, -0.15, 0.15, -0.15], [0.15, -0.15, 0.15, 0.15], [0.15, 0.15, -0.15, 0.15], [-0.15, 0.15, -0.15, -0.15],
|
|
292
|
+
]);
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* The crosshair's segment endpoints in the views' space for a placement, as a flat
|
|
296
|
+
* [x, y, z, x, y, z, …] array (16 points) — what a line renderer takes.
|
|
297
|
+
*/
|
|
298
|
+
export function cursorCrosshairPoints(placement, out = []) {
|
|
299
|
+
out.length = 0;
|
|
300
|
+
const { position: p, basis: b, height: h } = placement;
|
|
301
|
+
for (const [x0, y0, x1, y1] of CURSOR_CROSSHAIR) {
|
|
302
|
+
for (const [x, y] of [[x0, y0], [x1, y1]]) {
|
|
303
|
+
out.push(
|
|
304
|
+
p[0] + (b.x[0] * x + b.y[0] * y) * h,
|
|
305
|
+
p[1] + (b.x[1] * x + b.y[1] * y) * h,
|
|
306
|
+
p[2] + (b.x[2] * x + b.y[2] * y) * h,
|
|
307
|
+
);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
return out;
|
|
311
|
+
}
|
|
@@ -467,6 +467,7 @@ export function attachPlayCanvasModel(out, wall, canvas, src, opts, pending = []
|
|
|
467
467
|
farClip: opts.farClip,
|
|
468
468
|
sky: false,
|
|
469
469
|
viewerEase: opts.viewerEase,
|
|
470
|
+
cursor: opts.cursor,
|
|
470
471
|
// Each environment's own look: 'room' is three's (untonemapped), the rest the Sample Viewer's.
|
|
471
472
|
toneMapping: envMap ? 'neutral' : ENVIRONMENT_TONE_MAPPING[environment] || 'neutral',
|
|
472
473
|
});
|
package/js/inline3d-model.js
CHANGED
|
@@ -39,7 +39,7 @@
|
|
|
39
39
|
// docs/authoring-inline-3d.md#compressed-gltf.
|
|
40
40
|
|
|
41
41
|
import * as THREE from 'three';
|
|
42
|
-
import { EyeCamera, EdgeFeather } from './inline3d-three.js';
|
|
42
|
+
import { EyeCamera, EdgeFeather, DepthCursor, raycastHitTest } from './inline3d-three.js';
|
|
43
43
|
import { SceneViewer } from './inline3d-viewer.js';
|
|
44
44
|
// Procedural — built in memory, no asset to serve. Imported eagerly rather than lazily because it
|
|
45
45
|
// is the default lighting path, so deferring it would only add a frame of unlit content.
|
|
@@ -382,6 +382,7 @@ export function addModel(wall, canvas, src, opts = {}) {
|
|
|
382
382
|
logTag: '[inline3d/model]',
|
|
383
383
|
viewerEase: opts.viewerEase,
|
|
384
384
|
}).useEyeCamera(EyeCamera, EdgeFeather);
|
|
385
|
+
if (opts.cursor === 'depth') viewer.useDepthCursor(DepthCursor, (content) => raycastHitTest(THREE, [content]));
|
|
385
386
|
|
|
386
387
|
if (envMap) viewer.scene.environment = envMap;
|
|
387
388
|
else if (environment === 'room') addRoomEnvironment(viewer);
|
|
@@ -823,8 +823,14 @@ export class PlayCanvasSplatViewer {
|
|
|
823
823
|
pageCamera = false,
|
|
824
824
|
toneMapping = 'none',
|
|
825
825
|
viewerEase,
|
|
826
|
+
cursor = null,
|
|
826
827
|
} = opts;
|
|
827
828
|
this.canvas = canvas;
|
|
829
|
+
// `cursor: 'depth'` (ADR-046): the depth-aware cursor. OPT-IN AND FREE OTHERWISE — its module
|
|
830
|
+
// is imported only when asked for (attachEngine), and every per-frame hook is an optional call
|
|
831
|
+
// on `cursorDepth`, which stays null without it.
|
|
832
|
+
this._cursorOpt = cursor;
|
|
833
|
+
this.cursorDepth = null;
|
|
828
834
|
// The tracking-acquisition ease (./inline3d-viewer-ease.js): undefined = the session's
|
|
829
835
|
// createInline3D({ viewerEase }) default. Built on the first 3D frame, which knows the session.
|
|
830
836
|
this._viewerEaseOpt = viewerEase;
|
|
@@ -1253,6 +1259,7 @@ export class PlayCanvasSplatViewer {
|
|
|
1253
1259
|
this._monoRaf = requestAnimationFrame(loop);
|
|
1254
1260
|
this._beforeFrame?.(null);
|
|
1255
1261
|
if (this._disposed || this._mode !== 'mono') return; // the callback removed or re-wove us
|
|
1262
|
+
this.cursorDepth?.inactive(); // 2D: the normal cursor
|
|
1256
1263
|
this._tick();
|
|
1257
1264
|
this._drawMono();
|
|
1258
1265
|
};
|
|
@@ -1283,6 +1290,8 @@ export class PlayCanvasSplatViewer {
|
|
|
1283
1290
|
this._live = null;
|
|
1284
1291
|
this._videoPlane?.destroy();
|
|
1285
1292
|
this._videoPlane = null;
|
|
1293
|
+
this.cursorDepth?.dispose();
|
|
1294
|
+
this.cursorDepth = null;
|
|
1286
1295
|
this.layerRigs = null; // its cameras go with the app
|
|
1287
1296
|
try {
|
|
1288
1297
|
this.app?.destroy();
|
|
@@ -1405,6 +1414,13 @@ export class PlayCanvasSplatViewer {
|
|
|
1405
1414
|
if (this.featherPx > 0) this._makeFeather();
|
|
1406
1415
|
this._applyTransform();
|
|
1407
1416
|
app.start();
|
|
1417
|
+
if (this._cursorOpt === 'depth') {
|
|
1418
|
+
import('./inline3d-cursor-depth-playcanvas.js')
|
|
1419
|
+
.then(({ PlayCanvasDepthCursor }) => {
|
|
1420
|
+
if (!this._disposed) this.cursorDepth = new PlayCanvasDepthCursor(pc, this, { canvas: this.canvas });
|
|
1421
|
+
})
|
|
1422
|
+
.catch((err) => console.warn(`${this.logTag || '[inline3d/splat]'} cursor: 'depth' could not load; the normal cursor stays.`, err));
|
|
1423
|
+
}
|
|
1408
1424
|
return app;
|
|
1409
1425
|
}
|
|
1410
1426
|
|
|
@@ -2103,6 +2119,8 @@ export class PlayCanvasSplatViewer {
|
|
|
2103
2119
|
}
|
|
2104
2120
|
// handle.setVideo's plane: size, eye split, and a new frame's upload (./inline3d-splat-video.js).
|
|
2105
2121
|
this._videoPlane?.beforeDraw(entries, rect);
|
|
2122
|
+
// cursor: 'depth' — place the sprite and queue its lines for the tick below (no-op unless opted in).
|
|
2123
|
+
this.cursorDepth?.frame(entries);
|
|
2106
2124
|
app.tick(now());
|
|
2107
2125
|
this._afterTick();
|
|
2108
2126
|
return true;
|
|
@@ -3216,6 +3234,7 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
|
|
|
3216
3234
|
farClip: opts.farClip,
|
|
3217
3235
|
sky: opts.sky,
|
|
3218
3236
|
viewerEase: opts.viewerEase,
|
|
3237
|
+
cursor: opts.cursor,
|
|
3219
3238
|
});
|
|
3220
3239
|
|
|
3221
3240
|
let handle = null;
|
package/js/inline3d-splat.js
CHANGED
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
|
|
21
21
|
import * as THREE from 'three';
|
|
22
22
|
import { SparkRenderer, SplatMesh } from '@sparkjsdev/spark';
|
|
23
|
-
import { EyeCamera, EdgeFeather, cameraRigFromCamera } from './inline3d-three.js';
|
|
23
|
+
import { EyeCamera, EdgeFeather, cameraRigFromCamera, DepthCursor, raycastHitTest } from './inline3d-three.js';
|
|
24
24
|
import { SceneViewer, boundsFromPositions } from './inline3d-viewer.js';
|
|
25
25
|
import { readSogCamera } from './inline3d-sog.js';
|
|
26
26
|
import { resolveRevealOption, effectsNotOnSpark } from './inline3d-splat-effects.js';
|
|
@@ -248,6 +248,9 @@ export function addSplat(wall, canvas, src, opts = {}) {
|
|
|
248
248
|
logTag: '[inline3d/splat]',
|
|
249
249
|
viewerEase: opts.viewerEase,
|
|
250
250
|
}).useEyeCamera(EyeCamera, EdgeFeather);
|
|
251
|
+
// A gaussian-splat raycast is ~8 ms a ray on Spark: one ray a frame, cycling the footprint.
|
|
252
|
+
// (engine:'playcanvas' hit-tests with the engine's depth picker instead, which is far cheaper.)
|
|
253
|
+
if (opts.cursor === 'depth') viewer.useDepthCursor(DepthCursor, (content) => raycastHitTest(THREE, [content]), { raysPerFrame: 1 });
|
|
251
254
|
|
|
252
255
|
// Spark renders through the ordinary three.js pipeline, so splats and meshes co-exist and
|
|
253
256
|
// sort against each other — which is what lets a product page mix a captured hero with a
|
package/js/inline3d-three.js
CHANGED
|
@@ -52,6 +52,8 @@
|
|
|
52
52
|
// descriptor and nothing else: no Kooima, no off-axis math, no scale, here or anywhere in this
|
|
53
53
|
// SDK. That stays in the runtime, which is the point of the extension.
|
|
54
54
|
|
|
55
|
+
import { CursorDepthPlacer, CursorPointer, cursorFootprint, cursorViewRay, CURSOR_CROSSHAIR, CURSOR_DEFAULT_HEIGHT } from './inline3d-cursor-depth.js';
|
|
56
|
+
|
|
55
57
|
/**
|
|
56
58
|
* A reusable three.js camera driven directly by an XRView's matrices. Construct once with
|
|
57
59
|
* your THREE namespace and reuse across frames/windows.
|
|
@@ -413,3 +415,202 @@ export class EdgeFeather {
|
|
|
413
415
|
renderer.autoClear = prevAutoClear;
|
|
414
416
|
}
|
|
415
417
|
}
|
|
418
|
+
|
|
419
|
+
// ── Depth-aware cursor ─────────────────────────────────────────────────────────────────────
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* A hit test for {@link DepthCursor} that raycasts three.js objects: the first hit along the
|
|
423
|
+
* ray, as a world point, or null.
|
|
424
|
+
*
|
|
425
|
+
* @param {object} THREE your imported three.js module namespace.
|
|
426
|
+
* @param {object[]} targets the Object3Ds that count as content (searched recursively).
|
|
427
|
+
*/
|
|
428
|
+
export function raycastHitTest(THREE, targets) {
|
|
429
|
+
const raycaster = new THREE.Raycaster();
|
|
430
|
+
const o = new THREE.Vector3();
|
|
431
|
+
const d = new THREE.Vector3();
|
|
432
|
+
return (origin, direction) => {
|
|
433
|
+
o.set(origin[0], origin[1], origin[2]);
|
|
434
|
+
d.set(direction[0], direction[1], direction[2]).normalize();
|
|
435
|
+
raycaster.set(o, d);
|
|
436
|
+
const hit = raycaster.intersectObjects(targets, true)[0];
|
|
437
|
+
return hit ? [hit.point.x, hit.point.y, hit.point.z] : null;
|
|
438
|
+
};
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* A cursor that rises to the depth of the content under it, so it is never drawn behind
|
|
443
|
+
* something that pops out of the glass (a depth violation). The page supplies the content —
|
|
444
|
+
* a hit test — and this does the rest: tracks the pointer over the canvas, hit-tests the
|
|
445
|
+
* cursor FOOTPRINT (not just the hotspot), places a crosshair sprite with the same maths and
|
|
446
|
+
* defaults as the runtime's XR_DXR_cursor_depth (ADR-046), and hides the CSS cursor exactly
|
|
447
|
+
* while the sprite replaces it.
|
|
448
|
+
*
|
|
449
|
+
* Opt-in: nothing runs unless you construct one and call update(). In 2D (one view) or with the
|
|
450
|
+
* pointer off the canvas it is inactive, the sprite is hidden and the CSS cursor is back.
|
|
451
|
+
*
|
|
452
|
+
* const cursor = new DepthCursor(THREE, { canvas, hitTest: raycastHitTest(THREE, [model]) });
|
|
453
|
+
* scene.add(cursor.object);
|
|
454
|
+
* wall.addScene(canvas, (views, layer) => {
|
|
455
|
+
* cursor.update(views); // first, with THIS frame's views
|
|
456
|
+
* // ... render every view as usual; the sprite draws last, depth test off ...
|
|
457
|
+
* });
|
|
458
|
+
*
|
|
459
|
+
* Head motion: the sprite sits on the line from the viewer through the pointer's point on the
|
|
460
|
+
* canvas, so its image on the glass does not move when the head does — it stays under the
|
|
461
|
+
* mouse like the OS cursor, and only its depth follows the content under that line of sight.
|
|
462
|
+
*
|
|
463
|
+
* The attach pattern (eye cameras parented under an app camera): pass `viewSpace` = that
|
|
464
|
+
* parent, so the views' local transforms are carried into world space; keep `cursor.object` at
|
|
465
|
+
* the scene root.
|
|
466
|
+
*/
|
|
467
|
+
export class DepthCursor {
|
|
468
|
+
/**
|
|
469
|
+
* @param {object} THREE your imported three.js module namespace.
|
|
470
|
+
* @param {object} opts
|
|
471
|
+
* @param {HTMLElement} opts.canvas the woven canvas (pointer is tracked over it).
|
|
472
|
+
* @param {(origin:number[], direction:number[]) => number[]|null} opts.hitTest nearest
|
|
473
|
+
* content point along a ray in WORLD space, or null. {@link raycastHitTest} for meshes;
|
|
474
|
+
* for splats return the renderer's expected depth along the ray (never raycast splats).
|
|
475
|
+
* @param {number} [opts.height=0.03] sprite height as a fraction of the canvas height.
|
|
476
|
+
* @param {number} [opts.color=0xffd419]
|
|
477
|
+
* @param {object} [opts.viewSpace] Object3D whose world matrix maps view transforms to world.
|
|
478
|
+
* @param {number} [opts.raysPerFrame=0] 0 = the whole footprint every frame (two eyes × 9
|
|
479
|
+
* points). N > 0 = an EXPENSIVE hit test (e.g. a gaussian-splat raycast, ~8 ms a ray):
|
|
480
|
+
* cast N rays a frame from the first eye, cycling through the footprint, and take the
|
|
481
|
+
* nearest of the most recent full cycle.
|
|
482
|
+
*/
|
|
483
|
+
constructor(THREE, { canvas, hitTest, height = CURSOR_DEFAULT_HEIGHT, color = 0xffd419, viewSpace = null, raysPerFrame = 0 }) {
|
|
484
|
+
this._THREE = THREE;
|
|
485
|
+
this.canvas = canvas;
|
|
486
|
+
this.hitTest = hitTest;
|
|
487
|
+
this.height = height;
|
|
488
|
+
this.viewSpace = viewSpace;
|
|
489
|
+
this.raysPerFrame = raysPerFrame;
|
|
490
|
+
this._ring = []; // amortised mode: the last hit per footprint point (null = a miss)
|
|
491
|
+
this._next = 0;
|
|
492
|
+
this.placer = new CursorDepthPlacer();
|
|
493
|
+
this.pointer = new CursorPointer(canvas);
|
|
494
|
+
/** The last placement (diagnostics): `{active, position, height, disparity, targetDisparity}`. */
|
|
495
|
+
this.placement = { active: false };
|
|
496
|
+
|
|
497
|
+
const pos = [];
|
|
498
|
+
for (const [x0, y0, x1, y1] of CURSOR_CROSSHAIR) pos.push(x0, y0, 0, x1, y1, 0);
|
|
499
|
+
const geom = new THREE.BufferGeometry();
|
|
500
|
+
geom.setAttribute('position', new THREE.Float32BufferAttribute(pos, 3));
|
|
501
|
+
const mat = new THREE.LineBasicMaterial({ color, depthTest: false, depthWrite: false, transparent: true });
|
|
502
|
+
this.object = new THREE.LineSegments(geom, mat);
|
|
503
|
+
this.object.matrixAutoUpdate = false;
|
|
504
|
+
this.object.frustumCulled = false;
|
|
505
|
+
this.object.renderOrder = 1e9; // last: never occluded, it is in front by construction
|
|
506
|
+
this.object.visible = false;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
/** Set the pointer position directly (canvas-normalised, v down), or null — for scripted input. */
|
|
510
|
+
setPointer(u, v) {
|
|
511
|
+
this.pointer.set(u, v);
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/**
|
|
515
|
+
* Place the sprite for this frame. Call with the views you are about to render.
|
|
516
|
+
* @param {ArrayLike<XRView|{projectionMatrix:ArrayLike<number>,transformMatrix:ArrayLike<number>}>} views
|
|
517
|
+
* @param {number} [nowMs=performance.now()]
|
|
518
|
+
*/
|
|
519
|
+
update(views, nowMs = globalThis.performance ? globalThis.performance.now() : Date.now()) {
|
|
520
|
+
const vs = this._worldViews(views);
|
|
521
|
+
const uv = this.pointer.uv;
|
|
522
|
+
let p = { active: false };
|
|
523
|
+
if (uv && vs.length >= 2) {
|
|
524
|
+
const [u, v] = uv;
|
|
525
|
+
const nearestPoint = this._footprintHit(vs, u, v);
|
|
526
|
+
p = this.placer.update(vs, { u, v, nearestPoint, cursorHeight: this.height }, nowMs / 1000);
|
|
527
|
+
}
|
|
528
|
+
this.placement = p;
|
|
529
|
+
this.object.visible = !!p.active;
|
|
530
|
+
if (p.active) {
|
|
531
|
+
const { x, y, z } = p.basis;
|
|
532
|
+
const h = p.height;
|
|
533
|
+
const e = p.position;
|
|
534
|
+
this.object.matrix.fromArray([
|
|
535
|
+
x[0] * h, x[1] * h, x[2] * h, 0,
|
|
536
|
+
y[0] * h, y[1] * h, y[2] * h, 0,
|
|
537
|
+
z[0] * h, z[1] * h, z[2] * h, 0,
|
|
538
|
+
e[0], e[1], e[2], 1,
|
|
539
|
+
]);
|
|
540
|
+
this.object.matrixWorldNeedsUpdate = true;
|
|
541
|
+
}
|
|
542
|
+
this.pointer.hideCss(!!p.active);
|
|
543
|
+
return p;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
/** Remove listeners, give the CSS cursor back, free the sprite's GPU resources. */
|
|
547
|
+
dispose() {
|
|
548
|
+
this.pointer.dispose();
|
|
549
|
+
this.object.visible = false;
|
|
550
|
+
this.object.geometry.dispose();
|
|
551
|
+
this.object.material.dispose();
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
// Nearest content point under the footprint, from the two outer views (or, amortised, from
|
|
555
|
+
// the first eye over the last cycle of footprint points).
|
|
556
|
+
_footprintHit(views, u, v) {
|
|
557
|
+
const a = views[0], b = views[views.length - 1];
|
|
558
|
+
const m = a.transformMatrix;
|
|
559
|
+
const fl = Math.hypot(m[8], m[9], m[10]) || 1;
|
|
560
|
+
const f = [-m[8] / fl, -m[9] / fl, -m[10] / fl];
|
|
561
|
+
const fp = cursorFootprint(u, v, this.height, this.pointer.aspect());
|
|
562
|
+
let candidates;
|
|
563
|
+
if (this.raysPerFrame > 0) {
|
|
564
|
+
const ring = this._ring;
|
|
565
|
+
ring.length = fp.length;
|
|
566
|
+
for (let i = 0; i < this.raysPerFrame; i++) {
|
|
567
|
+
const k = this._next++ % fp.length;
|
|
568
|
+
const ray = cursorViewRay(a, fp[k][0], fp[k][1]);
|
|
569
|
+
ring[k] = this.hitTest(ray.origin, ray.direction);
|
|
570
|
+
}
|
|
571
|
+
candidates = ring;
|
|
572
|
+
} else {
|
|
573
|
+
candidates = [];
|
|
574
|
+
for (const [su, sv] of fp) {
|
|
575
|
+
for (const view of [a, b]) {
|
|
576
|
+
const ray = cursorViewRay(view, su, sv);
|
|
577
|
+
candidates.push(this.hitTest(ray.origin, ray.direction));
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
let best = Infinity, nearest = null;
|
|
582
|
+
for (const p of candidates) {
|
|
583
|
+
if (!p) continue;
|
|
584
|
+
const depth = p[0] * f[0] + p[1] * f[1] + p[2] * f[2]; // smaller = nearer the viewer
|
|
585
|
+
if (depth < best) {
|
|
586
|
+
best = depth;
|
|
587
|
+
nearest = p;
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
return nearest;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
_worldViews(views) {
|
|
594
|
+
const out = [];
|
|
595
|
+
if (!views) return out;
|
|
596
|
+
const parent = this.viewSpace ? this.viewSpace.matrixWorld.elements : null;
|
|
597
|
+
for (const v of views) {
|
|
598
|
+
const t = v.transformMatrix || v.transform.matrix;
|
|
599
|
+
out.push({ projectionMatrix: v.projectionMatrix, transformMatrix: parent ? mul4(parent, t) : t });
|
|
600
|
+
}
|
|
601
|
+
return out;
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
/** Column-major 4x4 multiply, a × b. */
|
|
606
|
+
function mul4(a, b) {
|
|
607
|
+
const o = new Array(16);
|
|
608
|
+
for (let c = 0; c < 4; c++) {
|
|
609
|
+
for (let r = 0; r < 4; r++) {
|
|
610
|
+
let s = 0;
|
|
611
|
+
for (let k = 0; k < 4; k++) s += a[k * 4 + r] * b[c * 4 + k];
|
|
612
|
+
o[c * 4 + r] = s;
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
return o;
|
|
616
|
+
}
|