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.
- package/LICENSE +202 -0
- package/NOTICE +10 -0
- package/README.md +216 -0
- package/android/build.gradle +15 -0
- package/android/src/main/AndroidManifest.xml +1 -0
- package/android/src/main/java/expo/modules/glasslens/GlassBackdropView.kt +335 -0
- package/android/src/main/java/expo/modules/glasslens/GlassLensModule.kt +49 -0
- package/android/src/main/java/expo/modules/glasslens/GlassLensView.kt +463 -0
- package/android/src/main/java/expo/modules/glasslens/GlassProbeView.kt +76 -0
- package/dist/adaptation-BSbbdExK.d.cts +78 -0
- package/dist/adaptation-BSbbdExK.d.ts +78 -0
- package/dist/adapters-BPB0D5nr.d.cts +357 -0
- package/dist/adapters-BgSc-smX.d.ts +357 -0
- package/dist/chunk-HF2AWZEZ.js +1741 -0
- package/dist/chunk-HF2AWZEZ.js.map +1 -0
- package/dist/chunk-JNRZNMJJ.js +20 -0
- package/dist/chunk-JNRZNMJJ.js.map +1 -0
- package/dist/index.cjs +2159 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +294 -0
- package/dist/index.d.ts +294 -0
- package/dist/index.js +286 -0
- package/dist/index.js.map +1 -0
- package/dist/material-CkUHUBoy.d.cts +408 -0
- package/dist/material-CkUHUBoy.d.ts +408 -0
- package/dist/react.cjs +73 -0
- package/dist/react.cjs.map +1 -0
- package/dist/react.d.cts +25 -0
- package/dist/react.d.ts +25 -0
- package/dist/react.js +59 -0
- package/dist/react.js.map +1 -0
- package/dist/web/index.cjs +1990 -0
- package/dist/web/index.cjs.map +1 -0
- package/dist/web/index.d.cts +148 -0
- package/dist/web/index.d.ts +148 -0
- package/dist/web/index.js +657 -0
- package/dist/web/index.js.map +1 -0
- package/expo-module.config.json +6 -0
- package/package.json +127 -0
- package/src/accessibility.ts +51 -0
- package/src/adaptation.ts +85 -0
- package/src/adapters.ts +362 -0
- package/src/concentric.ts +25 -0
- package/src/geometry.ts +151 -0
- package/src/glass-scale.ts +37 -0
- package/src/group-model.ts +224 -0
- package/src/index.ts +17 -0
- package/src/lens-shader.ts +547 -0
- package/src/material.ts +553 -0
- package/src/native/environment.ts +70 -0
- package/src/native/glass-group.tsx +107 -0
- package/src/native/glass-ink.tsx +44 -0
- package/src/native/glass-surface.tsx +477 -0
- package/src/native/index.ts +18 -0
- package/src/native/lens.ts +106 -0
- package/src/native/provider.tsx +58 -0
- package/src/native/surface-registry.ts +51 -0
- package/src/native/system-accessibility.ts +90 -0
- package/src/optics.ts +177 -0
- package/src/react.ts +102 -0
- package/src/reference-scene.ts +222 -0
- package/src/scroll-edge.ts +30 -0
- package/src/sdf.ts +149 -0
- package/src/surface-shader.ts +287 -0
- package/src/targets/agsl.ts +8 -0
- package/src/targets/glsl.ts +140 -0
- package/src/touch-response.ts +195 -0
- package/src/web/gl.ts +119 -0
- package/src/web/index.ts +3 -0
- package/src/web/probe.ts +312 -0
- package/src/web/reference-draw.ts +142 -0
- package/src/web/renderer.ts +524 -0
package/src/adapters.ts
ADDED
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
import {
|
|
2
|
+
bevelDp,
|
|
3
|
+
bevelFraction,
|
|
4
|
+
halfMinDp,
|
|
5
|
+
rimDp,
|
|
6
|
+
shadowReachDp,
|
|
7
|
+
surfacePadDp,
|
|
8
|
+
thicknessDp,
|
|
9
|
+
type VireGlassGeometry,
|
|
10
|
+
} from './geometry';
|
|
11
|
+
import { LENS_SHADER } from './lens-shader';
|
|
12
|
+
import { debugIndex, type VireGlassDebugMode, type VireGlassOptics } from './material';
|
|
13
|
+
|
|
14
|
+
/** Smooth union with a second shape — bench morphing experiment only. */
|
|
15
|
+
export type VireGlassMorph = {
|
|
16
|
+
offsetX: number;
|
|
17
|
+
offsetY: number;
|
|
18
|
+
width: number;
|
|
19
|
+
height: number;
|
|
20
|
+
cornerRadius: number;
|
|
21
|
+
/** Seam smoothing radius in dp; 0 turns the second shape off. */
|
|
22
|
+
smoothing: number;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
/** Surface response to a finger: the field around the touch point deforms, not the shape's
|
|
27
|
+
* bounding box (`vgTouchWarp` in `sdf.ts`). Lengths in dp; `radius` = 0 turns the response off
|
|
28
|
+
* entirely. */
|
|
29
|
+
export type VireGlassTouch = {
|
|
30
|
+
x: number;
|
|
31
|
+
y: number;
|
|
32
|
+
pullX: number;
|
|
33
|
+
pullY: number;
|
|
34
|
+
press: number;
|
|
35
|
+
radius: number;
|
|
36
|
+
waveAmp: number;
|
|
37
|
+
wavePhase: number;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const NO_TOUCH: VireGlassTouch = {
|
|
41
|
+
x: 0, y: 0, pullX: 0, pullY: 0, press: 0, radius: 0, waveAmp: 0, wavePhase: 0,
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const NO_MORPH = { offsetX: 0, offsetY: 0, width: 0, height: 0, cornerRadius: 0, smoothing: 0 };
|
|
45
|
+
|
|
46
|
+
/** No progress. Negative rather than zero: zero is the start of the track, a legitimate value. */
|
|
47
|
+
const NO_PROGRESS = -1;
|
|
48
|
+
|
|
49
|
+
/** Tinted glass for the primary action (M 16:08). Color is RGB 0…1. */
|
|
50
|
+
export type VireGlassAccent = { color: readonly [number, number, number]; amount?: number };
|
|
51
|
+
|
|
52
|
+
/** Default tint fraction: the color reads, but content beneath it is still visible. */
|
|
53
|
+
export const ACCENT_AMOUNT = 0.8;
|
|
54
|
+
const NO_ACCENT = [0, 0, 0, 0] as const;
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
/** Magnification at the flat center. Kept separate from the uniform channel: it's also used by
|
|
59
|
+
* the fallback below Android 13, where there's no RuntimeShader and only an affine magnifier is
|
|
60
|
+
* left. */
|
|
61
|
+
export const lensMagnify = (o: VireGlassOptics) => 1 + (o.refractionScale - 1) * o.refraction;
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Lens uniforms. Assembled as ONE channel: name, size and values travel together, so a mismatch
|
|
65
|
+
* with the shader is caught immediately, with the name right there in the log. There used to be a
|
|
66
|
+
* separate Kotlin `Prop` per value — Expo swallows an unknown prop silently, and the lens simply
|
|
67
|
+
* never turned on (docs/material-lab.md E-01, which is how refraction ended up disabled for an
|
|
68
|
+
* entire phase).
|
|
69
|
+
*
|
|
70
|
+
* Everything with a dimension is converted to PIXELS right here. The native view is left with
|
|
71
|
+
* only the uniforms it alone knows: its own size and its own place on screen.
|
|
72
|
+
*/
|
|
73
|
+
// The lens's uniform names and sizes are always the same — the set is fixed. They're handed over
|
|
74
|
+
// as ONE instance for the whole process: otherwise every frame of a drag would send three dozen
|
|
75
|
+
// fresh strings to the native side, React can't tell "unchanged" from "a new array", and motion
|
|
76
|
+
// goes stepwise. The values are the only thing that actually changes.
|
|
77
|
+
const shapes = new Map<number, { names: string[]; sizes: number[] }>();
|
|
78
|
+
|
|
79
|
+
function channel(entries: [string, number | readonly number[]][]) {
|
|
80
|
+
const uniformValues: number[] = [];
|
|
81
|
+
let shape = shapes.get(entries.length);
|
|
82
|
+
let same = shape !== undefined;
|
|
83
|
+
for (let i = 0; i < entries.length; i += 1) {
|
|
84
|
+
const [name, value] = entries[i];
|
|
85
|
+
const v = typeof value === 'number' ? [value] : value;
|
|
86
|
+
if (same && (shape!.names[i] !== name || shape!.sizes[i] !== v.length)) same = false;
|
|
87
|
+
for (const x of v) uniformValues.push(x);
|
|
88
|
+
}
|
|
89
|
+
if (!same) {
|
|
90
|
+
shape = {
|
|
91
|
+
names: entries.map((e) => e[0]),
|
|
92
|
+
sizes: entries.map((e) => (typeof e[1] === 'number' ? 1 : e[1].length)),
|
|
93
|
+
};
|
|
94
|
+
shapes.set(entries.length, shape);
|
|
95
|
+
}
|
|
96
|
+
return { uniformNames: shape!.names, uniformSizes: shape!.sizes, uniformValues };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Props for the native view. Besides the uniform channel, only what the view uses itself lives
|
|
100
|
+
* here: the shader source and the visible glass's size (the probe rectangle is taken from it). */
|
|
101
|
+
export function toLensProps(
|
|
102
|
+
optics: VireGlassOptics,
|
|
103
|
+
geometry: VireGlassGeometry,
|
|
104
|
+
density: number,
|
|
105
|
+
options: {
|
|
106
|
+
debug?: VireGlassDebugMode;
|
|
107
|
+
morph?: VireGlassMorph;
|
|
108
|
+
/** Third shape of the fused body; a zero size turns it off. */
|
|
109
|
+
morph2?: VireGlassMorph;
|
|
110
|
+
groupProbe?: number[];
|
|
111
|
+
touch?: VireGlassTouch;
|
|
112
|
+
/** Progress fraction, 0…1: to the left of the boundary the element is active. `undefined` means no progress. */
|
|
113
|
+
progress?: number;
|
|
114
|
+
/** Key light direction in screen space; defaults to the light at rest. */
|
|
115
|
+
light?: readonly [number, number];
|
|
116
|
+
/** 0…1: the lens builds up as the element appears — instead of a plain fade. */
|
|
117
|
+
appear?: number;
|
|
118
|
+
/** Tint for the primary action: the glass color and the fraction it lays over the content. */
|
|
119
|
+
accent?: VireGlassAccent;
|
|
120
|
+
} = {},
|
|
121
|
+
) {
|
|
122
|
+
const morph = options.morph ?? NO_MORPH;
|
|
123
|
+
const morph2 = options.morph2 ?? NO_MORPH;
|
|
124
|
+
const touch = options.touch ?? NO_TOUCH;
|
|
125
|
+
// Backdrop estimate for the whole surface group. Travels through the same channel as the
|
|
126
|
+
// material and applies AFTER the lens's own estimate — that is, it simply overrides it. It
|
|
127
|
+
// can't travel as a separate prop: the lens view is wrapped in an animated component.
|
|
128
|
+
const g = options.groupProbe;
|
|
129
|
+
const group: [string, number | readonly number[]][] =
|
|
130
|
+
g && g.length >= 9
|
|
131
|
+
? [
|
|
132
|
+
['u_probeLuma', g[0]],
|
|
133
|
+
['u_probeBusy', g[1]],
|
|
134
|
+
['u_probeRange', [g[2], g[3]]],
|
|
135
|
+
['u_probeSlope', [g[4], g[5]]],
|
|
136
|
+
['u_probe', [g[6], g[7], g[8]]],
|
|
137
|
+
]
|
|
138
|
+
: [];
|
|
139
|
+
const d = density;
|
|
140
|
+
const halfW = (geometry.width * d) / 2;
|
|
141
|
+
const halfH = (geometry.height * d) / 2;
|
|
142
|
+
const halfMin = Math.min(halfW, halfH);
|
|
143
|
+
|
|
144
|
+
return {
|
|
145
|
+
shaderSource: LENS_SHADER,
|
|
146
|
+
glassWidth: geometry.width,
|
|
147
|
+
glassHeight: geometry.height,
|
|
148
|
+
...channel([
|
|
149
|
+
['u_halfSize', [halfW, halfH]],
|
|
150
|
+
['u_corner', Math.min(geometry.cornerRadius * d, halfMin)],
|
|
151
|
+
['u_bevel', Math.max(bevelDp(geometry, optics) * d, 1)],
|
|
152
|
+
['u_thick', thicknessDp(geometry, optics) * d],
|
|
153
|
+
['u_rim', rimDp(geometry, optics) * d],
|
|
154
|
+
['u_ior', optics.ior],
|
|
155
|
+
['u_iorSpread', optics.iorSpread],
|
|
156
|
+
['u_light', options.light ?? REST_LIGHT],
|
|
157
|
+
['u_appear', options.appear ?? 1],
|
|
158
|
+
['u_accent', options.accent ? [...options.accent.color, options.accent.amount ?? ACCENT_AMOUNT] : NO_ACCENT],
|
|
159
|
+
// Haze from surface roughness. Lives in the same disc-gather pass as adaptive scattering,
|
|
160
|
+
// and fades out toward the bevel: that's a different job there — bending the ray and
|
|
161
|
+
// splitting it.
|
|
162
|
+
['u_frost', optics.blur * d],
|
|
163
|
+
['u_ink', optics.ink],
|
|
164
|
+
['u_legibility', optics.legibility],
|
|
165
|
+
['u_dim', optics.dimming],
|
|
166
|
+
['u_presence', optics.presence],
|
|
167
|
+
['u_adaptRadius', optics.adaptRadius * d],
|
|
168
|
+
['u_bodyDensity', optics.bodyDensity],
|
|
169
|
+
['u_edgeLight', optics.edgeLight],
|
|
170
|
+
['u_fresnel', optics.fresnel],
|
|
171
|
+
['u_specular', optics.specular],
|
|
172
|
+
// The rim gathers light from the element's vicinity — this is a radius around the shape,
|
|
173
|
+
// not its bevel.
|
|
174
|
+
['u_reflectReach', optics.gatherRadiusDp * d],
|
|
175
|
+
['u_film', optics.film],
|
|
176
|
+
['u_iridescence', optics.iridescence],
|
|
177
|
+
['u_diffraction', optics.diffraction],
|
|
178
|
+
['u_colorPickup', optics.colorPickup],
|
|
179
|
+
['u_morphOffset', [morph.offsetX * d, morph.offsetY * d]],
|
|
180
|
+
['u_morphHalf', [(morph.width * d) / 2, (morph.height * d) / 2]],
|
|
181
|
+
['u_morphCorner', morph.cornerRadius * d],
|
|
182
|
+
['u_morphK', morph.smoothing * d],
|
|
183
|
+
['u_morph2Offset', [morph2.offsetX * d, morph2.offsetY * d]],
|
|
184
|
+
['u_morph2Half', [(morph2.width * d) / 2, (morph2.height * d) / 2]],
|
|
185
|
+
['u_morph2Corner', morph2.cornerRadius * d],
|
|
186
|
+
['u_touch', [touch.x * d, touch.y * d]],
|
|
187
|
+
['u_pull', [touch.pullX * d, touch.pullY * d]],
|
|
188
|
+
['u_touchPress', touch.press],
|
|
189
|
+
['u_touchRadius', touch.radius * d],
|
|
190
|
+
['u_wave', [touch.waveAmp * d, touch.wavePhase]],
|
|
191
|
+
['u_progress', options.progress ?? NO_PROGRESS],
|
|
192
|
+
['u_debug', debugIndex(options.debug ?? 'normal')],
|
|
193
|
+
...group,
|
|
194
|
+
]),
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Static part of the surface uniforms. Dynamics (gesture, press, light) get mixed in inside the
|
|
199
|
+
* component's worklet — the adapter must stay an ordinary function. */
|
|
200
|
+
export function toSurfaceUniforms(
|
|
201
|
+
optics: VireGlassOptics,
|
|
202
|
+
geometry: VireGlassGeometry,
|
|
203
|
+
options: {
|
|
204
|
+
debug?: VireGlassDebugMode;
|
|
205
|
+
morph?: VireGlassMorph;
|
|
206
|
+
/** Third shape of the fused body; a zero size turns it off. */
|
|
207
|
+
morph2?: VireGlassMorph;
|
|
208
|
+
dragLimit?: number;
|
|
209
|
+
shadow?: number;
|
|
210
|
+
/** Average ambient color around the element: light from it bleeds into the shadow (reference 219 @8:22). */
|
|
211
|
+
ambient?: readonly [number, number, number];
|
|
212
|
+
/** The lens draws the glass body — the surface is left with the highlight, shadow and icon. */
|
|
213
|
+
bodyInLens?: boolean;
|
|
214
|
+
touch?: VireGlassTouch;
|
|
215
|
+
/** Progress fraction, 0…1: to the left of the boundary the element is active. `undefined` means no progress. */
|
|
216
|
+
progress?: number;
|
|
217
|
+
/** 0…1: the element is appearing — shadow and ink build up together with the lens. */
|
|
218
|
+
appear?: number;
|
|
219
|
+
/** 0 — the element sinks in under the finger, 1 — it rises into glass (reference §5). */
|
|
220
|
+
lift?: number;
|
|
221
|
+
} = {},
|
|
222
|
+
) {
|
|
223
|
+
const morph = options.morph ?? NO_MORPH;
|
|
224
|
+
const morph2 = options.morph2 ?? NO_MORPH;
|
|
225
|
+
const touch = options.touch ?? NO_TOUCH;
|
|
226
|
+
const pad = surfacePadDp(geometry, options.dragLimit ?? 0, morph);
|
|
227
|
+
return {
|
|
228
|
+
u_center: [geometry.width / 2 + pad, geometry.height / 2 + pad],
|
|
229
|
+
u_halfSize: [geometry.width / 2, geometry.height / 2],
|
|
230
|
+
u_corner: Math.min(geometry.cornerRadius, halfMinDp(geometry)),
|
|
231
|
+
u_bevel: bevelDp(geometry, optics),
|
|
232
|
+
u_thickness: bevelFraction(geometry, optics),
|
|
233
|
+
u_morphOffset: [morph.offsetX, morph.offsetY],
|
|
234
|
+
u_morphHalf: [morph.width / 2, morph.height / 2],
|
|
235
|
+
u_morphCorner: morph.cornerRadius,
|
|
236
|
+
u_morphK: morph.smoothing,
|
|
237
|
+
u_morph2Offset: [morph2.offsetX, morph2.offsetY],
|
|
238
|
+
u_morph2Half: [morph2.width / 2, morph2.height / 2],
|
|
239
|
+
u_morph2Corner: morph2.cornerRadius,
|
|
240
|
+
u_edgeDensity: optics.edgeDensity,
|
|
241
|
+
u_dispersion: optics.dispersion,
|
|
242
|
+
u_refraction: optics.refraction,
|
|
243
|
+
// Tint density is suppressed when the lens is drawing the body: painting it twice would
|
|
244
|
+
// double the fill, and adaptation is impossible on the surface anyway — it can't see the
|
|
245
|
+
// backdrop. The color itself stays: it drives absorption at the rim.
|
|
246
|
+
u_tint: [
|
|
247
|
+
optics.tint.r,
|
|
248
|
+
optics.tint.g,
|
|
249
|
+
optics.tint.b,
|
|
250
|
+
options.bodyInLens ? 0 : optics.tintStrength,
|
|
251
|
+
],
|
|
252
|
+
u_shadow: options.shadow ?? 1,
|
|
253
|
+
// Neutral by default: without an ambient sample, the shadow stays exactly as it was.
|
|
254
|
+
u_ambient: options.ambient ?? [0, 0, 0],
|
|
255
|
+
u_shadowReach: shadowReachDp(geometry),
|
|
256
|
+
u_touch: [touch.x, touch.y],
|
|
257
|
+
u_pull: [touch.pullX, touch.pullY],
|
|
258
|
+
u_touchPress: touch.press,
|
|
259
|
+
u_touchRadius: touch.radius,
|
|
260
|
+
u_wave: [touch.waveAmp, touch.wavePhase],
|
|
261
|
+
u_presence: optics.presence,
|
|
262
|
+
u_progress: options.progress ?? NO_PROGRESS,
|
|
263
|
+
u_appear: options.appear ?? 1,
|
|
264
|
+
// Zero by default: buttons sink in, the way every element behaved before this rule existed.
|
|
265
|
+
u_lift: options.lift ?? 0,
|
|
266
|
+
|
|
267
|
+
u_debug: debugIndex(options.debug ?? 'normal'),
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Surface fields that are LENGTHS: the adapter's contract is specified in dp, and on the web each
|
|
273
|
+
* one of them has to be multiplied by screen density. The list lives here rather than in the
|
|
274
|
+
* renderer, where it used to be a dozen and a half manual multiplications in a row: a missed
|
|
275
|
+
* field there was only caught by eye, and the miss isn't visible everywhere — at density 1,
|
|
276
|
+
* multiplying is indistinguishable from not doing it.
|
|
277
|
+
*/
|
|
278
|
+
export type SurfaceUniforms = ReturnType<typeof toSurfaceUniforms>;
|
|
279
|
+
|
|
280
|
+
export const SURFACE_LENGTH_UNIFORMS = [
|
|
281
|
+
'u_halfSize',
|
|
282
|
+
'u_corner',
|
|
283
|
+
'u_bevel',
|
|
284
|
+
'u_morphOffset',
|
|
285
|
+
'u_morphHalf',
|
|
286
|
+
'u_morphCorner',
|
|
287
|
+
'u_morphK',
|
|
288
|
+
'u_morph2Offset',
|
|
289
|
+
'u_morph2Half',
|
|
290
|
+
'u_morph2Corner',
|
|
291
|
+
'u_shadowReach',
|
|
292
|
+
'u_touch',
|
|
293
|
+
'u_pull',
|
|
294
|
+
'u_touchRadius',
|
|
295
|
+
// `satisfies`, not just `as const`: otherwise a typo in the name compiles silently and makes it
|
|
296
|
+
// to runtime — the field with the garbage name gets NaN, and the real one is left un-multiplied.
|
|
297
|
+
] as const satisfies readonly (keyof SurfaceUniforms)[];
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Surface uniforms in device pixels. Android draws in dp and calls the adapter as-is; the web
|
|
301
|
+
* needs this conversion. `u_center` isn't part of it: the renderer knows the element's center on
|
|
302
|
+
* its own.
|
|
303
|
+
*/
|
|
304
|
+
export function toDeviceSurfaceUniforms(raw: SurfaceUniforms, density: number): SurfaceUniforms {
|
|
305
|
+
const scaled: Record<string, unknown> = { ...raw };
|
|
306
|
+
for (const key of SURFACE_LENGTH_UNIFORMS) {
|
|
307
|
+
const value: unknown = scaled[key];
|
|
308
|
+
scaled[key] = Array.isArray(value)
|
|
309
|
+
? (value as number[]).map((v) => v * density)
|
|
310
|
+
: (value as number) * density;
|
|
311
|
+
}
|
|
312
|
+
// Of the wave, only the amplitude has a length; the phase is in turns and knows nothing about density.
|
|
313
|
+
scaled.u_wave = [raw.u_wave[0] * density, raw.u_wave[1]];
|
|
314
|
+
return scaled as SurfaceUniforms;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/** Key light direction in screen coordinates, for when the orientation response is off. */
|
|
318
|
+
export const REST_LIGHT: readonly [number, number] = [-0.577, -0.817];
|
|
319
|
+
|
|
320
|
+
/** Uniforms the component mixes in inside the worklet (press, active, light direction) and via
|
|
321
|
+
* the icon layer. Listed here so a test can check the shader contract's completeness without
|
|
322
|
+
* importing the component itself (it pulls in Skia and doesn't come up in a node environment). */
|
|
323
|
+
export const DYNAMIC_UNIFORMS = ['u_press', 'u_active'] as const;
|
|
324
|
+
|
|
325
|
+
export const ICON_UNIFORMS = ['u_iconOn', 'u_iconScale', 'u_inkIdle', 'u_inkActive'] as const;
|
|
326
|
+
|
|
327
|
+
/** App-colored overlay layer on the glass. The `u_overlay` sampler shares its contract with the
|
|
328
|
+
* ink mask and is sampled at the SAME coordinate — otherwise deformation would carry them apart. */
|
|
329
|
+
export const OVERLAY_UNIFORMS = ['u_overlayOn'] as const;
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Fusing two surfaces into one continuous medium.
|
|
333
|
+
*
|
|
334
|
+
* The second shape is described RELATIVE to the first one's center, because both live in the
|
|
335
|
+
* same shader: the union has no "two pieces of glass," just one body with two bulges — the mask,
|
|
336
|
+
* the refraction and the rim are all computed from a single shared scene.
|
|
337
|
+
*
|
|
338
|
+
* `t` — how fused the shapes are: 0 turns the second shape off before any computation, 1 gives a
|
|
339
|
+
* single shared medium. The seam's smoothing radius is a fraction of the smaller half-size: on
|
|
340
|
+
* large elements the bridge has to be wider, otherwise the seam is left with a sharp corner,
|
|
341
|
+
* which doesn't happen in a liquid.
|
|
342
|
+
*/
|
|
343
|
+
const MORPH_NECK = 0.35;
|
|
344
|
+
|
|
345
|
+
export function morphBetween(
|
|
346
|
+
a: VireGlassGeometry,
|
|
347
|
+
b: VireGlassGeometry,
|
|
348
|
+
offsetX: number,
|
|
349
|
+
offsetY: number,
|
|
350
|
+
t: number,
|
|
351
|
+
): VireGlassMorph | undefined {
|
|
352
|
+
if (t <= 0) return undefined;
|
|
353
|
+
const half = Math.min(halfMinDp(a), halfMinDp(b));
|
|
354
|
+
return {
|
|
355
|
+
offsetX,
|
|
356
|
+
offsetY,
|
|
357
|
+
width: b.width,
|
|
358
|
+
height: b.height,
|
|
359
|
+
cornerRadius: b.cornerRadius,
|
|
360
|
+
smoothing: half * MORPH_NECK * Math.min(t, 1),
|
|
361
|
+
};
|
|
362
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// Concentricity (reference 219 @7:53): nested shapes share the CENTER of curvature, not the radius.
|
|
2
|
+
// So the inner shape's radius is smaller by exactly the inset — otherwise the corners don't run
|
|
3
|
+
// parallel and the gap between the shapes either gets eaten or spreads apart.
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Radius of a nested shape: outer radius minus inset. Zero is a square corner; it never goes
|
|
7
|
+
* negative — once the inset exceeds the outer radius, the corner is simply a right angle.
|
|
8
|
+
*
|
|
9
|
+
* `minimum` — a floor on the radius (analogous to `concentric(minimum:)` in SwiftUI): far from
|
|
10
|
+
* the edge, honest concentricity yields zero and the nested element drops out of the family with
|
|
11
|
+
* a sharp corner. The floor breaks concentricity on purpose — here kinship between shapes matters
|
|
12
|
+
* more than a shared center of curvature.
|
|
13
|
+
*/
|
|
14
|
+
export function concentricRadius(outerRadius: number, inset: number, minimum = 0): number {
|
|
15
|
+
return Math.max(outerRadius - inset, Math.max(minimum, 0));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The inset at which an inner shape with a given radius stays concentric with the outer one.
|
|
20
|
+
* The inverse problem: the element's radius is known (its size dictates it), and what's needed
|
|
21
|
+
* is the margin around it.
|
|
22
|
+
*/
|
|
23
|
+
export function concentricInset(outerRadius: number, innerRadius: number): number {
|
|
24
|
+
return Math.max(outerRadius - innerRadius, 0);
|
|
25
|
+
}
|
package/src/geometry.ts
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import type { VireGlassOptics } from './material';
|
|
2
|
+
|
|
3
|
+
/** Glass shape in dp. Circle and capsule are special cases of a rounded rectangle. */
|
|
4
|
+
export type VireGlassGeometry = {
|
|
5
|
+
width: number;
|
|
6
|
+
height: number;
|
|
7
|
+
cornerRadius: number;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export const circleGeometry = (size: number): VireGlassGeometry => ({
|
|
11
|
+
width: size,
|
|
12
|
+
height: size,
|
|
13
|
+
cornerRadius: size / 2,
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
export const capsuleGeometry = (width: number, height: number): VireGlassGeometry => ({
|
|
17
|
+
width,
|
|
18
|
+
height,
|
|
19
|
+
cornerRadius: Math.min(width, height) / 2,
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
export const roundedRectGeometry = (
|
|
23
|
+
width: number,
|
|
24
|
+
height: number,
|
|
25
|
+
cornerRadius: number,
|
|
26
|
+
): VireGlassGeometry => ({ width, height, cornerRadius });
|
|
27
|
+
|
|
28
|
+
export const halfMinDp = (g: VireGlassGeometry) => Math.max(Math.min(g.width, g.height) / 2, 1);
|
|
29
|
+
|
|
30
|
+
/** Viewport margin for a drop that overshoots past the finger: a fraction of the element's half
|
|
31
|
+
* size beyond the drag travel itself. The drop overshoots the body by no more than `dragLimit`,
|
|
32
|
+
* and stitching adds smoothing on top of that — this term covers it too. */
|
|
33
|
+
export const MAX_STRETCH = 0.34;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Bigger element, thicker glass: a stronger lens, a deeper shadow (docs/reference.md §1). Bevel
|
|
37
|
+
* and material thickness are specified for an element at a reference half-size and grow as the
|
|
38
|
+
* square root of size.
|
|
39
|
+
*/
|
|
40
|
+
const SIZE_REF_DP = 24;
|
|
41
|
+
export const sizeGain = (g: VireGlassGeometry) =>
|
|
42
|
+
Math.min(Math.max(Math.sqrt(halfMinDp(g) / SIZE_REF_DP), 0.8), 2.4);
|
|
43
|
+
|
|
44
|
+
/** A bevel wider than this fraction of the half-size breaks the SDF: the roundings converge in
|
|
45
|
+
* the middle. */
|
|
46
|
+
export const MAX_BEVEL_FRACTION = 0.65;
|
|
47
|
+
|
|
48
|
+
export const bevelFraction = (g: VireGlassGeometry, o: VireGlassOptics) =>
|
|
49
|
+
Math.min(MAX_BEVEL_FRACTION, (o.bevelDp * sizeGain(g)) / halfMinDp(g));
|
|
50
|
+
|
|
51
|
+
export const bevelDp = (g: VireGlassGeometry, o: VireGlassOptics) =>
|
|
52
|
+
Math.max(bevelFraction(g, o) * halfMinDp(g), 1);
|
|
53
|
+
|
|
54
|
+
export const thicknessDp = (g: VireGlassGeometry, o: VireGlassOptics) =>
|
|
55
|
+
o.thicknessDp * sizeGain(g);
|
|
56
|
+
|
|
57
|
+
/** Glass height right at the silhouette, a fraction of thickness: without it there's nothing at
|
|
58
|
+
* the edge to bend the ray. */
|
|
59
|
+
export const RIM_FRACTION = 0.85;
|
|
60
|
+
|
|
61
|
+
export const rimDp = (g: VireGlassGeometry, o: VireGlassOptics) => thicknessDp(g, o) * RIM_FRACTION;
|
|
62
|
+
|
|
63
|
+
/** Bigger element, deeper and wider shadow (M 7:14). */
|
|
64
|
+
export const shadowReachDp = (g: VireGlassGeometry) =>
|
|
65
|
+
Math.min(Math.max(halfMinDp(g) * 0.65, 6), 36);
|
|
66
|
+
|
|
67
|
+
/** How far the second morph shape reaches past the first one's bounds. Without this margin the
|
|
68
|
+
* fused shape gets clipped by the canvas edge and the experiment shows the wrong thing. */
|
|
69
|
+
export function morphReachDp(
|
|
70
|
+
g: VireGlassGeometry,
|
|
71
|
+
morph?: { offsetX: number; offsetY: number; width: number; height: number; smoothing: number },
|
|
72
|
+
): number {
|
|
73
|
+
if (!morph || morph.smoothing <= 0) return 0;
|
|
74
|
+
return Math.max(
|
|
75
|
+
0,
|
|
76
|
+
Math.abs(morph.offsetX) + morph.width / 2 - g.width / 2,
|
|
77
|
+
Math.abs(morph.offsetY) + morph.height / 2 - g.height / 2,
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Quantization step for the margin. The margin only needs to cover the sampling — its exact value
|
|
83
|
+
* doesn't matter, but changing it every frame is expensive: the native lens view gets re-laid-out
|
|
84
|
+
* and re-sets its `RenderEffect`. While dragging a panel or morphing, the margin used to drift
|
|
85
|
+
* continuously, and that produced visible stutter. Round up to the step — size changes rarely.
|
|
86
|
+
*/
|
|
87
|
+
const PAD_STEP = 8;
|
|
88
|
+
const quantise = (v: number) => Math.ceil(v / PAD_STEP) * PAD_STEP;
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Margin around the lens view on each side: beyond it, the shader has nothing to sample.
|
|
92
|
+
*
|
|
93
|
+
* FOUR things have to fit in here. The refraction sample with aberrations. The blur radius —
|
|
94
|
+
* it also samples around the point. The gather radius — the rim picks it up from outside the
|
|
95
|
+
* shape, and without the margin it goes black exactly where it should be catching the
|
|
96
|
+
* surroundings. And drag travel: the lens view moves with the finger, but its bounds don't, and
|
|
97
|
+
* the sample falls off the edge, which is how layers from under the glass leak through.
|
|
98
|
+
*/
|
|
99
|
+
export function lensPadDp(
|
|
100
|
+
g: VireGlassGeometry,
|
|
101
|
+
o: VireGlassOptics,
|
|
102
|
+
morph?: Parameters<typeof morphReachDp>[1],
|
|
103
|
+
dragLimit = 0,
|
|
104
|
+
): number {
|
|
105
|
+
const sampling = Math.max(o.blur, o.gatherRadiusDp);
|
|
106
|
+
const stretch = halfMinDp(g) * MAX_STRETCH;
|
|
107
|
+
return quantise(sampling + dragLimit + stretch + morphReachDp(g, morph) + 2);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Margin around the surface canvas: the shadow reaches outside the shape, and dragging shifts it
|
|
112
|
+
* further still.
|
|
113
|
+
*
|
|
114
|
+
* The multiplier is smaller than the shadow's full reach: under the finger, the shader for a
|
|
115
|
+
* rising element multiplies the radius by 1.55, and the very end of that tail falls outside the
|
|
116
|
+
* margin. Left that way on purpose — at the margin's edge it's at 1.2% alpha (2.5% before the
|
|
117
|
+
* shadow was softened), and raising the multiplier means growing every surface view's area. If
|
|
118
|
+
* that tail ever shows up on a device, fix it here.
|
|
119
|
+
*/
|
|
120
|
+
export function surfacePadDp(
|
|
121
|
+
g: VireGlassGeometry,
|
|
122
|
+
dragLimit = 0,
|
|
123
|
+
morph?: Parameters<typeof morphReachDp>[1],
|
|
124
|
+
): number {
|
|
125
|
+
return quantise(shadowReachDp(g) * 1.2 + dragLimit + morphReachDp(g, morph) + 2);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* SHADOW DENSITY BY WHAT'S UNDER THE ELEMENT. Reference 219 @11:47, verbatim: "increases the
|
|
130
|
+
* opacity of its shadow when it is over text… lowers the opacity of its shadow when it is over a
|
|
131
|
+
* solid light background." This is about content BEHIND THE ELEMENT, so it's a single value per
|
|
132
|
+
* element, not a field over the shadow's area.
|
|
133
|
+
*
|
|
134
|
+
* The law lives here, not at the bench: while it was baked into the web renderer, shadows on
|
|
135
|
+
* Android never adapted at all. The endpoints were checked against the reference by the depth of
|
|
136
|
+
* the dip under the element — docs/benchmarks.md.
|
|
137
|
+
*/
|
|
138
|
+
const SHADOW_FLAT = 0.8;
|
|
139
|
+
const SHADOW_BUSY_GAIN = 6;
|
|
140
|
+
const SHADOW_BUSY_RISE = 1.2;
|
|
141
|
+
const SHADOW_STEP = 0.05;
|
|
142
|
+
|
|
143
|
+
export function shadowOpacity(busy: number): number {
|
|
144
|
+
return SHADOW_FLAT + Math.min(Math.max(busy, 0) * SHADOW_BUSY_GAIN, SHADOW_BUSY_RISE);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Same thing for platforms where the measurement flows through state: quantized coarsely, like
|
|
148
|
+
* the ambient color, otherwise every probe sample would trigger a surface repaint. */
|
|
149
|
+
export function shadowOpacityFrom(sample: { busy: number }): number {
|
|
150
|
+
return Math.round(shadowOpacity(sample.busy) / SHADOW_STEP) * SHADOW_STEP;
|
|
151
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// User-facing glass transparency scale: iOS 26.1 gave a choice between two options ("default clear
|
|
2
|
+
// look" or "tinted look which increases opacity of the material"), iOS 27 a continuous slider
|
|
3
|
+
// "ultra clear → fully tinted", and apps get it without recompiling.
|
|
4
|
+
//
|
|
5
|
+
// It adjusts EFFECTS, not causes, and that's not a compromise. The cause "medium density" is
|
|
6
|
+
// deliberately absent from the model: the glass has no color of its own, `bodyDensity` is derived
|
|
7
|
+
// from absorption through the body and never exceeds 0.03, and the visible density is held by the
|
|
8
|
+
// legibility requirement and scattering. A thickness scale would move a number that isn't visible
|
|
9
|
+
// in the frame (measured by the `check:optics` gate).
|
|
10
|
+
import type { VireGlassOptics } from './material';
|
|
11
|
+
|
|
12
|
+
/** The point on the scale where the material stays exactly as it is today. */
|
|
13
|
+
export const GLASS_SCALE_DEFAULT = 0.35;
|
|
14
|
+
|
|
15
|
+
/** Body density at the "fully tinted" end: content under the glass must be hidden. */
|
|
16
|
+
const TINTED_DENSITY = 0.9;
|
|
17
|
+
|
|
18
|
+
const clamp01 = (v: number) => (v < 0 ? 0 : v > 1 ? 1 : v);
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* `scale` 0 is ultra clear, 1 is fully tinted. Toward the transparent end, the legibility
|
|
22
|
+
* requirement and the dimming layer are relaxed (the user chose clarity); toward the tinted end,
|
|
23
|
+
* body density grows.
|
|
24
|
+
*
|
|
25
|
+
* Accessibility settings apply AFTER the scale: they set floors, and the user's chosen clarity
|
|
26
|
+
* does not override them (219 @18:45).
|
|
27
|
+
*/
|
|
28
|
+
export function applyGlassScale(optics: VireGlassOptics, scale: number): VireGlassOptics {
|
|
29
|
+
const s = clamp01(scale);
|
|
30
|
+
if (s === GLASS_SCALE_DEFAULT) return optics;
|
|
31
|
+
if (s < GLASS_SCALE_DEFAULT) {
|
|
32
|
+
const k = s / GLASS_SCALE_DEFAULT;
|
|
33
|
+
return { ...optics, legibility: optics.legibility * k, dimming: optics.dimming * k };
|
|
34
|
+
}
|
|
35
|
+
const k = (s - GLASS_SCALE_DEFAULT) / (1 - GLASS_SCALE_DEFAULT);
|
|
36
|
+
return { ...optics, bodyDensity: Math.max(optics.bodyDensity, TINTED_DENSITY * k) };
|
|
37
|
+
}
|