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
@@ -0,0 +1,524 @@
1
+ // The web lab's WebGL2 pipeline (per the Phase 3 plan; web-core spec §5). Four passes, and the
2
+ // ordering and layer split deliberately mirror Android — the lens and the surface are NOT
3
+ // collapsed into one pass, otherwise the lab stops predicting the phone (there they're two
4
+ // separate layers):
5
+ //
6
+ // 1. scene backdrop — rasterized into an offscreen 2D canvas (text and cover art are
7
+ // easier there than in GL) and uploaded into the `content`
8
+ // texture. This texture plays the role of the spec's FBO(scene):
9
+ // later passes only SAMPLE it, never redraw it.
10
+ // 2. blit backdrop to screen — `content` is drawn as the backing; without it the screen would
11
+ // show only the element, and the material is compared against
12
+ // exactly what's around it.
13
+ // 3. downsample (`probe.ts`) — stats for the lens rectangle, see there.
14
+ // 4. lens (`toGLSL(LENS_SHADER)`) — samples `content`, draws over the backdrop.
15
+ // 5. surface (`toGLSL(SURFACE_SHADER)`) — bevel and highlight over the lens.
16
+ //
17
+ // UNITS. Everything is device-px, one coordinate space for the whole canvas (web-core spec
18
+ // §4.2): `toLensProps` already multiplies by `density`; `toSurfaceUniforms` doesn't and
19
+ // shouldn't (its contract is shared with Android), so its result gets run through
20
+ // `toDeviceSurfaceUniforms`.
21
+ //
22
+ // SHARED CENTER. On Android the lens and the surface live in THEIR OWN local spaces (different
23
+ // `lensPadDp`/`surfacePadDp` margins), and concentricity is a convention the component
24
+ // maintains. Here both shaders get screen coordinates directly (`gl_FragCoord`), so the only way
25
+ // to align them is to pass the exact SAME `u_center` in canvas pixels to both: the lens gets it
26
+ // as-is (a native uniform), the surface gets it substituted in place of the adapter's own value
27
+ // (which is only meaningful in its own local canvas, which doesn't exist here).
28
+ import {
29
+ DYNAMIC_UNIFORMS,
30
+ ICON_UNIFORMS,
31
+ OVERLAY_UNIFORMS,
32
+ toLensProps,
33
+ toDeviceSurfaceUniforms,
34
+ toSurfaceUniforms,
35
+ type VireGlassAccent,
36
+ type VireGlassMorph,
37
+ type VireGlassTouch,
38
+ } from '../adapters';
39
+ import { lensPadDp, shadowOpacity, surfacePadDp, type VireGlassGeometry } from '../geometry';
40
+ import { LENS_SHADER } from '../lens-shader';
41
+ import type { VireGlassDebugMode, VireGlassOptics } from '../material';
42
+ import { SURFACE_SHADER } from '../surface-shader';
43
+ import { toGLSL } from '../targets/glsl';
44
+ import {
45
+ bindTextureAt,
46
+ createProgram,
47
+ createTexture,
48
+ drawFullscreenTriangle,
49
+ FULLSCREEN_TRIANGLE_VERTEX_SOURCE,
50
+ } from './gl';
51
+ import { createProbe, type ProbeStats } from './probe';
52
+
53
+ /** The scene draws itself with an offset: the lenses in the frame stay put while the canvas
54
+ * underneath them slides — only this way can you actually see what refraction does to whatever
55
+ * passes under the element. Offset in device-px. */
56
+ export type VireGlassSceneDrawer = (
57
+ ctx: CanvasRenderingContext2D,
58
+ width: number,
59
+ height: number,
60
+ offsetX: number,
61
+ offsetY: number,
62
+ /** Pixels per dp — the same value used for elements. The canvas and the glass must live at the
63
+ * same scale: as a fraction of canvas width, a cell on desktop came out five times larger than
64
+ * on a phone, and an element of the same size looked completely different on the lab bench
65
+ * than on a device. */
66
+ density: number,
67
+ ) => void;
68
+
69
+ /** One glass element in the frame. Each has its own material — that's how they're compared side by side. */
70
+ export type VireGlassPiece = {
71
+ optics: VireGlassOptics;
72
+ geometry: VireGlassGeometry;
73
+ /** Element center in CANVAS PIXELS (device-px). Shared between the lens and the surface. */
74
+ centerX: number;
75
+ centerY: number;
76
+ morph?: VireGlassMorph;
77
+ /** Third shape of the same body: breaking the element into pieces uses two bridges. */
78
+ morph2?: VireGlassMorph;
79
+ /** Local finger response: touch point, drag, press, wave. */
80
+ touch?: VireGlassTouch;
81
+ /** Press response, 0…1 — the highlight blooms, the body thickens slightly. */
82
+ press?: number;
83
+ /** Element under the finger, 0…1 — a stronger highlight and rim light. */
84
+ active?: number;
85
+ /** Played fraction, 0…1: an active state expressed as a field. Omitted means no progress. */
86
+ progress?: number;
87
+ /** Whether to draw the icon from the frame's shared mask on this element (see `iconMask` in the frame options). */
88
+ icon?: boolean;
89
+ /** Whether to draw the frame's colored layer on this element (see `colorLayer`). */
90
+ overlay?: boolean;
91
+ /** Icon color at rest and in the active state, RGBA 0…1. */
92
+ inkIdle?: readonly number[];
93
+ inkActive?: readonly number[];
94
+ /** Key light direction in screen space; defaults to the light at rest. */
95
+ light?: readonly [number, number];
96
+ /** 0…1: the element appearing as the lens builds up (M 2:55). */
97
+ appear?: number;
98
+ /** 0 — the element sinks in under the finger, 1 — it rises into glass (reference §5). */
99
+ lift?: number;
100
+ /** Tint for the primary action — colored glass, not a fill. */
101
+ accent?: VireGlassAccent;
102
+ };
103
+
104
+ export type VireGlassRenderOptions = {
105
+ /** `devicePixelRatio` — passed as an argument rather than read globally, so the renderer stays
106
+ * testable and isn't tied to a specific window. */
107
+ density: number;
108
+ debug: VireGlassDebugMode;
109
+ pieces: readonly VireGlassPiece[];
110
+ scene: VireGlassSceneDrawer;
111
+ /** Canvas offset under stationary elements, device-px. */
112
+ offsetX?: number;
113
+ offsetY?: number;
114
+ /**
115
+ * Ink mask the size of the canvas, screen coordinates, read from the GREEN channel. White on
116
+ * BLACK: antialiasing has to live in color, not alpha, otherwise green holds at one all the way
117
+ * to the edge inside a stroke and the icon's edges tear.
118
+ *
119
+ * The mask does NOT carry and must not carry a calm backing under the ink: that's a property of
120
+ * the material itself (`legibility`), the same across the whole element. While the app used to
121
+ * supply that backing, one element had it and another didn't, with nothing to explain the
122
+ * difference.
123
+ */
124
+ iconMask?: TexImageSource | null;
125
+ /**
126
+ * App-colored layer the size of the canvas, screen coordinates: cover art, a thumbnail —
127
+ * anything the app puts ON the glass in color. Separate from the ink mask, because that one
128
+ * carries a single channel colored by polarity, while this one has its own color.
129
+ *
130
+ * Drawn INSIDE the material, at the same coordinate as the ink: only this way does the
131
+ * surface's deformation carry them together. As a separate layer on top of the glass, text used
132
+ * to shake on press while the cover art stayed put.
133
+ */
134
+ colorLayer?: TexImageSource | null;
135
+ };
136
+
137
+ export type VireGlassRenderResult = {
138
+ /** One backdrop sample per element, in the same order as `pieces`. */
139
+ probes: readonly (ProbeStats | null)[];
140
+ };
141
+
142
+ export type VireGlassRenderer = {
143
+ resize(widthPx: number, heightPx: number): void;
144
+ render(options: VireGlassRenderOptions): VireGlassRenderResult;
145
+ destroy(): void;
146
+ };
147
+
148
+ const BLIT_FRAGMENT_SOURCE = `#version 300 es
149
+ precision highp float;
150
+ out vec4 fragColor;
151
+ uniform sampler2D u_content;
152
+ uniform vec2 u_resolution;
153
+ void main() {
154
+ // The same Y flip the transpiler does for the lens/surface (targets/glsl.ts): content is
155
+ // uploaded from the 2D canvas WITHOUT flipping, so its V=0 is the scene's top row. This keeps
156
+ // the same order here, otherwise the backdrop and what the lens refracts through it drift
157
+ // apart vertically.
158
+ vec2 uv = gl_FragCoord.xy / u_resolution;
159
+ uv.y = 1.0 - uv.y;
160
+ fragColor = texture(u_content, uv);
161
+ }
162
+ `;
163
+
164
+ type UniformValue = number | readonly number[];
165
+
166
+ function locationCache(gl: WebGL2RenderingContext, program: WebGLProgram) {
167
+ const cache = new Map<string, WebGLUniformLocation | null>();
168
+ return (name: string): WebGLUniformLocation | null => {
169
+ let loc = cache.get(name);
170
+ if (loc === undefined) {
171
+ loc = gl.getUniformLocation(program, name);
172
+ cache.set(name, loc);
173
+ }
174
+ return loc;
175
+ };
176
+ }
177
+
178
+ function setUniform(
179
+ gl: WebGL2RenderingContext,
180
+ loc: WebGLUniformLocation | null,
181
+ value: UniformValue,
182
+ ): void {
183
+ if (!loc) return;
184
+ if (typeof value === 'number') {
185
+ gl.uniform1f(loc, value);
186
+ return;
187
+ }
188
+ switch (value.length) {
189
+ case 1:
190
+ gl.uniform1f(loc, value[0]);
191
+ break;
192
+ case 2:
193
+ gl.uniform2f(loc, value[0], value[1]);
194
+ break;
195
+ case 3:
196
+ gl.uniform3f(loc, value[0], value[1], value[2]);
197
+ break;
198
+ case 4:
199
+ gl.uniform4f(loc, value[0], value[1], value[2], value[3]);
200
+ break;
201
+ default:
202
+ throw new Error(`vireglass/web: unsupported uniform size (${value.length})`);
203
+ }
204
+ }
205
+
206
+ /** The material arrives as ONE channel (web-core spec §4, "Uniform contract"): name/size/value
207
+ * as three parallel arrays — exactly the layout that used to get lost with Android Props. */
208
+ function applyChannel(
209
+ gl: WebGL2RenderingContext,
210
+ get: (name: string) => WebGLUniformLocation | null,
211
+ names: readonly string[],
212
+ sizes: readonly number[],
213
+ values: readonly number[],
214
+ ): void {
215
+ let cursor = 0;
216
+ for (let i = 0; i < names.length; i += 1) {
217
+ const size = sizes[i];
218
+ setUniform(gl, get(names[i]), values.slice(cursor, cursor + size));
219
+ cursor += size;
220
+ }
221
+ }
222
+
223
+ function applyObject(
224
+ gl: WebGL2RenderingContext,
225
+ get: (name: string) => WebGLUniformLocation | null,
226
+ values: Record<string, UniformValue>,
227
+ ): void {
228
+ for (const name of Object.keys(values)) setUniform(gl, get(name), values[name]);
229
+ }
230
+
231
+ export function createVireGlassRenderer(canvas: HTMLCanvasElement): VireGlassRenderer {
232
+ const context = canvas.getContext('webgl2', {
233
+ preserveDrawingBuffer: true,
234
+ alpha: false,
235
+ antialias: false,
236
+ });
237
+ if (!context) throw new Error('vireglass/web: WebGL2 unavailable');
238
+ // Explicit non-nullable typing — otherwise the `!context` narrowing doesn't survive the
239
+ // closures of the nested `function resize/render/destroy` below, and tsc goes back to treating
240
+ // `gl` as `WebGL2RenderingContext | null`.
241
+ const gl: WebGL2RenderingContext = context;
242
+
243
+ const blitProgram = createProgram(gl, FULLSCREEN_TRIANGLE_VERTEX_SOURCE, BLIT_FRAGMENT_SOURCE);
244
+ const lensProgram = createProgram(gl, FULLSCREEN_TRIANGLE_VERTEX_SOURCE, toGLSL(LENS_SHADER));
245
+ const surfaceProgram = createProgram(
246
+ gl,
247
+ FULLSCREEN_TRIANGLE_VERTEX_SOURCE,
248
+ toGLSL(SURFACE_SHADER),
249
+ );
250
+ const probe = createProbe(gl, FULLSCREEN_TRIANGLE_VERTEX_SOURCE);
251
+
252
+ const blitLoc = locationCache(gl, blitProgram);
253
+ const lensLoc = locationCache(gl, lensProgram);
254
+ const surfaceLoc = locationCache(gl, surfaceProgram);
255
+
256
+ let width = canvas.width;
257
+ let height = canvas.height;
258
+
259
+ let sceneCanvas = document.createElement('canvas');
260
+ let sceneCtx = sceneCanvas.getContext('2d');
261
+ if (!sceneCtx) throw new Error('vireglass/web: scene 2D context unavailable');
262
+
263
+ // Smoothed backdrop estimate per element: keyed by its position in the pieces list.
264
+ const settled = new Map<number, ProbeStats>();
265
+ /** Fraction of the new sample per frame. Same magnitude as SETTLE on Android. */
266
+ const SETTLE = 0.12;
267
+
268
+ function settleStats(index: number, fresh: ProbeStats): ProbeStats {
269
+ const prev = settled.get(index);
270
+ if (!prev) {
271
+ settled.set(index, fresh);
272
+ return fresh;
273
+ }
274
+ const mix = (a: number, b: number) => a + (b - a) * SETTLE;
275
+ const next: ProbeStats = {
276
+ luma: mix(prev.luma, fresh.luma),
277
+ busy: mix(prev.busy, fresh.busy),
278
+ lo: mix(prev.lo, fresh.lo),
279
+ hi: mix(prev.hi, fresh.hi),
280
+ slopeX: mix(prev.slopeX, fresh.slopeX),
281
+ slopeY: mix(prev.slopeY, fresh.slopeY),
282
+ r: mix(prev.r, fresh.r),
283
+ g: mix(prev.g, fresh.g),
284
+ b: mix(prev.b, fresh.b),
285
+ };
286
+ settled.set(index, next);
287
+ return next;
288
+ }
289
+
290
+ let contentTexture = createTexture(gl, {
291
+ width: Math.max(width, 1),
292
+ height: Math.max(height, 1),
293
+ });
294
+
295
+ // The icon mask is ONE per frame, the size of the canvas. The shader samples it at the pixel's
296
+ // screen coordinate (`u_center + p` is exactly `xy`), so each element's icon is simply drawn
297
+ // into the mask at its own spot: no separate texture per button is needed here.
298
+ const iconTexture = createTexture(gl, { width: 1, height: 1 });
299
+ const colorTexture = createTexture(gl, { width: 1, height: 1 });
300
+ gl.bindTexture(gl.TEXTURE_2D, iconTexture);
301
+ gl.texSubImage2D(
302
+ gl.TEXTURE_2D,
303
+ 0,
304
+ 0,
305
+ 0,
306
+ 1,
307
+ 1,
308
+ gl.RGBA,
309
+ gl.UNSIGNED_BYTE,
310
+ new Uint8Array([0, 0, 0, 0]),
311
+ );
312
+ gl.bindTexture(gl.TEXTURE_2D, null);
313
+
314
+ function resize(widthPx: number, heightPx: number): void {
315
+ width = Math.max(1, Math.round(widthPx));
316
+ height = Math.max(1, Math.round(heightPx));
317
+ canvas.width = width;
318
+ canvas.height = height;
319
+ gl.viewport(0, 0, width, height);
320
+
321
+ sceneCanvas = document.createElement('canvas');
322
+ sceneCanvas.width = width;
323
+ sceneCanvas.height = height;
324
+ sceneCtx = sceneCanvas.getContext('2d');
325
+ if (!sceneCtx) throw new Error('vireglass/web: scene 2D context unavailable');
326
+
327
+ gl.deleteTexture(contentTexture);
328
+ contentTexture = createTexture(gl, { width, height });
329
+ }
330
+
331
+ function render(options: VireGlassRenderOptions): VireGlassRenderResult {
332
+ if (!sceneCtx) throw new Error('vireglass/web: renderer not initialized (resize was never called)');
333
+
334
+ // 1. Scene backdrop: a 2D canvas is easier for text and "cover art" (task, item 3), and the
335
+ // texture itself plays the role of the plan's FBO(scene) — everything after this only reads
336
+ // it.
337
+ sceneCtx.clearRect(0, 0, width, height);
338
+ options.scene(sceneCtx, width, height, options.offsetX ?? 0, options.offsetY ?? 0, options.density);
339
+ gl.bindTexture(gl.TEXTURE_2D, contentTexture);
340
+ gl.texSubImage2D(gl.TEXTURE_2D, 0, 0, 0, gl.RGBA, gl.UNSIGNED_BYTE, sceneCanvas);
341
+ gl.bindTexture(gl.TEXTURE_2D, null);
342
+
343
+ if (options.colorLayer) {
344
+ gl.bindTexture(gl.TEXTURE_2D, colorTexture);
345
+ gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, options.colorLayer);
346
+ }
347
+ if (options.iconMask) {
348
+ gl.bindTexture(gl.TEXTURE_2D, iconTexture);
349
+ gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, options.iconMask);
350
+ gl.bindTexture(gl.TEXTURE_2D, null);
351
+ }
352
+
353
+ // 2. Backdrop to screen — the backing the element is seen against.
354
+ gl.bindFramebuffer(gl.FRAMEBUFFER, null);
355
+ gl.viewport(0, 0, width, height);
356
+ gl.disable(gl.BLEND);
357
+ gl.disable(gl.SCISSOR_TEST);
358
+ gl.useProgram(blitProgram);
359
+ bindTextureAt(gl, 0, contentTexture, blitProgram, 'u_content');
360
+ setUniform(gl, blitLoc('u_resolution'), [width, height]);
361
+ drawFullscreenTriangle(gl);
362
+
363
+ const probes: (ProbeStats | null)[] = [];
364
+
365
+ for (let index = 0; index < options.pieces.length; index += 1) {
366
+ const piece = options.pieces[index];
367
+
368
+ // 3. Downsample + stats for the rectangle of VISIBLE glass (no lens/surface margins — the
369
+ // same `glassWidth`/`glassHeight` that go into toLensProps). Only the first element
370
+ // captures the grid: it's shared for the frame, the rest just need their own rectangle out
371
+ // of it.
372
+ const halfWidth = (piece.geometry.width * options.density) / 2;
373
+ const halfHeight = (piece.geometry.height * options.density) / 2;
374
+ const rect = {
375
+ centerX: piece.centerX,
376
+ centerY: piece.centerY,
377
+ halfWidth,
378
+ halfHeight,
379
+ };
380
+ const fresh =
381
+ index === 0
382
+ ? probe.sample(contentTexture, width, height, rect)
383
+ : probe.statsFor(width, height, rect);
384
+ // The backdrop estimate eases toward the new value instead of jumping to it. On Android
385
+ // the native view does this (`GlassLensView.kt`, a per-frame SETTLE); nobody did it on the
386
+ // web: under a moving canvas, body density used to jerk with the instantaneous sample, and
387
+ // on screen that read as lighting artifacts.
388
+ const stats = fresh ? settleStats(index, fresh) : null;
389
+ probes.push(stats);
390
+ // Restore the viewport and the content texture binding — the probe pass reuses TEXTURE0
391
+ // and its own FBO, unbinds them itself, but does change the viewport.
392
+ gl.bindFramebuffer(gl.FRAMEBUFFER, null);
393
+ gl.viewport(0, 0, width, height);
394
+
395
+ gl.enable(gl.BLEND);
396
+ // Premultiplied-over: both the lens and the surface emit premultiplied alpha (see `return
397
+ // half4(half3(...) * alpha, alpha)` in both shaders).
398
+ gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA);
399
+
400
+ // Both passes draw a fullscreen triangle, but the element covers only a small part of the
401
+ // frame: without scissoring, a row of samples multiplies cost by the number of elements (on
402
+ // a headless software renderer this pushed a frame into seconds). Margin is added on both
403
+ // sides — the shadow, bevel and light gathering all reach past the element's own bounds.
404
+ // The morph drop travels past the element's bounds — its travel is part of the margin,
405
+ // otherwise scissoring would cut off the tail right where it matters.
406
+ const reachOf = (m?: VireGlassMorph) =>
407
+ m ? Math.hypot(m.offsetX, m.offsetY) + Math.max(m.width, m.height) / 2 + m.smoothing : 0;
408
+ const morphReach = Math.max(reachOf(piece.morph), reachOf(piece.morph2));
409
+ // Field deformation pushes the element's edge past its own bounds — drag, wave amplitude
410
+ // and growth on press. Without this term, scissoring cuts off exactly the part being
411
+ // dragged toward.
412
+ const touchReach = piece.touch
413
+ ? Math.hypot(piece.touch.pullX, piece.touch.pullY) +
414
+ piece.touch.waveAmp * 2 +
415
+ Math.max(piece.geometry.width, piece.geometry.height) * 0.05 * piece.touch.press
416
+ : 0;
417
+ const padPx =
418
+ (lensPadDp(piece.geometry, piece.optics) + surfacePadDp(piece.geometry, 0) + morphReach + touchReach) *
419
+ options.density;
420
+ const left = Math.max(0, Math.floor(piece.centerX - halfWidth - padPx));
421
+ const right = Math.min(width, Math.ceil(piece.centerX + halfWidth + padPx));
422
+ const top = Math.max(0, Math.floor(height - (piece.centerY + halfHeight + padPx)));
423
+ const bottom = Math.min(height, Math.ceil(height - (piece.centerY - halfHeight - padPx)));
424
+ gl.enable(gl.SCISSOR_TEST);
425
+ gl.scissor(left, top, Math.max(0, right - left), Math.max(0, bottom - top));
426
+
427
+ // 4. Lens.
428
+ gl.useProgram(lensProgram);
429
+ bindTextureAt(gl, 0, contentTexture, lensProgram, 'content');
430
+ setUniform(gl, lensLoc('u_contentSize'), [width, height]);
431
+ setUniform(gl, lensLoc('u_resolution'), [width, height]);
432
+ setUniform(gl, lensLoc('u_center'), [piece.centerX, piece.centerY]);
433
+ // There's no local-view margin here (one shared coordinate space) — on Android `u_reach`
434
+ // capped adaptive-blur gathering at the edge of the lens view's PADDING; there's no such
435
+ // boundary here, so the value is chosen deliberately larger than any real gather radius, so
436
+ // the `reach - length(p)` condition never triggers before `u_contentMin/Max` does.
437
+ setUniform(gl, lensLoc('u_reach'), Math.max(width, height));
438
+ setUniform(gl, lensLoc('u_contentMin'), [1, 1]);
439
+ setUniform(gl, lensLoc('u_contentMax'), [width - 1, height - 1]);
440
+ if (stats) {
441
+ setUniform(gl, lensLoc('u_probeLuma'), stats.luma);
442
+ setUniform(gl, lensLoc('u_probeBusy'), stats.busy);
443
+ setUniform(gl, lensLoc('u_probeRange'), [stats.lo, stats.hi]);
444
+ setUniform(gl, lensLoc('u_probeSlope'), [stats.slopeX, stats.slopeY]);
445
+ setUniform(gl, lensLoc('u_probe'), [stats.r, stats.g, stats.b]);
446
+ } else {
447
+ // The probe hasn't reported yet (the first frame or two) — the -1 sentinel keeps the
448
+ // shader on its own point samples (see the comment in `lens-shader.ts`).
449
+ setUniform(gl, lensLoc('u_probeLuma'), -1);
450
+ }
451
+ const lens = toLensProps(piece.optics, piece.geometry, options.density, {
452
+ debug: options.debug,
453
+ morph: piece.morph,
454
+ morph2: piece.morph2,
455
+ touch: piece.touch,
456
+ progress: piece.progress,
457
+ light: piece.light,
458
+ appear: piece.appear,
459
+ accent: piece.accent,
460
+ });
461
+ applyChannel(gl, lensLoc, lens.uniformNames, lens.uniformSizes, lens.uniformValues);
462
+ drawFullscreenTriangle(gl);
463
+
464
+ // 5. Surface — shares its center with the lens (see the comment at the top of this file).
465
+ // `toSurfaceUniforms`'s contract is dp, shared with Android; the core converts it to pixels.
466
+ gl.useProgram(surfaceProgram);
467
+ const rawSurface = toSurfaceUniforms(piece.optics, piece.geometry, {
468
+ debug: options.debug,
469
+ morph: piece.morph,
470
+ morph2: piece.morph2,
471
+ bodyInLens: true,
472
+ touch: piece.touch,
473
+ progress: piece.progress,
474
+ // The shadow is denser over text, weaker over a flat backdrop (M 11:47). The rule lives
475
+ // in the core: while it lived here, Android didn't have it at all.
476
+ shadow: stats ? shadowOpacity(stats.busy) : 1,
477
+ // The probe has already computed the ambient color — the shadow gets it for free. Taken
478
+ // as-is: `settleStats` has already smoothed it, and rounding would bring back exactly the
479
+ // steps the smoothing was written to remove.
480
+ ambient: stats ? ([stats.r, stats.g, stats.b] as const) : undefined,
481
+ appear: piece.appear,
482
+ lift: piece.lift,
483
+ });
484
+ // The dp → device pixel conversion lives in the core (`toDeviceSurfaceUniforms`): one list
485
+ // of lengths for every field, so a new field can't be forgotten here by oversight.
486
+ applyObject(gl, surfaceLoc, {
487
+ ...toDeviceSurfaceUniforms(rawSurface, options.density),
488
+ u_center: [piece.centerX, piece.centerY],
489
+ });
490
+ // What's worklet dynamics on Android (press, active, tilt) arrives here as fields on the element.
491
+ setUniform(gl, surfaceLoc(DYNAMIC_UNIFORMS[0]), piece.press ?? 0);
492
+ setUniform(gl, surfaceLoc(DYNAMIC_UNIFORMS[1]), piece.active ?? 0);
493
+ const hasIcon = Boolean(piece.icon && options.iconMask);
494
+ setUniform(gl, surfaceLoc(ICON_UNIFORMS[0]), hasIcon ? 1 : 0);
495
+ // Scale 1: the coordinate is already in screen space, the sampler itself normalizes by size.
496
+ setUniform(gl, surfaceLoc(ICON_UNIFORMS[1]), 1);
497
+ setUniform(gl, surfaceLoc(ICON_UNIFORMS[2]), piece.inkIdle ?? [1, 1, 1, 1]);
498
+ setUniform(gl, surfaceLoc(ICON_UNIFORMS[3]), piece.inkActive ?? [1, 1, 1, 1]);
499
+ bindTextureAt(gl, 1, iconTexture, surfaceProgram, 'u_icon');
500
+ setUniform(gl, surfaceLoc('u_iconSize'), hasIcon ? [width, height] : [1, 1]);
501
+ const hasColor = Boolean(piece.overlay && options.colorLayer);
502
+ setUniform(gl, surfaceLoc(OVERLAY_UNIFORMS[0]), hasColor ? 1 : 0);
503
+ bindTextureAt(gl, 2, colorTexture, surfaceProgram, 'u_overlay');
504
+ setUniform(gl, surfaceLoc('u_overlaySize'), hasColor ? [width, height] : [1, 1]);
505
+ setUniform(gl, surfaceLoc('u_resolution'), [width, height]);
506
+ drawFullscreenTriangle(gl);
507
+ }
508
+ gl.disable(gl.SCISSOR_TEST);
509
+
510
+ return { probes };
511
+ }
512
+
513
+ function destroy(): void {
514
+ probe.destroy();
515
+ gl.deleteProgram(blitProgram);
516
+ gl.deleteProgram(lensProgram);
517
+ gl.deleteProgram(surfaceProgram);
518
+ gl.deleteTexture(contentTexture);
519
+ gl.deleteTexture(iconTexture);
520
+ gl.deleteTexture(colorTexture);
521
+ }
522
+
523
+ return { resize, render, destroy };
524
+ }