@displayxr/inline3d 1.50.0 → 1.51.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 +15 -0
- package/README.md +3 -1
- package/call.d.ts +16 -0
- package/dist/call.js +6 -6
- package/docs/making-3d-look-right.md +14 -7
- package/docs/woven-canvas-rules.md +30 -1
- package/index.d.ts +4 -0
- package/js/call/share.js +90 -0
- package/js/camera/half-sbs.js +28 -2
- package/js/inline3d-call.js +1 -1
- package/js/inline3d-splat-playcanvas.js +10 -2
- package/llms.txt +2 -2
- package/package.json +1 -1
|
@@ -3,15 +3,22 @@
|
|
|
3
3
|
The SDK's defaults already give comfortable, real-looking 3D. These rules cover the mistakes seen
|
|
4
4
|
on real panels; everything else about how your scene looks is your call.
|
|
5
5
|
|
|
6
|
-
1. **
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
6
|
+
1. **The default depth is the glass. Keep the viewer's placement, and frame the first view.**
|
|
7
|
+
Centre your subject on z = 0 so it straddles the screen: a little in front, most of it just
|
|
8
|
+
behind, with as little total disparity as its shape allows. `addModel`, `addSplat` and
|
|
9
|
+
`SceneViewer` do this for you: they scale the subject to its real proportions and centre it on
|
|
10
|
+
the glass. A scene you build yourself follows the same rule. Don't push it back, shrink it or
|
|
11
|
+
flatten it to make it "more comfortable": recession costs disparity and crosstalk like pop-out
|
|
12
|
+
does, and only buys softness. Recede content only where it must clear something flat in front
|
|
13
|
+
of it (an HTML label over the scene), and only that part; HTML beside the scene needs no room.
|
|
14
|
+
The first view shows the whole subject, centred: a globe or a tank
|
|
10
15
|
that starts low and cut off by the frame reads as broken. If the user can zoom, cap zoom-in
|
|
11
16
|
before the subject comes uncomfortably far out, and leave room to zoom out.
|
|
12
|
-
2. **
|
|
13
|
-
|
|
14
|
-
|
|
17
|
+
2. **Tilt flat arrangements; don't push them back.** Orbits, a map, a board game: tilt the plane
|
|
18
|
+
they lie in about an axis through the glass (about 25–40° from face-on is plenty), so its near
|
|
19
|
+
edge comes slightly forward, its far edge recedes and its centre stays on the glass. Only the
|
|
20
|
+
exactly face-on view reads as flat circles; a fairly top-down view is fine. A chart, a figure
|
|
21
|
+
or a building starts upright.
|
|
15
22
|
3. **Moving things: keep the forward swing small.** Objects that orbit, fly or swing toward the
|
|
16
23
|
viewer are where pop-out becomes uncomfortable. Keep their paths mostly at or behind the glass,
|
|
17
24
|
and fade them out as they come close to the viewer. A follow camera keeps its subject in frame
|
|
@@ -159,7 +159,16 @@ one region are **refused as ambiguous**, and neither is drawn from. Cut between
|
|
|
159
159
|
two pictures, blend them **inside one canvas**
|
|
160
160
|
([motion, §3 Crossfade](authoring-motion-and-effects.md#crossfade)).
|
|
161
161
|
|
|
162
|
-
Many woven canvases in one frame (a gallery wall) are fine. Each one joins by its own identity
|
|
162
|
+
Many woven canvases in one frame (a gallery wall) are fine. Each one joins by its own identity,
|
|
163
|
+
**as long as no two of them overlap.**
|
|
164
|
+
|
|
165
|
+
**Never leave two woven canvases overlapping.** This is the static case of the crossfade: a splat
|
|
166
|
+
canvas laid over a full-page scene canvas, both live. The Android DisplayXR Browser logs
|
|
167
|
+
`[DisplayXR] inline-3D OVERLAPPING tiles …` and an `inline-3D recovery draw`; on a
|
|
168
|
+
tablet the top canvas head-tracked but showed no stereo, and adding a second splat crashed the tab.
|
|
169
|
+
The same page looked fine on a Windows panel, so a desktop check does not clear it. Hide the canvas
|
|
170
|
+
underneath with `display: none` while the other one shows, and restore it when that one goes, so
|
|
171
|
+
only one woven tile covers any point of the screen.
|
|
163
172
|
|
|
164
173
|
### 7. No CSS effects on a woven canvas or on any of its ancestors
|
|
165
174
|
|
|
@@ -362,6 +371,26 @@ like a runtime or browser bug on the panel and turned out to be the page.
|
|
|
362
371
|
`wall.hardwareDisplayState === '2d'`. The SDK's eased mode switch ramps disparity out before the
|
|
363
372
|
panel flips and in only after it reports 3D. The mode is display-wide: every woven tile goes flat
|
|
364
373
|
while it is in force.
|
|
374
|
+
- **A 2D/3D toggle: try the mode switch, then fall back.** Not every browser can forward a mode
|
|
375
|
+
request yet. The DisplayXR Browser for Android rejects `setStereoEnabled` /
|
|
376
|
+
`requestRenderingMode` with a `NotSupportedError` while still listing its modes as
|
|
377
|
+
`isRequestable: true`. You can't detect this in advance:
|
|
378
|
+
`isRequestable` overstates it, and the Android browser sends a desktop user agent. So call
|
|
379
|
+
`setStereoEnabled(false)` and, if it rejects with a `NotSupportedError`, flatten the image
|
|
380
|
+
yourself: ease your rig's `ipdFactor` and `parallaxFactor` to 0 over ~300 ms (the SDK's own
|
|
381
|
+
ramp length), and back to your values for 3D. Both views become identical and the image reads
|
|
382
|
+
flat, though the panel's lens stays on, so it is a softer 2D than the real 1-view mode. Keep
|
|
383
|
+
the button visible either way; only a different rejection (no 1-view mode listed at all) is a
|
|
384
|
+
reason to hide it.
|
|
385
|
+
```js
|
|
386
|
+
async function setFlat(flat) {
|
|
387
|
+
try { await wall.setStereoEnabled(!flat); }
|
|
388
|
+
catch (e) {
|
|
389
|
+
if (e.name !== 'NotSupportedError') throw e;
|
|
390
|
+
easeRigFactors(flat ? 0 : 1, 300); // your rig: ipdFactor / parallaxFactor × k
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
```
|
|
365
394
|
- **Keep the frames after `setSource` resolves free of per-splat CPU work.** A slideshow ran 105
|
|
366
395
|
`pick()` calls (24 to plan an animation path, 81 for a diagnostic depth grid) right after every
|
|
367
396
|
swap. On the Windows panel that was a 1.3 s main-thread stall at the end of every transition,
|
package/index.d.ts
CHANGED
|
@@ -522,6 +522,10 @@ export interface TileHandle {
|
|
|
522
522
|
* its layer, and a window that never set a rig at all.
|
|
523
523
|
*
|
|
524
524
|
* Rejects with an `Error` when no such mode is listed, otherwise as `requestRenderingMode` does.
|
|
525
|
+
* A browser that cannot forward mode requests (the Android DisplayXR Browser today)
|
|
526
|
+
* rejects with a `NotSupportedError` even though its modes read
|
|
527
|
+
* `isRequestable: true`. Try, then fall back to easing your rig's `ipdFactor`/`parallaxFactor`
|
|
528
|
+
* to 0 — docs/woven-canvas-rules.md, "A 2D/3D toggle".
|
|
525
529
|
*/
|
|
526
530
|
setStereoEnabled(enabled: boolean): Promise<boolean>;
|
|
527
531
|
/** Listen for one display event, re-emitted on this handle. Returns an unsubscribe function. */
|
package/js/call/share.js
CHANGED
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
|
|
25
25
|
import { isAppShareDevice, isAppShareHint, legacyAppCandidate, appNameFromLabel, APP_SHARE_ID_PREFIX, APP_SHARE_LABEL_RE, APP_SHARE_LEGACY_LABEL_RE } from '../camera/app-share.js';
|
|
26
26
|
import { sanitizeName, CONV_MAX_FRAC } from './wire.js';
|
|
27
|
+
import { unsqueezeTrack } from '../camera/half-sbs.js';
|
|
27
28
|
|
|
28
29
|
export { isAppShareDevice, isAppShareHint, legacyAppCandidate, appNameFromLabel, APP_SHARE_ID_PREFIX, APP_SHARE_LABEL_RE, APP_SHARE_LEGACY_LABEL_RE };
|
|
29
30
|
|
|
@@ -177,3 +178,92 @@ export async function openAppShare(deviceId, o = {}) {
|
|
|
177
178
|
}
|
|
178
179
|
return { stream, track, descriptor: describeAppShare(track) };
|
|
179
180
|
}
|
|
181
|
+
|
|
182
|
+
/** The hint layout of a stereo tab capture: each eye squeezed to half the frame's width. */
|
|
183
|
+
export const TAB_SHARE_LAYOUT = 'side-by-side-half';
|
|
184
|
+
|
|
185
|
+
/** Does this `displayxrStereo` hint describe a stereo tab capture? Pure. */
|
|
186
|
+
export function isTabShareHint(hint) {
|
|
187
|
+
return !!hint && typeof hint === 'object' && hint.source === 'tab' && hint.layout === TAB_SHARE_LAYOUT;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Let the user pick a browser tab to share, in 3D where the browser can. The browser's own picker
|
|
192
|
+
* is the consent. On a browser that captures tabs in stereo, a picked tab comes back as a true
|
|
193
|
+
* side-by-side track (`format: 'sbs'`), the same shape an app share has, so receivers need nothing
|
|
194
|
+
* new. A window or a screen, or a browser without stereo capture, comes back flat (`format: 'mono'`).
|
|
195
|
+
* The track ends when the sharing stops or the tab closes. Stop it yourself when you stop sharing.
|
|
196
|
+
*
|
|
197
|
+
* Cancelling the picker rejects with `code: 'share-denied'`; anything else with `'share-failed'`.
|
|
198
|
+
*
|
|
199
|
+
* @param {{ width?: number, frameRate?: number, mediaDevices?: MediaDevices, unsqueeze?: typeof unsqueezeTrack }} [o]
|
|
200
|
+
*/
|
|
201
|
+
export async function openTabShare(o = {}) {
|
|
202
|
+
const md = o.mediaDevices || (typeof navigator !== 'undefined' ? navigator.mediaDevices : null);
|
|
203
|
+
if (!md || typeof md.getDisplayMedia !== 'function') {
|
|
204
|
+
throw Object.assign(new Error('this browser cannot share a tab'), { code: 'share-failed' });
|
|
205
|
+
}
|
|
206
|
+
const frameRate = o.frameRate > 0 ? o.frameRate : SHARE_CONSTRAINTS.frameRate;
|
|
207
|
+
let stream;
|
|
208
|
+
try {
|
|
209
|
+
stream = await md.getDisplayMedia({
|
|
210
|
+
video: { width: { ideal: o.width > 0 ? o.width : SHARE_CONSTRAINTS.width }, frameRate: { ideal: frameRate } },
|
|
211
|
+
audio: false,
|
|
212
|
+
displayxrStereo: true,
|
|
213
|
+
});
|
|
214
|
+
} catch (err) {
|
|
215
|
+
const denied = err && (err.name === 'NotAllowedError' || err.name === 'AbortError');
|
|
216
|
+
throw Object.assign(new Error(denied ? 'the share was declined' : 'the tab could not be shared'), {
|
|
217
|
+
code: denied ? 'share-denied' : 'share-failed',
|
|
218
|
+
cause: err,
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
const source = stream.getVideoTracks()[0];
|
|
222
|
+
const st = (source && typeof source.getSettings === 'function' && source.getSettings()) || {};
|
|
223
|
+
const hint = st.displayxrStereo;
|
|
224
|
+
const flat = {
|
|
225
|
+
stream,
|
|
226
|
+
track: source,
|
|
227
|
+
stereo: false,
|
|
228
|
+
descriptor: {
|
|
229
|
+
kind: 'content',
|
|
230
|
+
format: 'mono',
|
|
231
|
+
width: st.width > 0 ? st.width : 0,
|
|
232
|
+
height: st.height > 0 ? st.height : 0,
|
|
233
|
+
convergence: { known: true, frac: 0 },
|
|
234
|
+
appName: null,
|
|
235
|
+
},
|
|
236
|
+
};
|
|
237
|
+
let out = flat;
|
|
238
|
+
if (isTabShareHint(hint)) {
|
|
239
|
+
const pair = (o.unsqueeze || unsqueezeTrack)(source, st.width, st.height, st.frameRate > 0 ? st.frameRate : frameRate);
|
|
240
|
+
if (!pair) {
|
|
241
|
+
// Sent as it is, every receiver would see two squeezed halves: refuse instead.
|
|
242
|
+
stream.getTracks().forEach((t) => t.stop());
|
|
243
|
+
throw Object.assign(new Error('this browser cannot convert a stereo tab capture'), { code: 'share-failed' });
|
|
244
|
+
}
|
|
245
|
+
const z = hint.zeroDisparityShiftFrac;
|
|
246
|
+
out = {
|
|
247
|
+
stream: pair.stream,
|
|
248
|
+
track: pair.track,
|
|
249
|
+
stereo: true,
|
|
250
|
+
descriptor: {
|
|
251
|
+
kind: 'content',
|
|
252
|
+
format: 'sbs',
|
|
253
|
+
width: pair.width,
|
|
254
|
+
height: pair.height,
|
|
255
|
+
convergence: {
|
|
256
|
+
known: true,
|
|
257
|
+
frac: typeof z === 'number' && Number.isFinite(z) ? Math.max(-CONV_MAX_FRAC, Math.min(CONV_MAX_FRAC, z)) : 0,
|
|
258
|
+
},
|
|
259
|
+
appName: null,
|
|
260
|
+
},
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
try {
|
|
264
|
+
out.track.contentHint = 'detail'; // text and resolution over frame rate
|
|
265
|
+
} catch {
|
|
266
|
+
/* older engines */
|
|
267
|
+
}
|
|
268
|
+
return out;
|
|
269
|
+
}
|
package/js/camera/half-sbs.js
CHANGED
|
@@ -45,14 +45,31 @@ export async function unsqueezeHalfSbs(source) {
|
|
|
45
45
|
}
|
|
46
46
|
const st = source.getSettings();
|
|
47
47
|
if (!(st.width > 0) || !(st.height > 0) || Math.abs(st.width / st.height - 16 / 9) > 0.01) return null;
|
|
48
|
+
return unsqueezeTrack(source, st.width, st.height, st.frameRate);
|
|
49
|
+
}
|
|
48
50
|
|
|
49
|
-
|
|
51
|
+
/**
|
|
52
|
+
* Redraw a half-width side-by-side `source` of `srcW`x`srcH` into a true side-by-side track, or null
|
|
53
|
+
* when the engine cannot. Each eye keeps the frame's aspect. Stopping the returned track stops the
|
|
54
|
+
* source; the source ending ends the returned track.
|
|
55
|
+
*
|
|
56
|
+
* @param {MediaStreamTrack} source
|
|
57
|
+
* @param {number} srcW
|
|
58
|
+
* @param {number} srcH
|
|
59
|
+
* @param {number} [frameRate]
|
|
60
|
+
* @returns {{stream: MediaStream, track: MediaStreamTrack, width: number, height: number}|null}
|
|
61
|
+
*/
|
|
62
|
+
export function unsqueezeTrack(source, srcW, srcH, frameRate) {
|
|
63
|
+
if (typeof MediaStreamTrackProcessor !== 'function' || typeof document === 'undefined') return null;
|
|
64
|
+
let { width, height } = unsqueezedSize(srcW, srcH);
|
|
65
|
+
let srcSeenW = srcW;
|
|
66
|
+
let srcSeenH = srcH;
|
|
50
67
|
const canvas = document.createElement('canvas');
|
|
51
68
|
canvas.width = width;
|
|
52
69
|
canvas.height = height;
|
|
53
70
|
const ctx = canvas.getContext('2d', { alpha: false });
|
|
54
71
|
if (!ctx) return null;
|
|
55
|
-
const stream = canvas.captureStream(
|
|
72
|
+
const stream = canvas.captureStream(frameRate > 0 ? frameRate : 30);
|
|
56
73
|
const track = stream.getVideoTracks()[0];
|
|
57
74
|
const reader = new MediaStreamTrackProcessor({ track: source }).readable.getReader();
|
|
58
75
|
|
|
@@ -68,6 +85,15 @@ export async function unsqueezeHalfSbs(source) {
|
|
|
68
85
|
for (;;) {
|
|
69
86
|
const { value: frame, done } = await reader.read();
|
|
70
87
|
if (done) break;
|
|
88
|
+
// Follow the frames, not the settings: a captured tab's frames are not the size its track
|
|
89
|
+
// reports, and it changes when the tab resizes.
|
|
90
|
+
if (frame.displayWidth > 0 && frame.displayHeight > 0 && (frame.displayWidth !== srcSeenW || frame.displayHeight !== srcSeenH)) {
|
|
91
|
+
srcSeenW = frame.displayWidth;
|
|
92
|
+
srcSeenH = frame.displayHeight;
|
|
93
|
+
const next = unsqueezedSize(srcSeenW, srcSeenH);
|
|
94
|
+
canvas.width = width = next.width;
|
|
95
|
+
canvas.height = height = next.height;
|
|
96
|
+
}
|
|
71
97
|
ctx.drawImage(frame, 0, 0, width, height);
|
|
72
98
|
frame.close();
|
|
73
99
|
}
|
package/js/inline3d-call.js
CHANGED
|
@@ -116,7 +116,7 @@ export { DxrCallElement, defineCallElement, attrsToOpts, CALL_EVENT_PREFIX } fro
|
|
|
116
116
|
export { dxrSignaling, DXR_SIGNAL_DEFAULT } from './call/signaling.js';
|
|
117
117
|
export { parseInviteLink };
|
|
118
118
|
// 3D screen share (runtime ADR-061): find and open a running DisplayXR app as content.
|
|
119
|
-
export { isAppShareDevice, listShareableApps, watchShareableApps, openAppShare } from './call/share.js';
|
|
119
|
+
export { isAppShareDevice, listShareableApps, watchShareableApps, openAppShare, openTabShare } from './call/share.js';
|
|
120
120
|
|
|
121
121
|
const TAG = '[inline3d/call]';
|
|
122
122
|
const HELLO_WAIT_MS = 2000;
|
|
@@ -587,14 +587,22 @@ export function capturePose(rest, flipY) {
|
|
|
587
587
|
* (epic #36, P4d); until then the anchors are regexes (the npm build re-indents chunks with
|
|
588
588
|
* tabs) and a miss warns once and renders unpatched rather than failing.
|
|
589
589
|
*
|
|
590
|
+
* PlayCanvas 2.23 fixed it upstream: its perspective branch takes the focal length per axis
|
|
591
|
+
* (`vec2 focal = viewport_size.xy * vec2(center.projMat00, center.projMat11)`). That chunk is
|
|
592
|
+
* returned untouched with `native: true` and counts as fixed — it is not a miss, and must not
|
|
593
|
+
* warn that splats will draw at half height.
|
|
594
|
+
*
|
|
590
595
|
* The rewritten lines are adapted from PlayCanvas engine src/scene/shader-lib/glsl/chunks/gsplat/vert/
|
|
591
596
|
* gsplatCorner.js (the `gsplatCornerVS` chunk) @ v2.22.3, MIT (THIRD_PARTY_NOTICES.md).
|
|
592
597
|
*
|
|
593
|
-
* @returns {{src:string, ok:boolean}}
|
|
598
|
+
* @returns {{src:string, ok:boolean, native?:boolean}}
|
|
594
599
|
*/
|
|
595
600
|
export function patchGsplatFootprint(src) {
|
|
596
601
|
if (typeof src !== 'string') return { src, ok: false };
|
|
597
602
|
if (src.includes('dxrFocalY')) return { src, ok: true };
|
|
603
|
+
if (/vec2\s+focal\s*=\s*viewport_size\.xy\s*\*\s*vec2\s*\(\s*center\.projMat00\s*,\s*center\.projMat11\s*\)/.test(src)) {
|
|
604
|
+
return { src, ok: true, native: true };
|
|
605
|
+
}
|
|
598
606
|
const r1 = /vec2\s+J2\s*=\s*-J1\s*\/\s*vp\.z\s*\*\s*vp\.xy\s*;/;
|
|
599
607
|
const r2 = /0\.0\s*,\s*J1\s*,\s*J2\.y\s*,/;
|
|
600
608
|
if (!r1.test(src) || !r2.test(src)) return { src, ok: false };
|
|
@@ -1542,7 +1550,7 @@ export class PlayCanvasSplatViewer {
|
|
|
1542
1550
|
// handle.engine.root on a model tile gets the engine's stock footprint (documented).
|
|
1543
1551
|
const chunks = patchSplats ? pc.ShaderChunks.get(device, pc.SHADERLANGUAGE_GLSL) : null;
|
|
1544
1552
|
const corner = chunks ? patchGsplatFootprint(chunks.get('gsplatCornerVS')) : { ok: false, skipped: true };
|
|
1545
|
-
if (corner.ok) chunks.set('gsplatCornerVS', corner.src);
|
|
1553
|
+
if (corner.ok && !corner.native) chunks.set('gsplatCornerVS', corner.src);
|
|
1546
1554
|
else if (!corner.skipped && !warnedFootprint) {
|
|
1547
1555
|
warnedFootprint = true;
|
|
1548
1556
|
console.warn(
|
package/llms.txt
CHANGED
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
> canvas, the DisplayXR Browser weaves them for the panel; an ordinary browser shows the page in 2D.
|
|
5
5
|
|
|
6
6
|
Read first: docs/making-3d-look-right.md — nine depth and comfort rules from real panels
|
|
7
|
-
(
|
|
8
|
-
|
|
7
|
+
(the default depth is the glass: centre the subject on z = 0 and frame the first view; tilt flat
|
|
8
|
+
arrangements about an axis through the glass, don't push them back; keep moving things' forward swing small, fixed convergence and no near geometry in worlds,
|
|
9
9
|
low contrast behind the subject, text as HTML near the glass, no coplanar surfaces, no sub-pixel
|
|
10
10
|
detail, a cover that always comes off). The SDK's defaults do the rest.
|
|
11
11
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@displayxr/inline3d",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.51.0",
|
|
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",
|