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.
- package/README.md +41 -1
- package/dist/{chunk-3PGYF2NK.js → chunk-CTTZNZFV.js} +3 -3
- package/dist/{chunk-3PGYF2NK.js.map → chunk-CTTZNZFV.js.map} +1 -1
- package/dist/{chunk-FL5UZPY5.js → chunk-H5QTBF67.js} +4 -4
- package/dist/{chunk-FL5UZPY5.js.map → chunk-H5QTBF67.js.map} +1 -1
- package/dist/{chunk-TTOKBARR.js → chunk-MMPK6DCK.js} +10 -3
- package/dist/chunk-MMPK6DCK.js.map +1 -0
- package/dist/{chunk-BZC2WHCK.js → chunk-PM2SWCSF.js} +4 -4
- package/dist/{chunk-BZC2WHCK.js.map → chunk-PM2SWCSF.js.map} +1 -1
- package/dist/{chunk-P2ZOODXD.js → chunk-SLRPHDV7.js} +3 -3
- package/dist/{chunk-P2ZOODXD.js.map → chunk-SLRPHDV7.js.map} +1 -1
- package/dist/dom.cjs +48 -20
- package/dist/dom.cjs.map +1 -1
- package/dist/dom.d.cts +15 -3
- package/dist/dom.d.ts +15 -3
- package/dist/dom.js +44 -23
- package/dist/dom.js.map +1 -1
- package/dist/index.cjs +1 -2
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +4 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +5 -5
- package/dist/law.cjs +8 -1
- package/dist/law.cjs.map +1 -1
- package/dist/law.d.cts +7 -0
- package/dist/law.d.ts +7 -0
- package/dist/law.js +1 -1
- package/dist/reference.js +2 -2
- package/dist/web/index.cjs +262 -153
- package/dist/web/index.cjs.map +1 -1
- package/dist/web/index.d.cts +88 -2
- package/dist/web/index.d.ts +88 -2
- package/dist/web/index.js +257 -156
- package/dist/web/index.js.map +1 -1
- package/docs/law.md +2 -2
- package/package.json +13 -9
- package/src/dom/body.ts +24 -9
- package/src/dom/index.ts +44 -10
- package/src/dom/probe.ts +27 -3
- package/src/law.ts +7 -0
- package/src/targets/glsl.ts +4 -0
- package/src/web/gl.ts +57 -4
- package/src/web/gpu-timer.ts +111 -0
- package/src/web/index.ts +1 -0
- package/src/web/renderer.ts +305 -218
- package/dist/chunk-TTOKBARR.js.map +0 -1
package/src/web/renderer.ts
CHANGED
|
@@ -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.
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
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
|
-
|
|
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
|
-
//
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
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.
|
|
348
|
-
|
|
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
|
-
//
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
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
|
-
//
|
|
448
|
-
//
|
|
449
|
-
|
|
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
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
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
|
-
|
|
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
|
}
|