vireglass 0.1.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.
Files changed (72) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +10 -0
  3. package/README.md +216 -0
  4. package/android/build.gradle +15 -0
  5. package/android/src/main/AndroidManifest.xml +1 -0
  6. package/android/src/main/java/expo/modules/glasslens/GlassBackdropView.kt +335 -0
  7. package/android/src/main/java/expo/modules/glasslens/GlassLensModule.kt +49 -0
  8. package/android/src/main/java/expo/modules/glasslens/GlassLensView.kt +463 -0
  9. package/android/src/main/java/expo/modules/glasslens/GlassProbeView.kt +76 -0
  10. package/dist/adaptation-BSbbdExK.d.cts +78 -0
  11. package/dist/adaptation-BSbbdExK.d.ts +78 -0
  12. package/dist/adapters-BPB0D5nr.d.cts +357 -0
  13. package/dist/adapters-BgSc-smX.d.ts +357 -0
  14. package/dist/chunk-HF2AWZEZ.js +1741 -0
  15. package/dist/chunk-HF2AWZEZ.js.map +1 -0
  16. package/dist/chunk-JNRZNMJJ.js +20 -0
  17. package/dist/chunk-JNRZNMJJ.js.map +1 -0
  18. package/dist/index.cjs +2159 -0
  19. package/dist/index.cjs.map +1 -0
  20. package/dist/index.d.cts +294 -0
  21. package/dist/index.d.ts +294 -0
  22. package/dist/index.js +286 -0
  23. package/dist/index.js.map +1 -0
  24. package/dist/material-CkUHUBoy.d.cts +408 -0
  25. package/dist/material-CkUHUBoy.d.ts +408 -0
  26. package/dist/react.cjs +73 -0
  27. package/dist/react.cjs.map +1 -0
  28. package/dist/react.d.cts +25 -0
  29. package/dist/react.d.ts +25 -0
  30. package/dist/react.js +59 -0
  31. package/dist/react.js.map +1 -0
  32. package/dist/web/index.cjs +1990 -0
  33. package/dist/web/index.cjs.map +1 -0
  34. package/dist/web/index.d.cts +148 -0
  35. package/dist/web/index.d.ts +148 -0
  36. package/dist/web/index.js +657 -0
  37. package/dist/web/index.js.map +1 -0
  38. package/expo-module.config.json +6 -0
  39. package/package.json +127 -0
  40. package/src/accessibility.ts +51 -0
  41. package/src/adaptation.ts +85 -0
  42. package/src/adapters.ts +362 -0
  43. package/src/concentric.ts +25 -0
  44. package/src/geometry.ts +151 -0
  45. package/src/glass-scale.ts +37 -0
  46. package/src/group-model.ts +224 -0
  47. package/src/index.ts +17 -0
  48. package/src/lens-shader.ts +547 -0
  49. package/src/material.ts +553 -0
  50. package/src/native/environment.ts +70 -0
  51. package/src/native/glass-group.tsx +107 -0
  52. package/src/native/glass-ink.tsx +44 -0
  53. package/src/native/glass-surface.tsx +477 -0
  54. package/src/native/index.ts +18 -0
  55. package/src/native/lens.ts +106 -0
  56. package/src/native/provider.tsx +58 -0
  57. package/src/native/surface-registry.ts +51 -0
  58. package/src/native/system-accessibility.ts +90 -0
  59. package/src/optics.ts +177 -0
  60. package/src/react.ts +102 -0
  61. package/src/reference-scene.ts +222 -0
  62. package/src/scroll-edge.ts +30 -0
  63. package/src/sdf.ts +149 -0
  64. package/src/surface-shader.ts +287 -0
  65. package/src/targets/agsl.ts +8 -0
  66. package/src/targets/glsl.ts +140 -0
  67. package/src/touch-response.ts +195 -0
  68. package/src/web/gl.ts +119 -0
  69. package/src/web/index.ts +3 -0
  70. package/src/web/probe.ts +312 -0
  71. package/src/web/reference-draw.ts +142 -0
  72. package/src/web/renderer.ts +524 -0
