vireglass 2.1.0 → 2.3.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 (46) hide show
  1. package/README.md +41 -1
  2. package/dist/{chunk-3PGYF2NK.js → chunk-CTTZNZFV.js} +3 -3
  3. package/dist/{chunk-3PGYF2NK.js.map → chunk-CTTZNZFV.js.map} +1 -1
  4. package/dist/{chunk-FL5UZPY5.js → chunk-H5QTBF67.js} +4 -4
  5. package/dist/{chunk-FL5UZPY5.js.map → chunk-H5QTBF67.js.map} +1 -1
  6. package/dist/{chunk-TTOKBARR.js → chunk-MMPK6DCK.js} +10 -3
  7. package/dist/chunk-MMPK6DCK.js.map +1 -0
  8. package/dist/{chunk-BZC2WHCK.js → chunk-PM2SWCSF.js} +4 -4
  9. package/dist/{chunk-BZC2WHCK.js.map → chunk-PM2SWCSF.js.map} +1 -1
  10. package/dist/{chunk-P2ZOODXD.js → chunk-SLRPHDV7.js} +3 -3
  11. package/dist/{chunk-P2ZOODXD.js.map → chunk-SLRPHDV7.js.map} +1 -1
  12. package/dist/dom.cjs +48 -20
  13. package/dist/dom.cjs.map +1 -1
  14. package/dist/dom.d.cts +15 -3
  15. package/dist/dom.d.ts +15 -3
  16. package/dist/dom.js +44 -23
  17. package/dist/dom.js.map +1 -1
  18. package/dist/index.cjs +1 -2
  19. package/dist/index.cjs.map +1 -1
  20. package/dist/index.d.cts +4 -0
  21. package/dist/index.d.ts +4 -0
  22. package/dist/index.js +5 -5
  23. package/dist/law.cjs +8 -1
  24. package/dist/law.cjs.map +1 -1
  25. package/dist/law.d.cts +7 -0
  26. package/dist/law.d.ts +7 -0
  27. package/dist/law.js +1 -1
  28. package/dist/reference.js +2 -2
  29. package/dist/web/index.cjs +262 -153
  30. package/dist/web/index.cjs.map +1 -1
  31. package/dist/web/index.d.cts +88 -2
  32. package/dist/web/index.d.ts +88 -2
  33. package/dist/web/index.js +257 -156
  34. package/dist/web/index.js.map +1 -1
  35. package/docs/law.md +2 -2
  36. package/package.json +13 -9
  37. package/src/dom/body.ts +24 -9
  38. package/src/dom/index.ts +44 -10
  39. package/src/dom/probe.ts +27 -3
  40. package/src/law.ts +7 -0
  41. package/src/targets/glsl.ts +4 -0
  42. package/src/web/gl.ts +57 -4
  43. package/src/web/gpu-timer.ts +111 -0
  44. package/src/web/index.ts +1 -0
  45. package/src/web/renderer.ts +305 -218
  46. package/dist/chunk-TTOKBARR.js.map +0 -1
@@ -3,10 +3,13 @@
3
3
  // collapsed into one pass, otherwise the lab stops predicting the phone (there they're two
4
4
  // separate layers):
5
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.
6
+ // 1. backdrop — fills the `content` texture one of two ways: `scene` rasterizes
7
+ // into an offscreen 2D canvas (text and cover art are easier there
8
+ // than in GL) and uploads it via `texSubImage2D`; `backdrop` draws
9
+ // straight into `content` with its own GPU pass instead, no canvas,
10
+ // no readback (see `VireGlassBackdropPass`). Either way the texture
11
+ // plays the role of the spec's FBO(scene): later passes only SAMPLE
12
+ // it, never redraw it.
10
13
  // 2. blit backdrop to screen — `content` is drawn as the backing; without it the screen would
11
14
  // show only the element, and the material is compared against
12
15
  // exactly what's around it.
