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,287 @@
1
+ import { VG_FALLOFF, VG_SDF } from './sdf';
2
+
3
+ /** Order matches `DEBUG_MODES` in `material.ts`: the index goes straight into `u_debug`. */
4
+ export const SURFACE_SHADER = `
5
+ uniform shader u_icon;
6
+ /** App-colored content ON the glass — cover art, a thumbnail. A separate layer from the ink
7
+ * mask: that one is single-channel and colored by polarity, this one carries its own color
8
+ * as-is. */
9
+ uniform shader u_overlay;
10
+
11
+ uniform float2 u_center;
12
+ uniform float2 u_halfSize;
13
+ uniform float u_corner;
14
+ uniform float u_bevel;
15
+ uniform float u_thickness;
16
+ uniform float2 u_morphOffset;
17
+ uniform float2 u_morphHalf;
18
+ uniform float u_morphCorner;
19
+ uniform float u_morphK;
20
+ uniform float2 u_morph2Offset;
21
+ uniform float2 u_morph2Half;
22
+ uniform float u_morph2Corner;
23
+
24
+ uniform float u_press;
25
+ uniform float u_active;
26
+ /* 0 — the element sinks in under the finger, 1 — it rises into glass (reference §5). */
27
+ uniform float u_lift;
28
+
29
+ uniform float u_edgeDensity;
30
+ uniform float u_dispersion;
31
+ uniform float u_refraction;
32
+ uniform float4 u_tint;
33
+ uniform float u_shadow;
34
+ uniform float u_shadowReach;
35
+ uniform float3 u_ambient;
36
+ uniform float u_presence;
37
+ uniform float u_progress;
38
+ uniform float u_appear;
39
+ uniform float u_debug;
40
+
41
+ uniform float u_iconOn;
42
+ uniform float u_overlayOn;
43
+ uniform float u_iconScale;
44
+ uniform float4 u_inkIdle;
45
+ uniform float4 u_inkActive;
46
+ uniform float2 u_touch;
47
+ uniform float2 u_pull;
48
+ uniform float u_touchPress;
49
+ uniform float u_touchRadius;
50
+ uniform float2 u_wave;
51
+
52
+ ${VG_SDF}
53
+
54
+ const float VG_FALLOFF = ${VG_FALLOFF};
55
+
56
+ // Tint density in the flat middle; at the bevel it's multiplied by u_edgeDensity.
57
+ const float VG_BODY_DENSITY = 0.19;
58
+ /* Fraction of the ambient hue in the shadow. The shadow has to stay a shadow, not a colored
59
+ * blob. */
60
+ const float VG_SHADOW_TINT = 1.0;
61
+ // How much weaker the shadow is right at the outline than under the middle of the gap, and what
62
+ // fraction of the offset it takes to reach full depth. In the reference the shadow's minimum
63
+ // sits below the rim, not right at it.
64
+ const float VG_GAP_LIGHT = 0.76;
65
+ const float VG_GAP_REACH = 0.20;
66
+ /* Depth of the ink beneath the surface, dp. The normal displaces it by exactly this much right
67
+ * at the rim. */
68
+ const float VG_INK_DEPTH = 4.0;
69
+ /* Ink defocus under the finger (reference §6): a glyph loses its edge and drowns in milk. Given
70
+ * as a FRACTION of the touch blob, not a number: the blob already arrives in device units, so
71
+ * the radius automatically follows both screen density and the element's size. At rest it's
72
+ * zero, and the sample stays a single one. */
73
+ const float VG_INK_DEFOCUS = 0.08;
74
+
75
+ half4 vgPack(half3 c, float a) { return half4(c * half(a), half(a)); }
76
+
77
+ // What bleeds into the shadow is the COLOR of the surroundings, not its brightness: the hue's own
78
+ // lightness is subtracted out of it, otherwise the shadow pales and loses depth instead of
79
+ // warming up. Strength is set by how much light there is around: over a nearly black backdrop
80
+ // there's nothing to bleed into the shadow.
81
+ float3 vgShadowTint(float3 ambient) {
82
+ float peak = max(max(ambient.r, ambient.g), max(ambient.b, 0.001));
83
+ float3 hue = ambient / peak;
84
+ float lumaWeights = dot(ambient, float3(0.2126, 0.7152, 0.0722));
85
+ return (hue - dot(hue, float3(0.2126, 0.7152, 0.0722))) * lumaWeights;
86
+ }
87
+
88
+ half3 vgHeat(float v) {
89
+ float x = clamp(v, 0.0, 1.0);
90
+ return half3(half(clamp(x * 2.2 - 0.2, 0.0, 1.0)),
91
+ half(clamp(1.0 - abs(x - 0.5) * 2.2, 0.0, 1.0)),
92
+ half(clamp(1.2 - x * 2.4, 0.0, 1.0)));
93
+ }
94
+
95
+ half4 main(float2 xy) {
96
+ float2 p = vgTouchWarp(xy - u_center, u_touch, u_pull, u_touchPress, u_touchRadius, u_wave.x, u_wave.y);
97
+
98
+ // Inverse deformation: stretch A along the pull vector, compression 1/sqrt(A) across it. This
99
+ // exact law mirrors the transform of the live backdrop under the canvas, otherwise the two
100
+ // would drift apart. There's NO motion geometry here — no pull, no press-driven bulge. All of
101
+ // that is done by a single wrapper transform, the same one that carries the native lens: the
102
+ // shader doesn't touch it, and two pipelines driving one motion would drift apart by a frame,
103
+ // making the layers visible as separate.
104
+
105
+ float halfMin = max(min(u_halfSize.x, u_halfSize.y), 1.0);
106
+ float bevel = max(u_bevel, 1.0);
107
+
108
+ float sd = vgScene(p, u_halfSize, u_corner, u_morphOffset, u_morphHalf, u_morphCorner, u_morphK,
109
+ u_morph2Offset, u_morph2Half, u_morph2Corner);
110
+ float t = vgBevelT(sd, bevel);
111
+ float2 n = vgSceneNormal(p, u_halfSize, u_corner, u_morphOffset, u_morphHalf, u_morphCorner, u_morphK,
112
+ u_morph2Offset, u_morph2Half, u_morph2Corner);
113
+ float3 N = normalize(float3(n * vgBevelSlope(t), 1.0));
114
+
115
+ // Progress is an active state turned into a field: the played fraction shines and glows
116
+ // exactly as much as an active element shines whole. This does NOT touch ink: recoloring text
117
+ // as the track advances would mean changing it mid-word.
118
+ float lit = max(u_active, vgProgress(p, u_halfSize, u_progress));
119
+
120
+ // The highlight and the rim are computed by the lens: reflection is a function of the
121
+ // surroundings, and only the lens can see them.
122
+
123
+ if (u_debug > 0.5) {
124
+ float inMask = 1.0 - smoothstep(-1.0, 1.0, sd);
125
+ if (u_debug < 1.5) {
126
+ float band = abs(fract(sd / (halfMin * 0.22)) - 0.5) * 2.0;
127
+ half3 c = sd < 0.0 ? half3(0.20, 0.62, 1.0) : half3(1.0, 0.42, 0.22);
128
+ return vgPack(c * half(0.25 + band * 0.75), 1.0);
129
+ }
130
+ if (u_debug < 2.5) { return vgPack(half3(1.0), inMask); }
131
+ if (u_debug < 3.5) { return vgPack(vgHeat(t), inMask); }
132
+ // Fresnel now lives in the lens; here we show its SHAPE — how grazing the view is.
133
+ if (u_debug < 4.5) { return vgPack(vgHeat(1.0 - clamp(N.z, 0.0, 1.0)), inMask); }
134
+ if (u_debug < 5.5) {
135
+ float push = pow(t, VG_FALLOFF) * u_refraction;
136
+ return vgPack(vgHeat(push), inMask);
137
+ }
138
+ if (u_debug < 6.5) { return half4(0.0); }
139
+ if (u_debug < 7.5) { return half4(0.0); }
140
+ // Dispersion is computed by the lens; here is the field it grows along.
141
+ if (u_debug < 8.5) { return vgPack(vgHeat(u_dispersion * t * t), inMask); }
142
+ if (u_debug < 9.5) { return vgPack(half3(N * 0.5 + 0.5), inMask); }
143
+ // spectral and adapt are shown by the LENS — the surface has to get out of the way.
144
+ return half4(0.0);
145
+ }
146
+
147
+ // The shadow and the halo live OUTSIDE the shape: inside, their place is taken by the glass
148
+ // itself. Separation from the content rests entirely on the shadow — without it the surface
149
+ // would sit ON the picture, not above it. The shadow only lives at the rim and outside it: at
150
+ // sd < −1 the outside multiplier is already zero. Computing it deep inside the shape would be
151
+ // a wasted FULL SDF evaluation for every such pixel, and the body covers almost the whole
152
+ // area. The branch here is on the coordinate, but only the threads right at the rim actually
153
+ // diverge.
154
+ float shade = 0.0;
155
+ float halo = 0.0;
156
+ if (sd > -1.0) {
157
+ float outside = smoothstep(-1.0, 1.0, sd);
158
+ // Under the finger a button moves TOWARD the backdrop, and the shadow tightens: it's exactly
159
+ // the gap between the two. A control rising into glass (a switch knob, a slider handle —
160
+ // reference §5) moves AWAY from it, and then the shadow spreads out instead. Only the app
161
+ // knows which one applies, so the direction arrives as a fraction: 0 is sinking in, the way
162
+ // every element behaved before this rule existed.
163
+ float reach = u_shadowReach * (1.0 + mix(-0.25, 0.55, u_lift) * u_press);
164
+ // The shadow is soft and wide, shifted downward (M 11:58): it's what holds the element's
165
+ // separation from the content.
166
+ float sdDrop = vgScene(p - float2(0.0, reach * 0.35), u_halfSize, u_corner,
167
+ u_morphOffset, u_morphHalf, u_morphCorner, u_morphK,
168
+ u_morph2Offset, u_morph2Half, u_morph2Corner);
169
+ float amb = 1.0 - smoothstep(-reach * 0.3, reach, sdDrop);
170
+ // THE GAP UNDER A RAISED ELEMENT IS LIT: right at the outline the shadow is WEAKER than
171
+ // further down. Otherwise the minimum sits right against the silhouette — both parts of the
172
+ // shadow are monotonic in distance.
173
+ float gap = smoothstep(0.0, max(reach * VG_GAP_REACH, 1.0), max(sd, 0.0));
174
+ shade = amb * amb * 0.13 * mix(VG_GAP_LIGHT, 1.0, gap) * outside * u_shadow * u_appear;
175
+ halo = 1.0 - smoothstep(0.0, reach * 0.30, max(sd, 0.0));
176
+ halo = halo * halo * lit * 0.10 * outside * u_appear;
177
+ }
178
+
179
+ if (sd > 1.0) {
180
+ // Ambient light bleeds into the shadow (219 @8:22): it isn't a black hole, it's tinted by
181
+ // whatever lies nearby. Otherwise an element over colored content would hang over a gray
182
+ // blob.
183
+ half3 spill = max(half3(half(halo)) + half3(vgShadowTint(u_ambient)) * half(VG_SHADOW_TINT * shade * (1.0 - halo)), half3(0.0));
184
+ return half4(spill, half(halo + shade * (1.0 - halo)));
185
+ }
186
+
187
+ float density = u_tint.w;
188
+ float inner = clamp(-sd / halfMin, 0.0, 1.0);
189
+ half3 col = half3(u_tint.rgb);
190
+
191
+ // The tint is LIGHT and weak, not dark: dark glass over dark content disappears, and has to be
192
+ // held together with a heavy rim — which makes the surface read as a chrome bead. Tint density
193
+ // at the bevel is a separate value: physically the bevel bends light harder, but it doesn't
194
+ // get hazier. Coupled together, the two produced a milky ring around the whole outline.
195
+ float body = mix(VG_BODY_DENSITY, VG_BODY_DENSITY * u_edgeDensity, smoothstep(0.10, 0.62, t)) * density;
196
+ float vignette = smoothstep(0.15, 1.0, inner) * 0.19 * density;
197
+ // There's NO density boost on press here. It was meant as "the element stands out more under
198
+ // the finger," but it pulls the body toward the tint, and the tint depends on polarity: over a
199
+ // light backdrop (dark polarity) press ended up DARKENING the element. Presence is shown
200
+ // instead by the vgTouchWarp field's deformation and the highlight blooming below — neither
201
+ // cares about the sign of polarity.
202
+ float a = max(body, vignette);
203
+
204
+ // Active state does NOT touch the body's color. Mixing in a fixed gray here flipped the
205
+ // effect's sign depending on the backdrop: over dark the element lightened, over light it
206
+ // darkened, for the exact same state. Active state is shown instead by the highlight and rim
207
+ // lighting above: neither cares about the backdrop.
208
+
209
+ // THE ICON SITS BENEATH THE SURFACE, it isn't stuck on top of it: it used to be mixed in last,
210
+ // over the highlight, and read as a flat sticker on volumetric glass. At the rim the normal
211
+ // displaces it, like everything seen through the glass, while the highlight sits ON TOP — it
212
+ // lives on the surface itself.
213
+ //
214
+ // There is NO shadow under the icon here. It used to be made from the difference of two offset
215
+ // mask samples and produced a second outline along the edge — the icon's borders looked
216
+ // ragged.
217
+ // THE INK'S SHIFT IS SET BY ITS OWN DEPTH, NOT BY BEVEL WIDTH. The ink sits right at the
218
+ // surface, and it's displaced by exactly the thin layer of glass above it — not by how wide a
219
+ // bevel this particular piece of glass happens to have. It used to be a fraction of the bevel:
220
+ // on thin glass the shift came out to 4 dp and everything lined up, but on thick glass it was
221
+ // 8, and cover art inset from the edge by 6 stretched toward the rim and spilled past its
222
+ // bounds. The ceiling is absolute: ink has one depth regardless of the glass.
223
+ float2 inkShift = n * (min(u_bevel * 0.5, VG_INK_DEPTH) * t);
224
+ float2 inkUv = u_center + p - inkShift;
225
+
226
+ // INK LOSES SHARPNESS UNDER THE FINGER (reference §6): in the reference, a glyph under
227
+ // pressure drowns in milk rather than simply getting lighter. This branch is uniform across
228
+ // the whole element: press arrives as a uniform, so defocus costs nothing while the element
229
+ // isn't being touched.
230
+ float defocus = VG_INK_DEFOCUS * u_touchRadius * u_touchPress;
231
+ float2 inkDx = float2(defocus, 0.0);
232
+ float2 inkDy = float2(0.0, defocus);
233
+
234
+ half4 ink;
235
+ half4 over;
236
+ if (defocus > 0.01) {
237
+ // A cross around the center with double weight in the middle: the ink mask is
238
+ // high-contrast, and four samples are enough for a stroke to stop holding its edge.
239
+ ink = (u_icon.eval((inkUv - inkDx) * u_iconScale)
240
+ + u_icon.eval((inkUv + inkDx) * u_iconScale)
241
+ + u_icon.eval((inkUv - inkDy) * u_iconScale)
242
+ + u_icon.eval((inkUv + inkDy) * u_iconScale)
243
+ + u_icon.eval(inkUv * u_iconScale) * 2.0) * half(u_iconOn / 6.0);
244
+ // Colored content lives in the same plane as the ink and blurs together with it: different
245
+ // sharpness for two layers in one plane would read as a defect, not a press.
246
+ over = (u_overlay.eval((inkUv - inkDx) * u_iconScale)
247
+ + u_overlay.eval((inkUv + inkDx) * u_iconScale)
248
+ + u_overlay.eval((inkUv - inkDy) * u_iconScale)
249
+ + u_overlay.eval((inkUv + inkDy) * u_iconScale)
250
+ + u_overlay.eval(inkUv * u_iconScale) * 2.0) * half(u_overlayOn * u_appear / 6.0);
251
+ } else {
252
+ ink = u_icon.eval(inkUv * u_iconScale) * half(u_iconOn);
253
+ over = u_overlay.eval(inkUv * u_iconScale) * half(u_overlayOn * u_appear);
254
+ }
255
+
256
+ // The backing under the ink is held by the LENS (body density from the probe), not the
257
+ // surface: legibility is a property of the material, and computing it here a second time
258
+ // would give two different answers to one question.
259
+
260
+ // Layers are composited premultiplied-over: in straight alpha, a nearly transparent dark body
261
+ // used to pull the color of semi-transparent ink and the finger's light toward itself — letter
262
+ // edges grayed out, pressing darkened things.
263
+ half3 pm = col * half(a);
264
+ half inkA = ink.g * half(mix(0.82, 1.0, u_active) * u_appear);
265
+ half3 inkCol = mix(half3(u_inkIdle.rgb), half3(u_inkActive.rgb), half(u_active));
266
+ pm = pm * (1.0 - inkA) + inkCol * inkA;
267
+ a = a + float(inkA) * (1.0 - a);
268
+
269
+ // COLORED CONTENT LIVES IN THE SAME PLACE AS THE INK — inside the material, at the same
270
+ // coordinate. Otherwise deformation would carry them apart: on press, the title and artist
271
+ // name would shake together with the surface while the cover art stayed put, because it used
272
+ // to be a separate layer on top of the glass. Polarity doesn't touch it — it has its own
273
+ // color, and there's nothing to swap it for.
274
+ pm = pm * (1.0 - over.a) + over.rgb * over.a;
275
+ a = a + float(over.a) * (1.0 - a);
276
+
277
+ // Light from the finger is gathered by the LENS: it's a concentration of the surroundings, not
278
+ // the glass's own whiteness. The surface is left with only what the glass spills outward — the
279
+ // halo above.
280
+
281
+ float mask = 1.0 - smoothstep(-1.0, 1.0, sd);
282
+ pm = clamp(pm, half3(0.0), half3(1.0)) * half(mask);
283
+ a *= mask;
284
+ pm = max(pm + half3(vgShadowTint(u_ambient)) * half(VG_SHADOW_TINT * shade * (1.0 - a)), half3(0.0));
285
+ return half4(pm, half(a + shade * (1.0 - a)));
286
+ }
287
+ `;
@@ -0,0 +1,8 @@
1
+ // Android target. AGSL and SkSL are the same language, so `lens-shader.ts`/`surface-shader.ts`
2
+ // already assemble the finished AGSL text as a string (SDF + body via `${VG_SDF}`) — there's
3
+ // nothing to translate. The identity function here fixes the "input = output" contract as a
4
+ // target in its own right, symmetric with `targets/glsl.ts`, and guards the native path against
5
+ // an accidental edit in some future refactor.
6
+ export function toAGSL(shaderSource: string): string {
7
+ return shaderSource;
8
+ }
@@ -0,0 +1,140 @@
1
+ // WebGL2 target. Translates the same AGSL text (SDF + body, see `targets/agsl.ts`) into GLSL
2
+ // ES 3.0. Each conversion rule is its own function with its own test in
3
+ // `__tests__/targets.test.ts`. Three spots are NOT mechanical: the coordinate origin, units of
4
+ // measure (settled in the web renderer's own setup, not here), and sampler filtering (also the
5
+ // renderer's concern). What's here is text conversion only.
6
+
7
+ /**
8
+ * The sampler-size uniform's name, for translating `NAME.eval(coord)` → `texture(NAME, coord / SIZE)`.
9
+ * A new uniform — there's no equivalent in AGSL: `.eval()` has the size implicitly known to the
10
+ * shader, while GLSL's `texture()` needs a normalized coordinate. One per `uniform shader`/
11
+ * `uniform sampler2D`, its name derived from the sampler's name rather than hardcoded.
12
+ */
13
+ function sizeUniformName(sampler: string): string {
14
+ return sampler.startsWith('u_') ? `${sampler}Size` : `u_${sampler}Size`;
15
+ }
16
+
17
+ /** float2/float3/float4 → vec2/vec3/vec4. */
18
+ export function convertVecTypes(src: string): string {
19
+ return src.replace(/\bfloat([234])\b/g, 'vec$1');
20
+ }
21
+
22
+ /** half/half2/half3/half4 → float/vec2/vec3/vec4, including half(x) → float(x) casts. */
23
+ export function convertHalfTypes(src: string): string {
24
+ return src.replace(/\bhalf([234])\b/g, 'vec$1').replace(/\bhalf\b/g, 'float');
25
+ }
26
+
27
+ /** `uniform shader NAME;` → `uniform sampler2D NAME;` + a new `uniform vec2 <sizeUniform>;`. */
28
+ export function convertShaderUniforms(src: string): string {
29
+ return src.replace(/uniform\s+shader\s+(\w+)\s*;/g, (_match, name: string) => {
30
+ return `uniform sampler2D ${name};\nuniform vec2 ${sizeUniformName(name)};`;
31
+ });
32
+ }
33
+
34
+ /**
35
+ * `NAME.eval(coord)` → `texture(NAME, (coord) / <sizeUniform>)` for each declared
36
+ * `uniform sampler2D`. The bounds of `coord` are found by scanning parens, not with a regex: the
37
+ * arguments are nested calls (`content.eval(vgInContent(s + dR - e))`) with commas and
38
+ * parentheses inside.
39
+ */
40
+ export function convertEvalCalls(src: string): string {
41
+ const samplers = new Set<string>();
42
+ const declRe = /uniform\s+sampler2D\s+(\w+)\s*;/g;
43
+ for (let m = declRe.exec(src); m; m = declRe.exec(src)) samplers.add(m[1]);
44
+ if (samplers.size === 0) return src;
45
+
46
+ const callRe = /(\w+)\.eval\(/g;
47
+ let out = '';
48
+ let cursor = 0;
49
+ for (let m = callRe.exec(src); m; m = callRe.exec(src)) {
50
+ const name = m[1];
51
+ if (!samplers.has(name)) continue;
52
+ const argStart = m.index + m[0].length;
53
+ let depth = 1;
54
+ let i = argStart;
55
+ while (i < src.length && depth > 0) {
56
+ if (src[i] === '(') depth += 1;
57
+ else if (src[i] === ')') depth -= 1;
58
+ i += 1;
59
+ }
60
+ const args = src.slice(argStart, i - 1);
61
+ out += src.slice(cursor, m.index);
62
+ out += `texture(${name}, (${args}) / ${sizeUniformName(name)})`;
63
+ cursor = i;
64
+ callRe.lastIndex = i;
65
+ }
66
+ out += src.slice(cursor);
67
+ return out;
68
+ }
69
+
70
+ /**
71
+ * Early `return <expression>;` inside `main` → `fragColor = <expression>; return;`. Takes ONLY
72
+ * the body of main (see `convertEntryPoint`) — ordinary `return`s in helper functions live
73
+ * outside this text and are untouched. Expressions never contain a `;` of their own (shaders have
74
+ * none — the only `;` lives inside `for(...)`, which isn't a return), so `[^;]+` is safe.
75
+ */
76
+ export function convertReturns(mainBody: string): string {
77
+ return mainBody.replace(/\breturn\s+([^;]+);/g, 'fragColor = $1; return;');
78
+ }
79
+
80
+ const ENTRY_RE = /half4\s+main\s*\(\s*float2\s+(\w+)\s*\)\s*\{/;
81
+
82
+ /**
83
+ * `half4 main(float2 xy) { ... }` → `void main() { vec2 xy = <Y-flipped coordinate>; ...; }`.
84
+ * AGSL gives `xy` in screen pixels, top-left origin, y down; `gl_FragCoord` is bottom-left
85
+ * origin, y up. The flip via `u_resolution.y - gl_FragCoord.y` restores the original convention
86
+ * (both coordinate systems center the pixel at `+0.5`, so no X shift or constant is needed).
87
+ * `out vec4 fragColor` and `uniform vec2 u_resolution` are declared in the prologue (`addPrologue`).
88
+ */
89
+ export function convertEntryPoint(src: string): string {
90
+ const match = ENTRY_RE.exec(src);
91
+ if (!match) {
92
+ throw new Error('vireglass/targets/glsl: entry point "half4 main(float2 xy)" not found');
93
+ }
94
+ const bodyStart = match.index + match[0].length;
95
+ let depth = 1;
96
+ let i = bodyStart;
97
+ while (i < src.length && depth > 0) {
98
+ if (src[i] === '{') depth += 1;
99
+ else if (src[i] === '}') depth -= 1;
100
+ i += 1;
101
+ }
102
+ const bodyEnd = i - 1;
103
+ const param = match[1];
104
+ const body = convertReturns(src.slice(bodyStart, bodyEnd));
105
+ const head = src.slice(0, match.index);
106
+ const tail = src.slice(i);
107
+ return (
108
+ `${head}void main() {\n` +
109
+ ` vec2 ${param} = vec2(gl_FragCoord.x, u_resolution.y - gl_FragCoord.y);\n` +
110
+ `${body}}${tail}`
111
+ );
112
+ }
113
+
114
+ /** `#version 300 es` + `precision highp float;` — no equivalent in AGSL, WebGL2 requires them as
115
+ * the file's first lines. `out vec4 fragColor` and `uniform vec2 u_resolution` ride along here
116
+ * too: both new, with no counterpart in the source, needed by exactly one entry point
117
+ * (`convertEntryPoint`). */
118
+ export function addPrologue(src: string): string {
119
+ return (
120
+ '#version 300 es\n' +
121
+ 'precision highp float;\n\n' +
122
+ 'out vec4 fragColor;\n' +
123
+ 'uniform vec2 u_resolution;\n\n' +
124
+ src
125
+ );
126
+ }
127
+
128
+ /** Full translation AGSL/SkSL → GLSL ES 3.0. Order matters: the entry point is found by its
129
+ * literal AGSL signature BEFORE type conversion, otherwise `half4 main(float2 xy)` can't be
130
+ * found. */
131
+ export function toGLSL(shaderSource: string): string {
132
+ let out = shaderSource;
133
+ out = convertEntryPoint(out);
134
+ out = convertShaderUniforms(out);
135
+ out = convertEvalCalls(out);
136
+ out = convertVecTypes(out);
137
+ out = convertHalfTypes(out);
138
+ out = addPrologue(out);
139
+ return out;
140
+ }
@@ -0,0 +1,195 @@
1
+ // The element's response to a finger — PART OF THE MATERIAL, not the app: the springs here
2
+ // describe what the medium feels like, exactly the way ior describes what it looks like through.
3
+ // Keeping them with the consumer would mean web and Android end up with two different glasses
4
+ // under one name.
5
+ //
6
+ // It's the FIELD around the touch point that deforms (vgTouchWarp in sdf.ts), so four values are
7
+ // computed here: where it was touched, how far it's being dragged, how deep the press is, and
8
+ // what wave is currently running.
9
+ //
10
+ // The key point: drag is anchored to the touch BLOB. Pull the right edge and the right edge
11
+ // moves, the left stays put; the finger has area, not a point — the plateau inside `vgTouchWarp`.
12
+ // Scaling the shape's bounding box isn't an option: that would make a rightward pull also bulge
13
+ // the left side, which liquid doesn't do.
14
+ //
15
+ // PRESS is viscous: it dents shallow and releases slowly.
16
+ // DRAG is thick: the body noticeably lags the finger, travel is short, and on release it snaps
17
+ // back quickly with almost no overshoot. A wide, springy overshoot would be liquid jelly, not
18
+ // dense glass.
19
+ // WAVE: touch and lift-off each throw a short ring that decays within a quarter second.
20
+
21
+ export type DeformSample = {
22
+ /** Touch point relative to the element's center, CSS pixels. */
23
+ touchX: number;
24
+ touchY: number;
25
+ pullX: number;
26
+ pullY: number;
27
+ press: number;
28
+ active: number;
29
+ /** Wave amplitude in CSS pixels and its phase in turns. */
30
+ waveAmp: number;
31
+ wavePhase: number;
32
+ };
33
+
34
+ const HOLD_STIFFNESS = 260;
35
+ const HOLD_DAMPING = 46;
36
+ /** Release is stiffer than hold, but damping is high: a dense medium snaps back fast with almost
37
+ * no overshoot. A visible overshoot would be liquid jelly, not dense glass. */
38
+ const RELEASE_STIFFNESS = 420;
39
+ const RELEASE_DAMPING = 34;
40
+
41
+ const PRESS_ATTACK = 0.07;
42
+ const PRESS_RELEASE = 0.16;
43
+
44
+ /** The wave is short and weak: in a viscous medium ripples decay within a quarter second rather
45
+ * than oscillating. */
46
+ const WAVE_DECAY = 0.22;
47
+ const WAVE_TURNS_PER_SECOND = 3.0;
48
+
49
+ export function createDeform() {
50
+ let touchX = 0;
51
+ let touchY = 0;
52
+ let targetTouchX = 0;
53
+ let targetTouchY = 0;
54
+ let pullX = 0;
55
+ let pullY = 0;
56
+ let vx = 0;
57
+ let vy = 0;
58
+ let targetX = 0;
59
+ let targetY = 0;
60
+ let held = false;
61
+ let press = 0;
62
+ let active = 0;
63
+ let waveAmp = 0;
64
+ let wavePhase = 0;
65
+
66
+ /** `x`, `y` — touch point relative to the element's center, CSS pixels. */
67
+ function grab(x: number, y: number, waveStart: number): void {
68
+ // Rest is checked BEFORE the grab: idle() looks at !held, and after `held = true` it's
69
+ // always false — the point would then jump even on a first touch of a resting element.
70
+ const atRest = idle();
71
+ held = true;
72
+ targetTouchX = x;
73
+ targetTouchY = y;
74
+ // On a resting element the finger lands instantly; if it hasn't settled yet from the
75
+ // previous touch, the point travels there over a couple of frames. An instant jump tears the
76
+ // deformation, and on rapid taps that reads as jitter.
77
+ if (atRest) {
78
+ touchX = x;
79
+ touchY = y;
80
+ }
81
+ targetX = 0;
82
+ targetY = 0;
83
+ // Impulses ADD UP rather than restart, and phase isn't reset: resetting it would cut off a
84
+ // running wave mid-period, which is exactly what causes a jolt on rapid clicks.
85
+ waveAmp = Math.min(waveAmp + waveStart, waveStart * 1.6);
86
+ }
87
+
88
+ function drag(dx: number, dy: number, limit: number): void {
89
+ if (!held) return;
90
+ const len = Math.hypot(dx, dy);
91
+ // Travel saturates rather than clips: near the limit the finger keeps moving while the field
92
+ // barely does — that's how a thick liquid behaves once it hits its limit.
93
+ const scale = len > 1e-3 ? (limit * Math.tanh(len / limit)) / len : 0;
94
+ targetX = dx * scale;
95
+ targetY = dy * scale;
96
+ }
97
+
98
+ /** Lifting the finger throws a second wave — weaker than the one from touching down. */
99
+ function release(waveStart: number): void {
100
+ held = false;
101
+ targetX = 0;
102
+ targetY = 0;
103
+ waveAmp = Math.min(waveAmp + waveStart, waveStart * 2);
104
+ }
105
+
106
+ function integrate(dt: number): void {
107
+ const k = held ? HOLD_STIFFNESS : RELEASE_STIFFNESS;
108
+ const c = held ? HOLD_DAMPING : RELEASE_DAMPING;
109
+ vx += (k * (targetX - pullX) - c * vx) * dt;
110
+ vy += (k * (targetY - pullY) - c * vy) * dt;
111
+ pullX += vx * dt;
112
+ pullY += vy * dt;
113
+
114
+ const pressTarget = held ? 1 : 0;
115
+ const tau = held ? PRESS_ATTACK : PRESS_RELEASE;
116
+ press += (pressTarget - press) * (1 - Math.exp(-dt / tau));
117
+ active += (pressTarget - active) * (1 - Math.exp(-dt / 0.09));
118
+
119
+ // The touch point catches up to the finger fast, but not instantly — see grab().
120
+ const follow = 1 - Math.exp(-dt / 0.045);
121
+ touchX += (targetTouchX - touchX) * follow;
122
+ touchY += (targetTouchY - touchY) * follow;
123
+
124
+ wavePhase += dt * WAVE_TURNS_PER_SECOND;
125
+ waveAmp *= Math.exp(-dt / WAVE_DECAY);
126
+ if (waveAmp < 0.01) waveAmp = 0;
127
+ }
128
+
129
+ /**
130
+ * The step is fixed, the frame isn't: on a software renderer there can be as few as ten frames
131
+ * a second, and integrating "however much time passed" makes the response lag behind real time
132
+ * (and at a large step the spring can even diverge). So time is caught up in sub-steps.
133
+ */
134
+ function step(dt: number): void {
135
+ let rest = Math.min(Math.max(dt, 0), 0.25);
136
+ const h = 1 / 120;
137
+ while (rest > 1e-6) {
138
+ const slice = Math.min(h, rest);
139
+ integrate(slice);
140
+ rest -= slice;
141
+ }
142
+ // Settled means exactly at rest. The `idle()` threshold stops rendering, but the values
143
+ // themselves only approach zero asymptotically, and the element would stay forever very
144
+ // slightly deformed: invisible to the eye, but a frame-vs-reference comparison would flag the
145
+ // mismatch forever.
146
+ if (idle()) {
147
+ pullX = 0;
148
+ pullY = 0;
149
+ vx = 0;
150
+ vy = 0;
151
+ press = 0;
152
+ active = 0;
153
+ wavePhase = 0;
154
+ }
155
+ }
156
+
157
+ /** Whether the element has settled — the bench uses this to decide whether to keep drawing frames. */
158
+ function idle(): boolean {
159
+ return (
160
+ !held &&
161
+ Math.abs(pullX) < 0.05 &&
162
+ Math.abs(pullY) < 0.05 &&
163
+ Math.hypot(vx, vy) < 0.5 &&
164
+ press < 0.004 &&
165
+ active < 0.004 &&
166
+ waveAmp === 0
167
+ );
168
+ }
169
+
170
+ function sample(): DeformSample {
171
+ return { touchX, touchY, pullX, pullY, press, active, waveAmp, wavePhase };
172
+ }
173
+
174
+ return { grab, drag, release, step, idle, sample };
175
+ }
176
+
177
+ /**
178
+ * RAISING INTO GLASS (reference §5, M 4:10–4:30). A control that isn't glass at rest — a switch
179
+ * knob, a slider handle — stops being frosted under the finger and becomes a lens: whatever is
180
+ * beneath it shows through.
181
+ *
182
+ * Glass comes in FASTER than frost leaves: the two fractions overlap, otherwise midway through
183
+ * the transition the backdrop flashes through the knob for an instant — a hole where the control
184
+ * should be.
185
+ *
186
+ * Lifting off the backdrop is NOT part of this. The shader's `u_lift` is a direction ("this kind
187
+ * of element sinks in or rises"), not a magnitude — how far it has already risen is carried by
188
+ * the press value that `u_lift` gets multiplied by. Returning a third fraction here would mean
189
+ * multiplying press by itself — which is exactly what happened the first time the web bench wired
190
+ * this up.
191
+ */
192
+ export function raiseIntoGlass(press: number): { glass: number; solid: number } {
193
+ const t = press < 0 ? 0 : press > 1 ? 1 : press;
194
+ return { glass: Math.pow(t, 0.6), solid: 1 - Math.pow(t, 1.4) };
195
+ }