@displayxr/inline3d 1.26.0 → 1.28.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.
@@ -455,6 +455,7 @@ export function attachPlayCanvasModel(out, wall, canvas, src, opts, pending = []
455
455
  orbit,
456
456
  idleSpin,
457
457
  renderScale,
458
+ logTag: '[inline3d/model]',
458
459
  flipY: false, // glTF is already Y-up
459
460
  orbitMaxDeg: opts.orbitMaxDeg,
460
461
  orbitEase: opts.orbitEase,
@@ -512,6 +513,11 @@ export function attachPlayCanvasModel(out, wall, canvas, src, opts, pending = []
512
513
  unexclude: (el) => handle?.unexclude(el),
513
514
  });
514
515
 
516
+ // The per-eye buffer scale in force (the request times the device-limit clamp,
517
+ // ./inline3d-buffer-limit.js) and the request as given. Accessors, so they stay live.
518
+ Object.defineProperty(out, 'renderScale', { get: () => viewer.effectiveRenderScale, enumerable: true, configurable: true });
519
+ Object.defineProperty(out, 'renderScaleRequested', { get: () => viewer.renderScale, enumerable: true, configurable: true });
520
+
515
521
  if (wall && wall.supported) {
516
522
  handle = wall.addScene(canvas, viewer.onFrame, {
517
523
  ...(pageMode
@@ -379,6 +379,7 @@ export function addModel(wall, canvas, src, opts = {}) {
379
379
  idleSpin,
380
380
  renderScale,
381
381
  feather,
382
+ logTag: '[inline3d/model]',
382
383
  }).useEyeCamera(EyeCamera, EdgeFeather);
383
384
 
384
385
  if (envMap) viewer.scene.environment = envMap;
@@ -404,6 +405,11 @@ export function addModel(wall, canvas, src, opts = {}) {
404
405
  unexclude: (el) => handle?.unexclude(el),
405
406
  };
406
407
 
408
+ // The per-eye buffer scale in force (the request times the device-limit clamp,
409
+ // ./inline3d-buffer-limit.js) and the request as given. Accessors, so they stay live.
410
+ Object.defineProperty(out, 'renderScale', { get: () => viewer.effectiveRenderScale, enumerable: true, configurable: true });
411
+ Object.defineProperty(out, 'renderScaleRequested', { get: () => viewer.renderScale, enumerable: true, configurable: true });
412
+
407
413
  // Window first, content when it lands — same reasoning as ./splat: a grid should not appear
408
414
  // one tile at a time in download order.
409
415
  let handle = null;
@@ -346,7 +346,8 @@ function drawFittedEye(ctx, src, srcW, srcH, layout, eye, dx, dy, dw, dh, fit, b
346
346
  * Pick the first source this browser can play, from candidates listed BEST FIRST. Each candidate
347
347
  * is a URL string, or `{ src, type }` where `type` is a full `canPlayType` string — e.g.
348
348
  * `'video/webm; codecs="vp9, opus"'`. Codec-less types answer 'maybe' for anything in the
349
- * container, which is why the full string matters: the DisplayXR Browser has no H.264 / AAC, and
349
+ * container, which is why the full string matters: only DisplayXR Browser 1.0.6+ plays H.264 /
350
+ * AAC (through the OS decoders, and not on a machine without hardware H.264 decode), and
350
351
  * `'video/mp4'` alone still says 'maybe'. A 'probably' wins over an earlier 'maybe'. A candidate
351
352
  * without a type is taken as-is if nothing typed was 'probably'. Warns and returns the first when
352
353
  * nothing is playable, so the `error` event (and the poster) still happens the usual way.
@@ -382,7 +383,7 @@ export function pickSource(candidates, canPlayType) {
382
383
  if (maybe) return maybe.src;
383
384
  console.warn(
384
385
  '[inline3d/player] none of the candidate sources is playable here — trying the first. The ' +
385
- 'DisplayXR Browser plays VP9/AV1 + Opus in WebM, not H.264/AAC.',
386
+ 'VP9/AV1 + Opus in WebM plays everywhere; H.264/AAC MP4 needs DisplayXR Browser 1.0.6+.',
386
387
  list.map((c) => c.type || '(untyped)')
387
388
  );
388
389
  return list[0].src;
@@ -16,8 +16,10 @@
16
16
  // view_round_i = view_photo_i · M_i, proj_round_i = proj_photo_i
17
17
  //
18
18
  // k = D / (m·n): D the declared convergence (world units), m the declared metersToVirtual, n the
19
- // nominal viewer distance in metres — the one quantity the browser does not expose (see the
20
- // design note §5). Everything here is PLAIN ARITHMETIC on arrays, unit-testable without a GPU.
19
+ // nominal viewer distance in metres. n comes from the page's `viewerDistance` if it passed one,
20
+ // else from the browser (XRDisplayInfo.nominalViewerPosition.z, DisplayXR Browser 0221+), else
21
+ // 0.6 m (design note §5, (c)1). Everything here is PLAIN ARITHMETIC on arrays, unit-testable
22
+ // without a GPU.
21
23
 
22
24
  import { invertAffine } from './inline3d-splat-rig-map.js';
23
25
 
@@ -27,6 +29,32 @@ export const LAYER_RIGS = Object.freeze(['display', 'camera']);
27
29
  /** Nominal viewer distance (m) when neither the page nor the platform says: the browser's own. */
28
30
  export const DEFAULT_VIEWER_DISTANCE_M = 0.6;
29
31
 
32
+ /** How many times a tile asks the layer for its display info before giving up (rejections only). */
33
+ const MAX_DISPLAY_INFO_ASKS = 3;
34
+
35
+ /**
36
+ * The runtime's nominal viewer DISTANCE n (m) from an XRDisplayInfo, or null when the browser does
37
+ * not report one. It is the position's **z** — the distance out of the glass along the display
38
+ * normal — not its length: the runtime's camera rig reads only `nominal_viewer->z`
39
+ * (displayxr-common dxr_view_math.c, `eye_local.z = m2v·(eye.z − nominal_z)`), and z is what
40
+ * D/n undoes. A browser without the field, a null info, or a z that is not finite and > 0 → null.
41
+ */
42
+ export function nominalViewerDistanceFromInfo(info) {
43
+ const z = info && info.nominalViewerPosition ? info.nominalViewerPosition.z : undefined;
44
+ return typeof z === 'number' && Number.isFinite(z) && z > 0 ? z : null;
45
+ }
46
+
47
+ /**
48
+ * The n the layer rig uses, and where it came from: the page's `viewerDistance` wins ('page'),
49
+ * then the browser's nominal viewer ('browser'), then DEFAULT_VIEWER_DISTANCE_M ('default').
50
+ * @returns {{ value: number, source: 'page'|'browser'|'default' }}
51
+ */
52
+ export function resolveViewerDistance(pageViewerDistance, browserViewerDistance) {
53
+ if (pageViewerDistance !== null && pageViewerDistance !== undefined) return { value: pageViewerDistance, source: 'page' };
54
+ if (browserViewerDistance !== null && browserViewerDistance !== undefined) return { value: browserViewerDistance, source: 'browser' };
55
+ return { value: DEFAULT_VIEWER_DISTANCE_M, source: 'default' };
56
+ }
57
+
30
58
  /** A round eye may not reach the window plane: h(E') is floored at this fraction of D. */
31
59
  const MIN_EYE_HEIGHT_FRAC = 1e-3;
32
60
 
@@ -45,7 +73,8 @@ export const LAYER_RIG_OPTION_KEYS = Object.freeze(['viewerDistance', 'gain', 'p
45
73
  /**
46
74
  * setLayerRig / setLayerRigOptions options, validated (throws at the call). Only the keys given
47
75
  * are returned: they MERGE into the tile's options. `null` clears a key (back to its default).
48
- * viewerDistance m, > 0 the nominal viewer distance n (default 0.6)
76
+ * viewerDistance m, > 0 the nominal viewer distance n (default: the browser's
77
+ * XRDisplayInfo.nominalViewerPosition.z, else 0.6)
49
78
  * gain > 0 an explicit rounding gain k instead of D/(m·n)
50
79
  * planeOffset m, finite moves the stage TOWARD the viewer by this much on the panel (display
51
80
  * space): the plane that lands on the glass moves back by offset·D/n
@@ -330,6 +359,11 @@ export class LayerRigCameras {
330
359
  this.layers = new Map();
331
360
  /** The tile's options: merged across setLayerRig / setLayerRigOptions calls. */
332
361
  this.opts = { viewerDistance: null, gain: null, planeOffset: null, planeDistance: null };
362
+ /** n as the browser reports it (XRDisplayInfo.nominalViewerPosition.z), once known; else null. */
363
+ this.browserViewerDistance = null;
364
+ this._infoAsks = 0;
365
+ this._infoPending = false;
366
+ this._infoSettled = false;
333
367
  this.disabled = false; // the kill switch (diag 'nolayerrig')
334
368
  this.cams = { display: [], post: [] };
335
369
  this.moved = { display: [], post: [] };
@@ -369,6 +403,41 @@ export class LayerRigCameras {
369
403
  for (const [k, v] of Object.entries(o)) this.opts[k] = v;
370
404
  }
371
405
 
406
+ /** The n this tile uses right now, and its source ('page' | 'browser' | 'default'). */
407
+ viewerDistance() {
408
+ return resolveViewerDistance(this.opts.viewerDistance, this.browserViewerDistance);
409
+ }
410
+
411
+ /**
412
+ * Called with the frame's XRDisplayLayer: ask it ONCE for the display info and keep the
413
+ * browser's nominal viewer distance. Asynchronous — frames drawn before it resolves use the
414
+ * page's value or 0.6 m, and the state's `viewerDistanceSource` says which. A browser without
415
+ * getDisplayInfo (or without the field) leaves the default in place; a REJECTED ask (e.g. no live
416
+ * layer yet) is retried on later frames, up to MAX_DISPLAY_INFO_ASKS times.
417
+ */
418
+ noteLayer(layer) {
419
+ if (this._infoSettled || this._infoPending || this._infoAsks >= MAX_DISPLAY_INFO_ASKS) return;
420
+ if (!layer || typeof layer.getDisplayInfo !== 'function') return;
421
+ this._infoAsks++;
422
+ this._infoPending = true;
423
+ let p;
424
+ try {
425
+ p = Promise.resolve(layer.getDisplayInfo());
426
+ } catch (e) {
427
+ p = Promise.reject(e);
428
+ }
429
+ return p.then(
430
+ (info) => {
431
+ this._infoPending = false;
432
+ this._infoSettled = true;
433
+ this.browserViewerDistance = nominalViewerDistanceFromInfo(info);
434
+ },
435
+ () => {
436
+ this._infoPending = false;
437
+ }
438
+ );
439
+ }
440
+
372
441
  /** The eye camera entities of the current view path. */
373
442
  _eyeCams() {
374
443
  const v = this.viewer;
@@ -494,8 +563,9 @@ export class LayerRigCameras {
494
563
  L.residual = residual;
495
564
  const hasRun = this.sync();
496
565
  const frame = cameraRigFrame(rig);
497
- const k = frame ? layerRigGain(frame, this.opts) : null;
498
- const plane = frame ? layerRigPlane(frame, this.opts) : null;
566
+ const opts = { ...this.opts, viewerDistance: this.viewerDistance().value };
567
+ const k = frame ? layerRigGain(frame, opts) : null;
568
+ const plane = frame ? layerRigPlane(frame, opts) : null;
499
569
  L.gain = k;
500
570
  L.planeM = plane;
501
571
  L.photoConvergenceM = frame ? frame.D : null;
@@ -606,6 +676,7 @@ export class LayerRigCameras {
606
676
  /** The public state (handle.layerRigState). */
607
677
  state() {
608
678
  const L = this.last;
679
+ const n = this.viewerDistance();
609
680
  return {
610
681
  display: [...this.requests.keys()],
611
682
  disabled: this.disabled,
@@ -613,7 +684,8 @@ export class LayerRigCameras {
613
684
  engaged: !!L.engaged,
614
685
  rounded: !!L.engaged,
615
686
  reason: this.disabled ? 'kill switch (?dxrdiag=nolayerrig)' : L.reason,
616
- viewerDistance: this.opts.viewerDistance ?? DEFAULT_VIEWER_DISTANCE_M,
687
+ viewerDistance: n.value,
688
+ viewerDistanceSource: n.source,
617
689
  gain: L.gain,
618
690
  planeM: L.planeM,
619
691
  photoConvergenceM: L.photoConvergenceM ?? null,
@@ -634,7 +706,7 @@ export function layerRigLine(st) {
634
706
  const n = (x, d = 3) => (x === null || x === undefined ? '-' : Number(x).toFixed(d));
635
707
  return (
636
708
  `path=${st.path ?? '-'} engaged=${st.engaged} layers=[${st.display.join(', ')}] ` +
637
- `viewerDistance=${n(st.viewerDistance, 2)}m gain=${n(st.gain)} planeM=${n(st.planeM)} ` +
709
+ `viewerDistance=${n(st.viewerDistance, 2)}m(${st.viewerDistanceSource ?? '-'}) gain=${n(st.gain)} planeM=${n(st.planeM)} ` +
638
710
  `photoConvergenceM=${n(st.photoConvergenceM)} planeOffset=${n(st.planeOffset)}m ` +
639
711
  `located=${st.located === null || st.located === undefined ? '-' : st.located} reason=${st.reason ?? '-'}`
640
712
  );
@@ -71,6 +71,14 @@ import { VideoPlane, validateSetVideo, PAGE_VIDEO_ERROR, eyeSplit, EYE_SPLIT_UNI
71
71
  import { LayerRigCameras, validateLayerRig, validateLayerRigOptions, DEFAULT_VIEWER_DISTANCE_M } from './inline3d-splat-layer-rig.js';
72
72
  import { RigTracker, remapViews, nodePose, sameRig } from './inline3d-splat-rig-map.js';
73
73
  import { resolveDiag, DiagRecorder, startDiagLoop, registerDiag, DIAG_SWITCHES } from './inline3d-splat-diag.js';
74
+ import {
75
+ glBufferLimits,
76
+ probeBufferLimits,
77
+ clampEyeBuffer,
78
+ clampWarning,
79
+ bufferScale,
80
+ mismatchWarning,
81
+ } from './inline3d-buffer-limit.js';
74
82
  import {
75
83
  clamp,
76
84
  finite,
@@ -890,6 +898,13 @@ export class PlayCanvasSplatViewer {
890
898
  this.depthLimit = depthLimit;
891
899
  this.fitSweep = fitSweep;
892
900
  this.renderScale = renderScale;
901
+ // The device-limit clamp on the backing store (./inline3d-buffer-limit.js), as SceneViewer:
902
+ // the factor the last _resize applied on top of renderScale; warned once per viewer.
903
+ this._bufClamp = 1;
904
+ this._warnedBufClamp = false;
905
+ this._warnedBufMismatch = false;
906
+ /** Prefix for this viewer's warnings; ./model sets its own. */
907
+ this.logTag = opts.logTag || '[inline3d/splat]';
893
908
  this.pitchLimit = pitchLimit;
894
909
  this.idleSpin = this.pageCamera ? 0 : idleSpin;
895
910
  this.flipY = opts.flipY !== false;
@@ -1184,7 +1199,11 @@ export class PlayCanvasSplatViewer {
1184
1199
  if (this._mode !== '3d') this.stopMono();
1185
1200
  // The rig these views were located with: Blink chained the rig declared BEFORE this callback,
1186
1201
  // and the tick below may declare a new one (a focus ease) for the NEXT locate.
1187
- if (this.layerRigs?.active) this._rigSnap = snapshotRig(this.layerRigSource?.(), this._rigSnap);
1202
+ if (this.layerRigs?.active) {
1203
+ this._rigSnap = snapshotRig(this.layerRigSource?.(), this._rigSnap);
1204
+ // n for the layer rig: the browser's nominal viewer, asked once (async; no-op after).
1205
+ this.layerRigs.noteLayer(layer);
1206
+ }
1188
1207
  // Same moment, for the rig map: the rig these views were most likely located for (a tie-break).
1189
1208
  this._rigAtPull = this.rigTrack ? this.rigTrack.latest : null;
1190
1209
  // BEFORE the tick: a pose the page sets in here is the one this very frame renders.
@@ -1316,6 +1335,9 @@ export class PlayCanvasSplatViewer {
1316
1335
  // The SDK's frame drives the engine: no second rAF. `tick` is the engine's own loop body.
1317
1336
  app.requestAnimationFrame = () => {};
1318
1337
  this.app = app;
1338
+ // The store may have been sized before the engine existed (from the probed limits, or none):
1339
+ // re-check it against THIS context's. A no-op when nothing moves.
1340
+ this._scheduleResize();
1319
1341
 
1320
1342
  // Footprint fix (§ patchGsplatFootprint) and the perf quad-extent cap, both as chunk
1321
1343
  // overrides. The gsplat chunks are registered by GSplatComponentSystem during init, so
@@ -1939,8 +1961,9 @@ export class PlayCanvasSplatViewer {
1939
1961
  const v = this._monoView();
1940
1962
  e.proj = v.proj;
1941
1963
  e.pose = v.pose;
1942
- e.width = c.width;
1943
- e.height = c.height;
1964
+ const b = this._bufScale();
1965
+ e.width = b.w || c.width;
1966
+ e.height = b.h || c.height;
1944
1967
  this._drawEntries(this._monoEntry, null);
1945
1968
  }
1946
1969
 
@@ -1952,9 +1975,7 @@ export class PlayCanvasSplatViewer {
1952
1975
  const app = this.app;
1953
1976
  if (!app || !this.pc) return false;
1954
1977
  const pc = this.pc;
1955
- const el = this.canvas;
1956
- const sx = cache && cache.bufW > 0 && el.width ? el.width / cache.bufW : 1;
1957
- const sy = cache && cache.bufH > 0 && el.height ? el.height / cache.bufH : 1;
1978
+ const { b, sx, sy } = this._entryScale(cache);
1958
1979
  if (this.pageCamera) {
1959
1980
  // The page's near/far join the caller's nearClip/farClip as a floor and a cap: only the
1960
1981
  // depth mapping moves, the runtime's frustum (fov, skew, principal point) stays untouched.
@@ -2029,8 +2050,8 @@ export class PlayCanvasSplatViewer {
2029
2050
  this._budgetViews = entries.length;
2030
2051
  this._applyBudget();
2031
2052
  }
2032
- const W = el.width || 1;
2033
- const H = el.height || 1;
2053
+ const W = b.w || 1;
2054
+ const H = b.h || 1;
2034
2055
  for (let i = 0; i < entries.length; i++) {
2035
2056
  const e = entries[i];
2036
2057
  const cam = cams[i];
@@ -2116,18 +2137,68 @@ export class PlayCanvasSplatViewer {
2116
2137
  const box = this.canvas.getBoundingClientRect();
2117
2138
  if (box.width < 1 || box.height < 1) return;
2118
2139
  const dpr = Math.min((typeof window !== 'undefined' && window.devicePixelRatio) || 1, 2) * this.renderScale;
2119
- const w = Math.max(1, Math.round(box.width * dpr));
2120
- const h = Math.max(1, Math.round(box.height * dpr));
2121
- const bufW = this._mode === 'mono' ? w : w * 2;
2140
+ // Clamp to the device's GL limits BEFORE sizing (./inline3d-buffer-limit.js). Measured on an
2141
+ // Adreno 740 tablet: MAX_TEXTURE_SIZE 4096, a 5120-wide request silently became a 4096-wide
2142
+ // drawing buffer, getViewport() still split 5120, and the eye boundary sat at 62.5%.
2143
+ const c = clampEyeBuffer(
2144
+ Math.max(1, Math.round(box.width * dpr)),
2145
+ Math.max(1, Math.round(box.height * dpr)),
2146
+ glBufferLimits(this.app?.graphicsDevice?.gl) || probeBufferLimits(),
2147
+ { cols: this._mode === 'mono' ? 1 : 2 },
2148
+ );
2149
+ this._noteClamp(c);
2150
+ const bufW = c.bufW;
2151
+ const h = c.bufH;
2122
2152
  this._updateMonoProjection();
2123
2153
  const el = this.canvas;
2124
- if (el.width === bufW && el.height === h) return;
2154
+ if (el.width === bufW && el.height === h) {
2155
+ this._bufScale(); // unchanged; still name a browser-side clamp (once)
2156
+ return;
2157
+ }
2125
2158
  el.width = bufW;
2126
2159
  el.height = h;
2160
+ this._bufScale(); // warns once if the browser clamped anyway
2127
2161
  if (this._mode === 'mono') this._drawMono();
2128
2162
  else this._replayLastGood();
2129
2163
  }
2130
2164
 
2165
+ /** The renderScale in force: the request times the device-limit clamp (SceneViewer's twin). */
2166
+ get effectiveRenderScale() {
2167
+ return this.renderScale * this._bufClamp;
2168
+ }
2169
+
2170
+ _noteClamp(c) {
2171
+ this._bufClamp = c.scale;
2172
+ if (c.clamped && !this._warnedBufClamp) {
2173
+ this._warnedBufClamp = true;
2174
+ console.warn(clampWarning(this.logTag, c, this.renderScale));
2175
+ }
2176
+ }
2177
+
2178
+ /**
2179
+ * How a frame's entries map into the buffer. Entries are in canvas-attribute px (getViewport()
2180
+ * splits canvas.width), cached with that size; they are drawn into the REAL drawing buffer.
2181
+ * Identity unless a resize happened since the cache or the browser clamped the buffer behind
2182
+ * the SDK (the device-limit clamp in _resize normally prevents that).
2183
+ */
2184
+ _entryScale(cache) {
2185
+ const b = this._bufScale();
2186
+ const sx = cache && cache.bufW > 0 && b.w ? b.w / cache.bufW : 1;
2187
+ const sy = cache && cache.bufH > 0 && b.h ? b.h / cache.bufH : 1;
2188
+ return { b, sx, sy };
2189
+ }
2190
+
2191
+ /** The real drawing buffer vs canvas.width/height; warns once per viewer on a mismatch. */
2192
+ _bufScale() {
2193
+ const el = this.canvas;
2194
+ const b = bufferScale(el, this.app?.graphicsDevice?.gl || null);
2195
+ if (b.mismatch && !this._warnedBufMismatch) {
2196
+ this._warnedBufMismatch = true;
2197
+ console.warn(mismatchWarning(this.logTag, el, b));
2198
+ }
2199
+ return b;
2200
+ }
2201
+
2131
2202
  _tick() {
2132
2203
  const t = now();
2133
2204
  const dt = this._lastTick ? Math.min((t - this._lastTick) / 1000, MAX_DT_S) : 0;
@@ -3047,9 +3118,13 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
3047
3118
  let cancelPendingVideo = null;
3048
3119
  let warnedAutoplayMuted = false;
3049
3120
 
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 });
3121
+ // The per-eye buffer scale IN FORCE: the request (the `renderScale` option, or the last
3122
+ // setRenderScale) times the device-limit clamp — e.g. 0.8 for a request of 1 on a device whose
3123
+ // MAX_TEXTURE_SIZE cannot hold the full SBS store (./inline3d-buffer-limit.js).
3124
+ // `renderScaleRequested` is the request as given. Real accessors: Object.assign below would
3125
+ // copy a getter's value once.
3126
+ Object.defineProperty(out, 'renderScale', { get: () => viewer.effectiveRenderScale, enumerable: true, configurable: true });
3127
+ Object.defineProperty(out, 'renderScaleRequested', { get: () => viewer.renderScale, enumerable: true, configurable: true });
3053
3128
  // The current eye offset (after the unit-disc clamp), a fresh object per read.
3054
3129
  Object.defineProperty(out, 'viewOffset', {
3055
3130
  get: () => ({ x: viewer._viewOffset[0], y: viewer._viewOffset[1] }),
@@ -3143,7 +3218,9 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
3143
3218
  /**
3144
3219
  * Change the per-eye buffer scale live (the `renderScale` option): e.g. full resolution on a
3145
3220
  * 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.
3221
+ * resized on the next animation frame (never mid-frame). The request is kept as given
3222
+ * (`renderScaleRequested`); the store is still clamped to the device's GL limits, and
3223
+ * `renderScale` reports what is in force. Returns the handle.
3147
3224
  */
3148
3225
  setRenderScale(s) {
3149
3226
  if (typeof s !== 'number' || !Number.isFinite(s) || s <= 0 || s > 4) {
@@ -3186,7 +3263,7 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
3186
3263
  layerRigState() {
3187
3264
  const lr = viewer.layerRigs;
3188
3265
  if (lr) return lr.state();
3189
- return { display: [], disabled: false, path: null, engaged: false, rounded: false, reason: 'no layer on the display rig', viewerDistance: DEFAULT_VIEWER_DISTANCE_M, gain: null, planeM: null, photoConvergenceM: null, planeOffset: 0, located: null };
3266
+ return { display: [], disabled: false, path: null, engaged: false, rounded: false, reason: 'no layer on the display rig', viewerDistance: DEFAULT_VIEWER_DISTANCE_M, viewerDistanceSource: 'default', gain: null, planeM: null, photoConvergenceM: null, planeOffset: 0, located: null };
3190
3267
  },
3191
3268
  /**
3192
3269
  * An unlit material showing the left half of `texture` to left-eye views and the right half to
@@ -245,6 +245,7 @@ export function addSplat(wall, canvas, src, opts = {}) {
245
245
  idleSpin,
246
246
  renderScale,
247
247
  feather,
248
+ logTag: '[inline3d/splat]',
248
249
  }).useEyeCamera(EyeCamera, EdgeFeather);
249
250
 
250
251
  // Spark renders through the ordinary three.js pipeline, so splats and meshes co-exist and
@@ -411,6 +412,10 @@ export function addSplat(wall, canvas, src, opts = {}) {
411
412
  },
412
413
  effects: () => [],
413
414
  };
415
+ // The per-eye buffer scale in force (the request times the device-limit clamp,
416
+ // ./inline3d-buffer-limit.js) and the request as given. Accessors, so they stay live.
417
+ Object.defineProperty(out, 'renderScale', { get: () => viewer.effectiveRenderScale, enumerable: true, configurable: true });
418
+ Object.defineProperty(out, 'renderScaleRequested', { get: () => viewer.renderScale, enumerable: true, configurable: true });
414
419
 
415
420
  // `src` may be a URL or the bytes themselves.
416
421
  //
@@ -64,6 +64,14 @@ import {
64
64
  MONO_NEAR,
65
65
  MONO_FAR,
66
66
  } from './inline3d-splat-shared.js';
67
+ import {
68
+ glBufferLimits,
69
+ clampEyeBuffer,
70
+ clampWarning,
71
+ bufferScale,
72
+ scaleViewport,
73
+ mismatchWarning,
74
+ } from './inline3d-buffer-limit.js';
67
75
 
68
76
  const clamp = (v, lo, hi) => (v < lo ? lo : v > hi ? hi : v);
69
77
  // NaN/Infinity into a transform silently blanks the tile — three propagates it into the
@@ -283,6 +291,14 @@ export class SceneViewer {
283
291
  this.depthLimit = depthLimit;
284
292
  this.fitSweep = fitSweep;
285
293
  this.renderScale = renderScale;
294
+ // The device-limit clamp on the backing store (./inline3d-buffer-limit.js): the factor _resize
295
+ // last had to apply on top of renderScale, 1 when the request fit. effectiveRenderScale reports
296
+ // the product. Warned once per viewer (one viewer per handle).
297
+ this._bufClamp = 1;
298
+ this._warnedBufClamp = false;
299
+ this._warnedBufMismatch = false;
300
+ /** Prefix for this viewer's warnings; ./splat and ./model set their own. */
301
+ this.logTag = opts.logTag || '[inline3d/viewer]';
286
302
  this.pitchLimit = pitchLimit;
287
303
  this.idleSpin = idleSpin;
288
304
 
@@ -711,10 +727,13 @@ export class SceneViewer {
711
727
 
712
728
  // Validated: this frame WILL draw over everything it clears.
713
729
  const r = this.renderer;
730
+ // getViewport() splits canvas.width; a drawing buffer the browser clamped behind our back
731
+ // (bufferScale().mismatch) gets the same split mapped onto its real size, never canvas.width/2.
732
+ const b = this._bufScale();
714
733
  r.clear();
715
734
  r.setScissorTest(true);
716
735
  for (let i = 0; i < views.length; i++) {
717
- const vp = vps[i];
736
+ const vp = scaleViewport(vps[i], b.sx, b.sy);
718
737
  r.setViewport(vp.x, vp.y, vp.width, vp.height);
719
738
  r.setScissor(vp.x, vp.y, vp.width, vp.height);
720
739
  if (eye) {
@@ -753,7 +772,8 @@ export class SceneViewer {
753
772
  this._tick();
754
773
  const r = this.renderer;
755
774
  r.clear();
756
- r.setViewport(0, 0, this.canvas.width, this.canvas.height);
775
+ const b = this._bufScale();
776
+ r.setViewport(0, 0, b.w, b.h);
757
777
  r.render(this.scene, this.monoCamera);
758
778
  };
759
779
  this._monoRaf = requestAnimationFrame(loop);
@@ -849,9 +869,11 @@ export class SceneViewer {
849
869
  const r = this.renderer;
850
870
  const eye = g.mono ? null : this._ensureEye();
851
871
  const el = this.renderer.domElement || this.canvas;
852
- // A resize between the cache and the replay changes the buffer, not the split.
853
- const sx = g.bufW > 0 && el.width ? el.width / g.bufW : 1;
854
- const sy = g.bufH > 0 && el.height ? el.height / g.bufH : 1;
872
+ // A resize between the cache and the replay changes the buffer, not the split. The cache is in
873
+ // canvas-attribute px (getViewport's space); the target is the REAL drawing buffer.
874
+ const b = this._bufScale();
875
+ const sx = g.bufW > 0 && b.w ? b.w / g.bufW : 1;
876
+ const sy = g.bufH > 0 && b.h ? b.h / g.bufH : 1;
855
877
  const scaled = sx !== 1 || sy !== 1;
856
878
  r.clear();
857
879
  r.setScissorTest(true);
@@ -903,7 +925,8 @@ export class SceneViewer {
903
925
  if (this._mode === 'mono') {
904
926
  const r = this.renderer;
905
927
  r.clear();
906
- r.setViewport(0, 0, this.canvas.width, this.canvas.height);
928
+ const b = this._bufScale();
929
+ r.setViewport(0, 0, b.w, b.h);
907
930
  r.render(this.scene, this.monoCamera);
908
931
  return;
909
932
  }
@@ -955,18 +978,71 @@ export class SceneViewer {
955
978
  const box = this.canvas.getBoundingClientRect();
956
979
  if (box.width < 1 || box.height < 1) return;
957
980
  const dpr = Math.min(window.devicePixelRatio || 1, 2) * this.renderScale;
958
- const w = Math.max(1, Math.round(box.width * dpr));
959
- const h = Math.max(1, Math.round(box.height * dpr));
960
- const bufW = this._mode === 'mono' ? w : w * 2;
981
+ // Clamp to the device's GL limits BEFORE sizing (./inline3d-buffer-limit.js): a store past
982
+ // MAX_TEXTURE_SIZE is silently clamped by the browser while getViewport() keeps splitting
983
+ // canvas.width, which puts the eye boundary off-centre in the woven buffer.
984
+ const c = clampEyeBuffer(
985
+ Math.max(1, Math.round(box.width * dpr)),
986
+ Math.max(1, Math.round(box.height * dpr)),
987
+ glBufferLimits(this._gl()),
988
+ { cols: this._mode === 'mono' ? 1 : 2 },
989
+ );
990
+ this._noteClamp(c);
991
+ const bufW = c.bufW;
992
+ const h = c.bufH;
961
993
  // Cheap and always correct to refresh, whether or not the backing store moves.
962
994
  this.monoCamera.aspect = box.width / box.height;
963
995
  this.monoCamera.updateProjectionMatrix();
964
996
  const el = this.renderer.domElement || this.canvas;
965
- if (el.width === bufW && el.height === h) return; // observer fired, geometry didn't move
997
+ if (el.width === bufW && el.height === h) {
998
+ this._bufScale(); // observer fired, geometry didn't move; still name a browser-side clamp
999
+ return;
1000
+ }
966
1001
  this.renderer.setSize(bufW, h, false);
1002
+ this._bufScale(); // warns once if the browser clamped anyway
967
1003
  this._repaintAfterResize();
968
1004
  }
969
1005
 
1006
+ /**
1007
+ * The renderScale actually in force: the request (`renderScale`) times the device-limit clamp
1008
+ * the last resize applied. Equal to renderScale wherever the store fits.
1009
+ */
1010
+ get effectiveRenderScale() {
1011
+ return this.renderScale * this._bufClamp;
1012
+ }
1013
+
1014
+ _gl() {
1015
+ try {
1016
+ return this.renderer?.getContext?.() || null;
1017
+ } catch {
1018
+ return null;
1019
+ }
1020
+ }
1021
+
1022
+ /** Record a resize's clamp; warn once per viewer the first time the device limit bites. */
1023
+ _noteClamp(c) {
1024
+ this._bufClamp = c.scale;
1025
+ if (c.clamped && !this._warnedBufClamp) {
1026
+ this._warnedBufClamp = true;
1027
+ console.warn(clampWarning(this.logTag, c, this.renderScale));
1028
+ }
1029
+ }
1030
+
1031
+ /**
1032
+ * The drawing buffer's real size and its ratio to canvas.width/height (the space getViewport()
1033
+ * reports in). Identity unless the browser clamped a store the limit query did not predict;
1034
+ * warns once per viewer when it did.
1035
+ */
1036
+ _bufScale() {
1037
+ const el = this.renderer.domElement || this.canvas;
1038
+ const b = bufferScale(el, this._gl());
1039
+ if (b.mismatch && !this._warnedBufMismatch) {
1040
+ this._warnedBufMismatch = true;
1041
+ console.warn(mismatchWarning(this.logTag, el, b));
1042
+ }
1043
+ return b;
1044
+ }
1045
+
970
1046
  /** Damping + idle turntable. Called once per rendered frame, 3D or mono. */
971
1047
  _tick() {
972
1048
  const t = now();