@@ -43,11 +46,16 @@ import { SURFACE_SHADER } from '../surface-shader';
43
46
  import { toGLSL } from '../targets/glsl';
44
47
  import {
45
48
  bindTextureAt,
49
+ createFramebuffer,
46
50
  createProgram,
47
51
  createTexture,
48
52
  drawFullscreenTriangle,
49
53
  FULLSCREEN_TRIANGLE_VERTEX_SOURCE,
54
+ locationCache,
55
+ setUniform,
56
+ type UniformValue,
50
57
  } from './gl';
58
+ import { createGpuTimer } from './gpu-timer';
51
59
  import { createProbe, type ProbeStats } from './probe';
52
60
 
53
61
  /** The scene draws itself with an offset: the lenses in the frame stay put while the canvas
@@ -66,6 +74,47 @@ export type VireGlassSceneDrawer = (
66
74
  density: number,
67
75
  ) => void;
68
76
 
77
+ /** The target a `VireGlassBackdropPass` must finish its frame in — the FBO wraps `contentTexture`
78
+ * itself, so drawing into it needs no readback: the very texture the lens samples gets the pixels
79
+ * directly. */
80
+ export type VireGlassBackdropTarget = {
81
+ /** Owned by the renderer — the FBO wrapping `contentTexture` itself, recreated on `resize` and
82
+ * freed in `destroy`. The pass must not delete it, and must not hold onto it past the call:
83
+ * the object may be gone or pointing at a different texture by the next `render()`. */
84
+ framebuffer: WebGLFramebuffer;
85
+ width: number;
86
+ height: number;
87
+ };
88
+
89
+ /**
90
+ * Alternative to `scene`: draws the backdrop with a GPU pass, in this same WebGL2 context, straight
91
+ * into the texture the lens samples — no offscreen 2D canvas, no `texSubImage2D` readback. The
92
+ * renderer doesn't know or care what's drawn; a fluid sim, a video frame, anything that ends up as
93
+ * pixels.
94
+ *
95
+ * ORIENTATION CONTRACT. `contentTexture` carries the same layout the `scene` path has always
96
+ * produced: uploaded from a 2D canvas without a Y flip, so texel row 0 (`v≈0`) is the scene's TOP
97
+ * row (see `BLIT_FRAGMENT_SOURCE` below) — and that row sits at `gl_FragCoord.y = 0` in this FBO,
98
+ * the window-coordinate BOTTOM, not the top. A pass that copies an ordinary top-row-first image
99
+ * (one loaded the normal way, via `texImage2D` from a canvas or an `<img>`) straight across with
100
+ * `texture(src, gl_FragCoord.xy / vec2(target.width, target.height))` reproduces it correctly,
101
+ * with no flip of its own. A pass that instead treats INCREASING y as its own "up" — the natural
102
+ * convention for a physical simulation — must flip before this final write, or its top ends up
103
+ * stored as the scene's bottom and appears upside down once the lens samples it.
104
+ *
105
+ * The pass may render through as many of its own FBOs first as it likes (a multi-step simulation)
106
+ * and leave the GL context in whatever state suits it; it must finish by drawing into
107
+ * `target.framebuffer` at `viewport(0, 0, target.width, target.height)`. The renderer puts back
108
+ * every piece of state its own remaining steps depend on right after — framebuffer, viewport,
109
+ * blend, scissor, depth/stencil/cull, the vertex array binding, the active texture unit, and the
110
+ * `UNPACK_*` pixel-store flags used to upload `iconMask`/`colorLayer` — so the pass is free to
111
+ * change any of it without arranging its own cleanup.
112
+ */
113
+ export type VireGlassBackdropPass = (
114
+ gl: WebGL2RenderingContext,
115
+ target: VireGlassBackdropTarget,
116
+ ) => void;
117
+
69
118
  /** One glass element in the frame. Each has its own material — that's how they're compared side by side. */
