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
|
@@ -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
|
+
}
|