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,106 @@
1
+ import type { ComponentType, ReactNode } from 'react';
2
+ import { Platform, type StyleProp, type ViewStyle } from 'react-native';
3
+ // Through `expo`, not directly from `expo-modules-core`: in this layout the latter is a
4
+ // transitive dependency, and pnpm doesn't hoist it into the app's node_modules.
5
+ import { requireNativeModule, requireNativeView } from 'expo';
6
+
7
+ /**
8
+ * Props of the native lens. Names MUST match the `Prop("…")` calls in `GlassLensModule.kt`:
9
+ * Expo swallows an unknown prop silently, and the lens just doesn't turn on — that's exactly how
10
+ * refraction ended up disabled in production for an entire phase. Parity is pinned down by a
11
+ * consuming app's own material-parity test.
12
+ *
13
+ * Material values aren't assembled here by hand — they come from `toLensProps` (`adapters.ts`).
14
+ */
15
+ export type GlassLensProps = {
16
+ /** Tag of the GlassBackdrop whose frame snapshot the lens uses for refraction. */
17
+ backdropId?: number | null;
18
+ /** AGSL source. Assembled in JS so the lens and the surface share one geometry string. */
19
+ shaderSource: string;
20
+ /** Size of the VISIBLE glass, in dp. The view itself must be bigger than it: the rim gathers
21
+ * light from OUTSIDE the shape, and scattering blurs the backdrop around a point — without
22
+ * the margin the shader has nothing there to sample. The lightness probe's rectangle is taken
23
+ * from the same size. */
24
+ glassWidth: number;
25
+ glassHeight: number;
26
+ /** Material uniforms as one channel (`toLensProps`): name ↔ size ↔ values. There used to be a
27
+ * separate prop per value, and a typo would silently turn the whole lens off. */
28
+ uniformNames: string[];
29
+ uniformSizes: number[];
30
+ /** Values arrive through the animated prop: the pull worklet substitutes them in. They CANNOT
31
+ * also be sent through the ordinary prop at the same time — on frequent repaints it
32
+ * overwrites what was substituted in. */
33
+ uniformValues?: number[];
34
+ /** Lightness, variegation and average color of the backdrop UNDER the glass. The app listens
35
+ * to this to recolor the ink once the glass has worked past its own limit. */
36
+ onBackdropSample?: (e: {
37
+ nativeEvent: {
38
+ luma: number;
39
+ busy: number;
40
+ lo: number;
41
+ hi: number;
42
+ r: number;
43
+ g: number;
44
+ b: number;
45
+ };
46
+ }) => void;
47
+ style?: StyleProp<ViewStyle>;
48
+ children?: ReactNode;
49
+ };
50
+
51
+
52
+ const native = (() => {
53
+ if (Platform.OS !== 'android') return null;
54
+ try {
55
+ const view = requireNativeView('GlassLens', 'GlassLensView') as ComponentType<GlassLensProps>;
56
+ const constants = requireNativeModule('GlassLens') as { isSupported?: boolean };
57
+ return { view, supported: constants.isSupported === true };
58
+ } catch (e) {
59
+ console.warn('GlassLens: native view failed to load', e);
60
+ return null;
61
+ }
62
+ })();
63
+
64
+ export type GlassBackdropProps = { style?: StyleProp<ViewStyle>; children?: ReactNode };
65
+
66
+ /**
67
+ * Backdrop capture WITHOUT blur. expo-blur hands back a dithered copy: on Android 13+ it routes
68
+ * the capture through `createBlurEffect`, and Skia dithers the blur's output (measured in the
69
+ * lab, see `docs/benchmarks.md`).
70
+ */
71
+ export const GlassBackdrop: ComponentType<GlassBackdropProps> | null =
72
+ Platform.OS === 'android'
73
+ ? (() => {
74
+ try {
75
+ return requireNativeView('GlassLens', 'GlassBackdropView') as ComponentType<GlassBackdropProps>;
76
+ } catch {
77
+ return null;
78
+ }
79
+ })()
80
+ : null;
81
+
82
+ /** Diagnostic probe: exactly what arrives in `createRuntimeShaderEffect`.
83
+ * Lab-only, not used in production UI. */
84
+ export type GlassProbeProps = {
85
+ /** 0 — raw sample, 1 — sample + a shader-liveness marker, 2 — alpha map. */
86
+ mode?: number;
87
+ style?: StyleProp<ViewStyle>;
88
+ children?: ReactNode;
89
+ };
90
+
91
+ export const GlassProbe: ComponentType<GlassProbeProps> | null =
92
+ Platform.OS === 'android'
93
+ ? (() => {
94
+ try {
95
+ return requireNativeView('GlassLens', 'GlassProbeView') as ComponentType<GlassProbeProps>;
96
+ } catch {
97
+ return null;
98
+ }
99
+ })()
100
+ : null;
101
+
102
+ /** `RenderEffect.createRuntimeShaderEffect` — Android 13+. Below it, the caller must stay on the
103
+ * previous affine magnification: the view there just works as an ordinary container. */
104
+ export const isGlassLensSupported: boolean = native?.supported ?? false;
105
+
106
+ export const GlassLens: ComponentType<GlassLensProps> | null = native?.view ?? null;
@@ -0,0 +1,58 @@
1
+ import { createContext, useContext, useMemo, type ReactNode } from 'react';
2
+ import { mergeAccessibility, useSystemAccessibility } from './system-accessibility';
3
+ import type { VireGlassAccessibility } from '../accessibility';
4
+
5
+ /**
6
+ * Host app policy for VireGlass, provided once at the root.
7
+ *
8
+ * `glassEnabled` is the app's escape hatch: the performance envelope here was measured on one
9
+ * flagship device, and behaviour on weak hardware is unknown, so the host must be able to turn
10
+ * all glass off at once. `backdropAllowed` lets the host suppress live backdrops on its own terms
11
+ * (e.g. while a sheet is open) — without it, `glassEnabled` is the fallback for that too.
12
+ */
13
+ type VireGlassContextValue = {
14
+ glassEnabled: boolean;
15
+ reduceMotion: boolean;
16
+ backdropAllowed?: (topLayer: boolean) => boolean;
17
+ };
18
+
19
+ const DEFAULT_CONTEXT: VireGlassContextValue = {
20
+ glassEnabled: true,
21
+ reduceMotion: false,
22
+ };
23
+
24
+ /** A default value, not a throw: a host that hasn't mounted the provider still gets working
25
+ * glass with sane defaults, rather than a crash on first render. */
26
+ const VireGlassContext = createContext<VireGlassContextValue>(DEFAULT_CONTEXT);
27
+
28
+ export function VireGlassProvider({
29
+ glassEnabled = true,
30
+ reduceMotion = false,
31
+ backdropAllowed,
32
+ children,
33
+ }: {
34
+ glassEnabled?: boolean;
35
+ reduceMotion?: boolean;
36
+ backdropAllowed?: (topLayer: boolean) => boolean;
37
+ children: ReactNode;
38
+ }) {
39
+ const value = useMemo<VireGlassContextValue>(
40
+ () => ({ glassEnabled, reduceMotion, backdropAllowed }),
41
+ [glassEnabled, reduceMotion, backdropAllowed],
42
+ );
43
+ return <VireGlassContext.Provider value={value}>{children}</VireGlassContext.Provider>;
44
+ }
45
+
46
+ /** System accessibility settings merged with the host's own motion toggle. Memoized, as the
47
+ * original store-backed version was: it feeds shader uniforms downstream, and an unstable
48
+ * reference there would recompute them on every render for no reason. */
49
+ export function useAccessibilityModifiers(): VireGlassAccessibility {
50
+ const { reduceMotion } = useContext(VireGlassContext);
51
+ const system = useSystemAccessibility();
52
+ return useMemo(() => mergeAccessibility(system, reduceMotion), [system, reduceMotion]);
53
+ }
54
+
55
+ export function useBackdropEnabled(topLayer?: boolean): boolean {
56
+ const { glassEnabled, backdropAllowed } = useContext(VireGlassContext);
57
+ return backdropAllowed?.(topLayer ?? false) ?? glassEnabled;
58
+ }
@@ -0,0 +1,51 @@
1
+ import { useEffect } from 'react';
2
+
3
+ /**
4
+ * Budget of simultaneous glass surfaces.
5
+ *
6
+ * Measured on the reference device (Xiaomi 2311DRK48G, Android 16, release, 120 Hz — an 8.33 ms
7
+ * frame budget): about 0.8 ms of GPU per surface, growing linearly with no threshold — 4 ms at
8
+ * one, 5 at two, 7 at three, 8 at six. Six is where the budget runs out, not where a measured
9
+ * cliff is: the saturation point was never found, because the lab scene lays surfaces out in a
10
+ * column and at six they already hit the screen edge.
11
+ *
12
+ * The cost also depends on the scene, not just the count: the same six surfaces over a patterned
13
+ * canvas with motion cost 20 ms. One, two and three agree across runs.
14
+ *
15
+ * See docs/benchmarks.md. Anything measured before 2026-08-31 is void — the capture node had no
16
+ * bounds and the lens was sampling emptiness.
17
+ */
18
+ export const GLASS_GREEN_MAX = 6;
19
+
20
+ /**
21
+ * Counter of live glass surfaces — dev only.
22
+ *
23
+ * A consuming app's own test can guard its **declared** budget model; this counter guards the
24
+ * **actual** one and catches drift between the two: a new screen, one panel too many, a
25
+ * suppression under a sheet that got forgotten.
26
+ */
27
+ let live = 0;
28
+ let peak = 0;
29
+
30
+ export function useGlassSurfaceRegistration(active: boolean): void {
31
+ useEffect(() => {
32
+ if (!__DEV__ || !active) return;
33
+ live += 1;
34
+ if (live > peak) peak = live;
35
+ if (live > GLASS_GREEN_MAX) {
36
+ console.warn(
37
+ `[vireglass] simultaneous surfaces with a live backdrop: ${live} — past the measured ` +
38
+ `green zone (${GLASS_GREEN_MAX}), where the frame budget is already spent. Beyond it ` +
39
+ `nothing is measured.`,
40
+ );
41
+ }
42
+ return () => {
43
+ live -= 1;
44
+ };
45
+ }, [active]);
46
+ }
47
+
48
+ /** For manual diagnostics from the lab and from logs. */
49
+ export function glassSurfaceStats(): { live: number; peak: number } {
50
+ return { live, peak };
51
+ }
@@ -0,0 +1,90 @@
1
+ // System accessibility settings (reference `docs/reference.md` §9): the material has to listen
2
+ // to them on its own, without help from screens. This module only READS the system; the host
3
+ // app's own toggle is mixed in by the provider (`provider.tsx`), so this module knows nothing
4
+ // about the app.
5
+ import { useSyncExternalStore } from 'react';
6
+ import { AccessibilityInfo, Platform } from 'react-native';
7
+ import type { VireGlassAccessibility } from '../accessibility';
8
+
9
+ export type SystemAccessibility = {
10
+ reduceMotion: boolean;
11
+ reduceTransparency: boolean;
12
+ increaseContrast: boolean;
13
+ };
14
+
15
+ const OFF: SystemAccessibility = {
16
+ reduceMotion: false,
17
+ reduceTransparency: false,
18
+ increaseContrast: false,
19
+ };
20
+
21
+ let state: SystemAccessibility = OFF;
22
+ const listeners = new Set<() => void>();
23
+ let subscriptions: { remove: () => void }[] = [];
24
+
25
+ function put(key: keyof SystemAccessibility, value: boolean): void {
26
+ if (state[key] === value) return;
27
+ state = { ...state, [key]: value };
28
+ for (const notify of listeners) notify();
29
+ }
30
+
31
+ /** Contrast is named differently per system: on Android it's high-contrast text, on iOS it's
32
+ * darker system colors. The signal is one and the same, and the material responds to it
33
+ * identically. */
34
+ const CONTRAST =
35
+ Platform.OS === 'ios'
36
+ ? { read: () => AccessibilityInfo.isDarkerSystemColorsEnabled(), event: 'darkerSystemColorsChanged' as const }
37
+ : { read: () => AccessibilityInfo.isHighTextContrastEnabled(), event: 'highTextContrastChanged' as const };
38
+
39
+ function start(): void {
40
+ // The first answer arrives as a promise, later ones as an event: without the first, a setting
41
+ // only gets picked up after someone toggles it — i.e. never, on a screen that's already open.
42
+ AccessibilityInfo.isReduceMotionEnabled().then((on) => put('reduceMotion', on)).catch(() => {});
43
+ AccessibilityInfo.isReduceTransparencyEnabled().then((on) => put('reduceTransparency', on)).catch(() => {});
44
+ CONTRAST.read().then((on) => put('increaseContrast', on)).catch(() => {});
45
+ subscriptions = [
46
+ AccessibilityInfo.addEventListener('reduceMotionChanged', (on) => put('reduceMotion', on)),
47
+ AccessibilityInfo.addEventListener('reduceTransparencyChanged', (on) => put('reduceTransparency', on)),
48
+ AccessibilityInfo.addEventListener(CONTRAST.event, (on) => put('increaseContrast', on)),
49
+ ];
50
+ }
51
+
52
+ function stop(): void {
53
+ for (const subscription of subscriptions) subscription.remove();
54
+ subscriptions = [];
55
+ }
56
+
57
+ /**
58
+ * ONE subscription per app, not per surface: a screen can hold up to a dozen glass elements at
59
+ * once, and a pair of bridge requests for each of them would be a real cost for no reason.
60
+ * Native listeners live as long as there's at least one reader.
61
+ */
62
+ export function subscribeToSystemAccessibility(listener: () => void): () => void {
63
+ if (listeners.size === 0) start();
64
+ listeners.add(listener);
65
+ return () => {
66
+ listeners.delete(listener);
67
+ if (listeners.size === 0) stop();
68
+ };
69
+ }
70
+
71
+ const read = (): SystemAccessibility => state;
72
+
73
+ export function useSystemAccessibility(): SystemAccessibility {
74
+ return useSyncExternalStore(subscribeToSystemAccessibility, read, read);
75
+ }
76
+
77
+ /**
78
+ * The app's own setting combines with the system one and can only TIGHTEN it: someone who asked
79
+ * the system to remove motion shouldn't get it back because of a toggle inside the kit.
80
+ */
81
+ export function mergeAccessibility(
82
+ system: SystemAccessibility,
83
+ appReduceMotion: boolean,
84
+ ): VireGlassAccessibility {
85
+ return {
86
+ reduceTransparency: system.reduceTransparency,
87
+ increaseContrast: system.increaseContrast,
88
+ reduceMotion: system.reduceMotion || appReduceMotion,
89
+ };
90
+ }
package/src/optics.ts ADDED
@@ -0,0 +1,177 @@
1
+ // Deriving optics from the medium's properties. The material describes CAUSES (index of
2
+ // refraction, thickness, bevel, roughness); the shaders need EFFECTS — this file translates one
3
+ // into the other. Pure functions, no imports: the physics is checked by tests, not on a device.
4
+ //
5
+ // There are normalization constants here, and they're honestly labeled as such: the rendering is
6
+ // stylized, and literal physical values (glass reflects 4% at normal incidence) produce effects
7
+ // right at the threshold of visibility. Each normalization is monotonic in its own cause, so
8
+ // "denser medium → more visible rim" always holds, and no impossible combination can arise.
9
+
10
+ const clamp = (v: number, lo: number, hi: number) => (v < lo ? lo : v > hi ? hi : v);
11
+ const clamp01 = (v: number) => clamp(v, 0, 1);
12
+
13
+ /** Reflectance at normal incidence: ((n−1)/(n+1))². For glass, n=1.5 gives 0.04. */
14
+ export const fresnelF0 = (ior: number) => ((ior - 1) / (ior + 1)) ** 2;
15
+
16
+ /** Translation of the physical 4% into the render's working range. */
17
+ const FRESNEL_GAIN = 17.5;
18
+
19
+ export const fresnelStrength = (ior: number) => clamp01(fresnelF0(ior) * FRESNEL_GAIN);
20
+
21
+ /** Exponent in the Schlick approximation — a constant of the model, not a knob. */
22
+ export const FRESNEL_EXPONENT = 5;
23
+
24
+ /** Strength of ray bending. As n→1 the medium is indistinguishable from air; n=1.6 is taken as
25
+ * full strength. */
26
+ export const refractionStrength = (ior: number) => clamp01((ior - 1) / 0.6);
27
+
28
+ /** Magnification behind a plane-parallel plate: grows with thickness and with (1 − 1/n). */
29
+ const MAGNIFY_PER_DP = 0.006;
30
+
31
+ export const refractionScale = (ior: number, thicknessDp: number) =>
32
+ 1 + MAGNIFY_PER_DP * thicknessDp * (1 - 1 / Math.max(ior, 1));
33
+
34
+ /** In ordinary glasses the Abbe number falls as n rises — dispersion grows along with it. */
35
+ const DISPERSION_PER_IOR = 1.1;
36
+
37
+ export const dispersion = (ior: number) => clamp01((ior - 1) * DISPERSION_PER_IOR);
38
+
39
+ /** Spread of the index between the red and blue channels. The physical value (~0.01 at an Abbe
40
+ * number of 55) sits at the threshold of visibility — a stylization of the same monotonicity as
41
+ * Fresnel's. */
42
+ const IOR_SPREAD_PER_DISPERSION = 0.045;
43
+
44
+ export const iorSpread = (ior: number) => dispersion(ior) * IOR_SPREAD_PER_DISPERSION;
45
+
46
+ /** Beer–Lambert: how much the medium absorbs over a path of length `pathDp`. */
47
+ const ABSORB_PER_DP = 0.017;
48
+
49
+ export const absorption = (pathDp: number) => 1 - Math.exp(-ABSORB_PER_DP * Math.max(pathDp, 0));
50
+
51
+ /**
52
+ * Density at the bevel relative to the body. Not a separate quantity: at the rim the ray travels
53
+ * LONGER through the medium by the bevel's width, and absorption over that path is greater. This
54
+ * used to be a separate `edgeDensity` slider, which is how a thin bevel with a milky rim got
55
+ * built — a state that doesn't occur in real glass.
56
+ */
57
+ export const edgeDensity = (thicknessDp: number, bevelDp: number) => {
58
+ const body = absorption(thicknessDp);
59
+ if (body <= 0) return 1;
60
+ return clamp(absorption(thicknessDp + bevelDp) / body, 1, 4);
61
+ };
62
+
63
+ /**
64
+ * Backdrop haziness — surface roughness, not a separate blur setting.
65
+ *
66
+ * The ceiling is deliberately low. Blur in this model is a SUPPORTING device: it helps ink sit
67
+ * over glass, but doesn't improve the picture on its own. Past roughly a dozen dp, letters under
68
+ * the glass stop being letters and turn into a mush of pixels — no amount of legibility justifies
69
+ * that. Separating content from the ink is body density's job, not blur's.
70
+ */
71
+ const BLUR_MAX = 12;
72
+
73
+ export const blur = (roughness: number) => clamp01(roughness) * BLUR_MAX;
74
+
75
+ /** A smooth surface gives a narrow highlight, a rough one a spread-out one. */
76
+ export const specularPower = (roughness: number) => 160 - clamp01(roughness) * 152;
77
+
78
+ /** Highlight brightness — the same reflectance as Fresnel, damped by roughness. */
79
+ export const specularStrength = (ior: number, roughness: number) =>
80
+ clamp01(fresnelStrength(ior) * (1 - clamp01(roughness) * 0.6));
81
+
82
+ /**
83
+ * Color of the medium. Never set by hand: in transparent media, hue is tied to density. Water
84
+ * absorbs red and skews cool, ordinary glass is nearly neutral with a faint green, dense
85
+ * high-index media skew warm. Lightness follows reflectance: the denser the medium, the more of
86
+ * its surroundings it returns, and the lighter its body reads.
87
+ */
88
+ const COOL = { r: 0.86, g: 1.0, b: 1.08 };
89
+ const NEUTRAL = { r: 1.0, g: 1.02, b: 0.99 };
90
+ const WARM = { r: 1.08, g: 1.0, b: 0.88 };
91
+
92
+ const mixHue = (a: typeof COOL, b: typeof COOL, t: number) => ({
93
+ r: a.r + (b.r - a.r) * t,
94
+ g: a.g + (b.g - a.g) * t,
95
+ b: a.b + (b.b - a.b) * t,
96
+ });
97
+
98
+ export function mediumTint(ior: number): { r: number; g: number; b: number } {
99
+ const t = clamp01((ior - 1.2) / 0.55);
100
+ const hue = t < 0.5 ? mixHue(COOL, NEUTRAL, t * 2) : mixHue(NEUTRAL, WARM, (t - 0.5) * 2);
101
+ const lift = 0.34 + fresnelF0(ior) * 2.4;
102
+ return { r: clamp01(hue.r * lift), g: clamp01(hue.g * lift), b: clamp01(hue.b * lift) };
103
+ }
104
+
105
+ /**
106
+ * How far the rim gathers light AROUND the element.
107
+ *
108
+ * On real glass, at a grazing angle the eye receives not what's under the glass but the
109
+ * surroundings: an object on a black table next to a lamp catches the lamp. So the radius is
110
+ * noticeably larger than the bevel — it's the element's vicinity, not its rim. Without this,
111
+ * glass on an empty black background has no source at all and honestly disappears.
112
+ */
113
+ const GATHER_PER_BEVEL = 4;
114
+
115
+ /**
116
+ * Ceiling on the radius. Without it, a wide bevel would gather its surroundings from fifty-odd dp
117
+ * away, and a READABLE copy of whatever sits nearby would appear inside the glass: eight samples
118
+ * aren't enough to blur a disc that large (E-33, E-37). Real rims also gather light mostly from
119
+ * nearby — an unbounded radius was a modeling mistake.
120
+ */
121
+ const GATHER_MAX = 28;
122
+
123
+ export const gatherRadius = (bevelDp: number) =>
124
+ clamp(Math.max(bevelDp, 1) * GATHER_PER_BEVEL, 4, GATHER_MAX);
125
+
126
+ /**
127
+ * The body's own density from absorption through its thickness. Small: glass has no color of its
128
+ * own, the body only moves toward the tint under the legibility requirement (HIG "Color"),
129
+ * otherwise it dims the content.
130
+ */
131
+ const BODY_DENSITY = 0.05;
132
+
133
+ export const bodyDensity = (thicknessDp: number) => BODY_DENSITY * absorption(thicknessDp);
134
+
135
+ /**
136
+ * Highlight normalization. `fresnelStrength`'s ×17.5 factor is tuned for the rim highlight's
137
+ * visibility and saturates at one for dense media; applying it to the highlight too would raise
138
+ * body lightness harder than the glass had just separated it from the ink — eating its own
139
+ * legibility work. This uses the physical scale instead.
140
+ */
141
+ const EDGE_LIGHT_GAIN = 6;
142
+
143
+ /** How much the rim picks up the light and color of its surroundings. The same reflectance as
144
+ * Fresnel: no separate knob — a dense medium returns more of its surroundings by construction. */
145
+
146
+ export const edgeLight = (ior: number) => clamp01(fresnelF0(ior) * EDGE_LIGHT_GAIN);
147
+
148
+ /**
149
+ * THE SPECTRAL PART. Three phenomena — dispersion, diffraction and interference — share one
150
+ * cause: index of refraction and phase both depend on wavelength. So there are no separate
151
+ * "rainbow strength" knobs here: everything is derived from the medium's density and film
152
+ * thickness.
153
+ */
154
+
155
+ /**
156
+ * Rim iridescence is visible exactly as much as the REFLECTION is bright: interference lives in
157
+ * the reflected ray, not the transmitted one. Hence the tie to Fresnel — there's no strength knob
158
+ * of its own.
159
+ */
160
+ export const iridescence = (ior: number, filmNm: number) =>
161
+ filmNm <= 0 ? 0 : clamp01(fresnelStrength(ior) * 1.6);
162
+
163
+ /**
164
+ * Edge diffraction. Same λ-dependence as dispersion, so it shares its strength; the bevel sets
165
+ * the fringe frequency — wide bevels give sparse fringes, sharp ones frequent (in the shader this
166
+ * is divided by bevel width). The 0.5 normalization reflects the stylized render: at the physical
167
+ * amplitude the fringes aren't visible at all.
168
+ */
169
+ export const diffraction = (ior: number) => clamp01(dispersion(ior) * 0.5);
170
+
171
+ /**
172
+ * How much the body picks up the COLOR of its surroundings. This is multiple internal reflection:
173
+ * the denser the medium, the more light it circulates inside itself, and the more strongly it's
174
+ * tinted by what's around it. The ceiling is deliberately low — the tint has to stay a medium, not
175
+ * become a flat color fill.
176
+ */
177
+ export const colorPickup = (ior: number) => clamp(fresnelStrength(ior) * 0.9, 0, 0.42);
package/src/react.ts ADDED
@@ -0,0 +1,102 @@
1
+ // The React binding for backdrop adaptation. It lives behind its own entry point (`vireglass/react`)
2
+ // so that the core stays dependency-free: the decision itself is a pure function in
3
+ // `adaptation.ts`, and only the confirmation policy and the fade need hooks.
4
+ import { useCallback, useEffect, useRef, useState } from 'react';
5
+ import type { VireGlassOptics } from './material';
6
+ import {
7
+ CONFIRMATIONS,
8
+ FADE_MS,
9
+ shouldInkBeLight,
10
+ type BackdropSample,
11
+ } from './adaptation';
12
+ export type GlassAdaptation = {
13
+ /** Ink polarity: 1 light, 0 dark. Travels smoothly between the ends. */
14
+ ink: number;
15
+ /** The latest backdrop sample — used for the accent and for debug hints. */
16
+ sample: BackdropSample | null;
17
+ /** Pass to `VireGlassSurface`. */
18
+ onBackdropSample: (e: { nativeEvent: BackdropSample }) => void;
19
+ };
20
+
21
+ /**
22
+ * Content polarity that follows the backdrop under the glass on its own.
23
+ *
24
+ * `initial` — the polarity the screen starts with (usually light ink on a dark theme).
25
+ * `enabled = false` freezes it: a screen that already knows its own content is entitled to not
26
+ * hand the decision to the automation.
27
+ */
28
+ export function useGlassAdaptation(
29
+ optics: Pick<VireGlassOptics, 'legibility' | 'bodyDensity' | 'ink' | 'edgeLight'>,
30
+ options: { enabled?: boolean } = {},
31
+ ): GlassAdaptation {
32
+ const enabled = options.enabled ?? true;
33
+ const [ink, setInk] = useState(optics.ink);
34
+ const [sample, setSample] = useState<BackdropSample | null>(null);
35
+
36
+ const target = useRef(optics.ink > 0.5 ? 1 : 0);
37
+ // The current value also lives in a ref: without it the callback would depend on state and get
38
+ // recreated on EVERY frame of the fade, meaning the native view's prop would change identity 60
39
+ // times a second for no reason but that.
40
+ const current = useRef(optics.ink);
41
+ const lastSample = useRef<BackdropSample | null>(null);
42
+ const pending = useRef(0);
43
+ const from = useRef(optics.ink);
44
+ const startedAt = useRef(0);
45
+ const raf = useRef<ReturnType<typeof requestAnimationFrame> | null>(null);
46
+
47
+ // The fade runs frame by frame, not on a worklet spring: the value feeds not only text color
48
+ // but also the lens uniform, and that's set through a prop — i.e. from a React render.
49
+ const animate = useCallback(() => {
50
+ const step = () => {
51
+ const t = Math.min((Date.now() - startedAt.current) / FADE_MS, 1);
52
+ // Cosine easing: a linear transition reads as motion, this one doesn't.
53
+ const e = 0.5 - 0.5 * Math.cos(Math.PI * t);
54
+ current.current = from.current + (target.current - from.current) * e;
55
+ setInk(current.current);
56
+ raf.current = t < 1 ? requestAnimationFrame(step) : null;
57
+ };
58
+ if (raf.current !== null) cancelAnimationFrame(raf.current);
59
+ raf.current = requestAnimationFrame(step);
60
+ }, []);
61
+
62
+ useEffect(() => () => {
63
+ if (raf.current !== null) cancelAnimationFrame(raf.current);
64
+ }, []);
65
+
66
+ const onBackdropSample = useCallback(
67
+ (e: { nativeEvent: BackdropSample }) => {
68
+ const next = e.nativeEvent;
69
+ // The sample arrives several times a second. It only lands in state when it actually
70
+ // moved: otherwise every sample would drag a consumer repaint behind it.
71
+ const prev = lastSample.current;
72
+ if (
73
+ prev === null ||
74
+ Math.abs(prev.luma - next.luma) > 0.01 ||
75
+ Math.abs(prev.busy - next.busy) > 0.02
76
+ ) {
77
+ lastSample.current = next;
78
+ setSample(next);
79
+ }
80
+ if (!enabled) return;
81
+
82
+ const wasLight = target.current === 1;
83
+ // The actual decision lives in `shouldInkBeLight`: one place for every platform. What's
84
+ // left here is just the confirmation policy, and that's the app's own.
85
+ const wants = shouldInkBeLight(next, wasLight) !== wasLight;
86
+ if (!wants) {
87
+ pending.current = 0;
88
+ return;
89
+ }
90
+ pending.current += 1;
91
+ if (pending.current < CONFIRMATIONS) return;
92
+ pending.current = 0;
93
+ from.current = current.current;
94
+ target.current = wasLight ? 0 : 1;
95
+ startedAt.current = Date.now();
96
+ animate();
97
+ },
98
+ [enabled, animate],
99
+ );
100
+
101
+ return { ink, sample, onBackdropSample };
102
+ }