@displayxr/inline3d 1.25.0 → 1.26.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,31 @@ 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.26.0 — 2026-09-25
9
+
10
+ Touches the **preview tier** (`./splat`, `engine: 'playcanvas'` only). Additive; nothing changes
11
+ for a page that does not call it.
12
+
13
+ - **`handle.setViewOffset({x, y})`** moves the 2D tier's mono EYE in its own plane, off-axis with
14
+ the focus plane pinned — a head-parallax analogue for a flat screen with no tracked eyes (a
15
+ phone's tilt). Normalised and clamped to the unit disc; |offset| = 1 swings the line of sight to
16
+ the focus by `orbitMaxDeg` (15°), the drag orbit's cone. It moves the eye, not the scene, so it
17
+ composes with the orbit instead of fighting its relax. A snap (ease it in the page); `null`
18
+ resets. Mono only: a no-op for the pixels in woven 3D, where the head tracker owns the eyes, and
19
+ ignored on `controls: 'page'`. **`handle.viewOffset`** reads it back. `pick()` and the effects'
20
+ eye frame follow the offset camera.
21
+
22
+ ## 1.25.1 — 2026-09-25
23
+
24
+ Touches the **preview tier** (`./splat`, `engine: 'playcanvas'` only). Additive; nothing changes
25
+ for a page that does not call it.
26
+
27
+ - **`handle.setRenderScale(s)`** changes the per-eye buffer scale (the `renderScale` option) live,
28
+ in (0, 4], and resizes the backing store on the next animation frame; **`handle.renderScale`**
29
+ reads it back. For an app whose screens differ: full resolution where the splat is hidden and
30
+ only stage quads draw, a lower scale where a large splat is on screen. Replaces poking the
31
+ viewer's internal field.
32
+
8
33
  ## 1.25.0 — 2026-09-25
9
34
 
10
35
  Touches the **preview tier** (a new subpath, `./player`) and the **core** (`.`, frozen tier): new
@@ -452,6 +452,40 @@ export function pageViewAxis(M) {
452
452
  return { origin: [M[12], M[13], M[14]], forward: [-M[8] / fl, -M[9] / fl, -M[10] / fl], scale: s };
453
453
  }
454
454
 
455
+ /**
456
+ * handle.setViewOffset — the mono camera moved IN ITS OWN PLANE, off-axis, with the focus plane
457
+ * pinned. A head-parallax analogue for the 2D tier (a phone's tilt), where there are no tracked
458
+ * eyes: the eye slides sideways/up, the window through the focus plane stays where it was, so the
459
+ * focus stays put on screen while nearer content moves against the farther. Not a rotation — the
460
+ * orbit (setPose / the drag) turns the SCENE about the focus; this moves the EYE, and the two
461
+ * compose.
462
+ *
463
+ * e = (ox, oy) · c · tan(maxDeg) camera-plane displacement, display units
464
+ * pose' = pose · T(e.x, e.y, 0)
465
+ * P'[8] = P[8] − P[0]·e.x / c, P'[9] = P[9] − P[5]·e.y / c (the window shifted by −e·n/c)
466
+ *
467
+ * `c` is the focus's distance along the camera's view axis, so |offset| = 1 swings the line of
468
+ * sight to the focus by `maxDeg` — the same comfort cone as the drag orbit (orbitMaxDeg, 15°).
469
+ * Being an ANGLE it is scale-free: zoom scales the scene about the focus, and c does not move.
470
+ * Writes into outPose/outProj and returns true; false (outputs untouched) when the offset is zero
471
+ * or the focus is not in front of the camera — the caller then draws the unshifted camera.
472
+ */
473
+ export function offsetMonoView(pose, proj, ox, oy, c, maxDeg, outPose, outProj) {
474
+ if ((!ox && !oy) || !(c > 1e-6) || !Number.isFinite(c)) return false;
475
+ const k = c * Math.tan(maxDeg * DEG);
476
+ const ex = ox * k;
477
+ const ey = oy * k;
478
+ outPose.set(pose);
479
+ // pose · T(e): translate along the camera's own x/y axes (columns 0 and 1).
480
+ outPose[12] = pose[12] + pose[0] * ex + pose[4] * ey;
481
+ outPose[13] = pose[13] + pose[1] * ex + pose[5] * ey;
482
+ outPose[14] = pose[14] + pose[2] * ex + pose[6] * ey;
483
+ outProj.set(proj);
484
+ outProj[8] = proj[8] - (proj[0] * ex) / c;
485
+ outProj[9] = proj[9] - (proj[5] * ey) / c;
486
+ return true;
487
+ }
488
+
455
489
  /**
456
490
  * The attach-pattern camera rig for controls:'page' — the auto-3D shim's buildRig, field for
457
491
  * field: identity pose, the page's vertical FOV, convergence d, metersToVirtual = depth · d / 0.5
@@ -874,6 +908,10 @@ export class PlayCanvasSplatViewer {
874
908
  this._orbitCentre = { x: 0, y: 0, z: 0 };
875
909
  this._focusRecentres = true;
876
910
  this._focusSettled = true;
911
+ // handle.setViewOffset: normalised camera-plane eye offset, mono only (offsetMonoView).
912
+ this._viewOffset = [0, 0];
913
+ this._offPose = new Float64Array(16);
914
+ this._offProj = new Float64Array(16);
877
915
  this.onFocusChange = null;
878
916
  this.onTick = null;
879
917
  /** controls:'page' — `(views|null) => void`, run once per frame before anything else. */
@@ -1867,12 +1905,40 @@ export class PlayCanvasSplatViewer {
1867
1905
  } else perspectiveFov(this.mono.fov, aspect, this.mono.near, this.mono.far, this.mono.proj);
1868
1906
  }
