@displayxr/inline3d 1.41.0 → 1.42.1
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 +38 -0
- package/cursor-depth.d.ts +3 -3
- package/dist/call.js +4 -4
- package/js/camera/converge.js +6 -2
- package/js/camera/disparity.js +12 -3
- package/js/camera/pair-cursor.js +109 -14
- package/js/inline3d-cursor-depth.js +5 -4
- package/js/inline3d-three.js +2 -2
- package/model.d.ts +1 -1
- package/package.json +1 -1
- package/splat.d.ts +1 -1
package/js/camera/converge.js
CHANGED
|
@@ -7,8 +7,12 @@
|
|
|
7
7
|
/** Low-pass factor for the convergence shift (RFC §3): next = prev + α·(target − prev). */
|
|
8
8
|
export const CONVERGENCE_ALPHA = 0.2;
|
|
9
9
|
|
|
10
|
-
/**
|
|
11
|
-
|
|
10
|
+
/**
|
|
11
|
+
* The convergence shift never exceeds this fraction of the per-eye width, either way. 0.2: a
|
|
12
|
+
* 120 mm-baseline camera (the SR laptop's) sees a face at 0.6 m at ~22% of the eye width, so it
|
|
13
|
+
* needs ~11% per eye — 0.12 left no room to lean in.
|
|
14
|
+
*/
|
|
15
|
+
export const CONVERGENCE_MAX_FRACTION = 0.2;
|
|
12
16
|
|
|
13
17
|
/** `setDepth(v)`: v in [-1, 1] adds v times this fraction of the per-eye width to the shift. */
|
|
14
18
|
export const DEPTH_RANGE_FRACTION = 0.05;
|
package/js/camera/disparity.js
CHANGED
|
@@ -144,7 +144,11 @@ export function matchBlock(img, W, H, x0, y0, bw, bh, { dMin, dMax, dyMax = 2, u
|
|
|
144
144
|
export function blockDisparities(img, W, H, o = {}) {
|
|
145
145
|
const E = W / 2;
|
|
146
146
|
const b = o.block || Math.max(8, Math.round(E / 20));
|
|
147
|
-
|
|
147
|
+
// One block per block width. A sparser grid (1.5 b) left a dim, grayscale SR-camera frame with
|
|
148
|
+
// ~9 matched blocks, 3-4 of them on the face — too few to form a mode — so the background won
|
|
149
|
+
// and auto-convergence flipped between the two. At 1 b: ~36 blocks, the face every frame, and a
|
|
150
|
+
// steadier value (±1 px instead of ±4); ~13 ms per full search at 240 px per eye.
|
|
151
|
+
const step = o.step || b;
|
|
148
152
|
const dMax = o.dMax ?? Math.round(E * 0.3);
|
|
149
153
|
const dMin = o.dMin ?? -Math.round(E * 0.05);
|
|
150
154
|
const x0 = Math.round(E * (o.roiX0 ?? 0.15));
|
|
@@ -165,12 +169,17 @@ export function blockDisparities(img, W, H, o = {}) {
|
|
|
165
169
|
* The subject = the NEAREST strong disparity mode: the largest `d` whose ±`tol` bin holds at least
|
|
166
170
|
* `minFrac` of the matched blocks (and `minBlocks`). Returns its members, or null.
|
|
167
171
|
*/
|
|
168
|
-
export function nearestMode(blocks, { tol = 2, minFrac = 0.12, minBlocks = 3 } = {}) {
|
|
172
|
+
export function nearestMode(blocks, { tol = 2, relTol = 0.1, minFrac = 0.12, minBlocks = 3 } = {}) {
|
|
169
173
|
if (!blocks.length) return null;
|
|
170
174
|
const need = Math.max(minBlocks, Math.ceil(blocks.length * minFrac));
|
|
171
175
|
const ds = blocks.map((b) => b.d).sort((a, b) => b - a); // descending: nearest first
|
|
172
176
|
for (const d of ds) {
|
|
173
|
-
|
|
177
|
+
// The bin widens with the disparity: a near subject's own relief spreads its blocks over
|
|
178
|
+
// ~relTol of its disparity (the spread scales with baseline / distance, as d does), so a
|
|
179
|
+
// fixed +-2 px split a face from a wide-baseline (120 mm) camera into groups too small to
|
|
180
|
+
// count, and the flat background won.
|
|
181
|
+
const t = Math.max(tol, relTol * Math.abs(d));
|
|
182
|
+
const members = blocks.filter((b) => Math.abs(b.d - d) <= t);
|
|
174
183
|
if (members.length >= need) {
|
|
175
184
|
const md = members.map((b) => b.d).sort((a, b) => a - b);
|
|
176
185
|
return { d: md[md.length >> 1], members };
|
package/js/camera/pair-cursor.js
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
// The policy is the one every DisplayXR depth cursor follows (runtime ADR-046 Amendment 2), taken
|
|
11
11
|
// from the shared core (../inline3d-cursor-depth.js), not re-implemented here:
|
|
12
12
|
// - never behind the glass: only content that comes OUT of the screen moves it, to the margin
|
|
13
|
-
// (0.
|
|
13
|
+
// (0.015 eye baselines, ~1 mm) in front of that content (cursorTarget);
|
|
14
14
|
// - on the plane it IS the OS cursor: the arrow is drawn into both halves only while the filtered
|
|
15
15
|
// disparity is past the draw threshold (-0.005 baselines, and >= 0.5 px; cursorShouldDraw), the
|
|
16
16
|
// OS cursor hidden exactly then;
|
|
@@ -72,21 +72,116 @@ export function pairFootprintDisparity(luma, geo, u, v, height, aspect) {
|
|
|
72
72
|
const dMin = -Math.round(E * 0.05);
|
|
73
73
|
const { rL, rR } = geo;
|
|
74
74
|
const k = geo.outW / rL.sw; // output px per source px
|
|
75
|
-
|
|
76
|
-
|
|
75
|
+
const swp = swappedHalves(luma);
|
|
76
|
+
const found = []; // { d, x, y, eye }: a consistent match and WHERE it was taken
|
|
77
|
+
const seen = new Set();
|
|
78
|
+
const sample = (su, pv) => {
|
|
79
|
+
const y = Math.round((rL.sy + pv * rL.sh) * scale - b / 2);
|
|
80
|
+
if (y < 0 || y + b > h) return;
|
|
81
|
+
const xl = Math.round((rL.sx + su * rL.sw) * scale - b / 2);
|
|
82
|
+
const xr = Math.round((rR.sx + su * rR.sw) * scale - b / 2);
|
|
83
|
+
// The arrow is far smaller than a block (~6 vs 12 copy px at the OS arrow's size), so
|
|
84
|
+
// neighbouring footprint points land on the SAME block: match each block once.
|
|
85
|
+
if (!seen.has(`L${xl},${y}`)) {
|
|
86
|
+
seen.add(`L${xl},${y}`);
|
|
87
|
+
const dl = consistentMatch(img, swp, w, h, xl, y, b, dMin, dMax);
|
|
88
|
+
if (dl !== null) found.push({ d: dl, x: xl, y, eye: 0 });
|
|
89
|
+
}
|
|
90
|
+
if (!seen.has(`R${xr},${y}`)) {
|
|
91
|
+
seen.add(`R${xr},${y}`);
|
|
92
|
+
const dr = consistentMatch(swp, img, w, h, xr, y, b, -dMax, -dMin); // right-anchored: d' = -d
|
|
93
|
+
if (dr !== null) found.push({ d: -dr, x: xr, y, eye: 1 });
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
// The tip, then a ring centred on the arrow's body: that is what content must not overlap. Each
|
|
97
|
+
// point is sampled from BOTH eyes' images (the pointer's spot in the left eye matched into the
|
|
98
|
+
// right, and the same spot in the right eye matched into the left), so neither eye is favoured:
|
|
99
|
+
// at a foreground edge one eye's block can sit on the subject and the other's on the background.
|
|
100
|
+
// Every match must also pass the left-right consistency check (consistentMatch).
|
|
101
|
+
let cu = 0, cv = 0, n = 0;
|
|
77
102
|
for (const [pu, pv] of cursorArrowFootprint(u, v, height, aspect)) {
|
|
78
103
|
if (!(pu >= 0 && pu <= 1 && pv >= 0 && pv <= 1)) continue;
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
104
|
+
sample(geo.mirror ? 1 - pu : pu, pv);
|
|
105
|
+
cu += pu; cv += pv; n++;
|
|
106
|
+
}
|
|
107
|
+
if (!n) return null;
|
|
108
|
+
// ...plus four blocks half a block around the footprint's centre, so that real content under
|
|
109
|
+
// the arrow is seen by more than one distinct block (pairNearestSupported needs that).
|
|
110
|
+
const du = (b / 2 / scale) / rL.sw, dv = (b / 2 / scale) / rL.sh;
|
|
111
|
+
for (const [ou, ov] of [[-du, 0], [du, 0], [0, -dv], [0, dv]]) {
|
|
112
|
+
const pu = cu / n + ou, pv = cv / n + ov;
|
|
113
|
+
if (pu >= 0 && pu <= 1 && pv >= 0 && pv <= 1) sample(geo.mirror ? 1 - pu : pu, pv);
|
|
114
|
+
}
|
|
115
|
+
// Source disparity → displayed: each eye's crop moved its half by its own offset.
|
|
116
|
+
for (const f of found) f.d = (f.d / scale - (rL.sx - rR.sx)) * k;
|
|
117
|
+
return pairNearestSupported(found, (PAIR_SUPPORT_COPY_PX / scale) * k, b / 2);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Two consistent footprint matches must agree (within this many px of the grayscale copy) before
|
|
122
|
+
* the nearest is believed: content that comes out of the screen covers several blocks, a lone
|
|
123
|
+
* block claiming something nearer than the rest is noise.
|
|
124
|
+
*/
|
|
125
|
+
export const PAIR_SUPPORT_COPY_PX = 1.5;
|
|
126
|
+
|
|
127
|
+
/** The SBS copy with its halves swapped (cached per copy): matching it is matching right → left. */
|
|
128
|
+
const _swapped = new WeakMap();
|
|
129
|
+
function swappedHalves(luma) {
|
|
130
|
+
let s = _swapped.get(luma);
|
|
131
|
+
if (s) return s;
|
|
132
|
+
const { img, w, h } = luma;
|
|
133
|
+
const E = w / 2;
|
|
134
|
+
s = new Uint8Array(w * h);
|
|
135
|
+
for (let y = 0; y < h; y++) {
|
|
136
|
+
const o = y * w;
|
|
137
|
+
for (let x = 0; x < E; x++) {
|
|
138
|
+
s[o + x] = img[o + E + x];
|
|
139
|
+
s[o + E + x] = img[o + x];
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
_swapped.set(luma, s);
|
|
143
|
+
return s;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* A block of image A's left half matched into its right half, kept only if the matched block,
|
|
148
|
+
* matched BACK (in B = A with its halves swapped), lands within 1 px of where it started — the
|
|
149
|
+
* classic left-right consistency check. A block straddling a depth edge (half subject, half
|
|
150
|
+
* background, each eye seeing a different half) fails it instead of returning a random depth.
|
|
151
|
+
* @returns {number|null} disparity in A's convention (left x − right x), copy px
|
|
152
|
+
*/
|
|
153
|
+
export function consistentMatch(A, B, w, h, x, y, b, dMin, dMax) {
|
|
154
|
+
const E = w / 2;
|
|
155
|
+
if (x < 0 || x + b > E) return null;
|
|
156
|
+
const m = matchBlock(A, w, h, x, y, b, b, { dMin, dMax, dyMax: 2 });
|
|
157
|
+
if (!m || m.c < MIN_NCC) return null;
|
|
158
|
+
const xb = Math.round(x - m.d);
|
|
159
|
+
const yb = y + m.dy;
|
|
160
|
+
if (xb < 0 || xb + b > E || yb < 0 || yb + b > h) return null;
|
|
161
|
+
const back = matchBlock(B, w, h, xb, yb, b, b, { dMin: -Math.ceil(m.d) - 2, dMax: -Math.floor(m.d) + 2, dyMax: 1 });
|
|
162
|
+
if (!back || back.c < MIN_NCC || Math.abs(-back.d - m.d) > 1) return null;
|
|
163
|
+
return m.d;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* The nearest disparity that an INDEPENDENT second sample corroborates (within `tol`), else null.
|
|
168
|
+
* Independent = taken from the other eye's image, or from a block at least `minSep` copy px away:
|
|
169
|
+
* two footprint points inside one block are one witness, not two. (A block straddling a face edge
|
|
170
|
+
* and a bright window matched at a bogus +49 px, consistently, and its twin counted as support.)
|
|
171
|
+
* @param {Array<{d:number, x?:number, y?:number, eye?:number}|number>} samples output px, + = in front
|
|
172
|
+
* @returns {number|null}
|
|
173
|
+
*/
|
|
174
|
+
export function pairNearestSupported(samples, tol, minSep = 0) {
|
|
175
|
+
const S = samples.map((s) => (typeof s === 'number' ? { d: s, x: 0, y: 0, eye: -1 } : s));
|
|
176
|
+
if (S.length < 2) return null;
|
|
177
|
+
const sorted = [...S].sort((p, q) => q.d - p.d); // nearest first
|
|
178
|
+
const independent = (p, q) => p.eye === -1 || p.eye !== q.eye || Math.abs(p.x - q.x) + Math.abs(p.y - q.y) >= minSep;
|
|
179
|
+
for (const p of sorted) {
|
|
180
|
+
for (const q of sorted) {
|
|
181
|
+
if (q !== p && Math.abs(p.d - q.d) <= tol && independent(p, q)) return p.d;
|
|
182
|
+
}
|
|
88
183
|
}
|
|
89
|
-
return
|
|
184
|
+
return null;
|
|
90
185
|
}
|
|
91
186
|
|
|
92
187
|
/**
|
|
@@ -109,7 +204,7 @@ export function pairPxPerBaseline(outW, tileCssWidth, metersPerCssPx, eyeSeparat
|
|
|
109
204
|
/**
|
|
110
205
|
* The arrow's target disparity in output px (+ = in front): the core's policy (cursorTarget) on
|
|
111
206
|
* the content's disparity, converted through `pxPerBaseline`. In front of content that comes OUT
|
|
112
|
-
* of the screen by the margin (0.
|
|
207
|
+
* of the screen by the margin (0.015 baselines), else the glass — never behind it.
|
|
113
208
|
*/
|
|
114
209
|
export function pairCursorTarget(contentPx, pxPerBaseline, tuning = CURSOR_DEFAULT_TUNING) {
|
|
115
210
|
if (contentPx === null || !Number.isFinite(contentPx) || !(pxPerBaseline > 0)) return 0;
|
|
@@ -46,7 +46,7 @@
|
|
|
46
46
|
// ./camera, which calls the pieces exported here):
|
|
47
47
|
// - never behind the glass: target = d < 0 ? max(d - margin, dMin) : 0 — it only comes forward,
|
|
48
48
|
// to stay ahead of content that comes OUT of the screen (cursorTarget);
|
|
49
|
-
// - margin 0.
|
|
49
|
+
// - margin 0.015 eye baselines (~1 mm of crossed on-screen disparity at a 63-65 mm IPD);
|
|
50
50
|
// - on the plane it IS the OS cursor: nothing is drawn while the filtered disparity is above
|
|
51
51
|
// CURSOR_DRAW_THRESHOLD (-0.005, ~0.3 mm) or under CURSOR_DRAW_MIN_PX on the panel
|
|
52
52
|
// (cursorShouldDraw), and the CSS cursor is left alone;
|
|
@@ -99,10 +99,11 @@ export function cursorShouldDraw(disparity, pxPerBaseline) {
|
|
|
99
99
|
*/
|
|
100
100
|
export const CURSOR_DEFAULT_TUNING = Object.freeze({
|
|
101
101
|
/**
|
|
102
|
-
* How far in front of out-of-screen content the cursor floats: 0.
|
|
103
|
-
* crossed on-screen disparity at a 65 mm IPD (ADR-046 Amendment 2;
|
|
102
|
+
* How far in front of out-of-screen content the cursor floats: 0.015 eye baselines, ~1 mm of
|
|
103
|
+
* crossed on-screen disparity at a 63-65 mm IPD (ADR-046 Amendment 2 A.2; halved from 0.03 on
|
|
104
|
+
* 2026-10-11 after a Windows panel check: 0.03 floated visibly off the content).
|
|
104
105
|
*/
|
|
105
|
-
margin: 0.
|
|
106
|
+
margin: 0.015,
|
|
106
107
|
/** Comfort clamp: t >= 0.625, never more than ~3/8 of the way to the eye. */
|
|
107
108
|
minDisparity: -0.6,
|
|
108
109
|
/** Never behind the glass: 0, and cursorTarget never goes above 0 whatever this says. */
|
package/js/inline3d-three.js
CHANGED
|
@@ -489,8 +489,8 @@ export class DepthCursor {
|
|
|
489
489
|
* @param {number} [opts.height] sprite height as a fraction of the canvas height. Default: the
|
|
490
490
|
* OS arrow's size, 19 CSS px of the canvas's on-page height (re-read every frame).
|
|
491
491
|
* @param {number} [opts.color=0xffffff] the arrow's fill colour (the outline is always dark).
|
|
492
|
-
* @param {number} [opts.margin=0.
|
|
493
|
-
* eye-baseline units (0.
|
|
492
|
+
* @param {number} [opts.margin=0.015] how far in front of out-of-screen content it floats, in
|
|
493
|
+
* eye-baseline units (0.015 ≈ 1 mm of on-screen disparity at a 63-65 mm IPD).
|
|
494
494
|
* @param {'hybrid'|'screen'|'world'} [opts.anchor='hybrid'] where along that depth: 'hybrid'
|
|
495
495
|
* follows the pointer exactly while it moves and stays world-fixed (parallaxes with the
|
|
496
496
|
* content) while it is still; 'screen' never parallaxes; 'world' always does.
|
package/model.d.ts
CHANGED
|
@@ -53,7 +53,7 @@ export interface ModelOptions {
|
|
|
53
53
|
* `'depth'`: a cursor that is never drawn on the glass behind content that pops out (ADR-046 +
|
|
54
54
|
* Amendment 2, the same policy as native `XR_DXR_cursor_depth`). While the subject under the
|
|
55
55
|
* pointer's footprint comes OUT of the screen, the classic arrow (the OS cursor's size, tip on
|
|
56
|
-
* the hotspot) is drawn the margin (0.
|
|
56
|
+
* the hotspot) is drawn the margin (0.015 eye baselines, ~1 mm) in front of it and the CSS cursor
|
|
57
57
|
* is hidden; over anything on or behind the glass nothing is drawn and the CSS cursor is the
|
|
58
58
|
* cursor, untouched. Never behind the glass. OPT-IN: without it nothing
|
|
59
59
|
* is loaded or run. PlayCanvas: hit-tested with the engine's depth picker (meshes and splats,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@displayxr/inline3d",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.42.1",
|
|
4
4
|
"description": "Turn any HTML <canvas> into a 3D window on a spatial display, inside an ordinary web page, for the DisplayXR Browser. Dependency-free; progressive enhancement (falls back to plain 2D everywhere else).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"types": "./index.d.ts",
|
package/splat.d.ts
CHANGED
|
@@ -477,7 +477,7 @@ export interface SplatOptions {
|
|
|
477
477
|
* `'depth'`: a cursor that is never drawn on the glass behind content that pops out (ADR-046 +
|
|
478
478
|
* Amendment 2, the same policy as native `XR_DXR_cursor_depth`). While the subject under the
|
|
479
479
|
* pointer's footprint comes OUT of the screen, the classic arrow (the OS cursor's size, tip on
|
|
480
|
-
* the hotspot) is drawn the margin (0.
|
|
480
|
+
* the hotspot) is drawn the margin (0.015 eye baselines, ~1 mm) in front of it and the CSS cursor
|
|
481
481
|
* is hidden; over anything on or behind the glass nothing is drawn and the CSS cursor is the
|
|
482
482
|
* cursor, untouched. Never behind the glass. OPT-IN: without it nothing
|
|
483
483
|
* is loaded or run. PlayCanvas: hit-tested with the engine's depth picker (meshes and splats,
|