@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.
@@ -0,0 +1,198 @@
1
+ // inline3d-buffer-limit.js — keep a woven canvas's backing store inside the device's GL limits.
2
+ //
3
+ // Internal to the SDK (core, ./viewer, ./splat on both engines, ./model on both engines). Not a
4
+ // public entry point; renderer-free and dependency-free, so the core can import it.
5
+ //
6
+ // WHY (measured, an Android 3D tablet on Adreno 740, DisplayXR Browser 154): a full-screen SBS
7
+ // canvas asked for 5120×1348 (2560 per eye) with renderScale 1. The browser reports
8
+ // MAX_TEXTURE_SIZE 4096 there (Chromium's `webgl_or_caps_max_texture_size_limit_4096` driver
9
+ // workaround) and SILENTLY clamps the WebGL drawing buffer to 4096×1348, while `canvas.width` still
10
+ // reads 5120. `XRDisplayLayer.getViewport()` splits `canvas.width` (the attribute), so each eye was
11
+ // drawn 2560 wide into a 4096-wide buffer: the eye boundary landed at 62.5% of the buffer while the
12
+ // weave splits it at 50%, and the panel showed a large double image. Nothing was logged. Windows
13
+ // and Linux report 16384, so the same page was fine there.
14
+ //
15
+ // Two layers of defence, both here:
16
+ // 1. clampEyeBuffer(): shrink the requested per-eye size, UNIFORMLY (the eye aspect is kept), so
17
+ // that the whole store (cols × eyeW by rows × eyeH) fits min(MAX_TEXTURE_SIZE,
18
+ // MAX_RENDERBUFFER_SIZE, MAX_VIEWPORT_DIMS). The browser then never has to clamp.
19
+ // 2. bufferScale(): if the drawing buffer STILL differs from canvas.width/height after a resize
20
+ // (a clamp we did not predict), the caller maps the layer's canvas-space viewports onto the
21
+ // real drawing buffer with these factors — never canvas.width / 2.
22
+
23
+ /** @typedef {{ maxW: number, maxH: number, nameW: string, nameH: string, valueW: number, valueH: number }} BufferLimits */
24
+
25
+ const cache = new WeakMap(); // gl context → BufferLimits
26
+ let probed; // undefined = not tried; null = no WebGL available; else BufferLimits
27
+
28
+ /** Smallest of the named candidates, with its name (the first one wins a tie). */
29
+ function smallest(cands) {
30
+ let best = null;
31
+ for (const [name, v] of cands) {
32
+ if (!(v > 0)) continue;
33
+ if (!best || v < best[1]) best = [name, v];
34
+ }
35
+ return best;
36
+ }
37
+
38
+ /**
39
+ * The largest backing store this GL context can hold and render into, queried once per context
40
+ * and cached. Returns null for anything that is not a GL context (or a context lost mid-query).
41
+ *
42
+ * @param {WebGLRenderingContext|WebGL2RenderingContext|null|undefined} gl
43
+ * @returns {BufferLimits|null}
44
+ */
45
+ export function glBufferLimits(gl) {
46
+ if (!gl || typeof gl.getParameter !== 'function') return null;
47
+ const hit = cache.get(gl);
48
+ if (hit) return hit;
49
+ let tex, rb, vp;
50
+ try {
51
+ tex = gl.getParameter(gl.MAX_TEXTURE_SIZE ?? 0x0d33);
52
+ rb = gl.getParameter(gl.MAX_RENDERBUFFER_SIZE ?? 0x84e8);
53
+ vp = gl.getParameter(gl.MAX_VIEWPORT_DIMS ?? 0x0d3a);
54
+ } catch {
55
+ return null;
56
+ }
57
+ const vpW = vp && vp.length >= 2 ? vp[0] : 0;
58
+ const vpH = vp && vp.length >= 2 ? vp[1] : 0;
59
+ const w = smallest([['MAX_TEXTURE_SIZE', tex], ['MAX_RENDERBUFFER_SIZE', rb], ['MAX_VIEWPORT_DIMS', vpW]]);
60
+ const h = smallest([['MAX_TEXTURE_SIZE', tex], ['MAX_RENDERBUFFER_SIZE', rb], ['MAX_VIEWPORT_DIMS', vpH]]);
61
+ if (!w || !h) return null; // a lost context answers 0/null — don't cache a non-answer
62
+ const lim = { maxW: w[1], maxH: h[1], nameW: w[0], nameH: h[0], valueW: w[1], valueH: h[1] };
63
+ cache.set(gl, lim);
64
+ return lim;
65
+ }
66
+
67
+ /**
68
+ * The limits of a throwaway WebGL context, for callers without one of their own yet: the core's
69
+ * image/video windows (2D canvases, which the weave still has to take as a GPU texture) and the
70
+ * PlayCanvas viewer before its engine has booted. Probed once per document, then the context is
71
+ * released. Null where there is no DOM or no WebGL (tests, workers, a GPU-less browser): no
72
+ * clamp is applied then.
73
+ *
74
+ * @returns {BufferLimits|null}
75
+ */
76
+ export function probeBufferLimits() {
77
+ if (probed !== undefined) return probed;
78
+ probed = null;
79
+ try {
80
+ if (typeof document === 'undefined' || !document || typeof document.createElement !== 'function') return probed;
81
+ const c = document.createElement('canvas');
82
+ const gl = c.getContext('webgl2') || c.getContext('webgl');
83
+ if (!gl) return probed;
84
+ probed = glBufferLimits(gl);
85
+ gl.getExtension?.('WEBGL_lose_context')?.loseContext?.();
86
+ } catch {
87
+ probed = null;
88
+ }
89
+ return probed;
90
+ }
91
+
92
+ /** Tests only: forget the probed document limits. */
93
+ export function _resetProbedLimits() {
94
+ probed = undefined;
95
+ }
96
+
97
+ /**
98
+ * Fit a requested per-eye size into the device's limits. The store is `cols × eyeW` by
99
+ * `rows × eyeH` (SBS: cols 2, rows 1; mono: cols 1). Both axes shrink by ONE factor, so the eye
100
+ * keeps its aspect; the result is floored so it can never round back over the limit.
101
+ *
102
+ * @param {number} eyeW requested per-eye width, buffer px
103
+ * @param {number} eyeH requested per-eye height, buffer px
104
+ * @param {BufferLimits|null} limits
105
+ * @param {{ cols?: number, rows?: number }} [layout]
106
+ * @returns {{ eyeW: number, eyeH: number, scale: number, clamped: boolean,
107
+ * reqW: number, reqH: number, bufW: number, bufH: number,
108
+ * limitName: string, limitValue: number }}
109
+ */
110
+ export function clampEyeBuffer(eyeW, eyeH, limits, { cols = 2, rows = 1 } = {}) {
111
+ const reqW = eyeW * cols;
112
+ const reqH = eyeH * rows;
113
+ const base = { eyeW, eyeH, cols, scale: 1, clamped: false, reqW, reqH, bufW: reqW, bufH: reqH, limitName: '', limitValue: 0 };
114
+ if (!limits || !(eyeW > 0) || !(eyeH > 0)) return base;
115
+ const sw = limits.maxW / reqW;
116
+ const sh = limits.maxH / reqH;
117
+ const s = Math.min(sw, sh);
118
+ if (!(s < 1)) return base;
119
+ const byW = sw <= sh;
120
+ // Floor (never round up past the limit), and never above the per-axis cap either way.
121
+ const w = Math.max(1, Math.min(Math.floor(eyeW * s + 1e-9), Math.floor(limits.maxW / cols)));
122
+ const h = Math.max(1, Math.min(Math.floor(eyeH * s + 1e-9), Math.floor(limits.maxH / rows)));
123
+ return {
124
+ eyeW: w,
125
+ eyeH: h,
126
+ cols,
127
+ scale: s,
128
+ clamped: true,
129
+ reqW,
130
+ reqH,
131
+ bufW: w * cols,
132
+ bufH: h * rows,
133
+ limitName: byW ? limits.nameW : limits.nameH,
134
+ limitValue: byW ? limits.valueW : limits.valueH,
135
+ };
136
+ }
137
+
138
+ /** Round a scale for a human-readable warning: 0.8, 0.625, never 0.8000000001. */
139
+ export function roundScale(s) {
140
+ return Math.round(s * 1000) / 1000;
141
+ }
142
+
143
+ /**
144
+ * The one warning text, so every path names the same things: the store it wanted, the limit, what
145
+ * it rendered at, and (where there is one) the renderScale request vs the effective value.
146
+ */
147
+ export function clampWarning(tag, c, requestedScale) {
148
+ const rs =
149
+ typeof requestedScale === 'number'
150
+ ? ` (renderScale ${roundScale(requestedScale)} → ${roundScale(requestedScale * c.scale)})`
151
+ : ` (scale ${roundScale(c.scale)})`;
152
+ return (
153
+ `${tag} ${c.cols === 1 ? 'buffer' : 'SBS buffer'} ${c.reqW}×${c.reqH} exceeds ${c.limitName} ${c.limitValue} on this device; ` +
154
+ `rendering at ${c.bufW}×${c.bufH}${rs}.`
155
+ );
156
+ }
157
+
158
+ /**
159
+ * How the layer's viewports (canvas-attribute space: XRDisplayLayer.getViewport() splits
160
+ * `canvas.width`) map onto the drawing buffer the GL context really has. {1, 1, false} whenever
161
+ * the two agree, which is every case the clamp above predicted.
162
+ *
163
+ * @param {{ width: number, height: number }} canvas
164
+ * @param {object|null} gl a WebGL context, or null (a 2D canvas: no separate drawing buffer)
165
+ */
166
+ export function bufferScale(canvas, gl) {
167
+ const cw = canvas?.width || 0;
168
+ const ch = canvas?.height || 0;
169
+ const dw = gl && gl.drawingBufferWidth > 0 ? gl.drawingBufferWidth : cw;
170
+ const dh = gl && gl.drawingBufferHeight > 0 ? gl.drawingBufferHeight : ch;
171
+ const sx = cw > 0 ? dw / cw : 1;
172
+ const sy = ch > 0 ? dh / ch : 1;
173
+ return { sx, sy, w: dw, h: dh, mismatch: sx !== 1 || sy !== 1 };
174
+ }
175
+
176
+ /** Scale one {x, y, width, height} viewport by bufferScale()'s factors (identity returns it as is). */
177
+ export function scaleViewport(vp, sx, sy) {
178
+ if (sx === 1 && sy === 1) return vp;
179
+ return {
180
+ x: Math.round(vp.x * sx),
181
+ y: Math.round(vp.y * sy),
182
+ width: Math.max(1, Math.round(vp.width * sx)),
183
+ height: Math.max(1, Math.round(vp.height * sy)),
184
+ };
185
+ }
186
+
187
+ /** The warning for a drawing buffer the clamp did not predict. `tail` says what happens next. */
188
+ export function mismatchWarning(
189
+ tag,
190
+ canvas,
191
+ b,
192
+ tail = 'laying the eyes out from the drawing buffer. Lower renderScale to stay inside the device limit.',
193
+ ) {
194
+ return (
195
+ `${tag} the browser clamped this canvas's drawing buffer to ${b.w}×${b.h} (canvas.width/height ` +
196
+ `say ${canvas.width}×${canvas.height}); ${tail}`
197
+ );
198
+ }