1869
1907
 
1908
+ /**
1909
+ * handle.setViewOffset — a normalised eye offset in the mono camera's plane (see offsetMonoView),
1910
+ * clamped to the unit disc. Mono only: in woven 3D the head tracker owns the eyes and this is
1911
+ * stored but not drawn; on controls:'page' the page owns the camera and it is ignored. A snap,
1912
+ * like setPose, so the page eases it (and the frame loop draws it next frame).
1913
+ */
1914
+ setViewOffset(x, y) {
1915
+ const r = Math.hypot(x, y);
1916
+ const k = r > 1 ? 1 / r : 1;
1917
+ this._viewOffset[0] = x * k;
1918
+ this._viewOffset[1] = y * k;
1919
+ }
1920
+
1921
+ /** The mono camera as drawn: the offset one when setViewOffset is engaged, else mono itself. */
1922
+ _monoView() {
1923
+ const m = this.mono;
1924
+ const [ox, oy] = this._viewOffset;
1925
+ if (this.pageCamera || (!ox && !oy)) return { proj: m.proj, pose: m.pose };
1926
+ const o = this._orbitCentre;
1927
+ const p = m.pose;
1928
+ // The focus's distance along the view axis (−z): the pivot puts it at the orbit centre.
1929
+ const c = -((o.x - p[12]) * p[8] + (o.y - p[13]) * p[9] + (o.z + this._depthOffset - p[14]) * p[10]);
1930
+ return offsetMonoView(p, m.proj, ox, oy, c, this.orbitMaxDeg, this._offPose, this._offProj)
1931
+ ? { proj: this._offProj, pose: this._offPose }
1932
+ : { proj: m.proj, pose: m.pose };
1933
+ }
1934
+
1870
1935
  _drawMono() {
1871
1936
  const c = this.canvas;
1872
1937
  this._monoEntry ||= [{ proj: this.mono.proj, pose: this.mono.pose, x: 0, y: 0, width: 0, height: 0 }];
1873
1938
  const e = this._monoEntry[0];
1874
- e.proj = this.mono.proj;
1875
- e.pose = this.mono.pose;
1939
+ const v = this._monoView();
1940
+ e.proj = v.proj;
1941
+ e.pose = v.pose;
1876
1942
  e.width = c.width;
1877
1943
  e.height = c.height;
1878
1944
  this._drawEntries(this._monoEntry, null);
@@ -2135,7 +2201,7 @@ export class PlayCanvasSplatViewer {
2135
2201
  */
2136
2202
  eyeFrame() {
2137
2203
  const R = this.rigMatrix();
2138
- const es = this._mode === '3d' && this._lastGood ? this._lastGood.entries : [{ pose: this.mono.pose, proj: this.mono.proj }];
2204
+ const es = this._mode === '3d' && this._lastGood ? this._lastGood.entries : [this._monoView()];
2139
2205
  const o = [0, 0, 0];
2140
2206
  for (const e of es) {
2141
2207
  const M = mat4Mul(R, e.pose);
@@ -2352,7 +2418,7 @@ export class PlayCanvasSplatViewer {
2352
2418
  /** The camera currently on screen: [proj, pose] of the first eye in 3D, the mono camera else. */
2353
2419
  currentView() {
2354
2420
  if (this._mode === '3d' && this._lastGood) return this._lastGood.entries[0];
2355
- return { proj: this.mono.proj, pose: this.mono.pose };
2421
+ return this._monoView();
2356
2422
  }
2357
2423
  }
2358
2424
 
@@ -2981,6 +3047,15 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
2981
3047
  let cancelPendingVideo = null;
2982
3048
  let warnedAutoplayMuted = false;
2983
3049
 
3050
+ // The current per-eye buffer scale (the `renderScale` option, or the last setRenderScale). A
3051
+ // real accessor: Object.assign below would copy a getter's value once.
3052
+ Object.defineProperty(out, 'renderScale', { get: () => viewer.renderScale, enumerable: true, configurable: true });
3053
+ // The current eye offset (after the unit-disc clamp), a fresh object per read.
3054
+ Object.defineProperty(out, 'viewOffset', {
3055
+ get: () => ({ x: viewer._viewOffset[0], y: viewer._viewOffset[1] }),
3056
+ enumerable: true,
3057
+ configurable: true,
3058
+ });
2984
3059
  Object.assign(out, {
2985
3060
  backend: 'playcanvas',
2986
3061
  engine: null, // { app, root, camera } once the engine has booted — see below
@@ -3065,6 +3140,38 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
3065
3140
  layerRigs().set(layer, r.rig, r.opts);
3066
3141
  return out;
3067
3142
  },
3143
+ /**
3144
+ * Change the per-eye buffer scale live (the `renderScale` option): e.g. full resolution on a
3145
+ * screen that hides the splat and draws only stage quads, 0.6 elsewhere. The backing store is
3146
+ * resized on the next animation frame (never mid-frame). Returns the handle.
3147
+ */
3148
+ setRenderScale(s) {
3149
+ if (typeof s !== 'number' || !Number.isFinite(s) || s <= 0 || s > 4) {
3150
+ throw new RangeError('@displayxr/inline3d/splat: setRenderScale takes a number in (0, 4].');
3151
+ }
3152
+ if (viewer.renderScale !== s) {
3153
+ viewer.renderScale = s;
3154
+ viewer._scheduleResize();
3155
+ }
3156
+ return out;
3157
+ },
3158
+ /**
3159
+ * The 2D tier's eye offset: `{x, y}` normalised in the mono camera's plane (+x right, +y up),
3160
+ * clamped to the unit disc; |offset| = 1 swings the line of sight to the focus by orbitMaxDeg
3161
+ * (15°), off-axis so the focus plane stays put — a head-parallax analogue (a phone's tilt)
3162
+ * where there are no tracked eyes. Composes with the orbit. A snap: ease it in the page.
3163
+ * `null` = {0, 0}. Mono only — a no-op for the pixels in woven 3D (the head tracker owns the
3164
+ * eyes; the value is kept for a later fall back to 2D) and on controls:'page'.
3165
+ */
3166
+ setViewOffset(o) {
3167
+ const x = o == null ? 0 : o.x ?? 0;
3168
+ const y = o == null ? 0 : o.y ?? 0;
3169
+ if (typeof x !== 'number' || typeof y !== 'number' || !Number.isFinite(x) || !Number.isFinite(y)) {
3170
+ throw new TypeError('@displayxr/inline3d/splat: setViewOffset takes {x, y} finite numbers (or null).');
3171
+ }
3172
+ viewer.setViewOffset(x, y);
3173
+ return out;
3174
+ },
3068
3175
  /** Change the layer rig's tile-wide options live (no layer change): merge, `null` clears. */
3069
3176
  setLayerRigOptions(o = {}) {
3070
3177
  layerRigs().setOptions(validateLayerRigOptions(o, 'setLayerRigOptions'));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@displayxr/inline3d",
3
- "version": "1.25.0",
3
+ "version": "1.26.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/splat.d.ts CHANGED
@@ -994,6 +994,29 @@ export interface SplatHandle {
994
994
  * `?dxrdiag=nolayerrig`. Callable before `ready`. docs/proposals/layer-display-rig.md.
995
995
  */
996
996
  setLayerRig(layer: string | number | { id: number }, rig: 'display' | 'camera', opts?: SplatLayerRigOptions): SplatHandle;
997
+ /**
998
+ * `engine: 'playcanvas'` only. Change the per-eye buffer scale live (the `renderScale` option),
999
+ * in (0, 4]; the backing store is resized on the next animation frame. Throws a RangeError
1000
+ * otherwise.
1001
+ */
1002
+ setRenderScale(scale: number): SplatHandle;
1003
+ /** `engine: 'playcanvas'` only. The current per-eye buffer scale. */
1004
+ readonly renderScale: number;
1005
+ /**
1006
+ * `engine: 'playcanvas'` only. The 2D tier's EYE offset — a head-parallax analogue for a flat
1007
+ * screen with no tracked eyes (e.g. a phone's tilt). `{x, y}` is normalised in the mono camera's
1008
+ * plane (+x right, +y up) and clamped to the unit disc; |offset| = 1 moves the eye far enough to
1009
+ * swing its line of sight to the focus by `orbitMaxDeg` (15° by default), the drag orbit's comfort
1010
+ * cone. Off-axis: the focus plane stays put on screen while nearer content moves against the
1011
+ * farther. It moves the eye, not the scene, so it composes with the orbit (`setPose`, the drag)
1012
+ * rather than fighting it. A snap, like `setPose` — ease it in the page. `null` resets to
1013
+ * `{0, 0}`. Mono only: in woven 3D the head tracker owns the eyes, so the value is kept (it applies
1014
+ * again if the tile falls back to 2D) but draws nothing; ignored on `controls: 'page'`. Throws a
1015
+ * TypeError on a non-finite component.
1016
+ */
1017
+ setViewOffset(offset: { x?: number; y?: number } | null): SplatHandle;
1018
+ /** `engine: 'playcanvas'` only. The current eye offset (after the unit-disc clamp). */
1019
+ readonly viewOffset: { x: number; y: number };
997
1020
  /** `engine: 'playcanvas'` only. Change the layer rig's tile-wide options live (merge; `null` clears). */
998
1021
  setLayerRigOptions(opts: SplatLayerRigOptions): SplatHandle;
999
1022
  /**