@displayxr/inline3d 1.2.0 → 1.3.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 CHANGED
@@ -5,6 +5,93 @@ entry points (`.`, `./three`) are frozen for 1.x, while the **scene subpaths** (
5
5
  `./splat`, `./model`) are a preview tier whose options may change in any release. Entries below say
6
6
  which tier they touch, because that is what tells you whether an upgrade can move your pixels.
7
7
 
8
+ ## 1.3.0 — 2026-09-04
9
+
10
+ ### Added
11
+
12
+ - **View rigs — the page can hand the runtime its own CAMERA, not just a virtual-display height.**
13
+ Every inline-3D frame is located against a *view rig*, and until now there was exactly one: a
14
+ display rig with an identity pose whose only knob was `virtualDisplayHeight`. That is the right
15
+ model for a portal — the canvas is a window onto a scene authored to fit it — and the wrong one
16
+ for a scene that owns a camera. An orbit, a walkthrough, a game has a pose and a field of view
17
+ already; it does not want to be told where the eyes are, it wants its own frustum perturbed by
18
+ them. That could not be expressed at all, so those pages either fought the display rig or
19
+ re-derived stereo themselves.
20
+
21
+ `handle.setViewRig(rig)` and `addScene`'s `viewRig` option send the whole descriptor:
22
+ `{type:'display'|'camera', position, orientation, virtualDisplayHeight, ipdFactor,
23
+ parallaxFactor, perspectiveFactor, convergenceDiopters, verticalFov, metersToVirtual}`. A rig
24
+ applies per-locate, so animating one is just sending new values each frame — nothing to tween,
25
+ nothing to tear down. **No projection math lands in the SDK**: it fills in a descriptor and the
26
+ off-axis (Kooima) frustum stays in the runtime, which is the same code the native apps consume.
27
+ *(core tier — additive)*
28
+
29
+ - **`inline3dViewRigSupported()`**, and a fallback that actually falls back. The gate reads a
30
+ capability (`XRDisplayLayer.prototype.setViewRig` being present), never a version or UA string.
31
+ Without it `setViewRig()` warns once and returns `false` while the window keeps weaving — so a
32
+ page that merely wants the extra control where it exists can call it unconditionally. And a
33
+ `viewRig` is only put in the layer init on a browser that *has* rigs: an older one would take
34
+ the init, find no member it recognised, and drop to its **own** default height, so passing a
35
+ camera rig alongside a `virtualDisplayHeight` now genuinely names the older browser's framing.
36
+ *(core tier — additive)*
37
+
38
+ - **`cameraRigFromCamera(THREE, camera, opts)` and `displayRig(opts)`** in
39
+ `@displayxr/inline3d/three` — descriptor builders, both accepting an `out` object so a per-frame
40
+ call allocates nothing. `cameraRigFromCamera` decomposes the pose from `matrixWorld` rather than
41
+ reading `.position`/`.quaternion` (those are local, and an app camera parented under a dolly —
42
+ the usual way to build an orbit — would otherwise report a pose in the wrong space), converts
43
+ three's degrees to the descriptor's radians, and turns a convergence *distance* into diopters so
44
+ "infinity" is a finite `0`. *(core tier — additive)*
45
+
46
+ - **`EyeCamera.setLocalFromView(view)` / `setLocalFromMatrices(proj, transform)`, for the attach
47
+ pattern.** The browser locates views **before** the page's rAF, so a rig set during frame N
48
+ drives the views delivered in frame N+1. On a slider that is invisible; on a camera moving under
49
+ the pointer it reads as a soft, swimming misalignment. The fix is not prediction: send an
50
+ identity-posed camera rig and parent the eye cameras under the app camera, and three's scene
51
+ graph composes *this* frame's world pose with no lag. These setters write `camera.matrix` and
52
+ leave `matrixWorld` to three's traversal, which is the whole of it — a scene-graph parent, not
53
+ projection math. `setFromView` (world) is unchanged. *(core tier — additive)*
54
+
55
+ - **`samples/camera-rig/`** — an orbiting scene on a camera rig, with sliders for FOV, convergence
56
+ and distance, an `attach` toggle, a live comfort readout, and `C` to cut between the camera rig
57
+ and a display rig framed to match it at the home angle. `samples/hello-cube/?debug` gains the
58
+ display rig's knobs; `samples/windows/`'s live scene tile moves to a camera rig in the attach
59
+ pattern. The comfort rule the readout prints is the runtime's own
60
+ (`ipdFactor × metersToVirtual × convergenceDiopters × N`, N ≈ 0.5 m): at 1 the viewer's eyes are
61
+ parallel on infinitely far content and past it they diverge. The SDK documents it and never
62
+ enforces it — the runtime clamps its own inputs, once, with a warning.
63
+
64
+ ### Docs
65
+
66
+ - **[`docs/porting-three-js-apps.md`](docs/porting-three-js-apps.md) — porting an existing three.js
67
+ app (WebXR or plain) to inline 3D.** The rig work above closed the gap that made this guide
68
+ possible: an app that owns a camera can now hand it over, so "port your WebXR app" stops meaning
69
+ "re-author it as a portal". The guide is the WebXR→inline-3d mapping table (what each of
70
+ `isSessionSupported`, `renderer.xr`, `setAnimationLoop`, `XRWebGLLayer`, reference spaces,
71
+ offset-reference-space locomotion, `ArrayCamera`, controllers and `updateRenderState` becomes, and
72
+ why), the whole render loop with validate-before-clear and last-good replay, the Spark
73
+ double-sort, DOM UI over a woven canvas, picking, a hardware checklist, and a 24-item pitfalls
74
+ register. Linked from the README and from the top of the authoring guide.
75
+
76
+ ## 1.2.1 — 2026-08-25
77
+
78
+ ### Fixed
79
+
80
+ - **`./viewer` wheel zoom is proportional and eased.** It scaled by the delta's SIGN only — a flat
81
+ 8% step per wheel event — which is about right for one mouse notch and badly wrong for a
82
+ trackpad, where a single two-finger flick emits dozens of small events. Measured: a 20-event
83
+ flick reached **5.3x** and a longer swipe hit the 6x clamp, on gestures the user reads as gentle.
84
+
85
+ The handler now scales by magnitude, normalises `deltaMode` (Chrome reports pixels; Firefox
86
+ reports LINES for a mouse wheel, so identical hardware was ~2x more sensitive in one browser),
87
+ clamps per event against OS pointer-acceleration spikes, and applies the zoom as `exp()` so equal
88
+ deltas give equal ratios in both directions — `1 + d` and `1 - d` are not inverses, and the
89
+ asymmetry was felt as zooming out being weaker than zooming in. Zoom then eases toward its target
90
+ on the same damping curve yaw and pitch already used, so a notch glides instead of stepping.
91
+
92
+ Same gestures after: trackpad flick **1.13x**, mouse 3 notches **1.35x**, and Chrome and Firefox
93
+ now agree per notch. *(preview tier — no API change)*
94
+
8
95
  ## 1.2.0 — 2026-08-20
