@displayxr/inline3d 1.27.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;
@@ -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;
@@ -1320,6 +1335,9 @@ export class PlayCanvasSplatViewer {
1320
1335
  // The SDK's frame drives the engine: no second rAF. `tick` is the engine's own loop body.
1321
1336
  app.requestAnimationFrame = () => {};
1322
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();
1323
1341
 
1324
1342
  // Footprint fix (§ patchGsplatFootprint) and the perf quad-extent cap, both as chunk
1325
1343
  // overrides. The gsplat chunks are registered by GSplatComponentSystem during init, so
@@ -1943,8 +1961,9 @@ export class PlayCanvasSplatViewer {
1943
1961
  const v = this._monoView();
1944
1962
  e.proj = v.proj;
1945
1963
  e.pose = v.pose;
1946
- e.width = c.width;
1947
- e.height = c.height;
1964
+ const b = this._bufScale();
1965
+ e.width = b.w || c.width;
1966
+ e.height = b.h || c.height;
1948
1967
  this._drawEntries(this._monoEntry, null);
1949
1968
  }
1950
1969
 
@@ -1956,9 +1975,7 @@ export class PlayCanvasSplatViewer {
1956
1975
  const app = this.app;
1957
1976
  if (!app || !this.pc) return false;
1958
1977
  const pc = this.pc;
1959
- const el = this.canvas;
1960
- const sx = cache && cache.bufW > 0 && el.width ? el.width / cache.bufW : 1;
1961
- const sy = cache && cache.bufH > 0 && el.height ? el.height / cache.bufH : 1;
1978
+ const { b, sx, sy } = this._entryScale(cache);
1962
1979
  if (this.pageCamera) {
1963
1980
  // The page's near/far join the caller's nearClip/farClip as a floor and a cap: only the
1964
1981
  // depth mapping moves, the runtime's frustum (fov, skew, principal point) stays untouched.
@@ -2033,8 +2050,8 @@ export class PlayCanvasSplatViewer {
2033
2050
  this._budgetViews = entries.length;
2034
2051
  this._applyBudget();
2035
2052
  }
2036
- const W = el.width || 1;
2037
- const H = el.height || 1;
2053
+ const W = b.w || 1;
2054
+ const H = b.h || 1;
2038
2055
  for (let i = 0; i < entries.length; i++) {
2039
2056
  const e = entries[i];
2040
2057
  const cam = cams[i];
@@ -2120,18 +2137,68 @@ export class PlayCanvasSplatViewer {
2120
2137
  const box = this.canvas.getBoundingClientRect();
2121
2138
  if (box.width < 1 || box.height < 1) return;
2122
2139
  const dpr = Math.min((typeof window !== 'undefined' && window.devicePixelRatio) || 1, 2) * this.renderScale;
2123
- const w = Math.max(1, Math.round(box.width * dpr));
2124
- const h = Math.max(1, Math.round(box.height * dpr));
2125
- 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;
2126
2152
  this._updateMonoProjection();
2127
2153
  const el = this.canvas;
2128
- 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
+ }
2129
2158
  el.width = bufW;
2130
2159
  el.height = h;
2160
+ this._bufScale(); // warns once if the browser clamped anyway
2131
2161
  if (this._mode === 'mono') this._drawMono();
2132
2162
  else this._replayLastGood();
2133
2163
  }
2134
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
+
2135
2202
  _tick() {
2136
2203
  const t = now();
2137
2204
  const dt = this._lastTick ? Math.min((t - this._lastTick) / 1000, MAX_DT_S) : 0;
@@ -3051,9 +3118,13 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
3051
3118
  let cancelPendingVideo = null;
3052
3119
  let warnedAutoplayMuted = false;
3053
3120
 
3054
- // The current per-eye buffer scale (the `renderScale` option, or the last setRenderScale). A
3055
- // real accessor: Object.assign below would copy a getter's value once.
3056
- 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 });
3057
3128
  // The current eye offset (after the unit-disc clamp), a fresh object per read.
3058
3129
  Object.defineProperty(out, 'viewOffset', {
3059
3130
  get: () => ({ x: viewer._viewOffset[0], y: viewer._viewOffset[1] }),
@@ -3147,7 +3218,9 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
3147
3218
  /**
3148
3219
  * Change the per-eye buffer scale live (the `renderScale` option): e.g. full resolution on a
3149
3220
  * screen that hides the splat and draws only stage quads, 0.6 elsewhere. The backing store is
3150
- * 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.
3151
3224
  */
3152
3225
  setRenderScale(s) {
3153
3226
  if (typeof s !== 'number' || !Number.isFinite(s) || s <= 0 || s > 4) {
@@ -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();
package/js/inline3d.js CHANGED
@@ -35,6 +35,10 @@ export { undock, undockAvailable, undockUrl, tileScreenRect };
35
35
  // The eased 2D<->3D transition. A pure state machine (no DOM, no WebXR) ported from the native
36
36
  // `dxr::ModeSwitch`, so the browser eases the disparity around a mode switch the same way — and in
37
37
  // the same ORDER — as the native apps and the demos. See _requestRenderingModeEased.
38
+ // The device-limit clamp on a backing store (MAX_TEXTURE_SIZE & co.), shared with ./viewer,
39
+ // ./splat and ./model. Dependency-free.
40
+ import { probeBufferLimits, clampEyeBuffer, clampWarning, bufferScale, mismatchWarning } from './inline3d-buffer-limit.js';
41
+
38
42
  import {
39
43
  ModeSwitch,
40
44
  MODE_SWITCH_DEFAULT_DURATION_MS,
@@ -2340,17 +2344,108 @@ class Inline3D {
2340
2344
  win.autoExcluded.clear();
2341
2345
  }
2342
2346
 
2343
- /** The per-eye buffer size this window should have right now (explicit, or box × dpr). */
2344
- _eyeSize(win) {
2347
+ /**
2348
+ * The per-eye buffer size this window should have right now: explicit, or box × dpr — capped
2349
+ * at the SOURCE's own per-eye resolution for image and video windows.
2350
+ *
2351
+ * WHY THE CAP (measured, NP02J tablet, DisplayXR Browser 1.0.6): the player sample's 90vw
2352
+ * tile at dpr 2 asked for a 4608×1296 backing store for a 640×360-per-eye clip — 13× the
2353
+ * source's pixels, re-drawn every frame. The page fell to 25 fps and dropped 47 of 97 video
2354
+ * frames; the same clip in a 45vw tile (2304×648) ran at 57 fps with 0 dropped. Drawing a
2355
+ * source into a buffer larger than itself adds no detail: the compositor scales the layer to
2356
+ * the box either way, so upscaling here only spends GPU fill-rate. The cap keeps the BOX
2357
+ * aspect (the eyes stay correctly shaped) and only shrinks, never grows past box × dpr.
2358
+ * An explicit {width, height} is the page's call and is never capped by the source.
2359
+ *
2360
+ * THEN THE DEVICE LIMIT, on every window including an explicit size: the whole store (2 × eye
2361
+ * wide in SBS) must fit min(MAX_TEXTURE_SIZE, MAX_RENDERBUFFER_SIZE, MAX_VIEWPORT_DIMS), or
2362
+ * the weave cannot take it as one texture. An Android 3D tablet (Adreno 740) reports 4096 where
2363
+ * desktops report 16384. Both axes shrink by one factor (./inline3d-buffer-limit.js), warned
2364
+ * once per window. `win.bufScale` records the total shrink so buffer-px decoration
2365
+ * (cornerRadius, feather) keeps its on-screen size.
2366
+ */
2367
+ _eyeSize(win, sbs = true) {
2345
2368
  const dpr = Math.min(window.devicePixelRatio || 1, 2);
2346
- return {
2347
- w: win.reqW || Math.round((win.canvas.clientWidth || 256) * dpr),
2348
- h: win.reqH || Math.round((win.canvas.clientHeight || 256) * dpr),
2349
- };
2369
+ let w = win.reqW || Math.round((win.canvas.clientWidth || 256) * dpr);
2370
+ let h = win.reqH || Math.round((win.canvas.clientHeight || 256) * dpr);
2371
+ let scale = 1;
2372
+ const eye = !win.reqW && !win.reqH && this._sourceEyeSize(win);
2373
+ if (eye) {
2374
+ // The smallest buffer that still holds every source pixel along the axis that fills the box.
2375
+ const s = Math.max(eye.w / w, eye.h / h);
2376
+ if (s < 1) {
2377
+ scale = s;
2378
+ w = Math.max(1, Math.round(w * s));
2379
+ h = Math.max(1, Math.round(h * s));
2380
+ }
2381
+ }
2382
+ const c = clampEyeBuffer(w, h, probeBufferLimits(), { cols: sbs ? 2 : 1 });
2383
+ if (c.clamped) {
2384
+ scale *= c.scale;
2385
+ w = c.eyeW;
2386
+ h = c.eyeH;
2387
+ if (!win.warnedBufClamp) {
2388
+ win.warnedBufClamp = true;
2389
+ console.warn(clampWarning('[inline3d]', c));
2390
+ }
2391
+ }
2392
+ win.bufScale = scale;
2393
+ return { w, h };
2394
+ }
2395
+
2396
+ /** One eye of an image/video window's source (an SBS pair: left eye = left half), or null. */
2397
+ _sourceEyeSize(win) {
2398
+ if (win.kind !== 'video' && win.kind !== 'image') return null;
2399
+ const src = win.kind === 'video' ? win.video : win.img;
2400
+ if (!src) return null;
2401
+ const sw = src.videoWidth || src.naturalWidth || src.width;
2402
+ const sh = src.videoHeight || src.naturalHeight || src.height;
2403
+ if (!(sw > 1) || !(sh > 0)) return null;
2404
+ return { w: sw / 2, h: sh };
2405
+ }
2406
+
2407
+ /**
2408
+ * A scene canvas is the app's to size, so the core cannot clamp it — but it can SEE the browser
2409
+ * clamp it. Where the store exceeds the device's MAX_TEXTURE_SIZE (4096 on an Adreno 740 tablet)
2410
+ * the drawing buffer silently comes out smaller than canvas.width, while getViewport() keeps
2411
+ * splitting canvas.width: the eye boundary lands off-centre and the panel shows a double image.
2412
+ * Say so once. ./viewer, ./splat and ./model clamp before sizing and never trip this.
2413
+ *
2414
+ * Only called after the app has drawn a stereo frame, so the canvas already HAS its context:
2415
+ * getContext() of the same type returns it, of another type returns null — it never creates one.
2416
+ */
2417
+ _checkSceneBuffer(win) {
2418
+ const c = win.canvas;
2419
+ if (!c || typeof c.getContext !== 'function') return;
2420
+ if (win.sceneGl === undefined) {
2421
+ let gl = null;
2422
+ try {
2423
+ gl = c.getContext('webgl2') || c.getContext('webgl');
2424
+ } catch {
2425
+ gl = null;
2426
+ }
2427
+ win.sceneGl = gl && typeof gl.drawingBufferWidth === 'number' ? gl : null;
2428
+ }
2429
+ if (!win.sceneGl) return;
2430
+ const b = bufferScale(c, win.sceneGl);
2431
+ if (b.mismatch) {
2432
+ win.warnedBufMismatch = true;
2433
+ console.warn(
2434
+ mismatchWarning(
2435
+ '[inline3d]',
2436
+ c,
2437
+ b,
2438
+ 'getViewport() splits canvas.width, so the eyes are off-centre in the woven buffer. This ' +
2439
+ 'scene canvas is sized by the page: keep 2 × eye width and the height within ' +
2440
+ 'MAX_TEXTURE_SIZE / MAX_RENDERBUFFER_SIZE / MAX_VIEWPORT_DIMS, or map getViewport() onto ' +
2441
+ 'gl.drawingBufferWidth/Height.',
2442
+ ),
2443
+ );
2444
+ }
2350
2445
  }
2351
2446
 
2352
2447
  _sizeBuffer(win, sbs) {
2353
- const { w: boxW, h: boxH } = this._eyeSize(win);
2448
+ const { w: boxW, h: boxH } = this._eyeSize(win, sbs);
2354
2449
  win.eyeW = boxW;
2355
2450
  win.eyeH = boxH;
2356
2451
  win.canvas.width = sbs ? boxW * 2 : boxW; // SBS = two eye tiles wide
@@ -2473,10 +2568,25 @@ class Inline3D {
2473
2568
  const srcW = src.videoWidth || src.naturalWidth || src.width;
2474
2569
  const srcH = src.videoHeight || src.naturalHeight || src.height;
2475
2570
  if (!srcW || !srcH) return;
2571
+ // The buffer is capped at the source's per-eye resolution (_eyeSize), and a source's size is
2572
+ // only known once it has decoded — or changes on a new title. Re-derive on a change; the
2573
+ // resize clears the buffer, and the paint right below refills it.
2574
+ const srcKey = srcW + 'x' + srcH;
2575
+ if (win.srcKey !== srcKey) {
2576
+ win.srcKey = srcKey;
2577
+ if (win.ownsBuffer && !(win.reqW && win.reqH)) {
2578
+ const { w, h } = this._eyeSize(win, win.sbs);
2579
+ if (w !== win.eyeW || h !== win.eyeH) this._sizeBuffer(win, win.sbs);
2580
+ }
2581
+ }
2582
+ // cornerRadius / feather are buffer px at the UNCAPPED size; keep their on-screen size.
2583
+ const k = win.bufScale || 1;
2584
+ const radius = win.cornerRadius * k;
2585
+ const feather = win.feather * k;
2476
2586
  ctx.clearRect(0, 0, c.width, c.height);
2477
2587
  if (!win.sbs) {
2478
2588
  // Flat fallback: left eye only, stretched to the square buffer.
2479
- drawEye(ctx, src, 0, 0, srcW / 2, srcH, 0, 0, c.width, c.height, win.cornerRadius, win.feather);
2589
+ drawEye(ctx, src, 0, 0, srcW / 2, srcH, 0, 0, c.width, c.height, radius, feather);
2480
2590
  return;
2481
2591
  }
2482
2592
  const halfDst = c.width / 2;
@@ -2487,16 +2597,16 @@ class Inline3D {
2487
2597
  // as a squeezed pair). See _trackBakedStereo.
2488
2598
  const stereo = Math.min(this._stereoFactor, this._bakedStereo);
2489
2599
  if (stereo < 1) {
2490
- drawEye(ctx, src, 0, 0, srcW / 2, srcH, 0, 0, halfDst, c.height, win.cornerRadius, win.feather); // L
2491
- drawEyeBlend(ctx, src, srcW / 2, srcH, halfDst, c.height, stereo, win.cornerRadius, win.feather); // L+R
2600
+ drawEye(ctx, src, 0, 0, srcW / 2, srcH, 0, 0, halfDst, c.height, radius, feather); // L
2601
+ drawEyeBlend(ctx, src, srcW / 2, srcH, halfDst, c.height, stereo, radius, feather); // L+R
2492
2602
  return;
2493
2603
  }
2494
2604
  // A single stretched draw maps SBS source → SBS buffer (left→left, right→right); the
2495
2605
  // per-eye path is only needed to bake decoration (rounded corners / edge feather), which
2496
2606
  // MUST be applied to each eye separately — see drawEye/featherEye.
2497
- if (win.cornerRadius > 0 || win.feather > 0) {
2498
- drawEye(ctx, src, 0, 0, srcW / 2, srcH, 0, 0, halfDst, c.height, win.cornerRadius, win.feather); // L
2499
- drawEye(ctx, src, srcW / 2, 0, srcW / 2, srcH, halfDst, 0, halfDst, c.height, win.cornerRadius, win.feather); // R
2607
+ if (radius > 0 || feather > 0) {
2608
+ drawEye(ctx, src, 0, 0, srcW / 2, srcH, 0, 0, halfDst, c.height, radius, feather); // L
2609
+ drawEye(ctx, src, srcW / 2, 0, srcW / 2, srcH, halfDst, 0, halfDst, c.height, radius, feather); // R
2500
2610
  } else {
2501
2611
  ctx.drawImage(src, 0, 0, srcW, srcH, 0, 0, c.width, c.height);
2502
2612
  }
@@ -2656,7 +2766,10 @@ class Inline3D {
2656
2766
  win.onFrame(views, win.layer, f);
2657
2767
  // A stereo frame the page drew without throwing. A short view list is the load
2658
2768
  // fallback (a mono frame), which is not what a poster is waiting for.
2659
- if (views.length >= 2) win.fwStereo = true;
2769
+ if (views.length >= 2) {
2770
+ win.fwStereo = true;
2771
+ if (!win.warnedBufMismatch) this._checkSceneBuffer(win);
2772
+ }
2660
2773
  } catch (err) {
2661
2774
  if (!win.frameThrewWarned) {
2662
2775
  win.frameThrewWarned = true;
package/model.d.ts CHANGED
@@ -34,7 +34,11 @@ export interface ModelOptions {
34
34
  * so a long subject still fits once the turntable turns it (default true).
35
35
  */
36
36
  fitSweep?: boolean;
37
- /** Per-eye buffer scale; 0.5–0.7 is usually free (default 1). */
37
+ /**
38
+ * Per-eye buffer scale; 0.5–0.7 is usually free (default 1). Clamped to the device's GL limits: the whole store (2 × eye wide in 3D) is kept within
39
+ * min(MAX_TEXTURE_SIZE, MAX_RENDERBUFFER_SIZE, MAX_VIEWPORT_DIMS), both axes shrunk by one factor,
40
+ * with a one-time console warning. The handle's `renderScale` reports the value in force.
41
+ */
38
42
  renderScale?: number;
39
43
  feather?: number;
40
44
  /**
@@ -176,6 +180,11 @@ export interface ModelHandle {
176
180
  /** The last pose the page set (a copy), or null. */
177
181
  getCameraPose(): SplatCameraPose | null;
178
182
 
183
+ /** The per-eye buffer scale in force: the `renderScale` option times the device-limit clamp. */
184
+ readonly renderScale: number;
185
+ /** The `renderScale` option as given. */
186
+ readonly renderScaleRequested: number;
187
+
179
188
  remove(): void;
180
189
  exclude(el: Element): void;
181
190
  unexclude(el: Element): void;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@displayxr/inline3d",
3
- "version": "1.27.0",
3
+ "version": "1.28.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",
@@ -36,6 +36,10 @@
36
36
  "types": "./player.d.ts",
37
37
  "import": "./js/inline3d-player.js"
38
38
  },
39
+ "./call": {
40
+ "types": "./call.d.ts",
41
+ "import": "./js/inline3d-call.js"
42
+ },
39
43
  "./undock": {
40
44
  "types": "./index.d.ts",
41
45
  "import": "./js/inline3d-undock.js"
@@ -46,6 +50,7 @@
46
50
  "js/inline3d.js",
47
51
  "js/inline3d-undock.js",
48
52
  "js/inline3d-mode-switch.js",
53
+ "js/inline3d-buffer-limit.js",
49
54
  "js/inline3d-three.js",
50
55
  "js/inline3d-viewer.js",
51
56
  "js/inline3d-splat.js",
@@ -66,12 +71,15 @@
66
71
  "js/inline3d-pc-look.js",
67
72
  "js/inline3d-playcanvas-engine.js",
68
73
  "js/inline3d-player.js",
74
+ "js/inline3d-call.js",
75
+ "js/call/",
69
76
  "index.d.ts",
70
77
  "three.d.ts",
71
78
  "viewer.d.ts",
72
79
  "splat.d.ts",
73
80
  "model.d.ts",
74
81
  "player.d.ts",
82
+ "call.d.ts",
75
83
  "CHANGELOG.md",
76
84
  "README.md",
77
85
  "LICENSE",