@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.
@@ -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. **Objects: keep the viewer's placement, and frame the first view.** `addModel`, `addSplat` and
7
- `SceneViewer` scale the subject to its real proportions and centre it on the glass. That gives
8
- it the smallest spread of disparity it can have. Don't push it back, shrink it or flatten it to
9
- make it "more comfortable". The first view shows the whole subject, centred: a globe or a tank
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. **Lay flat arrangements back into depth.** Orbits, a map, a board game: tilt the plane they lie
13
- in back from the glass so it recedes, rather than looking straight down on it, where it reads
14
- as flat circles. A chart, a figure or a building starts upright.
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
+ }
@@ -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
- const { width, height } = unsqueezedSize(st.width, st.height);
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(st.frameRate > 0 ? st.frameRate : 30);
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
  }
@@ -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
- (keep the viewers' object placement and frame the first view, lay flat arrangements back into
8
- depth, keep moving things' forward swing small, fixed convergence and no near geometry in worlds,
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.50.0",
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",