9
96
 
10
97
  ### Added
package/README.md CHANGED
@@ -65,6 +65,19 @@ if (!wall.supported) {
65
65
  The browser weaves each element's stereo pair at its on-screen rect; the surrounding DOM stays flat.
66
66
  The runtime batches every visible window into one weave per frame, so it scales to a wall of elements.
67
67
 
68
+ A scene with its own camera can hand that camera to the runtime instead of being told where the eyes
69
+ are — a **camera rig** — and let eye tracking perturb its frustum:
70
+
71
+ ```js
72
+ import { cameraRigFromCamera } from '@displayxr/inline3d/three';
73
+ const handle = wall.addScene(canvas, onFrame, { viewRig: cameraRigFromCamera(THREE, cam, { convergence: 1.2 }) });
74
+ handle.setViewRig(cameraRigFromCamera(THREE, cam, { convergence: 1.2, out: rig })); // per frame
75
+ ```
76
+
77
+ No projection math lands in your page or in the SDK — the off-axis frustum stays in the runtime. See
78
+ [view rigs](docs/authoring-inline-3d.md#view-rigs-display-vs-camera) and
79
+ [`samples/camera-rig/`](samples/camera-rig/).
80
+
68
81
  > **Detection:** call `createInline3D()` and check `wall.supported` — do **not** gate on
69
82
  > `navigator.xr.isSessionSupported('inline-3d')`. That async probe resolves `false` if it runs before the
70
83
  > OS weave service has bound (typically at page load), a false-negative that silently drops you to 2D.
@@ -78,6 +91,8 @@ Three.js glue (an off-axis `EyeCamera`) in [`js/inline3d-three.js`](js/inline3d-
78
91
  ```
79
92
  index.html landing (Pages entry point)
80
93
  samples/
94
+ camera-rig/ the camera rig — an orbiting scene that sends its OWN camera each frame;
95
+ convergence + comfort, the attach pattern, C to A/B a display rig
81
96
  windows/ mixed 3D windows — still photos + a live video + a real-time three.js scene,
82
97
  each woven with one SDK call, all on one session
83
98
  splat/ a 3D Gaussian splat in a tile, auto-framed, with a 2D price plate over it
@@ -95,6 +110,11 @@ js/
95
110
  from what the asset declares (you serve the decoder files — see the guide)
96
111
  docs/
97
112
  authoring-inline-3d.md the authoring guide
113
+ authoring-motion-and-effects.md
114
+ motion, transitions and per-eye effects — the half of authoring
115
+ that is not the API
116
+ porting-three-js-apps.md porting an existing three.js app (WebXR or plain) to inline 3D —
117
+ the WebXR→inline-3d mapping table and the whole render loop
98
118
  ```
99
119
 
100
120
  ## The inline-3D model (under the SDK)
package/index.d.ts CHANGED
@@ -13,6 +13,45 @@ export interface TileOptions {
13
13
  feather?: number;
14
14
  }
15
15
 
16
+ /**
17
+ * A view-rig descriptor — what the runtime locates the eye views against. Two rigs, one shape:
18
+ *
19
+ * - **`"display"`** — the canvas is a PORTAL. Its plane is world `z = 0` and the viewer looks
20
+ * through it at a virtual display `virtualDisplayHeight` tall. This is the default rig and
21
+ * what `SceneOptions.virtualDisplayHeight` is shorthand for (identity pose, all factors 1).
22
+ * - **`"camera"`** — an APP CAMERA whose frustum eye tracking perturbs. The runtime keeps your
23
+ * `verticalFov`, offsets the eyes, and skews each frustum so `convergenceDiopters` lands on
24
+ * the zero-disparity plane.
25
+ *
26
+ * Every field is optional; the runtime **clamps** an out-of-range value (once, with a warning)
27
+ * rather than rejecting the rig. A descriptor applies per-locate, so animating one means sending
28
+ * new values each frame — see {@link TileHandle.setViewRig} for the one-frame latency that
29
+ * implies. Build one with `cameraRigFromCamera` / `displayRig` from
30
+ * `@displayxr/inline3d/three`, or by hand.
31
+ */
32
+ export interface XRViewRigInit {
33
+ /** `"display"` (portal) or `"camera"` (app camera). */
34
+ type?: 'display' | 'camera';
35
+ /** Rig pose in the app's world units (default 0,0,0). */
36
+ position?: DOMPointInit;
37
+ /** Rig orientation as a quaternion (default identity). */
38
+ orientation?: DOMPointInit;
39
+ /** **Display rig.** Metres of virtual display; `m2v = this / the element's physical height`. */
40
+ virtualDisplayHeight?: number;
41
+ /** Eye separation — display rig: relative `[0,1]`; camera rig: absolute `>= 0`. */
42
+ ipdFactor?: number;
43
+ /** Head-tracking response — display rig: `[0,1]`; camera rig: absolute `>= 0`. */
44
+ parallaxFactor?: number;
45
+ /** **Display rig only**, `[0.1,10]`: exaggerates or flattens the off-axis skew. */
46
+ perspectiveFactor?: number;
47
+ /** **Camera rig.** `1 / (convergence distance in world units)`; `0` = infinity. */
48
+ convergenceDiopters?: number;
49
+ /** **Camera rig.** The FULL vertical angle, in RADIANS (three's `camera.fov` is degrees). */
50
+ verticalFov?: number;
51
+ /** **Camera rig.** Metres → world units on the eye; `0`/unset means 1. */
52
+ metersToVirtual?: number;
53
+ }
54
+
16
55
  /** Extra options for {@link Inline3D.addScene}. */
17
56
  export interface SceneOptions extends TileOptions {
18
57
  /**
@@ -21,6 +60,15 @@ export interface SceneOptions extends TileOptions {
21
60
  * and render the reported views as-is. Halving it doubles how much of the window an object fills.
22
61
  */
23
62
  virtualDisplayHeight?: number;
63
+ /**
64
+ * A full {@link XRViewRigInit} instead of the scalar height — a posed display rig, or a camera
65
+ * rig. **Supersedes `virtualDisplayHeight`** (which is one particular display rig); passing
66
+ * both warns once and the rig wins **where rigs are supported** — which is the one reason to
67
+ * pass the pair deliberately, since a browser without {@link inline3dViewRigSupported} then
68
+ * falls back to the height you named rather than to its own default. The window weaves either
69
+ * way. Replaceable per frame with {@link TileHandle.setViewRig}.
70
+ */
71
+ viewRig?: XRViewRigInit;
24
72
  /** Element whose visibility drives the lazy create/close lifecycle (defaults to the canvas). */
25
73
  observe?: Element;
26
74
  }
@@ -52,6 +100,23 @@ export interface TileHandle {
52
100
  * @deprecated See {@link TileHandle.exclude} — no-op on browsers with draw-order occlusion.
53
101
  */
54
102
  unexclude(el: Element): void;
103
+ /**
104
+ * Replace this window's view rig. Cheap enough to call every frame — a rig applies per-locate,
105
+ * so animating one means sending new values, not tweening anything.
106
+ *
107
+ * Returns whether the rig reached a **live** layer. `false` means it was stored and will build
108
+ * the next one (a window scrolled away in lazy mode), or that the browser has no rig support
109
+ * ({@link inline3dViewRigSupported}) — in which case it warns once and the window keeps
110
+ * weaving on the runtime's default display rig.
111
+ *
112
+ * **One frame of lag, by construction.** The browser locates views *before* the page's rAF, so
113
+ * a rig set during frame N drives the views delivered in frame N+1. Invisible for a slider or
114
+ * a settled camera; not for a camera that moves with the pointer — for that, send an
115
+ * identity-posed camera rig and parent your eye cameras under the app camera
116
+ * (`cameraRigFromCamera(THREE, cam, { attach: true })` + `EyeCamera.setLocalFromView`), so the
117
+ * scene graph supplies this frame's world pose with no lag at all.
118
+ */
119
+ setViewRig(rig: XRViewRigInit): boolean;
55
120
  /**
56
121
  * Per-window frame counters, for diagnosing the load-induced mono fallback.
57
122
  *
@@ -194,6 +259,19 @@ export function inline3dOverlaySupported(): boolean;
194
259
  */
195
260
  export function inline3dOcclusionByDrawOrder(): boolean;
196
261
 
262
+ /**
263
+ * True when this browser accepts a full {@link XRViewRigInit} — {@link TileHandle.setViewRig} and
264
+ * {@link SceneOptions.viewRig}, i.e. a posed display rig or a camera rig, instead of only the
265
+ * scalar `virtualDisplayHeight`. Sync + cheap; implies {@link inline3DAvailable}.
266
+ *
267
+ * Reads a capability (the presence of `XRDisplayLayer.setViewRig`), never a version or UA string,
268
+ * and is `false` on every browser that predates the rig API — where `virtualDisplayHeight` still
269
+ * works. Branch on it only if a camera rig is load-bearing for your page: `setViewRig` no-ops
270
+ * (warning once) rather than throwing, so a page that merely wants the extra control where it
271
+ * exists can call it unconditionally.
272
+ */
273
+ export function inline3dViewRigSupported(): boolean;
274
+
197
275
  /** Open the page's inline-3D session and return a manager you add windows to. */
198
276
  export function createInline3D(
199
277
  opts?: CreateInline3DOptions,
@@ -229,5 +307,14 @@ export interface XRDisplayLayer {
229
307
  * browser shipped so far — the SDK treats absent as `false` and runs the legacy path.
230
308
  */
231
309
  readonly occlusionByDrawOrder?: boolean;
310
+ /**
311
+ * Replace the rig the runtime locates this layer's views against. Optional because it is
312
+ * absent on browsers that predate the rig API — its PRESENCE on the prototype is the
313
+ * capability signal ({@link inline3dViewRigSupported}), which is why the browser exposes it as
314
+ * a method: a Blink IDL attribute getter throws `Illegal invocation` when read off a prototype
315
+ * (see {@link XRDisplayLayer.occlusionByDrawOrder}), so an attribute could not be probed at
316
+ * all on the browser that has it.
317
+ */
318
+ setViewRig?(rig: XRViewRigInit): void;
232
319
  close(): void;
233
320
  }
@@ -45,6 +45,12 @@
45
45
  // in front), and render `eye.camera` directly. No per-frame world scaling — that is the whole
46
46
  // point of using the rig instead of re-deriving it in the app, and it mirrors the native
47
47
  // reference apps (cube_handle), which supply one scale number and consume render-ready views.
48
+ //
49
+ // VIEW RIGS. virtualDisplayHeight is one number out of a whole descriptor. cameraRigFromCamera()
50
+ // and displayRig() below build the full thing — a posed portal, or an app CAMERA whose frustum
51
+ // the runtime perturbs with the viewer's eyes — for handle.setViewRig(). They fill in a
52
+ // descriptor and nothing else: no Kooima, no off-axis math, no scale, here or anywhere in this
53
+ // SDK. That stays in the runtime, which is the point of the extension.
48
54
 
49
55
  /**
50
56
  * A reusable three.js camera driven directly by an XRView's matrices. Construct once with
@@ -89,6 +95,188 @@ export class EyeCamera {
89
95
  cam.matrixWorldInverse.copy(cam.matrixWorld).invert();
90
96
  return cam;
91
97
  }
98
+
99
+ /** Set the camera's projection + LOCAL pose from an XRView — the attach pattern below. */
100
+ setLocalFromView(view) {
101
+ return this.setLocalFromMatrices(view.projectionMatrix, view.transform.matrix);
102
+ }
103
+
104
+ /**
105
+ * Like setFromMatrices, but the view's transform is written as the camera's LOCAL matrix and
106
+ * three composes `matrixWorld` from the parent — so the eye can hang off another object.
107
+ *
108
+ * WHY THIS EXISTS: the browser locates views BEFORE the page's rAF, so a view rig set during
109
+ * frame N drives the views delivered in frame N+1. Send a camera rig with an IDENTITY pose
110
+ * instead, parent both eye cameras under your app camera object, and the runtime's job shrinks
111
+ * to what it is uniquely good at (the eye offsets and the tracking-perturbed frustum, in rig
112
+ * space) while the app's own scene graph supplies the world pose — this frame's, not last
113
+ * frame's. A camera whipping around under the pointer then has zero rig lag.
114
+ *
115
+ * That is a SCENE-GRAPH parent and nothing more. No projection math moves into the page: the
116
+ * projectionMatrix is still the runtime's, untouched, and the local transform is still the eye
117
+ * pose the runtime reported — it is simply interpreted in rig space rather than world space,
118
+ * which is exactly what an identity-posed rig means.
119
+ *
120
+ * appCamera.add(eyeL.camera); appCamera.add(eyeR.camera); // once
121
+ * handle.setViewRig(cameraRigFromCamera(THREE, appCamera, { attach: true, convergence }));
122
+ * eyeL.setLocalFromView(views[0]); // per frame
123
+ *
124
+ * `matrixAutoUpdate` is false (the matrix is ours, not three's) but that does NOT opt out of
125
+ * world composition: `updateMatrixWorld` still multiplies parent × local. So the eye cameras
126
+ * must be reached by a normal traversal — `renderer.render(scene, eye.camera)` only
127
+ * auto-updates a camera whose `parent` is null, so make sure the app camera is IN the scene
128
+ * (or call `scene.updateMatrixWorld()` yourself) or the eyes will render at a stale pose.
129
+ *
130
+ * @param {ArrayLike<number>} projectionMatrix 16 floats, column-major (view.projectionMatrix).
131
+ * @param {ArrayLike<number>} transformMatrix 16 floats, column-major (view.transform.matrix),
132
+ * read as a pose in the RIG's space.
133
+ */
134
+ setLocalFromMatrices(projectionMatrix, transformMatrix) {
135
+ const cam = this.camera;
136
+ cam.projectionMatrix.fromArray(projectionMatrix);
137
+ cam.projectionMatrixInverse.copy(cam.projectionMatrix).invert();
138
+ cam.matrix.fromArray(transformMatrix);
139
+ // Hand the world matrices back to three. Marking the flag is the whole handshake: with
140
+ // matrixAutoUpdate off, nothing else tells updateMatrixWorld that the local matrix moved,
141
+ // and a parent that happens not to move that frame would leave the eye at its old world
142
+ // pose (Camera.updateMatrixWorld re-derives matrixWorldInverse from matrixWorld, so both
143
+ // stay consistent once it runs).
144
+ cam.matrixWorldNeedsUpdate = true;
145
+ return cam;
146
+ }
147
+ }
148
+
149
+ // Scratch for cameraRigFromCamera's decompose. Module-scoped and lazily built from the caller's
150
+ // THREE, so a per-frame rig costs no allocation — the values are copied straight out into the
151
+ // descriptor before anything else can observe them, so sharing is safe.
152
+ let _scratch = null;
153
+ function scratch(THREE) {
154
+ if (!_scratch) {
155
+ _scratch = { p: new THREE.Vector3(), q: new THREE.Quaternion(), s: new THREE.Vector3() };
156
+ }
157
+ return _scratch;
158
+ }
159
+
160
+ /**
161
+ * Build a CAMERA-rig descriptor from a three.js PerspectiveCamera.
162
+ *
163
+ * A camera rig says "here is an app camera; perturb its frustum with the viewer's eyes" — the
164
+ * runtime keeps your vertical FOV, offsets the eyes, and skews each frustum so the convergence
165
+ * distance lands on the zero-disparity plane. Contrast the DISPLAY rig ({@link displayRig}),
166
+ * which says "the canvas is a portal onto a virtual display this tall". Neither computes
167
+ * anything here: this function only fills in a descriptor, and every off-axis projection stays
168
+ * in the runtime, where it is the same code the native apps use.
169
+ *
170
+ * CONVERGENCE IS THE ONE KNOB TO GET RIGHT. It is the distance at which content sits ON the
171
+ * glass; everything nearer pops out, everything further recedes. Point it at whatever the viewer
172
+ * is meant to be looking at (an orbit target, a hit-tested surface) — for an orbiting camera
173
+ * that is usually just the orbit radius. Left at 0 it means infinity, which puts the entire
174
+ * scene in front of the display and is comfortable for almost nothing.
175
+ *
176
+ * COMFORT. The runtime's rule is `ipdFactor × metersToVirtual × convergenceDiopters × N <= 1`
177
+ * (N = nominal viewing distance, ~0.5 m): at 1 the viewer's eyes are parallel on infinitely far
178
+ * content, and past it they diverge, which no one can fuse. With the defaults (factors 1,
179
+ * metersToVirtual 1) that is `convergence >= ~0.5` world units. Nothing here enforces it — the
180
+ * runtime clamps out-of-range values itself, once, with a warning — but a scene authored in
181
+ * centimetres with a 0.1-unit convergence is the shape of the mistake.
182
+ *
183
+ * @param {object} THREE your imported three.js module namespace.
184
+ * @param {object} camera a THREE.PerspectiveCamera (`.fov` in degrees, `.matrixWorld` current).
185
+ * @param {object} [opts]
186
+ * @param {number} [opts.convergence=0] zero-disparity distance in WORLD units (0 = infinity).
187
+ * @param {boolean} [opts.attach=false] emit an IDENTITY pose, for the attach pattern above —
188
+ * you parent the eye cameras under this camera and three supplies the world pose.
189
+ * @param {number} [opts.ipdFactor=1] eye separation, ABSOLUTE on a camera rig (world units per
190
+ * metre of real IPD); 0 collapses to mono.
191
+ * @param {number} [opts.parallaxFactor=1] how far the rig tracks head motion, absolute likewise.
192
+ * @param {number} [opts.metersToVirtual=1] metres → world units on the eye.
193
+ * @param {object} [opts.out] a descriptor object to overwrite instead of allocating one.
194
+ * @returns {object} an XRViewRigInit-shaped plain object.
195
+ */
196
+ export function cameraRigFromCamera(THREE, camera, opts = {}) {
197
+ const {
198
+ convergence = 0,
199
+ attach = false,
200
+ ipdFactor = 1,
201
+ parallaxFactor = 1,
202
+ metersToVirtual = 1,
203
+ out = {},
204
+ } = opts;
205
+ out.type = 'camera';
206
+ if (attach) {
207
+ // Identity pose: the rig IS the camera, so the runtime reports eyes in camera space and the
208
+ // scene graph does the rest. Deliberately not "the camera's pose from a frame ago".
209
+ out.position = { x: 0, y: 0, z: 0 };
210
+ out.orientation = { x: 0, y: 0, z: 0, w: 1 };
211
+ } else {
212
+ // World pose, decomposed from the matrix rather than read off .position/.quaternion: those
213
+ // are LOCAL, and an app camera parented under a rig/dolly (the usual way to build an orbit)
214
+ // would then send the runtime a pose in the wrong space.
215
+ camera.updateMatrixWorld();
216
+ const { p, q, s } = scratch(THREE);
217
+ camera.matrixWorld.decompose(p, q, s);
218
+ out.position = { x: p.x, y: p.y, z: p.z };
219
+ out.orientation = { x: q.x, y: q.y, z: q.z, w: q.w };
220
+ }
221
+ out.ipdFactor = ipdFactor;
222
+ out.parallaxFactor = parallaxFactor;
223
+ // Diopters, not distance: the wire unit is 1/distance so that "infinity" is representable as
224
+ // a finite 0 instead of a sentinel.
225
+ out.convergenceDiopters = convergence > 0 ? 1 / convergence : 0;
226
+ out.verticalFov = THREE.MathUtils.degToRad(camera.fov); // three's fov is the FULL angle, in degrees
227
+ out.metersToVirtual = metersToVirtual;
228
+ return out;
229
+ }
230
+
231
+ /**
232
+ * Build a DISPLAY-rig descriptor — the default rig, made explicit and posable.
233
+ *
234
+ * The display rig treats the canvas as a PORTAL: the element's plane is world z = 0 and the
235
+ * viewer looks through it at a virtual display `virtualDisplayHeight` metres tall (the m2v knob
236
+ * `addScene`'s scalar option sets). This adds what the scalar cannot say — a pose, so the portal
237
+ * can be tilted or offset, and the three factors, so eye separation, head-tracking response and
238
+ * perspective strength can be dialled independently.
239
+ *
240
+ * The factors are RELATIVE here (unlike a camera rig, where ipd/parallax are absolute):
241
+ * `ipdFactor` and `parallaxFactor` are [0,1] multipliers on what the display would naturally do
242
+ * — 1 is correct-by-construction, 0 is flat/frozen, and the values between are a comfort dial,
243
+ * not a correctness one. `perspectiveFactor` is [0.1,10] and exaggerates or flattens the
244
+ * off-axis skew; it is the one knob with no physical justification, so treat it as an effect.
245
+ * The runtime clamps anything out of range (once, with a warning) rather than refusing the rig.
246
+ *
247
+ * @param {object} [opts]
248
+ * @param {number} [opts.virtualDisplayHeight=0.24] metres of virtual display (the zoom knob).
249
+ * @param {{x?:number,y?:number,z?:number}} [opts.position] rig pose, app world units.
250
+ * @param {{x?:number,y?:number,z?:number,w?:number}} [opts.orientation] rig orientation quat.
251
+ * @param {number} [opts.ipdFactor=1] [opts.parallaxFactor=1] [opts.perspectiveFactor=1]
252
+ * @param {object} [opts.out] a descriptor object to overwrite instead of allocating one.
253
+ * @returns {object} an XRViewRigInit-shaped plain object.
254
+ */
255
+ export function displayRig(opts = {}) {
256
+ const {
257
+ virtualDisplayHeight = 0.24,
258
+ position = { x: 0, y: 0, z: 0 },
259
+ orientation = { x: 0, y: 0, z: 0, w: 1 },
260
+ ipdFactor = 1,
261
+ parallaxFactor = 1,
262
+ perspectiveFactor = 1,
263
+ out = {},
264
+ } = opts;
265
+ out.type = 'display';
266
+ // Copied field by field, not aliased: a caller reusing `out` every frame must not end up
267
+ // holding a live reference to a THREE.Vector3 it is also mutating.
268
+ out.position = { x: position.x || 0, y: position.y || 0, z: position.z || 0 };
269
+ out.orientation = {
270
+ x: orientation.x || 0,
271
+ y: orientation.y || 0,
272
+ z: orientation.z || 0,
273
+ w: orientation.w === undefined ? 1 : orientation.w,
274
+ };
275
+ out.virtualDisplayHeight = virtualDisplayHeight;
276
+ out.ipdFactor = ipdFactor;
277
+ out.parallaxFactor = parallaxFactor;
278
+ out.perspectiveFactor = perspectiveFactor;
279
+ return out;
92
280
  }
93
281
 
94
282
  /**
@@ -219,6 +219,7 @@ export class SceneViewer {
219
219
 
220
220
  this._fitScale = 1;
221
221
  this._zoom = 1;
222
+ this._targetZoom = 1;
222
223
  this._yaw = 0;
223
224
  this._pitch = 0;
224
225
  this._targetYaw = 0;
@@ -346,7 +347,7 @@ export class SceneViewer {
346
347
  if (pitch !== undefined) {
347
348
  this._targetPitch = this._pitch = clamp(pitch, this.pitchLimit[0], this.pitchLimit[1]);
348
349
  }
349
- if (zoom !== undefined) this._zoom = clamp(zoom, 0.2, 6);
350
+ if (zoom !== undefined) this._targetZoom = this._zoom = clamp(zoom, ZOOM_MIN, ZOOM_MAX);
350
351
  this._applyTransform();
351
352
  }
352
353
 
@@ -682,6 +683,14 @@ export class SceneViewer {
682
683
  const k = dt > 0 ? 1 - Math.pow(0.001, dt) : 1;
683
684
  this._yaw += (this._targetYaw - this._yaw) * k;
684
685
  this._pitch += (this._targetPitch - this._pitch) * k;
686
+ // Zoom eases on the same curve. Multiplicatively, because zoom is a ratio: approaching 2x
687
+ // linearly spends most of its time near the start and then lurches, while a ratio approach
688
+ // covers equal PERCEPTUAL steps per frame.
689
+ if (Math.abs(this._targetZoom - this._zoom) > 1e-4) {
690
+ this._zoom *= Math.pow(this._targetZoom / this._zoom, k);
691
+ } else {
692
+ this._zoom = this._targetZoom;
693
+ }
685
694
  this._applyTransform();
686
695
  }
687
696
 
@@ -725,9 +734,29 @@ export class SceneViewer {
725
734
  };
726
735
  this._onWheel = (ev) => {
727
736
  ev.preventDefault();
728
- this._zoom = clamp(this._zoom * (ev.deltaY > 0 ? 0.92 : 1.087), 0.2, 6);
737
+ // Scale by the delta's MAGNITUDE, not just its sign. The previous version applied a fixed
738
+ // 8% step per event, which is roughly right for one mouse notch and badly wrong for a
739
+ // trackpad: a two-finger flick emits dozens of small events, so an 8% step compounded per
740
+ // event sent the subject to the clamp on a gesture the user read as gentle.
741
+ //
742
+ // deltaMode has to be normalised first or the same code means different things per browser:
743
+ // Chrome reports pixels, Firefox reports LINES for a mouse wheel (deltaY 3, not 100), and
744
+ // a page-mode device would otherwise be ~200x more sensitive than a trackpad.
745
+ let px = ev.deltaY;
746
+ if (ev.deltaMode === 1) px *= WHEEL_LINE_PX;
747
+ else if (ev.deltaMode === 2) px *= WHEEL_PAGE_PX;
748
+ // OS pointer acceleration can spike a single event past 500px. Clamping per event keeps one
749
+ // hard flick from teleporting the subject while leaving the gesture's total travel intact,
750
+ // since the events keep coming.
751
+ px = clamp(px, -WHEEL_MAX_PX, WHEEL_MAX_PX);
752
+
753
+ // exp() rather than a multiply-add: zoom is a ratio, so equal deltas should give equal
754
+ // ratios in both directions. `1 + d` and `1 - d` are not inverses, and the asymmetry is
755
+ // felt as zooming out being weaker than zooming in.
756
+ this._targetZoom = clamp(this._targetZoom * Math.exp(-px * ZOOM_PER_PX), ZOOM_MIN, ZOOM_MAX);
729
757
  this._lastInput = now();
730
- this._applyTransform();
758
+ // No _applyTransform() here: _tick() eases toward the target and applies it, which is what
759
+ // makes a wheel notch glide instead of step.
731
760
  };
732
761
 
733
762
  el.style.touchAction = 'none'; // or the browser eats the drag as a scroll
@@ -749,6 +778,24 @@ export class SceneViewer {
749
778
  }
750
779
  }
751
780
 
781
+ /**
782
+ * Wheel-zoom tuning.
783
+ *
784
+ * ZOOM_PER_PX is set so one ordinary mouse notch (~100 px in Chrome) is about a 10% step, which
785
+ * puts a trackpad's 1-10 px events at a fraction of a percent each — small enough that the easing
786
+ * reads as continuous rather than as a stack of jumps.
787
+ */
788
+ // A deltaMode-1 "line" is sized to match a wheel DETENT, not a line of text. Firefox reports a
789
+ // notch as deltaY 3 in lines where Chrome reports it as ~100 in pixels, so 33 makes one physical
790
+ // notch feel the same in both; 16 (a text line) would make Firefox roughly half as responsive as
791
+ // Chrome for identical hardware.
792
+ const WHEEL_LINE_PX = 33;
793
+ const WHEEL_PAGE_PX = 400; // a "page" in deltaMode 2; rare, but it must not be unbounded
794
+ const WHEEL_MAX_PX = 120; // per-event ceiling, against OS pointer acceleration spikes
795
+ const ZOOM_PER_PX = 0.001;
796
+ const ZOOM_MIN = 0.2;
797
+ const ZOOM_MAX = 6;
798
+
752
799
  function now() {
753
800
  return typeof performance !== 'undefined' ? performance.now() : Date.now();
754
801
  }
package/js/inline3d.js CHANGED
@@ -41,6 +41,23 @@ const hasLayer = () =>
41
41
  // no-op — the page still works, the overlay just weaves like before.
42
42
  const hasExclusion = () =>
43
43
  hasLayer() && 'excludeElement' in window.XRDisplayLayer.prototype;
44
+ // View rigs (XR_DXR_view_rig, browser-side `XRDisplayLayer.setViewRig`). The browser has always
45
+ // chained a rig descriptor onto its per-frame xrLocateViews — a DISPLAY rig with an identity
46
+ // pose whose only knob was `virtualDisplayHeight`. setViewRig opens the whole descriptor: a
47
+ // posed display rig, or a CAMERA rig that puts the app's own camera in the runtime's hands.
48
+ //
49
+ // The capability signal is the METHOD's presence, and that is deliberate: a Blink IDL attribute
50
+ // getter throws `Illegal invocation` when read off the prototype (the trap documented at length
51
+ // under occlusionByDrawOrder below), so an attribute would be undetectable on exactly the
52
+ // browser that has it. `'setViewRig' in prototype` calls nothing and is safe.
53
+ const hasViewRig = () => {
54
+ if (!hasLayer()) return false;
55
+ try {
56
+ return 'setViewRig' in window.XRDisplayLayer.prototype;
57
+ } catch {
58
+ return false; // a prototype that refuses to be probed is not a capability
59
+ }
60
+ };
44
61
  // ── draw-order occlusion (browser Phase 2, browser patches 0063/0064) ─────────────────────
45
62
  //
46
63
  // The browser composites ANY 2D content over a woven tile per-pixel BY DRAW ORDER — headers,
@@ -170,6 +187,49 @@ export function inline3dOcclusionByDrawOrder() {
170
187
  return hasDrawOrderOcclusion();
171
188
  }
172
189
 
190
+ /**
191
+ * True when this browser accepts a full VIEW RIG descriptor — `handle.setViewRig(rig)` and
192
+ * `addScene`'s `viewRig` option, i.e. a posed display rig or a camera rig, instead of only the
193
+ * scalar `virtualDisplayHeight`. Sync + cheap; implies {@link inline3DAvailable}.
194
+ *
195
+ * Reads a capability (the presence of `XRDisplayLayer.prototype.setViewRig`), never a version or
196
+ * UA string. False on every browser that predates the rig API — where `virtualDisplayHeight`
197
+ * still works, which is why a page needs to branch on this only if a camera rig is load-bearing
198
+ * for it: `setViewRig` no-ops loudly-once rather than throwing, so a page that just wants the
199
+ * extra control when it is there can call it unconditionally.
200
+ */
201
+ export function inline3dViewRigSupported() {
202
+ return inline3DAvailable() && hasViewRig();
203
+ }
204
+
205
+ // One-shot notices about view rigs. Both are per-document, and both describe a situation that is
206
+ // identical on every frame — so warning per call would bury the page's own logs in a rAF loop.
207
+ let notedNoViewRig = false;
208
+ function noteNoViewRig() {
209
+ if (notedNoViewRig) return;
210
+ notedNoViewRig = true;
211
+ console.warn(
212
+ '[inline3d] This browser has no XRDisplayLayer.setViewRig, so the view rig was ignored ' +
213
+ '(the window still weaves — the runtime keeps the default display rig, scaled by ' +
214
+ "virtualDisplayHeight). Gate on inline3dViewRigSupported() if your page's framing " +
215
+ 'depends on the rig; further calls are silent.'
216
+ );
217
+ }
218
+
219
+ let notedRigWinsOverHeight = false;
220
+ function noteRigWinsOverHeight() {
221
+ if (notedRigWinsOverHeight) return;
222
+ notedRigWinsOverHeight = true;
223
+ console.warn(
224
+ '[inline3d] addScene got BOTH viewRig and virtualDisplayHeight; on a browser with rig ' +
225
+ 'support the rig wins and the height is dropped. They describe the same slot — ' +
226
+ 'virtualDisplayHeight is shorthand for exactly one rig (display, identity pose, all ' +
227
+ 'factors 1) — so there is no merge. This is only worth passing as a PAIR deliberately: ' +
228
+ 'the height is what a browser without setViewRig will use, so a camera-rig scene can ' +
229
+ 'name its own fallback framing. Otherwise say it once, inside the rig.'
230
+ );
231
+ }
232
+
173
233
  /**
174
234
  * Open the page's inline-3D session and return a manager you add windows to.
175
235
  *
@@ -491,10 +551,21 @@ class Inline3D {
491
551
  * @param {object} [opts]
492
552
  * @param {number} [opts.virtualDisplayHeight=0.24] metres of virtual display the scene is
493
553
  * composed for. Larger = the element shows a bigger slice of the world.
554
+ * @param {object} [opts.viewRig] a full view-rig descriptor (XRViewRigInit) instead of the
555
+ * scalar height: a POSED display rig, or a CAMERA rig that hands the runtime your app
556
+ * camera's pose/FOV/convergence and lets eye tracking perturb its frustum. Supersedes
557
+ * virtualDisplayHeight (which is one particular display rig), and can be replaced per
558
+ * frame with `handle.setViewRig()`. Ignored on a browser without rig support, which
559
+ * falls back to `virtualDisplayHeight` if one was given (that pair is the one reason
560
+ * to pass both) — either way the window still weaves.
494
561
  * @param {Element} [opts.observe=canvas] element whose visibility gates lazy create/close.
495
562
  * @returns {{remove():void}}
496
563
  */
497
564
  addScene(canvas, onFrame, opts = {}) {
565
+ // The rig and the height describe the same one slot in the layer init, so warn where a
566
+ // caller has said it twice — silently dropping one of two things the page explicitly asked
567
+ // for is how a scene ends up framed at a scale nobody chose.
568
+ if (opts.viewRig && opts.virtualDisplayHeight !== undefined) noteRigWinsOverHeight();
498
569
  const win = this._register(canvas, 'scene', { virtualDisplayHeight: 0.24, ...opts });
499
570
  win.onFrame = onFrame;
500
571
  win.ownsBuffer = false; // the app sizes a scene canvas; we never touch canvas.width/height
@@ -528,6 +599,40 @@ class Inline3D {
528
599
  if (!el || !win.excluded.delete(el)) return;
529
600
  this._dropExclusion(win, el);
530
601
  },
602
+ /**
603
+ * Replace this window's VIEW RIG — the descriptor the runtime locates views against.
604
+ * Cheap enough to call every frame (that is the intended use: a rig is per-locate, so
605
+ * animating one means sending new values, not tweening anything).
606
+ *
607
+ * Remembered on the window as well as pushed at the layer, so the lazy lifecycle cannot
608
+ * quietly lose it (see `viewRig` in _register). Returns whether it reached a LIVE layer:
609
+ * false also means "stored, and it will build the next layer" for a window that is
610
+ * currently scrolled away — which is the honest answer for a page driving this per frame,
611
+ * and the reason it is a boolean rather than void.
612
+ *
613
+ * ONE FRAME OF LAG, by construction. The browser locates views BEFORE the page's rAF, so
614
+ * the rig you set during frame N drives the views delivered in frame N+1. For a slow
615
+ * knob (a slider, a settled camera) that is invisible; for a camera that moves with the
616
+ * pointer it is not, and the fix is not to fight it — send an IDENTITY-posed camera rig
617
+ * and parent your eye cameras under the app camera, so three composes the world pose with
618
+ * zero lag (see `cameraRigFromCamera(..., {attach:true})` + `EyeCamera.setLocalFromView`).
619
+ */
620
+ setViewRig: (rig) => {
621
+ win.viewRig = rig || null;
622
+ if (!hasViewRig()) {
623
+ noteNoViewRig();
624
+ return false;
625
+ }
626
+ if (!win.layer) return false;
627
+ try {
628
+ win.layer.setViewRig(rig);
629
+ return true;
630
+ } catch {
631
+ // A closed layer or a descriptor the browser refused. Neither is worth throwing over
632
+ // in a per-frame call — the window keeps weaving on the rig it already has.
633
+ return false;
634
+ }
635
+ },
531
636
  // Read-only counters, for pages that want to see the load-induced mono fallback rather
532
637
  // than wait for a bug report about "blinking". Scene windows only; 0/0 elsewhere.
533
638
  stats: () => ({ frames: win.frames, monoFrames: win.monoFrames }),
@@ -565,6 +670,11 @@ class Inline3D {
565
670
  reqW: opts.width || 0,
566
671
  reqH: opts.height || 0,
567
672
  virtualDisplayHeight: opts.virtualDisplayHeight || 0,
673
+ // The rig this window's NEXT layer is built with. Latest wins, and it is kept on the
674
+ // window rather than only pushed at the live layer because the lazy lifecycle destroys
675
+ // and rebuilds layers behind the page's back: a tile that scrolls away and back would
676
+ // otherwise silently revert to the default display rig mid-scene.
677
+ viewRig: opts.viewRig || null,
568
678
  observeEl: opts.observe || canvas,
569
679
  ctx: kind === 'scene' ? null : canvas.getContext('2d'),
570
680
  repaint: () => this._paint(win, null),
@@ -621,10 +731,23 @@ class Inline3D {
621
731
  // layers churn with scroll, so activations double as cheap rescan points.
622
732
  this._scanChrome();
623
733
  try {
624
- // virtualDisplayHeight (display-rig m2v) tells the runtime what scale this
625
- // window's scene is authored at, so it returns render-ready scaled views.
734
+ // What scale/pose the runtime should report views at. virtualDisplayHeight (display-rig
735
+ // m2v) is the scalar shorthand; a viewRig is the whole descriptor and therefore replaces
736
+ // it rather than combining with it. Building the layer WITH the rig (instead of
737
+ // constructing then calling setViewRig) matters for a re-activated window: the layer's
738
+ // very first located frame is already on the page's rig, so a tile scrolling back into
739
+ // view never shows one frame of default framing.
740
+ //
741
+ // The rig is only offered to a browser that HAS rigs. An older one would take the init
742
+ // object, find no member it knows, and fall back to its own default height — so a page
743
+ // that passed both (a camera rig plus the height an older browser should use) would get
744
+ // neither. Gating here is what makes that fallback pair actually work.
626
745
  const init =
627
- win.virtualDisplayHeight > 0 ? { virtualDisplayHeight: win.virtualDisplayHeight } : {};
746
+ win.viewRig && hasViewRig()
747
+ ? { viewRig: win.viewRig }
748
+ : win.virtualDisplayHeight > 0
749
+ ? { virtualDisplayHeight: win.virtualDisplayHeight }
750
+ : {};
628
751
  win.layer = new XRDisplayLayer(this.session, win.canvas, init);
629
752
  } catch {
630
753
  win.layer = null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@displayxr/inline3d",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
4
4
  "description": "Turn any HTML <canvas> into a glasses-free-3D window on a DisplayXR display, inside an ordinary web page. Dependency-free; progressive enhancement (falls back to plain 2D everywhere else).",
5
5
  "type": "module",
6
6
  "types": "./index.d.ts",
package/three.d.ts CHANGED
@@ -2,6 +2,8 @@
2
2
  // These take your imported THREE namespace as a constructor arg, so the SDK never
3
3
  // bundles three.js (it is an optional peer dependency).
4
4
 
5
+ import type { XRViewRigInit } from './index.js';
6
+
5
7
  /**
6
8
  * A reusable three.js camera driven directly by an XRView's matrices. Construct once with your
7
9
  * THREE namespace and reuse across frames/windows. Read `.camera` and render it as-is — author
@@ -27,8 +29,84 @@ export class EyeCamera {
27
29
  projectionMatrix: ArrayLike<number>,
28
30
  transformMatrix: ArrayLike<number>,
29
31
  ): void;
32
+ /** Set the projection + LOCAL pose from an XRView — the attach pattern (see below). */
33
+ setLocalFromView(view: XRView): void;
34
+ /**
35
+ * Like {@link EyeCamera.setFromMatrices}, but the transform becomes the camera's **local**
36
+ * matrix and three composes `matrixWorld` from the parent — for the attach pattern: send an
37
+ * identity-posed camera rig, parent the eye cameras under your app camera, and the scene graph
38
+ * supplies this frame's world pose instead of the rig's one-frame-old one.
39
+ *
40
+ * `matrixAutoUpdate` is false but world composition still runs, so the eyes must be reached by
41
+ * a normal traversal: `renderer.render(scene, eye.camera)` auto-updates a camera only when its
42
+ * `parent` is null, so keep the app camera IN the scene (or call `scene.updateMatrixWorld()`).
43
+ */
44
+ setLocalFromMatrices(
45
+ projectionMatrix: ArrayLike<number>,
46
+ transformMatrix: ArrayLike<number>,
47
+ ): void;
48
+ }
49
+
50
+ /** Options for {@link cameraRigFromCamera}. */
51
+ export interface CameraRigOptions {
52
+ /** Zero-disparity distance in WORLD units — content there sits on the glass. 0 = infinity. */
53
+ convergence?: number;
54
+ /** Emit an IDENTITY pose for the attach pattern ({@link EyeCamera.setLocalFromMatrices}). */
55
+ attach?: boolean;
56
+ /** Eye separation, ABSOLUTE on a camera rig (default 1); 0 collapses to mono. */
57
+ ipdFactor?: number;
58
+ /** Head-tracking response, absolute likewise (default 1). */
59
+ parallaxFactor?: number;
60
+ /** Metres → world units on the eye (default 1). */
61
+ metersToVirtual?: number;
62
+ /** A descriptor to overwrite instead of allocating one (for a per-frame call). */
63
+ out?: XRViewRigInit;
30
64
  }
31
65
 
66
+ /** Options for {@link displayRig}. */
67
+ export interface DisplayRigOptions {
68
+ /** Metres of virtual display — the zoom knob (default 0.24). */
69
+ virtualDisplayHeight?: number;
70
+ /** Rig pose in app world units (default 0,0,0). */
71
+ position?: { x?: number; y?: number; z?: number };
72
+ /** Rig orientation quaternion (default identity). */
73
+ orientation?: { x?: number; y?: number; z?: number; w?: number };
74
+ /** Eye separation as a RELATIVE `[0,1]` multiplier (default 1). */
75
+ ipdFactor?: number;
76
+ /** Head-tracking response, `[0,1]` (default 1). */
77
+ parallaxFactor?: number;
78
+ /** Off-axis skew strength, `[0.1,10]` (default 1) — an effect, not a correctness knob. */
79
+ perspectiveFactor?: number;
80
+ /** A descriptor to overwrite instead of allocating one. */
81
+ out?: XRViewRigInit;
82
+ }
83
+
84
+ /**
85
+ * Build a CAMERA-rig descriptor from a `THREE.PerspectiveCamera` for `handle.setViewRig()`: the
86
+ * runtime keeps the camera's vertical FOV, offsets the eyes and skews each frustum so
87
+ * `convergence` lands on the zero-disparity plane. The pose is decomposed from `matrixWorld`
88
+ * (so a camera parented under a dolly still reports world space) unless `attach` is set, which
89
+ * emits identity instead.
90
+ *
91
+ * Comfort rule, from the runtime: `ipdFactor × metersToVirtual × convergenceDiopters × N <= 1`
92
+ * (N ≈ 0.5 m nominal viewing distance). Nothing here enforces it; the runtime clamps.
93
+ *
94
+ * @param THREE your imported three.js module namespace.
95
+ * @param camera a `THREE.PerspectiveCamera` (`.fov` degrees, `.matrixWorld`).
96
+ */
97
+ export function cameraRigFromCamera(
98
+ THREE: unknown,
99
+ camera: unknown,
100
+ opts?: CameraRigOptions,
101
+ ): XRViewRigInit;
102
+
103
+ /**
104
+ * Build a DISPLAY-rig descriptor — the default rig, made explicit and posable: the canvas is a
105
+ * portal onto a virtual display `virtualDisplayHeight` metres tall. Adds what the scalar
106
+ * `SceneOptions.virtualDisplayHeight` cannot say: a pose, and the three factors.
107
+ */
108
+ export function displayRig(opts?: DisplayRigOptions): XRViewRigInit;
109
+
32
110
  /**
33
111
  * Fade a rendered eye's edges to transparent, so a 3D window dissolves into the page instead of
34
112
  * ending at a hard rectangle. Call once per eye, straight after `renderer.render(scene, eye.camera)`,