70
119
  export type VireGlassPiece = {
71
120
  optics: VireGlassOptics;
@@ -107,7 +156,14 @@ export type VireGlassRenderOptions = {
107
156
  density: number;
108
157
  debug: VireGlassDebugMode;
109
158
  pieces: readonly VireGlassPiece[];
110
- scene: VireGlassSceneDrawer;
159
+ /** CPU-side backdrop: draws into an offscreen 2D canvas, uploaded via `texSubImage2D`. Exactly
160
+ * one of `scene`/`backdrop` is required — `render()` throws if both or neither are given — see
161
+ * `backdrop` for the GPU alternative. */
162
+ scene?: VireGlassSceneDrawer;
163
+ /** GPU-side backdrop: draws straight into the texture the lens samples, in this context, no
164
+ * readback. Exactly one of `scene`/`backdrop` is required — see `VireGlassBackdropPass` for the
165
+ * orientation contract it must honor. */
166
+ backdrop?: VireGlassBackdropPass | null;
111
167
  /** Canvas offset under stationary elements, device-px. */
112
168
  offsetX?: number;
113
169
  offsetY?: number;
@@ -142,6 +198,10 @@ export type VireGlassRenderResult = {
142
198
  export type VireGlassRenderer = {
143
199
  resize(widthPx: number, heightPx: number): void;
144
200
  render(options: VireGlassRenderOptions): VireGlassRenderResult;
201
+ /** GPU time of the last `render()` call, milliseconds, via
202
+ * `EXT_disjoint_timer_query_webgl2`. `null` when the extension is unavailable or the sample
203
+ * came back disjoint — never an error either way. */
204
+ getLastGpuMs(): number | null;
145
205
  destroy(): void;
146
206
  };
147
207
 
@@ -161,48 +221,6 @@ void main() {
161
221
  }
162
222
  `;
163
223
 
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
224
  /** The material arrives as ONE channel (web-core spec §4, "Uniform contract"): name/size/value
207
225
  * as three parallel arrays — exactly the layout that used to get lost with Android Props. */
208
226
  function applyChannel(
@@ -292,6 +310,20 @@ export function createVireGlassRenderer(canvas: HTMLCanvasElement): VireGlassRen
292
310
  height: Math.max(height, 1),
293
311
  });
294
312
 
313
+ // FBO for the `backdrop` path, wrapping `contentTexture` itself — lazy, so a `scene`-only
314
+ // caller never pays for it. Stale after `resize` (it points at a texture that's about to be
315
+ // deleted); dropped there and rebuilt against the new one on next use.
316
+ let backdropFbo: WebGLFramebuffer | null = null;
317
+ function ensureBackdropFbo(): WebGLFramebuffer {
318
+ if (!backdropFbo) backdropFbo = createFramebuffer(gl, contentTexture);
319
+ return backdropFbo;
320
+ }
321
+
322
+ // GPU timing for `getLastGpuMs()` — the ring buffer and disjoint handling live in
323
+ // `gpu-timer.ts`, isolated so they can be unit-tested against a fake (this extension is
324
+ // unavailable in the headless Chromium the shader gates run in).
325
+ const gpuTimer = createGpuTimer(gl);
326
+
295
327
  // The icon mask is ONE per frame, the size of the canvas. The shader samples it at the pixel's
296
328
  // screen coordinate (`u_center + p` is exactly `xy`), so each element's icon is simply drawn
297
329
  // into the mask at its own spot: no separate texture per button is needed here.
@@ -326,188 +358,241 @@ export function createVireGlassRenderer(canvas: HTMLCanvasElement): VireGlassRen
326
358
 
327
359
  gl.deleteTexture(contentTexture);
328
360
  contentTexture = createTexture(gl, { width, height });
361
+
362
+ if (backdropFbo) {
363
+ gl.deleteFramebuffer(backdropFbo);
364
+ backdropFbo = null;
365
+ }
329
366
  }
330
367
 
331
368
  function render(options: VireGlassRenderOptions): VireGlassRenderResult {
332
369
  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);
370
+ // Narrowed once, before the timed section: a `let` read from inside the closure below would
371
+ // widen back to `| null`, since TS cannot see that nothing reassigns it in between.
372
+ const ctx = sceneCtx;
373
+
374
+ // Validated BEFORE the GPU timer starts, not inside it: a bad call here has to throw cleanly
375
+ // with no query left open, which an error thrown mid-timing would risk.
376
+ if (options.backdrop && options.scene) {
377
+ throw new Error(
378
+ 'vireglass/web: render() needs exactly one of options.scene or options.backdrop, not both',
379
+ );
346
380
  }
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);
381
+ if (!options.backdrop && !options.scene) {
382
+ throw new Error('vireglass/web: render() needs either options.scene or options.backdrop');
351
383
  }
352
384
 
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]);
385
+ // The rest of the frame, including a caller-supplied `backdrop` pass that may itself throw
386
+ // (a shader typo, a bad uniform), runs inside `time`'s own try/finally — `endQuery` always
387
+ // pairs with `beginQuery` no matter how this exits.
388
+ return gpuTimer.time((): VireGlassRenderResult => {
389
+ // 1. Backdrop. Exactly one of two paths fills `contentTexture` — everything after this only
390
+ // SAMPLES it, never redraws it.
391
+ if (options.backdrop) {
392
+ // GPU pass, straight into `contentTexture` via its own FBO: no 2D canvas, no readback. See
393
+ // `VireGlassBackdropPass` for the orientation contract the pass has to honor.
394
+ const fbo = ensureBackdropFbo();
395
+ gl.bindFramebuffer(gl.FRAMEBUFFER, fbo);
396
+ gl.viewport(0, 0, width, height);
397
+ options.backdrop(gl, { framebuffer: fbo, width, height });
398
+ // The pass is free to leave the context in ANY state (per its own JSDoc) — put back
399
+ // everything the rest of this render depends on, not just the obvious framebuffer/
400
+ // viewport pair: a pass that enables depth/stencil/face culling, binds its own VAO, or
401
+ // changes the unpack flags used to upload `iconMask`/`colorLayer` just below would
402
+ // otherwise corrupt a step it never touched directly.
403
+ gl.bindFramebuffer(gl.FRAMEBUFFER, null);
404
+ gl.viewport(0, 0, width, height);
405
+ gl.disable(gl.BLEND);
406
+ gl.disable(gl.SCISSOR_TEST);
407
+ gl.disable(gl.DEPTH_TEST);
408
+ gl.disable(gl.CULL_FACE);
409
+ gl.disable(gl.STENCIL_TEST);
410
+ gl.colorMask(true, true, true, true);
411
+ gl.depthMask(true);
412
+ gl.bindVertexArray(null);
413
+ gl.activeTexture(gl.TEXTURE0);
414
+ gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, false);
415
+ gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, false);
416
+ gl.pixelStorei(gl.UNPACK_COLORSPACE_CONVERSION_WEBGL, gl.BROWSER_DEFAULT_WEBGL);
446
417
  } 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);
418
+ // CPU-side: a 2D canvas is easier for text and "cover art" (task, item 3), and the texture
419
+ // itself plays the role of the plan's FBO(scene).
420
+ ctx.clearRect(0, 0, width, height);
421
+ options.scene?.(ctx, width, height, options.offsetX ?? 0, options.offsetY ?? 0, options.density);
422
+ gl.bindTexture(gl.TEXTURE_2D, contentTexture);
423
+ gl.texSubImage2D(gl.TEXTURE_2D, 0, 0, 0, gl.RGBA, gl.UNSIGNED_BYTE, sceneCanvas);
424
+ gl.bindTexture(gl.TEXTURE_2D, null);
450
425
  }
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
426
 
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]);
427
+ if (options.colorLayer) {
428
+ gl.bindTexture(gl.TEXTURE_2D, colorTexture);
429
+ gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, options.colorLayer);
430
+ }
431
+ if (options.iconMask) {
432
+ gl.bindTexture(gl.TEXTURE_2D, iconTexture);
433
+ gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, options.iconMask);
434
+ gl.bindTexture(gl.TEXTURE_2D, null);
435
+ }
436
+
437
+ // 2. Backdrop to screen — the backing the element is seen against.
438
+ gl.bindFramebuffer(gl.FRAMEBUFFER, null);
439
+ gl.viewport(0, 0, width, height);
440
+ gl.disable(gl.BLEND);
441
+ gl.disable(gl.SCISSOR_TEST);
442
+ gl.useProgram(blitProgram);
443
+ bindTextureAt(gl, 0, contentTexture, blitProgram, 'u_content');
444
+ setUniform(gl, blitLoc('u_resolution'), [width, height]);
506
445
  drawFullscreenTriangle(gl);
507
- }
508
- gl.disable(gl.SCISSOR_TEST);
509
446
 
510
- return { probes };
447
+ const probes: (ProbeStats | null)[] = [];
448
+
449
+ for (let index = 0; index < options.pieces.length; index += 1) {
450
+ const piece = options.pieces[index];
451
+
452
+ // 3. Downsample + stats for the rectangle of VISIBLE glass (no lens/surface margins — the
453
+ // same `glassWidth`/`glassHeight` that go into toLensProps). Only the first element
454
+ // captures the grid: it's shared for the frame, the rest just need their own rectangle out
455
+ // of it.
456
+ const halfWidth = (piece.geometry.width * options.density) / 2;
457
+ const halfHeight = (piece.geometry.height * options.density) / 2;
458
+ const rect = {
459
+ centerX: piece.centerX,
460
+ centerY: piece.centerY,
461
+ halfWidth,
462
+ halfHeight,
463
+ };
464
+ const fresh =
465
+ index === 0
466
+ ? probe.sample(contentTexture, width, height, rect)
467
+ : probe.statsFor(width, height, rect);
468
+ // The backdrop estimate eases toward the new value instead of jumping to it. On Android
469
+ // the native view does this (`GlassLensView.kt`, a per-frame SETTLE); nobody did it on the
470
+ // web: under a moving canvas, body density used to jerk with the instantaneous sample, and
471
+ // on screen that read as lighting artifacts.
472
+ const stats = fresh ? settleStats(index, fresh) : null;
473
+ probes.push(stats);
474
+ // Restore the viewport and the content texture binding — the probe pass reuses TEXTURE0
475
+ // and its own FBO, unbinds them itself, but does change the viewport.
476
+ gl.bindFramebuffer(gl.FRAMEBUFFER, null);
477
+ gl.viewport(0, 0, width, height);
478
+
479
+ gl.enable(gl.BLEND);
480
+ // Premultiplied-over: both the lens and the surface emit premultiplied alpha (see `return
481
+ // half4(half3(...) * alpha, alpha)` in both shaders).
482
+ gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA);
483
+
484
+ // Both passes draw a fullscreen triangle, but the element covers only a small part of the
485
+ // frame: without scissoring, a row of samples multiplies cost by the number of elements (on
486
+ // a headless software renderer this pushed a frame into seconds). Margin is added on both
487
+ // sides — the shadow, bevel and light gathering all reach past the element's own bounds.
488
+ // The morph drop travels past the element's bounds — its travel is part of the margin,
489
+ // otherwise scissoring would cut off the tail right where it matters.
490
+ const reachOf = (m?: VireGlassMorph) =>
491
+ m ? Math.hypot(m.offsetX, m.offsetY) + Math.max(m.width, m.height) / 2 + m.smoothing : 0;
492
+ const morphReach = Math.max(reachOf(piece.morph), reachOf(piece.morph2));
493
+ // Field deformation pushes the element's edge past its own bounds — drag, wave amplitude
494
+ // and growth on press. Without this term, scissoring cuts off exactly the part being
495
+ // dragged toward.
496
+ const touchReach = piece.touch
497
+ ? Math.hypot(piece.touch.pullX, piece.touch.pullY) +
498
+ piece.touch.waveAmp * 2 +
499
+ Math.max(piece.geometry.width, piece.geometry.height) * 0.05 * piece.touch.press
500
+ : 0;
501
+ const padPx =
502
+ (lensPadDp(piece.geometry, piece.optics) + surfacePadDp(piece.geometry, 0) + morphReach + touchReach) *
503
+ options.density;
504
+ const left = Math.max(0, Math.floor(piece.centerX - halfWidth - padPx));
505
+ const right = Math.min(width, Math.ceil(piece.centerX + halfWidth + padPx));
506
+ const top = Math.max(0, Math.floor(height - (piece.centerY + halfHeight + padPx)));
507
+ const bottom = Math.min(height, Math.ceil(height - (piece.centerY - halfHeight - padPx)));
508
+ gl.enable(gl.SCISSOR_TEST);
509
+ gl.scissor(left, top, Math.max(0, right - left), Math.max(0, bottom - top));
510
+
511
+ // 4. Lens.
512
+ gl.useProgram(lensProgram);
513
+ bindTextureAt(gl, 0, contentTexture, lensProgram, 'content');
514
+ setUniform(gl, lensLoc('u_contentSize'), [width, height]);
515
+ setUniform(gl, lensLoc('u_resolution'), [width, height]);
516
+ setUniform(gl, lensLoc('u_center'), [piece.centerX, piece.centerY]);
517
+ // There's no local-view margin here (one shared coordinate space) — on Android `u_reach`
518
+ // capped adaptive-blur gathering at the edge of the lens view's PADDING; there's no such
519
+ // boundary here, so the value is chosen deliberately larger than any real gather radius, so
520
+ // the `reach - length(p)` condition never triggers before `u_contentMin/Max` does.
521
+ setUniform(gl, lensLoc('u_reach'), Math.max(width, height));
522
+ setUniform(gl, lensLoc('u_contentMin'), [1, 1]);
523
+ setUniform(gl, lensLoc('u_contentMax'), [width - 1, height - 1]);
524
+ if (stats) {
525
+ setUniform(gl, lensLoc('u_probeLuma'), stats.luma);
526
+ setUniform(gl, lensLoc('u_probeBusy'), stats.busy);
527
+ setUniform(gl, lensLoc('u_probeRange'), [stats.lo, stats.hi]);
528
+ setUniform(gl, lensLoc('u_probeSlope'), [stats.slopeX, stats.slopeY]);
529
+ setUniform(gl, lensLoc('u_probe'), [stats.r, stats.g, stats.b]);
530
+ } else {
531
+ // The probe hasn't reported yet (the first frame or two) — the -1 sentinel keeps the
532
+ // shader on its own point samples (see the comment in `lens-shader.ts`).
533
+ setUniform(gl, lensLoc('u_probeLuma'), -1);
534
+ }
535
+ const lens = toLensProps(piece.optics, piece.geometry, options.density, {
536
+ debug: options.debug,
537
+ morph: piece.morph,
538
+ morph2: piece.morph2,
539
+ touch: piece.touch,
540
+ progress: piece.progress,
541
+ light: piece.light,
542
+ appear: piece.appear,
543
+ accent: piece.accent,
544
+ });
545
+ applyChannel(gl, lensLoc, lens.uniformNames, lens.uniformSizes, lens.uniformValues);
546
+ drawFullscreenTriangle(gl);
547
+
548
+ // 5. Surface — shares its center with the lens (see the comment at the top of this file).
549
+ // `toSurfaceUniforms`'s contract is dp, shared with Android; the core converts it to pixels.
550
+ gl.useProgram(surfaceProgram);
551
+ const rawSurface = toSurfaceUniforms(piece.optics, piece.geometry, {
552
+ debug: options.debug,
553
+ morph: piece.morph,
554
+ morph2: piece.morph2,
555
+ bodyInLens: true,
556
+ touch: piece.touch,
557
+ progress: piece.progress,
558
+ // The shadow is denser over text, weaker over a flat backdrop (M 11:47). The rule lives
559
+ // in the core: while it lived here, Android didn't have it at all.
560
+ shadow: stats ? shadowOpacity(stats.busy) : 1,
561
+ // The probe has already computed the ambient color — the shadow gets it for free. Taken
562
+ // as-is: `settleStats` has already smoothed it, and rounding would bring back exactly the
563
+ // steps the smoothing was written to remove.
564
+ ambient: stats ? ([stats.r, stats.g, stats.b] as const) : undefined,
565
+ appear: piece.appear,
566
+ lift: piece.lift,
567
+ });
568
+ // The dp → device pixel conversion lives in the core (`toDeviceSurfaceUniforms`): one list
569
+ // of lengths for every field, so a new field can't be forgotten here by oversight.
570
+ applyObject(gl, surfaceLoc, {
571
+ ...toDeviceSurfaceUniforms(rawSurface, options.density),
572
+ u_center: [piece.centerX, piece.centerY],
573
+ });
574
+ // What's worklet dynamics on Android (press, active, tilt) arrives here as fields on the element.
575
+ setUniform(gl, surfaceLoc(DYNAMIC_UNIFORMS[0]), piece.press ?? 0);
576
+ setUniform(gl, surfaceLoc(DYNAMIC_UNIFORMS[1]), piece.active ?? 0);
577
+ const hasIcon = Boolean(piece.icon && options.iconMask);
578
+ setUniform(gl, surfaceLoc(ICON_UNIFORMS[0]), hasIcon ? 1 : 0);
579
+ // Scale 1: the coordinate is already in screen space, the sampler itself normalizes by size.
580
+ setUniform(gl, surfaceLoc(ICON_UNIFORMS[1]), 1);
581
+ setUniform(gl, surfaceLoc(ICON_UNIFORMS[2]), piece.inkIdle ?? [1, 1, 1, 1]);
582
+ setUniform(gl, surfaceLoc(ICON_UNIFORMS[3]), piece.inkActive ?? [1, 1, 1, 1]);
583
+ bindTextureAt(gl, 1, iconTexture, surfaceProgram, 'u_icon');
584
+ setUniform(gl, surfaceLoc('u_iconSize'), hasIcon ? [width, height] : [1, 1]);
585
+ const hasColor = Boolean(piece.overlay && options.colorLayer);
586
+ setUniform(gl, surfaceLoc(OVERLAY_UNIFORMS[0]), hasColor ? 1 : 0);
587
+ bindTextureAt(gl, 2, colorTexture, surfaceProgram, 'u_overlay');
588
+ setUniform(gl, surfaceLoc('u_overlaySize'), hasColor ? [width, height] : [1, 1]);
589
+ setUniform(gl, surfaceLoc('u_resolution'), [width, height]);
590
+ drawFullscreenTriangle(gl);
591
+ }
592
+ gl.disable(gl.SCISSOR_TEST);
593
+
594
+ return { probes };
595
+ });
511
596
  }
512
597
 
513
598
  function destroy(): void {
@@ -518,7 +603,9 @@ export function createVireGlassRenderer(canvas: HTMLCanvasElement): VireGlassRen
518
603
  gl.deleteTexture(contentTexture);
519
604
  gl.deleteTexture(iconTexture);
520
605
  gl.deleteTexture(colorTexture);
606
+ if (backdropFbo) gl.deleteFramebuffer(backdropFbo);
607
+ gpuTimer.destroy();
521
608
  }
522
609
 
523
- return { resize, render, destroy };
610
+ return { resize, render, getLastGpuMs: gpuTimer.getLastMs, destroy };
524
611
  }