package/src/web/gl.ts ADDED
@@ -0,0 +1,119 @@
1
+ // Low-level GL helpers for the pipeline: program compilation, textures, FBOs, the fullscreen
2
+ // triangle. Knows nothing about VireGlass — a reusable layer on top of WebGL2.
3
+
4
+ /** The same trick as in `scripts/check-glsl.mjs`: three vertices with no buffer, indexed by
5
+ * `gl_VertexID`. One vertex shader works for EVERY pass of the pipeline, because each fragment
6
+ * shader pulls its own coordinate from `gl_FragCoord`. */
7
+ export const FULLSCREEN_TRIANGLE_VERTEX_SOURCE = `#version 300 es
8
+ const vec2 VG_POS[3] = vec2[3](vec2(-1.0, -1.0), vec2(3.0, -1.0), vec2(-1.0, 3.0));
9
+ void main() {
10
+ gl_Position = vec4(VG_POS[gl_VertexID], 0.0, 1.0);
11
+ }
12
+ `;
13
+
14
+ export function compileShader(gl: WebGL2RenderingContext, type: number, source: string): WebGLShader {
15
+ const shader = gl.createShader(type);
16
+ if (!shader) throw new Error('vireglass/web: gl.createShader returned null');
17
+ gl.shaderSource(shader, source);
18
+ gl.compileShader(shader);
19
+ if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
20
+ const log = gl.getShaderInfoLog(shader) ?? '(no log)';
21
+ gl.deleteShader(shader);
22
+ throw new Error(`vireglass/web: shader failed to compile:\n${log}`);
23
+ }
24
+ return shader;
25
+ }
26
+
27
+ export function createProgram(
28
+ gl: WebGL2RenderingContext,
29
+ vertexSource: string,
30
+ fragmentSource: string,
31
+ ): WebGLProgram {
32
+ const vertex = compileShader(gl, gl.VERTEX_SHADER, vertexSource);
33
+ const fragment = compileShader(gl, gl.FRAGMENT_SHADER, fragmentSource);
34
+ const program = gl.createProgram();
35
+ if (!program) throw new Error('vireglass/web: gl.createProgram returned null');
36
+ gl.attachShader(program, vertex);
37
+ gl.attachShader(program, fragment);
38
+ gl.linkProgram(program);
39
+ gl.deleteShader(vertex);
40
+ gl.deleteShader(fragment);
41
+ if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
42
+ const log = gl.getProgramInfoLog(program) ?? '(no log)';
43
+ gl.deleteProgram(program);
44
+ throw new Error(`vireglass/web: program failed to link:\n${log}`);
45
+ }
46
+ return program;
47
+ }
48
+
49
+ /** Draws the fullscreen triangle with the current program. No attributes — the vertices are
50
+ * baked into the shader, so even a VAO isn't needed. */
51
+ export function drawFullscreenTriangle(gl: WebGL2RenderingContext): void {
52
+ gl.drawArrays(gl.TRIANGLES, 0, 3);
53
+ }
54
+
55
+ export type TextureOptions = {
56
+ width: number;
57
+ height: number;
58
+ /** RGBA8 by default; the probe needs float when it needs more than 8 bits of precision. */
59
+ internalFormat?: number;
60
+ format?: number;
61
+ type?: number;
62
+ };
63
+
64
+ /**
65
+ * content/probe texture: LINEAR on MIN and MAG, no mipmaps, CLAMP_TO_EDGE. Skia's `.eval` is
66
+ * bilinear by default — NEAREST would break the disc gather into pixel-stepped bands (spec §4).
67
+ */
68
+ export function createTexture(gl: WebGL2RenderingContext, options: TextureOptions): WebGLTexture {
69
+ const texture = gl.createTexture();
70
+ if (!texture) throw new Error('vireglass/web: gl.createTexture returned null');
71
+ const internalFormat = options.internalFormat ?? gl.RGBA8;
72
+ const format = options.format ?? gl.RGBA;
73
+ const type = options.type ?? gl.UNSIGNED_BYTE;
74
+ gl.bindTexture(gl.TEXTURE_2D, texture);
75
+ gl.texImage2D(
76
+ gl.TEXTURE_2D,
77
+ 0,
78
+ internalFormat,
79
+ options.width,
80
+ options.height,
81
+ 0,
82
+ format,
83
+ type,
84
+ null,
85
+ );
86
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
87
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
88
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
89
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
90
+ gl.bindTexture(gl.TEXTURE_2D, null);
91
+ return texture;
92
+ }
93
+
94
+ export function createFramebuffer(gl: WebGL2RenderingContext, texture: WebGLTexture): WebGLFramebuffer {
95
+ const fbo = gl.createFramebuffer();
96
+ if (!fbo) throw new Error('vireglass/web: gl.createFramebuffer returned null');
97
+ gl.bindFramebuffer(gl.FRAMEBUFFER, fbo);
98
+ gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, texture, 0);
99
+ const status = gl.checkFramebufferStatus(gl.FRAMEBUFFER);
100
+ gl.bindFramebuffer(gl.FRAMEBUFFER, null);
101
+ if (status !== gl.FRAMEBUFFER_COMPLETE) {
102
+ throw new Error(`vireglass/web: FBO incomplete, status ${status}`);
103
+ }
104
+ return fbo;
105
+ }
106
+
107
+ /** The one texture unit any pipeline pass needs here — a bind with no extra slot bookkeeping. */
108
+ export function bindTextureAt(
109
+ gl: WebGL2RenderingContext,
110
+ unit: number,
111
+ texture: WebGLTexture,
112
+ program: WebGLProgram,
113
+ uniformName: string,
114
+ ): void {
115
+ gl.activeTexture(gl.TEXTURE0 + unit);
116
+ gl.bindTexture(gl.TEXTURE_2D, texture);
117
+ const loc = gl.getUniformLocation(program, uniformName);
118
+ gl.uniform1i(loc, unit);
119
+ }
@@ -0,0 +1,3 @@
1
+ export * from './renderer';
2
+ export * from './probe';
3
+ export * from './reference-draw';
@@ -0,0 +1,312 @@
1
+ // Backdrop lightness probe under the lens — the web counterpart to the native capture in
2
+ // ImageReader (`GlassLensView.kt`); see the web-core design spec and its Phase 3 plan.
3
+ //
4
+ // Downsampling the scene into a 48×96 grid runs as a GL pass (sampling `content` in the
5
+ // downsample program, see `renderer.ts`), while READING the result happens here, through a PBO
6
+ // with `fenceSync`: a synchronous `readPixels` stalls the pipeline, and a naive port stutters
7
+ // while dragging sliders.
8
+ //
9
+ // Lagging by a frame or two isn't a bug, it's part of the contract (per the Phase 3 plan): we
10
+ // read whatever the GPU already finished by the previous frame, rather than waiting on the
11
+ // current one.
12
+
13
+ import { bindTextureAt, createFramebuffer, createProgram, drawFullscreenTriangle } from './gl';
14
+
15
+ export const PROBE_GRID_WIDTH = 48;
16
+ export const PROBE_GRID_HEIGHT = 96;
17
+
18
+ /** Stats for the lens rectangle from a single probe sample. `null` until the first ready frame
19
+ * has come in — the shader falls back to `u_probeLuma = -1` in that case (see `lens-shader.ts`). */
20
+ export type ProbeStats = {
21
+ luma: number;
22
+ busy: number;
23
+ lo: number;
24
+ hi: number;
25
+ slopeX: number;
26
+ slopeY: number;
27
+ r: number;
28
+ g: number;
29
+ b: number;
30
+ };
31
+
32
+ const DOWNSAMPLE_FRAGMENT_SOURCE = `#version 300 es
33
+ precision highp float;
34
+ out vec4 fragColor;
35
+ uniform sampler2D u_content;
36
+ uniform vec2 u_contentSize;
37
+ uniform vec2 u_gridSize;
38
+
39
+ // Cell → content UV mapping. Does NOT correspond to the canvas's visible top/bottom — this is an
40
+ // internal pass, its buffer is never displayed, only read back on the CPU with the same formula
41
+ // (see rectStats below). It doesn't need to match the blit pass's orientation.
42
+ void main() {
43
+ vec2 uv = gl_FragCoord.xy / u_gridSize;
44
+ vec3 sum = vec3(0.0);
45
+ const int N = 5;
46
+ vec2 block = u_contentSize / u_gridSize;
47
+ vec2 origin = uv * u_contentSize;
48
+ for (int y = 0; y < N; y++) {
49
+ for (int x = 0; x < N; x++) {
50
+ vec2 p = origin + (vec2(float(x), float(y)) + 0.5) * (block / float(N));
51
+ sum += texture(u_content, p / u_contentSize).rgb;
52
+ }
53
+ }
54
+ fragColor = vec4(sum / float(N * N), 1.0);
55
+ }
56
+ `;
57
+
58
+ type PboSlot = {
59
+ buffer: WebGLBuffer;
60
+ sync: WebGLSync | null;
61
+ pending: boolean;
62
+ };
63
+
64
+ export type ProbeRect = { centerX: number; centerY: number; halfWidth: number; halfHeight: number };
65
+
66
+ export type Probe = {
67
+ /** Renders a downsample of the current `contentTexture` into its own FBO and advances the PBO
68
+ * read pipeline. Returns stats for the lens rectangle — lagging by a frame or two, or `null`
69
+ * until a reader is ready. */
70
+ sample(
71
+ contentTexture: WebGLTexture,
72
+ contentWidth: number,
73
+ contentHeight: number,
74
+ rect: ProbeRect,
75
+ ): ProbeStats | null;
76
+ /** Stats for another rectangle from the ALREADY-captured grid — no new pass, no new read. */
77
+ statsFor(contentWidth: number, contentHeight: number, rect: ProbeRect): ProbeStats | null;
78
+ destroy(): void;
79
+ };
80
+
81
+ /** RGBA32F gives the probe sub-pixel averaging precision (spec §5: "where the web has the edge")
82
+ * — without it, the sum of 25 eight-bit samples per cell gets requantized back to 8 bits, and a
83
+ * near-black gradient slice bands right at this step. Without `EXT_color_buffer_float` we fall
84
+ * back to RGBA8 — renders everywhere, just coarser. */
85
+ function pickDownsampleFormat(
86
+ gl: WebGL2RenderingContext,
87
+ ): { internalFormat: number; type: number; floatPrecision: boolean } {
88
+ const hasFloat = gl.getExtension('EXT_color_buffer_float') !== null;
89
+ return hasFloat
90
+ ? { internalFormat: gl.RGBA32F, type: gl.FLOAT, floatPrecision: true }
91
+ : { internalFormat: gl.RGBA8, type: gl.UNSIGNED_BYTE, floatPrecision: false };
92
+ }
93
+
94
+ function luma(r: number, g: number, b: number): number {
95
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b;
96
+ }
97
+
98
+ /**
99
+ * Rectangle stats from the 48×96 buffer. Buffer row `r` is `(r+0.5)/H` by the same formula as in
100
+ * `DOWNSAMPLE_FRAGMENT_SOURCE`; here that same formula is inverted, so the buffer's orientation
101
+ * (up/down) doesn't matter — only symmetry between reading and writing does.
102
+ */
103
+ type DownsampleBuffer = Float32Array | Uint8Array;
104
+
105
+ function rectStats(
106
+ buffer: DownsampleBuffer,
107
+ floatPrecision: boolean,
108
+ gridW: number,
109
+ gridH: number,
110
+ contentWidth: number,
111
+ contentHeight: number,
112
+ rect: ProbeRect,
113
+ ): ProbeStats | null {
114
+ const x0 = rect.centerX - rect.halfWidth;
115
+ const x1 = rect.centerX + rect.halfWidth;
116
+ const y0 = rect.centerY - rect.halfHeight;
117
+ const y1 = rect.centerY + rect.halfHeight;
118
+
119
+ const lumas: number[] = [];
120
+ const nx: number[] = [];
121
+ const ny: number[] = [];
122
+ let sr = 0;
123
+ let sg = 0;
124
+ let sb = 0;
125
+
126
+ const norm = floatPrecision ? 1 : 255;
127
+ for (let row = 0; row < gridH; row++) {
128
+ const cy = ((row + 0.5) / gridH) * contentHeight;
129
+ if (cy < y0 || cy > y1) continue;
130
+ for (let col = 0; col < gridW; col++) {
131
+ const cx = ((col + 0.5) / gridW) * contentWidth;
132
+ if (cx < x0 || cx > x1) continue;
133
+ const i = (row * gridW + col) * 4;
134
+ const r = buffer[i] / norm;
135
+ const g = buffer[i + 1] / norm;
136
+ const b = buffer[i + 2] / norm;
137
+ lumas.push(luma(r, g, b));
138
+ nx.push((cx - rect.centerX) / Math.max(rect.halfWidth, 1));
139
+ ny.push((cy - rect.centerY) / Math.max(rect.halfHeight, 1));
140
+ sr += r;
141
+ sg += g;
142
+ sb += b;
143
+ }
144
+ }
145
+ const n = lumas.length;
146
+ if (n === 0) return null;
147
+
148
+ let mean = 0;
149
+ for (const v of lumas) mean += v;
150
+ mean /= n;
151
+
152
+ let busy = 0;
153
+ for (const v of lumas) busy += Math.abs(v - mean);
154
+ busy = (busy / n) * 2;
155
+
156
+ const sorted = [...lumas].sort((a, b) => a - b);
157
+ const percentile = (p: number) => {
158
+ const idx = p * (n - 1);
159
+ const lo = Math.floor(idx);
160
+ const hi = Math.ceil(idx);
161
+ return sorted[lo] + (sorted[hi] - sorted[lo]) * (idx - lo);
162
+ };
163
+
164
+ const slope = (xs: number[]) => {
165
+ let mx = 0;
166
+ for (const v of xs) mx += v;
167
+ mx /= n;
168
+ let sxx = 0;
169
+ let sxl = 0;
170
+ for (let i = 0; i < n; i++) {
171
+ const dx = xs[i] - mx;
172
+ sxx += dx * dx;
173
+ sxl += dx * (lumas[i] - mean);
174
+ }
175
+ return sxx > 1e-6 ? sxl / sxx : 0;
176
+ };
177
+
178
+ return {
179
+ luma: mean,
180
+ busy: Math.min(busy, 1),
181
+ lo: percentile(0.1),
182
+ hi: percentile(0.9),
183
+ slopeX: slope(nx),
184
+ slopeY: slope(ny),
185
+ r: sr / n,
186
+ g: sg / n,
187
+ b: sb / n,
188
+ };
189
+ }
190
+
191
+ export function createProbe(gl: WebGL2RenderingContext, vertexSource: string): Probe {
192
+ const program = createProgram(gl, vertexSource, DOWNSAMPLE_FRAGMENT_SOURCE);
193
+ const format = pickDownsampleFormat(gl);
194
+
195
+ const texture = gl.createTexture();
196
+ if (!texture) throw new Error('vireglass/web/probe: gl.createTexture returned null');
197
+ gl.bindTexture(gl.TEXTURE_2D, texture);
198
+ gl.texImage2D(
199
+ gl.TEXTURE_2D,
200
+ 0,
201
+ format.internalFormat,
202
+ PROBE_GRID_WIDTH,
203
+ PROBE_GRID_HEIGHT,
204
+ 0,
205
+ gl.RGBA,
206
+ format.type,
207
+ null,
208
+ );
209
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
210
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
211
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
212
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
213
+ gl.bindTexture(gl.TEXTURE_2D, null);
214
+ const fbo = createFramebuffer(gl, texture);
215
+
216
+ const byteSize = PROBE_GRID_WIDTH * PROBE_GRID_HEIGHT * 4 * (format.floatPrecision ? 4 : 1);
217
+ const slots: PboSlot[] = [0, 1].map(() => {
218
+ const buffer = gl.createBuffer();
219
+ if (!buffer) throw new Error('vireglass/web/probe: gl.createBuffer returned null');
220
+ gl.bindBuffer(gl.PIXEL_PACK_BUFFER, buffer);
221
+ gl.bufferData(gl.PIXEL_PACK_BUFFER, byteSize, gl.STREAM_READ);
222
+ gl.bindBuffer(gl.PIXEL_PACK_BUFFER, null);
223
+ return { buffer, sync: null, pending: false };
224
+ });
225
+ let cursor = 0;
226
+ let latestRaw: DownsampleBuffer | null = null;
227
+
228
+ const contentLoc = gl.getUniformLocation(program, 'u_content');
229
+ const contentSizeLoc = gl.getUniformLocation(program, 'u_contentSize');
230
+ const gridSizeLoc = gl.getUniformLocation(program, 'u_gridSize');
231
+
232
+ function pollReady(slot: PboSlot): void {
233
+ if (!slot.pending || !slot.sync) return;
234
+ // The flag flushes the command queue, otherwise a fence placed after readPixels might never
235
+ // fire at all. The wait itself is zero. WAIT_FAILED isn't "ready": without a separate branch
236
+ // for it, execution fell through and read garbage out of the PBO.
237
+ const status = gl.clientWaitSync(slot.sync, gl.SYNC_FLUSH_COMMANDS_BIT, 0);
238
+ if (status === gl.TIMEOUT_EXPIRED || status === gl.WAIT_FAILED) return;
239
+ gl.deleteSync(slot.sync);
240
+ slot.sync = null;
241
+ slot.pending = false;
242
+ gl.bindBuffer(gl.PIXEL_PACK_BUFFER, slot.buffer);
243
+ const out: DownsampleBuffer = format.floatPrecision
244
+ ? new Float32Array(PROBE_GRID_WIDTH * PROBE_GRID_HEIGHT * 4)
245
+ : new Uint8Array(PROBE_GRID_WIDTH * PROBE_GRID_HEIGHT * 4);
246
+ gl.getBufferSubData(gl.PIXEL_PACK_BUFFER, 0, out);
247
+ gl.bindBuffer(gl.PIXEL_PACK_BUFFER, null);
248
+ latestRaw = out;
249
+ }
250
+
251
+ function sample(
252
+ contentTexture: WebGLTexture,
253
+ contentWidth: number,
254
+ contentHeight: number,
255
+ rect: ProbeRect,
256
+ ): ProbeStats | null {
257
+ // Collect finished reads from PAST frames before requesting a new one.
258
+ for (const slot of slots) pollReady(slot);
259
+
260
+ gl.bindFramebuffer(gl.FRAMEBUFFER, fbo);
261
+ gl.viewport(0, 0, PROBE_GRID_WIDTH, PROBE_GRID_HEIGHT);
262
+ gl.useProgram(program);
263
+ bindTextureAt(gl, 0, contentTexture, program, 'u_content');
264
+ gl.uniform1i(contentLoc, 0);
265
+ gl.uniform2f(contentSizeLoc, contentWidth, contentHeight);
266
+ gl.uniform2f(gridSizeLoc, PROBE_GRID_WIDTH, PROBE_GRID_HEIGHT);
267
+ gl.disable(gl.BLEND);
268
+ drawFullscreenTriangle(gl);
269
+ gl.bindFramebuffer(gl.FRAMEBUFFER, null);
270
+
271
+ const slot = slots[cursor];
272
+ cursor = (cursor + 1) % slots.length;
273
+ if (!slot.pending) {
274
+ gl.bindFramebuffer(gl.READ_FRAMEBUFFER, fbo);
275
+ gl.bindBuffer(gl.PIXEL_PACK_BUFFER, slot.buffer);
276
+ gl.readPixels(0, 0, PROBE_GRID_WIDTH, PROBE_GRID_HEIGHT, gl.RGBA, format.type, 0);
277
+ gl.bindBuffer(gl.PIXEL_PACK_BUFFER, null);
278
+ gl.bindFramebuffer(gl.READ_FRAMEBUFFER, null);
279
+ slot.sync = gl.fenceSync(gl.SYNC_GPU_COMMANDS_COMPLETE, 0);
280
+ slot.pending = true;
281
+ }
282
+
283
+ return statsFor(contentWidth, contentHeight, rect);
284
+ }
285
+
286
+ // The downsample is one per frame, but a frame can have many elements: each needs stats for
287
+ // ITS OWN rectangle out of the same grid, not its own pass and its own PBO read.
288
+ function statsFor(contentWidth: number, contentHeight: number, rect: ProbeRect): ProbeStats | null {
289
+ if (!latestRaw) return null;
290
+ return rectStats(
291
+ latestRaw,
292
+ format.floatPrecision,
293
+ PROBE_GRID_WIDTH,
294
+ PROBE_GRID_HEIGHT,
295
+ contentWidth,
296
+ contentHeight,
297
+ rect,
298
+ );
299
+ }
300
+
301
+ function destroy(): void {
302
+ for (const slot of slots) {
303
+ if (slot.sync) gl.deleteSync(slot.sync);
304
+ gl.deleteBuffer(slot.buffer);
305
+ }
306
+ gl.deleteFramebuffer(fbo);
307
+ gl.deleteTexture(texture);
308
+ gl.deleteProgram(program);
309
+ }
310
+
311
+ return { sample, statsFor, destroy };
312
+ }
@@ -0,0 +1,142 @@
1
+ /**
2
+ * Drawing the reference canvas onto a canvas element. Lives in `web/` because it knows about the
3
+ * 2D context; the canvases themselves are data in `reference-scene.ts`, and the mobile lab lays
4
+ * out the same data as native views.
5
+ *
6
+ * The same drawer is used by the `check:optics` gate: only this way do the eye at the bench and
7
+ * the number in the gate look at the same canvas. The one difference between them is the fit mode.
8
+ */
9
+
10
+ import {
11
+ REFERENCE_SCENE_HEIGHT,
12
+ REFERENCE_SCENE_WIDTH,
13
+ REFERENCE_SURROUND,
14
+ refCheckerCells,
15
+ refGradientSteps,
16
+ refGray,
17
+ type VireGlassRefLayer,
18
+ type VireGlassRefScene,
19
+ } from '../reference-scene';
20
+
21
+ export type VireGlassRefDrawOptions = {
22
+ /** Pixels per dp. The same value used for elements: the canvas and the glass must live at the
23
+ * same scale. */
24
+ readonly density: number;
25
+ /** Canvas lightness; canvases that don't need it ignore it. Defaults to the canvas's own. */
26
+ readonly level?: number;
27
+ /**
28
+ * `band` — a `REFERENCE_SCENE_WIDTH`×`REFERENCE_SCENE_HEIGHT` dp panel centered on the stage,
29
+ * with a surrounding field: only this way do two benches show the SAME canvas, including the
30
+ * pattern's phase under the element. `frame` — stripes stretched across the whole frame: the
31
+ * gate has its own fixed stage, and the surrounding field would get in the way of sampling
32
+ * pixels.
33
+ */
34
+ readonly fit?: 'band' | 'frame';
35
+ };
36
+
37
+ function drawLayer(
38
+ ctx: CanvasRenderingContext2D,
39
+ layer: VireGlassRefLayer,
40
+ x: number,
41
+ y: number,
42
+ w: number,
43
+ h: number,
44
+ d: number,
45
+ ): void {
46
+ switch (layer.kind) {
47
+ case 'flat': {
48
+ ctx.fillStyle = refGray(layer.level);
49
+ ctx.fillRect(x, y, w, h);
50
+ return;
51
+ }
52
+ case 'stripes': {
53
+ ctx.fillStyle = refGray(layer.level);
54
+ ctx.fillRect(x, y, w, h);
55
+ ctx.fillStyle = refGray(layer.other);
56
+ for (let at = 0; at < w; at += layer.periodDp * d) {
57
+ ctx.fillRect(x + at, y, layer.widthDp * d, h);
58
+ }
59
+ return;
60
+ }
61
+ case 'checker': {
62
+ for (const cell of refCheckerCells(layer, w / d, h / d)) {
63
+ ctx.fillStyle = refGray(cell.level);
64
+ ctx.fillRect(x + cell.xDp * d, y + cell.yDp * d, layer.cellDp * d + 1, layer.cellDp * d + 1);
65
+ }
66
+ return;
67
+ }
68
+ case 'step': {
69
+ ctx.fillStyle = refGray(layer.left);
70
+ ctx.fillRect(x, y, w / 2, h);
71
+ ctx.fillStyle = refGray(layer.right);
72
+ ctx.fillRect(x + w / 2, y, w - w / 2, h);
73
+ return;
74
+ }
75
+ case 'bar': {
76
+ ctx.fillStyle = refGray(layer.level);
77
+ ctx.fillRect(x, y, w, h);
78
+ const t = layer.thicknessDp * d;
79
+ ctx.fillStyle = refGray(layer.barLevel);
80
+ ctx.fillRect(x, y + Math.round((h - t) / 2), w, t);
81
+ return;
82
+ }
83
+ case 'grid': {
84
+ ctx.fillStyle = refGray(layer.level);
85
+ ctx.fillRect(x, y, w, h);
86
+ // A line exactly ONE device pixel wide — the same as what the native view draws
87
+ // (`StyleSheet.hairlineWidth`). Half density gave two pixels on this screen against one,
88
+ // and the two benches' canvases diverged on the scene's finest detail.
89
+ const thin = 1;
90
+ const step = layer.stepDp * d;
91
+ ctx.fillStyle = refGray(layer.lineLevel);
92
+ for (let at = 0; at <= w; at += step) ctx.fillRect(x + Math.round(at), y, thin, h);
93
+ for (let at = 0; at <= h; at += step) ctx.fillRect(x, y + Math.round(at), w, thin);
94
+ return;
95
+ }
96
+ case 'gradient': {
97
+ const steps = refGradientSteps(layer);
98
+ const band = h / steps.length;
99
+ for (let i = 0; i < steps.length; i += 1) {
100
+ ctx.fillStyle = refGray(steps[i]);
101
+ ctx.fillRect(x, y + Math.round(i * band), w, Math.ceil(band) + 1);
102
+ }
103
+ return;
104
+ }
105
+ }
106
+ }
107
+
108
+ export function drawReferenceScene(
109
+ ctx: CanvasRenderingContext2D,
110
+ scene: VireGlassRefScene,
111
+ width: number,
112
+ height: number,
113
+ options: VireGlassRefDrawOptions,
114
+ ): void {
115
+ const d = options.density;
116
+ const bands = scene.bands(options.level ?? scene.level);
117
+ const fill = options.fit !== 'band';
118
+
119
+ let x: number;
120
+ let y: number;
121
+ let w: number;
122
+ let scale: number;
123
+ if (fill) {
124
+ x = 0;
125
+ y = 0;
126
+ w = width;
127
+ scale = height / (REFERENCE_SCENE_HEIGHT * d);
128
+ } else {
129
+ ctx.fillStyle = REFERENCE_SURROUND;
130
+ ctx.fillRect(0, 0, width, height);
131
+ w = Math.round(REFERENCE_SCENE_WIDTH * d);
132
+ x = Math.round((width - w) / 2);
133
+ y = Math.round((height - REFERENCE_SCENE_HEIGHT * d) / 2);
134
+ scale = 1;
135
+ }
136
+
137
+ for (const band of bands) {
138
+ const h = Math.round(band.heightDp * d * scale);
139
+ drawLayer(ctx, band.layer, x, y, w, h, d);
140
+ y += h;
141
+ }
142
+ }