@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 +87 -0
- package/README.md +20 -0
- package/index.d.ts +87 -0
- package/js/inline3d-three.js +188 -0
- package/js/inline3d-viewer.js +50 -3
- package/js/inline3d.js +126 -3
- package/package.json +1 -1
- package/three.d.ts +78 -0
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
|
}
|
package/js/inline3d-three.js
CHANGED
|
@@ -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
|
/**
|
package/js/inline3d-viewer.js
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
625
|
-
//
|
|
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.
|
|
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.
|
|
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)`,
|