@displayxr/inline3d 1.27.0 → 1.29.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.
@@ -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
@@ -1897,6 +1915,9 @@ export class PlayCanvasSplatViewer {
1897
1915
  const box = this.canvas.getBoundingClientRect();
1898
1916
  const aspect = box.height > 0 ? box.width / box.height : 1;
1899
1917
  this.boxAspect = aspect; // setVideo's plane reads it per frame (no layout read in the draw)
1918
+ // and this, for its on-screen rect: a NEW object only when the size changed (the plane's rect
1919
+ // check compares identity, so an unchanged frame allocates nothing).
1920
+ if (!this.boxCss || this.boxCss.w !== (box.width || 0) || this.boxCss.h !== (box.height || 0)) this.boxCss = { w: box.width || 0, h: box.height || 0 };
1900
1921
  if (this.mono.capture) {
1901
1922
  captureProjection(this.mono.capture, aspect, this.mono.near, this.mono.far, this.mono.proj, this.captureFit);
1902
1923
  if (this.captureFit !== 'height') {
@@ -1943,8 +1964,9 @@ export class PlayCanvasSplatViewer {
1943
1964
  const v = this._monoView();
1944
1965
  e.proj = v.proj;
1945
1966
  e.pose = v.pose;
1946
- e.width = c.width;
1947
- e.height = c.height;
1967
+ const b = this._bufScale();
1968
+ e.width = b.w || c.width;
1969
+ e.height = b.h || c.height;
1948
1970
  this._drawEntries(this._monoEntry, null);
1949
1971
  }
1950
1972
 
@@ -1956,9 +1978,7 @@ export class PlayCanvasSplatViewer {
1956
1978
  const app = this.app;
1957
1979
  if (!app || !this.pc) return false;
1958
1980
  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;
1981
+ const { b, sx, sy } = this._entryScale(cache);
1962
1982
  if (this.pageCamera) {
1963
1983
  // The page's near/far join the caller's nearClip/farClip as a floor and a cap: only the
1964
1984
  // depth mapping moves, the runtime's frustum (fov, skew, principal point) stays untouched.
@@ -2033,8 +2053,8 @@ export class PlayCanvasSplatViewer {
2033
2053
  this._budgetViews = entries.length;
2034
2054
  this._applyBudget();
2035
2055
  }
2036
- const W = el.width || 1;
2037
- const H = el.height || 1;
2056
+ const W = b.w || 1;
2057
+ const H = b.h || 1;
2038
2058
  for (let i = 0; i < entries.length; i++) {
2039
2059
  const e = entries[i];
2040
2060
  const cam = cams[i];
@@ -2120,18 +2140,68 @@ export class PlayCanvasSplatViewer {
2120
2140
  const box = this.canvas.getBoundingClientRect();
2121
2141
  if (box.width < 1 || box.height < 1) return;
2122
2142
  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;
2143
+ // Clamp to the device's GL limits BEFORE sizing (./inline3d-buffer-limit.js). Measured on an
2144
+ // Adreno 740 tablet: MAX_TEXTURE_SIZE 4096, a 5120-wide request silently became a 4096-wide
2145
+ // drawing buffer, getViewport() still split 5120, and the eye boundary sat at 62.5%.
2146
+ const c = clampEyeBuffer(
2147
+ Math.max(1, Math.round(box.width * dpr)),
2148
+ Math.max(1, Math.round(box.height * dpr)),
2149
+ glBufferLimits(this.app?.graphicsDevice?.gl) || probeBufferLimits(),
2150
+ { cols: this._mode === 'mono' ? 1 : 2 },
2151
+ );
2152
+ this._noteClamp(c);
2153
+ const bufW = c.bufW;
2154
+ const h = c.bufH;
2126
2155
  this._updateMonoProjection();
2127
2156
  const el = this.canvas;
2128
- if (el.width === bufW && el.height === h) return;
2157
+ if (el.width === bufW && el.height === h) {
2158
+ this._bufScale(); // unchanged; still name a browser-side clamp (once)
2159
+ return;
2160
+ }
2129
2161
  el.width = bufW;
2130
2162
  el.height = h;
2163
+ this._bufScale(); // warns once if the browser clamped anyway
2131
2164
  if (this._mode === 'mono') this._drawMono();
2132
2165
  else this._replayLastGood();
2133
2166
  }
2134
2167
 
2168
+ /** The renderScale in force: the request times the device-limit clamp (SceneViewer's twin). */
2169
+ get effectiveRenderScale() {
2170
+ return this.renderScale * this._bufClamp;
2171
+ }
2172
+
2173
+ _noteClamp(c) {
2174
+ this._bufClamp = c.scale;
2175
+ if (c.clamped && !this._warnedBufClamp) {
2176
+ this._warnedBufClamp = true;
2177
+ console.warn(clampWarning(this.logTag, c, this.renderScale));
2178
+ }
2179
+ }
2180
+
2181
+ /**
2182
+ * How a frame's entries map into the buffer. Entries are in canvas-attribute px (getViewport()
2183
+ * splits canvas.width), cached with that size; they are drawn into the REAL drawing buffer.
2184
+ * Identity unless a resize happened since the cache or the browser clamped the buffer behind
2185
+ * the SDK (the device-limit clamp in _resize normally prevents that).
2186
+ */
2187
+ _entryScale(cache) {
2188
+ const b = this._bufScale();
2189
+ const sx = cache && cache.bufW > 0 && b.w ? b.w / cache.bufW : 1;
2190
+ const sy = cache && cache.bufH > 0 && b.h ? b.h / cache.bufH : 1;
2191
+ return { b, sx, sy };
2192
+ }
2193
+
2194
+ /** The real drawing buffer vs canvas.width/height; warns once per viewer on a mismatch. */
2195
+ _bufScale() {
2196
+ const el = this.canvas;
2197
+ const b = bufferScale(el, this.app?.graphicsDevice?.gl || null);
2198
+ if (b.mismatch && !this._warnedBufMismatch) {
2199
+ this._warnedBufMismatch = true;
2200
+ console.warn(mismatchWarning(this.logTag, el, b));
2201
+ }
2202
+ return b;
2203
+ }
2204
+
2135
2205
  _tick() {
2136
2206
  const t = now();
2137
2207
  const dt = this._lastTick ? Math.min((t - this._lastTick) / 1000, MAX_DT_S) : 0;
@@ -3051,9 +3121,19 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
3051
3121
  let cancelPendingVideo = null;
3052
3122
  let warnedAutoplayMuted = false;
3053
3123
 
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 });
3124
+ // The per-eye buffer scale IN FORCE: the request (the `renderScale` option, or the last
3125
+ // setRenderScale) times the device-limit clamp — e.g. 0.8 for a request of 1 on a device whose
3126
+ // MAX_TEXTURE_SIZE cannot hold the full SBS store (./inline3d-buffer-limit.js).
3127
+ // `renderScaleRequested` is the request as given. Real accessors: Object.assign below would
3128
+ // copy a getter's value once.
3129
+ Object.defineProperty(out, 'renderScale', { get: () => viewer.effectiveRenderScale, enumerable: true, configurable: true });
3130
+ Object.defineProperty(out, 'renderScaleRequested', { get: () => viewer.renderScale, enumerable: true, configurable: true });
3131
+ // setVideo's current element: the <video> the plane is SHOWING (not one still waiting for its first
3132
+ // frame), else null. Read-only. A player attached to this handle (./player attachPlayer) reads it
3133
+ // to notice another setVideo taking the slot, which no event reports.
3134
+ Object.defineProperty(out, 'videoElement', { get: () => (vid && vid.on ? vid.el : null), enumerable: true, configurable: true });
3135
+ // The tile's canvas, for page chrome that sits beside it (attachPlayer's controls are its siblings).
3136
+ Object.defineProperty(out, 'canvas', { value: canvas, enumerable: true, configurable: true });
3057
3137
  // The current eye offset (after the unit-disc clamp), a fresh object per read.
3058
3138
  Object.defineProperty(out, 'viewOffset', {
3059
3139
  get: () => ({ x: viewer._viewOffset[0], y: viewer._viewOffset[1] }),
@@ -3147,7 +3227,9 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
3147
3227
  /**
3148
3228
  * Change the per-eye buffer scale live (the `renderScale` option): e.g. full resolution on a
3149
3229
  * 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.
3230
+ * resized on the next animation frame (never mid-frame). The request is kept as given
3231
+ * (`renderScaleRequested`); the store is still clamped to the device's GL limits, and
3232
+ * `renderScale` reports what is in force. Returns the handle.
3151
3233
  */
3152
3234
  setRenderScale(s) {
3153
3235
  if (typeof s !== 'number' || !Number.isFinite(s) || s <= 0 || s > 4) {
@@ -4332,7 +4414,9 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
4332
4414
  * handle.setVideo(src, options) — play a stereo video on THIS handle: no new canvas, layer or
4333
4415
  * session. The splat is hidden and a screen-locked plane on the display rig shows the video, each
4334
4416
  * eye its own half (sbs: left/right, tb: top/bottom); flat (mono), the left half at full
4335
- * resolution. Applies on the first frame the video has. Resolves to `{ video, remove(), stats() }`;
4417
+ * resolution. Applies on the first frame the video has; `transition: 'crossfade'` (with
4418
+ * `durationMs`, `easing`) dissolves from a video already on the plane, on the GPU, and is a cut
4419
+ * from the splat. Resolves to `{ video, transition, remove(), stats() }`;
4336
4420
  * the page drives transport through `video`. setVideo(null) exits and restores the splat, the rig
4337
4421
  * and its declaration exactly as they were. Throws during an in-flight setSource.
4338
4422
  */
@@ -4359,7 +4443,7 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
4359
4443
  if (owned ? r.autoplay !== false : r.autoplay === true) autoplay(el);
4360
4444
  // The guard state is taken NOW (setSource / setRig refuse from this call on), the pixels change
4361
4445
  // on the first frame the video has.
4362
- const state = { seq, el, owned, format: r.format, fit: r.fit, vH: r.vH ?? bootFraming.vH, on: false, saved: null, pending: true };
4446
+ const state = { seq, el, owned, format: r.format, fit: r.fit, band: r.band, vH: r.vH ?? bootFraming.vH, on: false, saved: null, pending: true, rectOffs: new Set() };
4363
4447
  if (!prev || !prev.on) vid = state; // a pending one it supersedes is cancelled above
4364
4448
  return first
4365
4449
  .catch(() => null)
@@ -4371,6 +4455,9 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
4371
4455
  // Another video on screen: keep ITS snapshot (the pre-video state), swap the source.
4372
4456
  state.saved = live.saved;
4373
4457
  state.on = true;
4458
+ // S2: the plane outlives this swap; the replaced video's rect subscriptions go with it.
4459
+ for (const off of live.rectOffs) off();
4460
+ live.rectOffs.clear();
4374
4461
  if (live.owned && live.el !== el) releaseOwned(live.el);
4375
4462
  if (state.vH !== live.vH) {
4376
4463
  viewer.vH = state.vH;
@@ -4381,13 +4468,42 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
4381
4468
  } else enterVideo(state);
4382
4469
  vid = state;
4383
4470
  state.pending = false;
4471
+ // A crossfade only dissolves between two videos: from the splat (entry) it is a cut.
4472
+ const fade = live && r.transition.type === 'crossfade' && r.transition.durationMs > 0 ? r.transition : null;
4384
4473
  viewer._videoPlane ||= new VideoPlane(viewer);
4385
- viewer._videoPlane.setSource(el, { format: state.format, fit: state.fit, vH: state.vH });
4474
+ const faded = viewer._videoPlane.setSource(el, { format: state.format, fit: state.fit, vH: state.vH, band: state.band }, fade);
4386
4475
  const plane = viewer._videoPlane;
4387
4476
  return Object.freeze({
4388
4477
  video: el,
4389
4478
  format: state.format,
4390
4479
  fit: state.fit,
4480
+ /** The letterbox band's aspect, or null. */
4481
+ band: state.band,
4482
+ /**
4483
+ * Where the picture is on screen, in CSS px from the canvas's top-left, clipped to the
4484
+ * canvas: `{ x, y, width, height }`. The bars are the canvas minus this. Null once
4485
+ * another setVideo replaced this one, or before the first drawn frame.
4486
+ */
4487
+ get rect() {
4488
+ return vid === state && viewer._videoPlane === plane ? plane.rect : null;
4489
+ },
4490
+ /** Called with the new rect whenever it changes (resize, band, a new size). Returns an unsubscribe. */
4491
+ onRectChange(cb) {
4492
+ if (typeof cb !== 'function') throw new TypeError('@displayxr/inline3d/splat: onRectChange(cb) — expected a function.');
4493
+ if (vid !== state) return () => {};
4494
+ // S2: never outlives this video — every subscription is dropped at the swap that
4495
+ // replaces it (and with the plane on setVideo(null)).
4496
+ // Each subscription is its own entry on the plane, so calling this twice is harmless.
4497
+ const off = plane.onRect(cb);
4498
+ const unsubscribe = () => {
4499
+ off();
4500
+ state.rectOffs.delete(unsubscribe);
4501
+ };
4502
+ state.rectOffs.add(unsubscribe);
4503
+ return unsubscribe;
4504
+ },
4505
+ /** What this swap did: 'crossfade' only when a fade actually started, else 'cut'. */
4506
+ transition: faded ? 'crossfade' : 'cut',
4391
4507
  /** Exit (setVideo(null)) — a no-op once another setVideo replaced this one. */
4392
4508
  remove: () => (vid === state ? setVideo(null) : Promise.resolve(null)),
4393
4509
  /** Upload accounting: frames drawn with the plane up, and texture uploads (new frames). */