Skip to main content

cranpose_liquid/
material.rs

1//! The Liquid Glass material: Gaussian backdrop frost followed by wcKSRD
2//! refraction applied to a composable's own bounds through
3//! [`LiquidModifierExt::glass_effect`] — the analogue of SwiftUI's
4//! `.glassEffect(_:in:)`.
5
6use crate::theme::LiquidColors;
7use cranpose_ui::current_density;
8use cranpose_ui::Modifier;
9use cranpose_ui_graphics::{
10    Color, GraphicsLayer, LayerShape, RenderEffect, RoundedCornerShape, RuntimeShader, TileMode,
11    GLASS_ACTIVITY_UNIFORM, GLASS_BLUR_RADIUS_UNIFORM, GLASS_DISPERSION_UNIFORM,
12    GLASS_EFFECT_DENSITY_UNIFORM, GLASS_FOLD_DEPTH_UNIFORM, GLASS_LIGHT_DIRECTION_UNIFORM,
13    GLASS_MENISCUS_ABSORPTION_UNIFORM, GLASS_OPTICAL_ZOOM_ANCHOR_UNIFORM,
14    GLASS_OPTICAL_ZOOM_UNIFORM, GLASS_REFRACTION_CURVE_UNIFORM, GLASS_RESTING_TINT_UNIFORM,
15    GLASS_TRANSMISSION_REFRACTION_UNIFORM, LIQUID_GLASS_WGSL,
16};
17use std::cell::Cell;
18use std::rc::Rc;
19
20// The ambient light environment shared by every glass material. The
21// reference material's bevel arcs rotate with the device: platforms feed
22// the current attitude here (Android: rotation sensor; desktop: the
23// default overhead light) and every glass surface re-lights on the next
24// frame — materials read it in their per-frame effect resolvers.
25//
26// The vector is the light's RETURN direction in screen space (where the
27// wide bright glow lands); the default (0, 1) is the reference's overhead
28// light with the return at the bottom rim.
29thread_local! {
30    static GLASS_LIGHT_RETURN: Cell<(f32, f32)> = const { Cell::new((0.0, 1.0)) };
31}
32
33/// Sets the ambient glass light return direction (screen space, need not be
34/// normalized — the shader normalizes). Feed device attitude here.
35pub fn set_glass_light_direction(direction: (f32, f32)) {
36    GLASS_LIGHT_RETURN.with(|cell| cell.set(direction));
37}
38
39/// The current ambient glass light return direction.
40pub fn glass_light_direction() -> (f32, f32) {
41    GLASS_LIGHT_RETURN.with(|cell| cell.get())
42}
43
44/// Corner radius large enough that [`cranpose_ui_graphics::CornerRadii::resolve`]
45/// clamps it to half the shape's size — i.e. a capsule.
46const CAPSULE_CLIP_RADIUS: f32 = 1.0e6;
47
48/// Shader sentinel requesting the capsule radius (resolved against the node's
49/// size at render time; see `liquid_glass.wgsl` cover mode).
50const CAPSULE_SHADER_RADIUS: f32 = -1.0;
51
52/// wcKSRD uses a tightly sampled 9x9 footprint (four half-pixel steps in
53/// either direction). Larger frost radii belong in the renderer's Gaussian
54/// pass; spreading those 81 taps over a large radius produces a visible grid.
55const WCKSRD_OPTICAL_BLUR_RADIUS_PX: f32 = 2.0;
56
57/// The shape of a glass element (also its clip and shadow shape).
58#[derive(Clone, Copy, Debug, PartialEq, Default)]
59pub enum LiquidShape {
60    /// A pill: corner radius follows the smaller half-extent.
61    #[default]
62    Capsule,
63    /// Rounded rectangle with the radius in dp.
64    RoundedRect(f32),
65    /// A circle (capsule of a square node).
66    Circle,
67}
68
69impl LiquidShape {
70    /// The clip shape handed to the graphics layer.
71    pub fn clip_shape(&self) -> RoundedCornerShape {
72        match self {
73            LiquidShape::Capsule | LiquidShape::Circle => {
74                RoundedCornerShape::uniform(CAPSULE_CLIP_RADIUS)
75            }
76            LiquidShape::RoundedRect(radius) => RoundedCornerShape::uniform(*radius),
77        }
78    }
79
80    /// The layer shape (clip + shadow geometry).
81    pub fn layer_shape(&self) -> LayerShape {
82        LayerShape::Rounded(self.clip_shape())
83    }
84
85    /// The radius uniform for the lens shader, in px (negative = capsule).
86    fn shader_radius_px(&self, density: f32) -> f32 {
87        match self {
88            LiquidShape::Capsule | LiquidShape::Circle => CAPSULE_SHADER_RADIUS,
89            LiquidShape::RoundedRect(radius) => radius * density,
90        }
91    }
92}
93
94/// Material variant, mirroring SwiftUI's `.regular` / `.clear` glass, plus
95/// the interactive lens.
96#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
97pub enum GlassVariant {
98    /// Frosted: strong backdrop blur, vibrancy boost, scheme-adaptive lift.
99    #[default]
100    Regular,
101    /// Transparent: minimal blur, mild vibrancy — for media-rich backdrops.
102    Clear,
103    /// The interactive magnifying bubble (drag lenses, flying selection):
104    /// no frost, no tone shift, a full-element dome and pronounced rainbow
105    /// dispersion at the rim.
106    Lens,
107}
108
109/// Shadow owned by a glass surface. Morphing glass evaluates the same live
110/// SDF for this shadow; clipped static glass forwards the values to the layer
111/// shadow primitive.
112#[derive(Clone, Copy, Debug, PartialEq)]
113pub struct GlassShadow {
114    pub color: Color,
115    pub radius: f32,
116    pub offset_y: f32,
117    pub spread: f32,
118}
119
120impl GlassShadow {
121    pub fn new(color: Color, radius: f32, offset_y: f32, spread: f32) -> Self {
122        Self {
123            color,
124            radius: radius.max(0.0),
125            offset_y,
126            spread,
127        }
128    }
129}
130
131/// Per-frame motion inputs for an interactive glass element, read lazily at
132/// scene-build time (no recomposition per frame).
133#[derive(Clone, Debug, PartialEq, Default)]
134pub struct GlassDynamics {
135    /// Continuous optical presence. `None` preserves the resolved material;
136    /// `Some(0)` is an exact backdrop identity while retaining the same node,
137    /// SDF, and pointer ride path.
138    pub activity: Option<f32>,
139    /// Base tint of the same persistent SDF surface at zero optical activity.
140    /// Its alpha cross-fades out as the refractive material rises.
141    pub resting_tint: Option<Color>,
142    /// Extra specular intensity (0 = spec default; e.g. press boost).
143    pub highlight_boost: f32,
144    /// Per-frame saturation added to the resolved material. Interactive
145    /// surfaces use this to raise chroma without changing their base tint.
146    pub saturation_boost: f32,
147    /// Optional per-frame multiplier for the material tint alpha. Values
148    /// below one clear a resting wash; values above one densify a raised tint.
149    pub tint_alpha_multiplier: Option<f32>,
150    /// Shape morph: when set, the glass geometry is these node-local rects
151    /// instead of the node cover — the shapeshift channel.
152    pub morph: Option<GlassMorph>,
153    /// Touch glow: `(x_dp, y_dp, intensity)` in node-local dp. A pressed
154    /// surface concentrates saturation and a soft light in a radial
155    /// gradient under the finger (never a flat recolor).
156    pub touch: Option<(f32, f32, f32)>,
157    /// Dome press depth: how squashed the interactive dome is. At 1 the
158    /// pressed dome refracts deep and vivid (wide rim band, strong
159    /// chromatic split — the reference toggle's gray-hold rainbow); toward
160    /// 0 the released bead relaxes shallow and its split fades with it
161    /// (the reference's thin settled ring). `None` = 1.
162    pub press_depth: Option<f32>,
163}
164
165fn foreground_is_dark(foreground: Color) -> bool {
166    let foreground_luma =
167        0.2126 * foreground.r() + 0.7152 * foreground.g() + 0.0722 * foreground.b();
168    foreground_luma < 0.5
169}
170
171fn boost_tint_saturation(tint: Color, boost: f32) -> Color {
172    if boost.abs() <= f32::EPSILON {
173        return tint;
174    }
175    let luma = 0.2126 * tint.r() + 0.7152 * tint.g() + 0.0722 * tint.b();
176    let saturation = (1.0 + boost).max(0.0);
177    Color::rgba(
178        (luma + (tint.r() - luma) * saturation).clamp(0.0, 1.0),
179        (luma + (tint.g() - luma) * saturation).clamp(0.0, 1.0),
180        (luma + (tint.b() - luma) * saturation).clamp(0.0, 1.0),
181        tint.a(),
182    )
183}
184
185pub(crate) fn neutral_surface_tint(foreground: Color, light_alpha: f32, dark_alpha: f32) -> Color {
186    if foreground_is_dark(foreground) {
187        Color::BLACK.with_alpha(light_alpha.clamp(0.0, 1.0))
188    } else {
189        Color::WHITE.with_alpha(dark_alpha.clamp(0.0, 1.0))
190    }
191}
192
193pub(crate) fn neutral_surface_lift(foreground: Color, light_lift: f32, dark_lift: f32) -> f32 {
194    if foreground_is_dark(foreground) {
195        light_lift
196    } else {
197        dark_lift
198    }
199}
200
201/// A liquid shapeshift frame: the primary shape plus any number of nearby
202/// glass shapes, ALL smooth-unioned into one field — liquid glass glues to
203/// whatever glass it passes near (a growing menu necks with a neighboring
204/// button, the drag lens merges with the search circle). Up to
205/// [`GlassMorph::MAX_SHAPES`] extra shapes; an angular wobble makes the
206/// mid-flight field bubble like a droplet. All geometry is node-local dp:
207/// `(center_x, center_y, width, height, corner_radius)`; radius sentinel
208/// `-1` means capsule; `-2` means SUBTRACT capsule — the shape carves a
209/// smooth hole in the field (the growing menu leaves its anchor button
210/// crisp on top until it swallows it).
211#[derive(Clone, Debug, PartialEq, Default)]
212pub struct GlassMorph {
213    /// The glass node's own size in dp. The shader receives all morph
214    /// geometry in dp and derives px-per-dp from the renderer-injected node
215    /// pixel rect divided by this — geometry then lands correctly at ANY
216    /// render scale (live window density, robot captures at 1.0, fractional
217    /// desktop scales). The authoring widget always knows its node size.
218    pub node_size: (f32, f32),
219    pub primary: (f32, f32, f32, f32, f32),
220    /// Nearby glass shapes participating in the field.
221    pub shapes: Vec<(f32, f32, f32, f32, f32)>,
222    /// Smooth-union glue radius (dp): shapes within it neck together.
223    pub glue: f32,
224    /// Wobble amplitude (dp) and phase (radians).
225    pub wobble_amplitude: f32,
226    pub wobble_phase: f32,
227    /// Viscous leading-edge bulge: while the shape travels or inflates, its
228    /// side facing `bulge_direction` (radians, math convention) swells like a
229    /// pulled droplet. Amplitude in dp, usually driven by morph velocity.
230    pub bulge_amplitude: f32,
231    pub bulge_direction: f32,
232    /// Blends the primary rounded-rectangle field toward an ellipse. This is
233    /// used by expanding droplets whose broad phase has continuous curvature
234    /// rather than the straight sides of a capsule.
235    pub ellipse_blend: f32,
236    /// Area-preserving affine strain applied to the primary shape. Extra
237    /// scene shapes remain fixed so nearby glass can join the travelling
238    /// droplet without being dragged through its local deformation.
239    pub deformation: Option<GlassDeformation>,
240    /// Offset (dp) of the optical-zoom axis from the primary SDF center. A
241    /// leaning droplet's silhouette shifts toward its travel side while its
242    /// curvature apex stays over the content it rides; anchoring the
243    /// magnification there keeps the face filled by that content instead of
244    /// pulling in whatever lies beyond it.
245    pub zoom_anchor: (f32, f32),
246}
247
248/// A normalized motion axis and reciprocal scales for incompressible glass.
249/// Construction derives the cross-axis scale, so callers cannot describe a
250/// deformation that changes the bubble's area.
251#[derive(Clone, Copy, Debug, PartialEq)]
252pub struct GlassDeformation {
253    axis: (f32, f32),
254    along: f32,
255}
256
257impl GlassDeformation {
258    pub fn incompressible(axis: (f32, f32), along: f32) -> Self {
259        let length = (axis.0 * axis.0 + axis.1 * axis.1).sqrt();
260        let axis = if length > f32::EPSILON {
261            (axis.0 / length, axis.1 / length)
262        } else {
263            (1.0, 0.0)
264        };
265        Self {
266            axis,
267            along: along.max(f32::EPSILON),
268        }
269    }
270
271    pub fn axis(self) -> (f32, f32) {
272        self.axis
273    }
274
275    pub fn along(self) -> f32 {
276        self.along
277    }
278
279    pub fn across(self) -> f32 {
280        1.0 / self.along
281    }
282}
283
284impl GlassMorph {
285    /// Shader budget for extra scene shapes.
286    pub const MAX_SHAPES: usize = 8;
287}
288
289/// Builder describing a glass material. Resolved against the theme at the
290/// composition site, then evaluated per frame for density and dynamics.
291#[derive(Clone, Debug, PartialEq)]
292pub struct Glass {
293    pub variant: GlassVariant,
294    pub shape: LiquidShape,
295    /// Tint over the refracted backdrop; defaults to the theme's glass tint.
296    pub tint: Option<Color>,
297    /// Backdrop blur radius in dp (defaults per variant).
298    pub blur_radius: Option<f32>,
299    /// Saturation boost (defaults per variant).
300    pub saturation: Option<f32>,
301    /// wcKSRD refraction depth as a fraction of the shape inradius.
302    pub refraction_depth: f32,
303    /// Normalized wcKSRD ray-return exponent.
304    pub refraction_curve: f32,
305    /// Normalized wcKSRD spectral ray separation.
306    pub dispersion: f32,
307    /// Fraction of the wcKSRD displacement applied to the transmitted
308    /// backdrop. The mirrored meniscus follows its own optical path.
309    pub transmission_refraction: f32,
310    /// Energy removed from the transmitted ray at the meniscus. Reflection
311    /// and spectral return follow independent paths.
312    pub meniscus_absorption: f32,
313    /// Depth of the rim fold band in dp: the raised lens rim replays its
314    /// interior mirrored toward the edge (a pure displacement). Zero
315    /// disables the fold.
316    pub fold_depth: f32,
317    /// Uniform face magnification of a riding lens (1.0 = no zoom): the
318    /// backdrop projects enlarged across the whole face while the rim band
319    /// keeps the wcKSRD edge mapping.
320    pub optical_zoom: f32,
321    /// Meniscus rim reflectivity multiplier (1.0 = the reference toggle's
322    /// visible rim line; near 0 = the segmented lens's invisible body).
323    pub rim_reflection: f32,
324    /// Optical ink recolor: the lens recolors dark transmitted ink toward
325    /// this color at the given strength (the reference tab bubble's
326    /// color-mask act). None = off.
327    pub ink_recolor: Option<(Color, f32)>,
328    /// Specular rim intensity.
329    pub highlight: f32,
330    /// Screen-lift override (brightening toward white; negative darkens).
331    /// Defaults per variant.
332    pub lift: Option<f32>,
333    /// Tone-compression override around the shader's mid pivot (<1 pulls
334    /// every backdrop toward mid-luminance — the reference dark menu reads
335    /// bright magenta over deep purple AND dim gray over white through one
336    /// law). Defaults per variant.
337    pub contrast: Option<f32>,
338    /// Drop shadow below the glass.
339    pub shadow: bool,
340    /// Per-surface shadow override.
341    pub shadow_style: Option<GlassShadow>,
342    /// Clip the layer to `shape`. Morphing glass disables this — coverage
343    /// comes entirely from the shader's SDF.
344    pub clip: bool,
345    /// Foreground color whose contrast the frost must protect. `None` uses
346    /// the theme label color.
347    pub foreground: Option<Color>,
348    /// Strength of backdrop+foreground frost adaptation.
349    pub adaptive_frost: f32,
350}
351
352impl Glass {
353    pub fn regular() -> Self {
354        Self {
355            variant: GlassVariant::Regular,
356            shape: LiquidShape::Capsule,
357            tint: None,
358            blur_radius: None,
359            saturation: None,
360            refraction_depth: 0.34,
361            refraction_curve: 0.25,
362            dispersion: 0.0,
363            transmission_refraction: 1.0,
364            meniscus_absorption: 1.0,
365            fold_depth: 0.0,
366            optical_zoom: 1.0,
367            rim_reflection: 1.0,
368            ink_recolor: None,
369            highlight: 0.9,
370            lift: None,
371            contrast: None,
372            shadow: true,
373            shadow_style: None,
374            clip: true,
375            foreground: None,
376            adaptive_frost: 0.65,
377        }
378    }
379
380    pub fn clear() -> Self {
381        Self {
382            variant: GlassVariant::Clear,
383            ..Self::regular()
384        }
385    }
386
387    /// The interactive lens bubble (reference: the iOS toggle/tab-bar drag
388    /// lens): fully transparent, magnifying dome across the whole element,
389    /// strong rainbow rim.
390    pub fn lens() -> Self {
391        Self {
392            variant: GlassVariant::Lens,
393            shape: LiquidShape::Capsule,
394            tint: Some(Color::rgba(1.0, 1.0, 1.0, 0.07)),
395            blur_radius: None,
396            saturation: None,
397            refraction_depth: 0.34,
398            refraction_curve: 1.0,
399            dispersion: 0.30,
400            transmission_refraction: 1.0,
401            meniscus_absorption: 1.0,
402            fold_depth: 0.0,
403            optical_zoom: 1.0,
404            rim_reflection: 1.0,
405            ink_recolor: None,
406            highlight: 1.15,
407            lift: None,
408            contrast: None,
409            shadow: true,
410            shadow_style: None,
411            clip: true,
412            foreground: None,
413            adaptive_frost: 0.0,
414        }
415    }
416
417    pub fn shape(mut self, shape: LiquidShape) -> Self {
418        self.shape = shape;
419        self
420    }
421
422    pub fn tint(mut self, tint: Color) -> Self {
423        self.tint = Some(tint);
424        self
425    }
426
427    pub fn blur_radius(mut self, radius_dp: f32) -> Self {
428        self.blur_radius = Some(radius_dp);
429        self
430    }
431
432    pub fn saturation(mut self, saturation: f32) -> Self {
433        self.saturation = Some(saturation);
434        self
435    }
436
437    pub fn refraction_depth(mut self, fraction: f32) -> Self {
438        self.refraction_depth = fraction.clamp(0.0, 2.0);
439        self
440    }
441
442    pub fn refraction_curve(mut self, curve: f32) -> Self {
443        self.refraction_curve = curve.clamp(0.05, 1.0);
444        self
445    }
446
447    pub fn dispersion(mut self, strength: f32) -> Self {
448        self.dispersion = strength.clamp(0.0, 1.0);
449        self
450    }
451
452    pub fn transmission_refraction(mut self, strength: f32) -> Self {
453        self.transmission_refraction = strength.clamp(0.0, 1.0);
454        self
455    }
456
457    pub fn meniscus_absorption(mut self, strength: f32) -> Self {
458        self.meniscus_absorption = strength.clamp(0.0, 1.0);
459        self
460    }
461
462    /// Sets the rim fold band depth in dp (zero disables the fold).
463    pub fn fold_depth(mut self, depth_dp: f32) -> Self {
464        self.fold_depth = depth_dp.max(0.0);
465        self
466    }
467
468    /// Sets the uniform face magnification of a riding lens (1.0 = none).
469    pub fn optical_zoom(mut self, zoom: f32) -> Self {
470        self.optical_zoom = zoom.max(1.0);
471        self
472    }
473
474    /// Sets the meniscus rim reflectivity (1.0 = full reference line).
475    pub fn rim_reflection(mut self, reflectivity: f32) -> Self {
476        self.rim_reflection = reflectivity.clamp(0.0, 2.0);
477        self
478    }
479
480    /// The lens recolors dark transmitted ink toward `color` at `strength`
481    /// (0..1) — the reference bubble's color-mask act as a material optic.
482    pub fn ink_recolor(mut self, color: Color, strength: f32) -> Self {
483        self.ink_recolor = Some((color, strength.clamp(0.0, 1.0)));
484        self
485    }
486
487    pub fn highlight(mut self, highlight: f32) -> Self {
488        self.highlight = highlight;
489        self
490    }
491
492    /// Overrides the screen-lift (how hard the glass brightens what it
493    /// shows; the bar lens uses a near-zero lift to stay transmissive).
494    pub fn lift(mut self, lift: f32) -> Self {
495        self.lift = Some(lift);
496        self
497    }
498
499    /// Overrides the tone compression around the shader's mid pivot
500    /// (values below one pull every backdrop toward mid-luminance).
501    pub fn contrast(mut self, contrast: f32) -> Self {
502        self.contrast = Some(contrast.max(0.05));
503        self
504    }
505
506    pub fn adaptive_frost(mut self, foreground: Color, strength: f32) -> Self {
507        self.foreground = Some(foreground);
508        self.adaptive_frost = strength.clamp(0.0, 1.0);
509        self
510    }
511
512    pub fn shadow(mut self, shadow: bool) -> Self {
513        self.shadow = shadow;
514        self
515    }
516
517    pub fn shadow_style(mut self, shadow: GlassShadow) -> Self {
518        self.shadow_style = Some(shadow);
519        self
520    }
521
522    /// Disables the layer clip: the shader's SDF coverage is the only shape
523    /// (required while morphing across geometry the clip can't follow).
524    pub fn no_clip(mut self) -> Self {
525        self.clip = false;
526        self
527    }
528
529    fn default_blur_radius(&self) -> f32 {
530        match self.variant {
531            GlassVariant::Regular => 8.0,
532            GlassVariant::Clear => 3.0,
533            GlassVariant::Lens => 0.0,
534        }
535    }
536
537    fn default_saturation(&self) -> f32 {
538        match self.variant {
539            GlassVariant::Regular => 1.5,
540            GlassVariant::Clear => 1.25,
541            GlassVariant::Lens => 1.0,
542        }
543    }
544
545    /// Theme-resolved material constants captured at composition time.
546    pub(crate) fn resolve(&self, colors: &LiquidColors) -> ResolvedGlass {
547        // Screen-lift keeps the blurred backdrop's colors alive while reading
548        // bright (the reference material is far whiter than an alpha tint
549        // could get without going milky).
550        // The reference menu/bar glass shows blurred content smudges through
551        // its body — lift bright but never opaque; the lens lightens what it
552        // magnifies noticeably (the pressed toggle's green reads lifted).
553        let lift = self.lift.unwrap_or(match (self.variant, colors.is_dark) {
554            (GlassVariant::Regular, false) => 0.42,
555            (GlassVariant::Regular, true) => -0.38,
556            (GlassVariant::Clear, false) => 0.12,
557            (GlassVariant::Clear, true) => -0.12,
558            (GlassVariant::Lens, false) => 0.10,
559            (GlassVariant::Lens, true) => -0.08,
560        });
561        let foreground = self.foreground.unwrap_or(colors.label);
562        let shadow = self.shadow_style.unwrap_or_else(|| {
563            GlassShadow::new(
564                Color::BLACK.with_alpha(match (self.variant, colors.is_dark) {
565                    (GlassVariant::Lens, false) => 0.14,
566                    (GlassVariant::Lens, true) => 0.28,
567                    (_, false) => 0.16,
568                    (_, true) => 0.5,
569                }),
570                if self.variant == GlassVariant::Lens {
571                    10.0
572                } else {
573                    22.0
574                },
575                if self.variant == GlassVariant::Lens {
576                    3.0
577                } else {
578                    8.0
579                },
580                if self.variant == GlassVariant::Lens {
581                    -6.0
582                } else {
583                    -2.0
584                },
585            )
586        });
587        ResolvedGlass {
588            shape: self.shape,
589            tint: self.tint.unwrap_or(colors.glass_tint),
590            blur_radius_dp: self
591                .blur_radius
592                .unwrap_or_else(|| self.default_blur_radius()),
593            saturation: self.saturation.unwrap_or_else(|| self.default_saturation()),
594            refraction_depth: self.refraction_depth,
595            refraction_curve: self.refraction_curve,
596            dispersion: self.dispersion,
597            transmission_refraction: self.transmission_refraction,
598            meniscus_absorption: self.meniscus_absorption,
599            fold_depth: self.fold_depth,
600            optical_zoom: self.optical_zoom,
601            rim_reflection: self.rim_reflection,
602            ink_recolor: self.ink_recolor,
603            highlight: self.highlight,
604            lift,
605            contrast: self.contrast.unwrap_or(match self.variant {
606                GlassVariant::Lens => 1.0,
607                _ => 1.03,
608            }),
609            shadow: self.shadow,
610            clip: self.clip,
611            foreground_luma: 0.2126 * foreground.r()
612                + 0.7152 * foreground.g()
613                + 0.0722 * foreground.b(),
614            adaptive_frost: self.adaptive_frost,
615            rim_style: if self.variant == GlassVariant::Lens {
616                1.0
617            } else {
618                0.0
619            },
620            shadow_color: shadow.color,
621            shadow_radius: shadow.radius,
622            shadow_offset_y: shadow.offset_y,
623            shadow_spread: shadow.spread,
624        }
625    }
626}
627
628impl Default for Glass {
629    fn default() -> Self {
630        Self::regular()
631    }
632}
633
634/// A theme-resolved glass material; density and dynamics are applied per
635/// frame in the lazy graphics-layer resolver.
636#[derive(Clone, Debug, PartialEq)]
637pub(crate) struct ResolvedGlass {
638    pub shape: LiquidShape,
639    pub tint: Color,
640    pub blur_radius_dp: f32,
641    pub saturation: f32,
642    pub refraction_depth: f32,
643    pub refraction_curve: f32,
644    pub dispersion: f32,
645    pub transmission_refraction: f32,
646    pub meniscus_absorption: f32,
647    pub fold_depth: f32,
648    pub optical_zoom: f32,
649    pub rim_reflection: f32,
650    pub ink_recolor: Option<(Color, f32)>,
651    pub highlight: f32,
652    pub lift: f32,
653    pub contrast: f32,
654    pub shadow: bool,
655    pub clip: bool,
656    /// 0 = surface glass (soft white spec rim); 1 = interactive lens (thin
657    /// bright line + stronger dark outline, chroma does the color).
658    pub rim_style: f32,
659    pub foreground_luma: f32,
660    pub adaptive_frost: f32,
661    pub shadow_color: Color,
662    /// Variant-scaled drop shadow geometry: the lens bubble carries a tight
663    /// contact hint, large surfaces a soft wide ambient.
664    pub shadow_radius: f32,
665    pub shadow_offset_y: f32,
666    pub shadow_spread: f32,
667}
668
669impl ResolvedGlass {
670    /// Builds the wcKSRD shader for the current density and
671    /// per-frame dynamics. Cover mode keeps geometry in pixels with the
672    /// container uniform zeroed; the shader owns all backdrop samples.
673    pub(crate) fn backdrop_effect(&self, density: f32, dynamics: GlassDynamics) -> RenderEffect {
674        self.runtime_effect(density, dynamics, false)
675    }
676
677    fn content_mask_effect(&self, density: f32, dynamics: GlassDynamics) -> RenderEffect {
678        self.runtime_effect(density, dynamics, true)
679    }
680
681    fn runtime_effect(
682        &self,
683        density: f32,
684        dynamics: GlassDynamics,
685        content_mask: bool,
686    ) -> RenderEffect {
687        let density = density.max(f32::EPSILON);
688        let activity = dynamics
689            .activity
690            .filter(|value| value.is_finite())
691            .unwrap_or(1.0)
692            .clamp(0.0, 1.0);
693        let mut shader = RuntimeShader::new(LIQUID_GLASS_WGSL);
694        if let Some(morph) = dynamics.morph.as_ref() {
695            // Morph glass: the container carries the node size in dp and ALL
696            // geometry is dp — the shader divides the renderer-injected node
697            // pixel rect by the container, so the field lands correctly at
698            // any render scale (density-scaled packing broke every capture
699            // whose render scale differed from the platform density).
700            let (node_w, node_h) = morph.node_size;
701            let (cx, cy, w, h, radius) = morph.primary;
702            shader.set_float2(0, node_w.max(1.0), node_h.max(1.0));
703            shader.set_float2(2, cx, cy);
704            shader.set_float2(4, w, h);
705            shader.set_float(6, radius);
706            let count = morph.shapes.len().min(GlassMorph::MAX_SHAPES);
707            shader.set_float(30, count as f32);
708            for (index, (sx, sy, sw, sh, sr)) in morph.shapes.iter().take(count).enumerate() {
709                let base = 36 + index * 5;
710                shader.set_float(base, *sx);
711                shader.set_float(base + 1, *sy);
712                shader.set_float(base + 2, *sw);
713                shader.set_float(base + 3, *sh);
714                shader.set_float(base + 4, *sr);
715            }
716            shader.set_float(31, morph.glue);
717            shader.set_float(32, morph.wobble_amplitude * activity);
718            shader.set_float(33, morph.wobble_phase);
719            shader.set_float(26, morph.bulge_amplitude * activity);
720            shader.set_float(27, morph.bulge_direction);
721            shader.set_float(110, morph.ellipse_blend.clamp(0.0, 1.0) * activity);
722            if let Some(deformation) = morph.deformation {
723                let axis = deformation.axis();
724                let along = 1.0 + (deformation.along() - 1.0) * activity;
725                shader.set_float2(106, axis.0, axis.1);
726                shader.set_float(108, along);
727                shader.set_float(109, 1.0 / along);
728            } else {
729                shader.set_float2(106, 1.0, 0.0);
730                shader.set_float2(108, 1.0, 1.0);
731            }
732            // Bezel in dp: the shader scales by px-per-dp.
733        } else {
734            // Cover mode marker: container size stays zero. Geometry is px at
735            // the platform density (node size only known at render time).
736            shader.set_float2(0, 0.0, 0.0);
737            shader.set_float(6, self.shape.shader_radius_px(density));
738        }
739        let press_depth = dynamics.press_depth.unwrap_or(1.0).clamp(0.0, 1.0);
740        shader.set_float(9, self.refraction_depth * activity * press_depth);
741        shader.set_float(
742            GLASS_REFRACTION_CURVE_UNIFORM,
743            self.refraction_curve * activity,
744        );
745        shader.set_float(
746            GLASS_DISPERSION_UNIFORM,
747            self.dispersion * activity * press_depth,
748        );
749        shader.set_float(
750            GLASS_TRANSMISSION_REFRACTION_UNIFORM,
751            self.transmission_refraction * activity,
752        );
753        shader.set_float(GLASS_MENISCUS_ABSORPTION_UNIFORM, self.meniscus_absorption);
754        // Always write optional channels — a conditional write leaves stale
755        // values behind if a shader instance is ever pooled or reused, and
756        // that leak class renders one surface with another's optics.
757        shader.set_float(GLASS_FOLD_DEPTH_UNIFORM, self.fold_depth.max(0.0));
758        shader.set_float(
759            GLASS_OPTICAL_ZOOM_UNIFORM,
760            1.0 + (self.optical_zoom - 1.0).max(0.0) * activity,
761        );
762        let zoom_anchor = dynamics
763            .morph
764            .as_ref()
765            .map(|morph| morph.zoom_anchor)
766            .unwrap_or((0.0, 0.0));
767        shader.set_float2(
768            GLASS_OPTICAL_ZOOM_ANCHOR_UNIFORM,
769            zoom_anchor.0,
770            zoom_anchor.1,
771        );
772        shader.set_float(121, self.rim_reflection.max(0.001));
773        let (ink_color, ink_strength) = self
774            .ink_recolor
775            .map(|(color, strength)| (color, strength * activity))
776            .unwrap_or((Color::TRANSPARENT, 0.0));
777        shader.set_float(124, ink_color.r());
778        shader.set_float(125, ink_color.g());
779        shader.set_float(126, ink_color.b());
780        shader.set_float(127, ink_strength);
781        let (light_x, light_y) = glass_light_direction();
782        shader.set_float(GLASS_LIGHT_DIRECTION_UNIFORM, light_x);
783        shader.set_float(GLASS_LIGHT_DIRECTION_UNIFORM + 1, light_y);
784        let (touch_x, touch_y, touch_intensity) = dynamics.touch.unwrap_or((0.0, 0.0, 0.0));
785        shader.set_float(118, touch_x);
786        shader.set_float(119, touch_y);
787        shader.set_float(120, touch_intensity.clamp(0.0, 1.0));
788        shader.set_float(GLASS_EFFECT_DENSITY_UNIFORM, density);
789        shader.set_float(
790            11,
791            (self.highlight + dynamics.highlight_boost).clamp(0.0, 2.0) * activity,
792        );
793        let dynamic_tint = boost_tint_saturation(self.tint, dynamics.saturation_boost);
794        let dynamic_tint_alpha = (dynamic_tint.a()
795            * dynamics
796                .tint_alpha_multiplier
797                .unwrap_or(1.0)
798                .clamp(0.0, 2.0)
799            * activity)
800            .clamp(0.0, 1.0);
801        shader.set_float4(
802            14,
803            dynamic_tint.r(),
804            dynamic_tint.g(),
805            dynamic_tint.b(),
806            dynamic_tint_alpha,
807        );
808        let saturation = (self.saturation + dynamics.saturation_boost).max(0.0);
809        shader.set_float(18, 1.0 + (saturation - 1.0) * activity);
810        shader.set_float(20, self.lift * activity);
811        shader.set_float(21, 0.5 * activity);
812        shader.set_float2(22, 0.0, 1.0);
813        shader.set_float(24, 1.0 + (self.contrast - 1.0) * activity);
814        shader.set_float(28, self.rim_style * activity);
815        let requested_blur_radius_px = if content_mask {
816            0.0
817        } else {
818            self.blur_radius_dp * density * activity
819        };
820        let wcksrd_blur_radius = requested_blur_radius_px.min(WCKSRD_OPTICAL_BLUR_RADIUS_PX);
821        let gaussian_blur_radius = (requested_blur_radius_px - wcksrd_blur_radius).max(0.0);
822        shader.set_float(GLASS_BLUR_RADIUS_UNIFORM, wcksrd_blur_radius);
823        shader.set_float(GLASS_ACTIVITY_UNIFORM, activity);
824        let resting_tint = dynamics.resting_tint.unwrap_or(Color::TRANSPARENT);
825        shader.set_float4(
826            GLASS_RESTING_TINT_UNIFORM,
827            resting_tint.r(),
828            resting_tint.g(),
829            resting_tint.b(),
830            resting_tint.a(),
831        );
832        shader.set_float(112, if content_mask { 1.0 } else { 0.0 });
833        shader.set_float(91, self.adaptive_frost * activity);
834        shader.set_float(97, self.foreground_luma);
835        let dynamic_shadow = !self.clip && self.shadow;
836        shader.set_float(
837            102,
838            if dynamic_shadow {
839                self.shadow_color.a() * 0.55 * activity
840            } else {
841                0.0
842            },
843        );
844        shader.set_float(103, self.shadow_radius);
845        shader.set_float(104, self.shadow_offset_y);
846        shader.set_float(105, self.shadow_spread);
847        // Morph padding: wobble reach plus how far any scene shape (plus its
848        // glue neck) extends beyond the primary rect — the capture and the
849        // composite surface must cover the whole glued field.
850        let morph_pad = dynamics
851            .morph
852            .as_ref()
853            .map(|morph| {
854                let (px, py, pw, ph, _) = morph.primary;
855                let (left, top) = (px - pw * 0.5, py - ph * 0.5);
856                let (right, bottom) = (px + pw * 0.5, py + ph * 0.5);
857                let mut shape_reach = 0.0f32;
858                for (sx, sy, sw, sh, _) in &morph.shapes {
859                    let reach_x = ((sx + sw * 0.5) - right)
860                        .max(left - (sx - sw * 0.5))
861                        .max(0.0);
862                    let reach_y = ((sy + sh * 0.5) - bottom)
863                        .max(top - (sy - sh * 0.5))
864                        .max(0.0);
865                    shape_reach = shape_reach.max(reach_x.max(reach_y));
866                }
867                let glue_pad = if morph.shapes.is_empty() {
868                    0.0
869                } else {
870                    morph.glue * 2.0
871                };
872                morph.wobble_amplitude * 2.0 + morph.bulge_amplitude + shape_reach + glue_pad
873            })
874            .unwrap_or(0.0);
875        // Paddings are consumed in LOGICAL units by the backdrop capture and
876        // output rects — dp, never density-scaled.
877        shader.set_input_padding(self.input_padding() + morph_pad + wcksrd_blur_radius / density);
878        // Morphing glass WRITES outside the node rect (wobble, bulge, glued
879        // neighbors, plus the ~2px antialiased rim); declare it so the
880        // composite scissor doesn't clip the field at the node edge.
881        if dynamics.morph.is_some() {
882            let shadow_reach = if dynamic_shadow {
883                self.shadow_radius + self.shadow_offset_y.abs() + self.shadow_spread.max(0.0)
884            } else {
885                0.0
886            };
887            shader.set_output_padding(morph_pad + shadow_reach + 4.0);
888        }
889
890        let optical_effect = RenderEffect::runtime_shader(shader);
891        if gaussian_blur_radius > f32::EPSILON {
892            // Mirror at the capture boundary: a backdrop capture is clipped
893            // at the surface's own edge (a top nav band's capture cannot
894            // extend above the page), and clamp-to-edge there stretches a
895            // single jittering content row across half the kernel — the
896            // band's top pixels pulse ~12 gray levels per scroll step
897            // (measured live). Mirroring keeps the edge statistics stable.
898            RenderEffect::blur_with_edge_treatment(gaussian_blur_radius, TileMode::Mirror)
899                .then(optical_effect)
900        } else {
901            optical_effect
902        }
903    }
904
905    /// Backdrop capture padding (px) covering the largest refracted sample
906    /// (see `liquid_glass_input_padding` for the explicit-rect twin). Padded
907    /// for tilt up to ±1 per axis so per-frame tilt never outruns the capture.
908    fn input_padding(&self) -> f32 {
909        // wcKSRD maps every refracted coordinate toward the center of the
910        // already-captured backdrop. Only its minimum 9x9 sample footprint
911        // extends beyond that segment; blur and morph reach are added by the
912        // caller from their actual runtime values.
913        2.0
914    }
915}
916
917/// Modifier extension installing the Liquid Glass material.
918pub trait LiquidModifierExt {
919    /// Applies the glass material to this composable's bounds: backdrop blur +
920    /// lens shader, clipped to `glass.shape`, with a soft drop shadow.
921    ///
922    /// Must be called in composable context (the material resolves theme
923    /// colors at the call site).
924    fn glass_effect(self, glass: Glass) -> Modifier;
925
926    /// [`glass_effect`](Self::glass_effect) with per-frame motion inputs; the
927    /// closure is read at scene-build time, so animating tilt or highlight
928    /// does not recompose.
929    fn glass_effect_with(
930        self,
931        glass: Glass,
932        dynamics: impl Fn() -> GlassDynamics + 'static,
933    ) -> Modifier;
934}
935
936impl LiquidModifierExt for Modifier {
937    fn glass_effect(self, glass: Glass) -> Modifier {
938        self.glass_effect_with(glass, GlassDynamics::default)
939    }
940
941    fn glass_effect_with(
942        self,
943        glass: Glass,
944        dynamics: impl Fn() -> GlassDynamics + 'static,
945    ) -> Modifier {
946        let colors = crate::theme::liquid_colors();
947        let resolved = Rc::new(glass.resolve(&colors));
948        let shape = resolved.shape;
949
950        let mut modifier = self;
951        if resolved.shadow && resolved.clip {
952            let shadow_color = resolved.shadow_color;
953            let (radius, offset_y, spread) = (
954                resolved.shadow_radius,
955                resolved.shadow_offset_y,
956                resolved.shadow_spread,
957            );
958            modifier = modifier.drop_shadow(shape.layer_shape(), move |scope| {
959                scope.radius = radius;
960                scope.spread = spread;
961                scope.offset.y = offset_y;
962                scope.color = shadow_color;
963                // Glass samples the backdrop behind itself — knock the shape
964                // out of its own shadow so the material stays bright.
965                scope.cutout = true;
966            });
967        }
968
969        let layer_resolved = Rc::clone(&resolved);
970        let clip = resolved.clip;
971        modifier.graphics_layer(move || {
972            let density = current_density();
973            let frame = dynamics();
974            let render_effect = (!clip && frame.morph.is_some())
975                .then(|| layer_resolved.content_mask_effect(density, frame.clone()));
976            GraphicsLayer {
977                backdrop_effect: Some(layer_resolved.backdrop_effect(density, frame)),
978                render_effect,
979                shape: shape.layer_shape(),
980                clip,
981                ..Default::default()
982            }
983        })
984    }
985}
986
987#[cfg(test)]
988mod tests {
989    use super::*;
990
991    fn light_colors() -> LiquidColors {
992        LiquidColors::light(Color::from_rgb_u8(0, 122, 255))
993    }
994
995    fn terminal_shader(effect: RenderEffect) -> RuntimeShader {
996        match effect {
997            RenderEffect::Shader { shader } => shader,
998            RenderEffect::Chain { second, .. } => terminal_shader(*second),
999            effect => panic!("expected runtime shader, got {effect:?}"),
1000        }
1001    }
1002
1003    #[test]
1004    fn glass_light_direction_defaults_overhead_and_reaches_the_shader() {
1005        assert_eq!(glass_light_direction(), (0.0, 1.0));
1006        let resolved = Glass::regular().resolve(&light_colors());
1007        let effect = resolved.backdrop_effect(2.0, GlassDynamics::default());
1008        let shader = terminal_shader(effect);
1009        let u = shader.uniforms();
1010        assert_eq!(u[GLASS_LIGHT_DIRECTION_UNIFORM], 0.0);
1011        assert_eq!(u[GLASS_LIGHT_DIRECTION_UNIFORM + 1], 1.0);
1012
1013        // Rotating the environment (device attitude) re-lights the next
1014        // resolved effect.
1015        set_glass_light_direction((1.0, 0.0));
1016        let effect = resolved.backdrop_effect(2.0, GlassDynamics::default());
1017        let u_rotated = terminal_shader(effect);
1018        let u_rotated = u_rotated.uniforms();
1019        assert_eq!(u_rotated[GLASS_LIGHT_DIRECTION_UNIFORM], 1.0);
1020        assert_eq!(u_rotated[GLASS_LIGHT_DIRECTION_UNIFORM + 1], 0.0);
1021        set_glass_light_direction((0.0, 1.0));
1022    }
1023
1024    #[test]
1025    fn liquid_shape_builds_matching_clip_and_layer_shapes() {
1026        for shape in [
1027            LiquidShape::Capsule,
1028            LiquidShape::Circle,
1029            LiquidShape::RoundedRect(12.0),
1030        ] {
1031            assert_eq!(shape.layer_shape(), LayerShape::Rounded(shape.clip_shape()));
1032        }
1033    }
1034
1035    #[test]
1036    fn glass_shadow_clamps_negative_radius() {
1037        let shadow = GlassShadow::new(Color::BLACK, -2.0, 3.0, -1.0);
1038        assert_eq!(shadow.radius, 0.0);
1039        assert_eq!(shadow.offset_y, 3.0);
1040        assert_eq!(shadow.spread, -1.0);
1041    }
1042
1043    #[test]
1044    fn incompressible_deformation_normalizes_axis_and_conserves_area() {
1045        let deformation = GlassDeformation::incompressible((3.0, 4.0), 1.25);
1046        assert_eq!(deformation.axis(), (0.6, 0.8));
1047        assert_eq!(deformation.along(), 1.25);
1048        assert!((deformation.along() * deformation.across() - 1.0).abs() < 1.0e-6);
1049        assert_eq!(
1050            GlassDeformation::incompressible((0.0, 0.0), 0.0).axis(),
1051            (1.0, 0.0)
1052        );
1053    }
1054
1055    #[test]
1056    fn glass_builders_clamp_physical_inputs() {
1057        let glass = Glass::lens()
1058            .shape(LiquidShape::Circle)
1059            .tint(Color::BLACK)
1060            .blur_radius(-2.0)
1061            .saturation(1.2)
1062            .refraction_depth(3.0)
1063            .refraction_curve(2.0)
1064            .dispersion(2.0)
1065            .transmission_refraction(2.0)
1066            .meniscus_absorption(2.0)
1067            .highlight(0.4)
1068            .lift(-0.2)
1069            .adaptive_frost(Color::WHITE, 2.0)
1070            .shadow(false)
1071            .no_clip();
1072        assert_eq!(glass.shape, LiquidShape::Circle);
1073        assert_eq!(glass.tint, Some(Color::BLACK));
1074        assert_eq!(glass.blur_radius, Some(-2.0));
1075        assert_eq!(glass.saturation, Some(1.2));
1076        assert_eq!(glass.refraction_depth, 2.0);
1077        assert_eq!(glass.refraction_curve, 1.0);
1078        assert_eq!(glass.dispersion, 1.0);
1079        assert_eq!(glass.transmission_refraction, 1.0);
1080        assert_eq!(glass.meniscus_absorption, 1.0);
1081        assert_eq!(glass.highlight, 0.4);
1082        assert_eq!(glass.lift, Some(-0.2));
1083        assert_eq!(glass.adaptive_frost, 1.0);
1084        assert!(!glass.shadow);
1085        assert!(!glass.clip);
1086    }
1087
1088    #[test]
1089    fn material_variants_resolve_distinct_frost_levels() {
1090        let regular = Glass::regular().resolve(&light_colors());
1091        let clear = Glass::clear().resolve(&light_colors());
1092        let lens = Glass::lens().resolve(&light_colors());
1093        assert!(regular.blur_radius_dp > clear.blur_radius_dp);
1094        assert!(clear.blur_radius_dp > lens.blur_radius_dp);
1095        assert!(regular.saturation > clear.saturation);
1096        assert_eq!(lens.rim_style, 1.0);
1097        assert_eq!(regular.refraction_curve, 0.25);
1098        assert_eq!(lens.refraction_curve, 1.0);
1099        assert_eq!(regular.dispersion, 0.0);
1100        assert_eq!(lens.dispersion, 0.30);
1101    }
1102
1103    #[test]
1104    fn neutral_surface_helpers_follow_foreground_polarity() {
1105        assert_eq!(
1106            neutral_surface_tint(Color::BLACK, 0.08, 0.10),
1107            Color::BLACK.with_alpha(0.08)
1108        );
1109        assert_eq!(
1110            neutral_surface_tint(Color::WHITE, 0.08, 0.10),
1111            Color::WHITE.with_alpha(0.10)
1112        );
1113        assert_eq!(neutral_surface_lift(Color::BLACK, 0.7, -0.3), 0.7);
1114        assert_eq!(neutral_surface_lift(Color::WHITE, 0.7, -0.3), -0.3);
1115    }
1116
1117    #[test]
1118    fn dynamic_saturation_reaches_the_material_tint() {
1119        let resting = Color::from_rgb_u8(0, 199, 208);
1120        let raised = boost_tint_saturation(resting, 0.55);
1121        assert_eq!(raised.r(), 0.0);
1122        assert!(raised.g() > resting.g());
1123        assert!(raised.b() > resting.b());
1124        assert_eq!(raised.a(), resting.a());
1125    }
1126
1127    #[test]
1128    fn resolved_material_packs_wcksrd_and_dynamic_tint() {
1129        let resolved = Glass::lens()
1130            .refraction_depth(0.72)
1131            .refraction_curve(0.8)
1132            .dispersion(0.42)
1133            .transmission_refraction(0.35)
1134            .meniscus_absorption(0.3)
1135            .blur_radius(3.0)
1136            .tint(Color::BLACK.with_alpha(0.8))
1137            .resolve(&light_colors());
1138        let effect = resolved.backdrop_effect(
1139            2.0,
1140            GlassDynamics {
1141                highlight_boost: 0.2,
1142                saturation_boost: 0.35,
1143                tint_alpha_multiplier: Some(0.25),
1144                ..Default::default()
1145            },
1146        );
1147        let RenderEffect::Chain { first, .. } = &effect else {
1148            panic!("macroscopic frost must precede the wcKSRD optical pass");
1149        };
1150        assert!(matches!(
1151            first.as_ref(),
1152            RenderEffect::Blur {
1153                radius_x: 4.0,
1154                radius_y: 4.0,
1155                ..
1156            }
1157        ));
1158        let shader = terminal_shader(effect);
1159        assert_eq!(shader.uniforms()[9], 0.72);
1160        assert_eq!(shader.uniforms()[GLASS_REFRACTION_CURVE_UNIFORM], 0.8);
1161        assert_eq!(shader.uniforms()[GLASS_DISPERSION_UNIFORM], 0.42);
1162        assert_eq!(
1163            shader.uniforms()[GLASS_TRANSMISSION_REFRACTION_UNIFORM],
1164            0.35
1165        );
1166        assert_eq!(shader.uniforms()[GLASS_MENISCUS_ABSORPTION_UNIFORM], 0.3);
1167        assert_eq!(shader.uniforms()[11], resolved.highlight + 0.2);
1168        assert_eq!(shader.uniforms()[18], resolved.saturation + 0.35);
1169        assert!((shader.uniforms()[17] - 0.2).abs() < 1.0e-6);
1170        assert_eq!(
1171            shader.uniforms()[GLASS_BLUR_RADIUS_UNIFORM],
1172            WCKSRD_OPTICAL_BLUR_RADIUS_PX
1173        );
1174        assert_eq!(shader.uniforms()[GLASS_EFFECT_DENSITY_UNIFORM], 2.0);
1175    }
1176
1177    #[test]
1178    fn raised_tint_density_stays_premultiplied_alpha_safe() {
1179        let effect = Glass::lens()
1180            .tint(Color::WHITE.with_alpha(0.8))
1181            .resolve(&light_colors())
1182            .backdrop_effect(
1183                1.0,
1184                GlassDynamics {
1185                    tint_alpha_multiplier: Some(2.0),
1186                    ..Default::default()
1187                },
1188            );
1189        assert_eq!(terminal_shader(effect).uniforms()[17], 1.0);
1190    }
1191
1192    #[test]
1193    fn optical_activity_reaches_identity_without_removing_the_glass_geometry() {
1194        let resolved = Glass::lens()
1195            .refraction_depth(0.72)
1196            .refraction_curve(0.8)
1197            .dispersion(0.42)
1198            .blur_radius(3.0)
1199            .saturation(1.4)
1200            .highlight(0.6)
1201            .lift(0.2)
1202            .tint(Color::BLACK.with_alpha(0.8))
1203            .no_clip()
1204            .resolve(&light_colors());
1205        let morph = GlassMorph {
1206            node_size: (120.0, 72.0),
1207            primary: (60.0, 36.0, 96.0, 52.0, -1.0),
1208            wobble_amplitude: 3.0,
1209            bulge_amplitude: 4.0,
1210            deformation: Some(GlassDeformation::incompressible((1.0, 0.0), 1.25)),
1211            ..Default::default()
1212        };
1213
1214        let RenderEffect::Shader { shader } = resolved.backdrop_effect(
1215            2.0,
1216            GlassDynamics {
1217                activity: Some(0.0),
1218                morph: Some(morph),
1219                ..Default::default()
1220            },
1221        ) else {
1222            panic!("material must resolve to the shared wcKSRD shader");
1223        };
1224        let uniforms = shader.uniforms();
1225        assert_eq!(uniforms[GLASS_ACTIVITY_UNIFORM], 0.0);
1226        assert_eq!(uniforms[9], 0.0);
1227        assert_eq!(uniforms[GLASS_REFRACTION_CURVE_UNIFORM], 0.0);
1228        assert_eq!(uniforms[GLASS_DISPERSION_UNIFORM], 0.0);
1229        assert_eq!(uniforms[GLASS_TRANSMISSION_REFRACTION_UNIFORM], 0.0);
1230        assert_eq!(uniforms[11], 0.0);
1231        assert_eq!(uniforms[17], 0.0);
1232        assert_eq!(uniforms[18], 1.0);
1233        assert_eq!(uniforms[20], 0.0);
1234        assert_eq!(uniforms[21], 0.0);
1235        assert_eq!(uniforms[24], 1.0);
1236        assert_eq!(uniforms[28], 0.0);
1237        assert_eq!(uniforms[GLASS_BLUR_RADIUS_UNIFORM], 0.0);
1238        assert_eq!(uniforms[91], 0.0);
1239        assert_eq!(uniforms[102], 0.0);
1240        assert_eq!(
1241            &uniforms[GLASS_RESTING_TINT_UNIFORM..GLASS_RESTING_TINT_UNIFORM + 4],
1242            &[0.0, 0.0, 0.0, 0.0]
1243        );
1244        assert_eq!(uniforms[32], 0.0);
1245        assert_eq!(uniforms[26], 0.0);
1246        assert_eq!(&uniforms[108..110], &[1.0, 1.0]);
1247        assert_eq!(&uniforms[2..6], &[60.0, 36.0, 96.0, 52.0]);
1248    }
1249
1250    #[test]
1251    fn resting_surface_tint_survives_zero_optical_activity() {
1252        let tint = Color::BLACK.with_alpha(0.11);
1253        let RenderEffect::Shader { shader } = Glass::lens()
1254            .no_clip()
1255            .resolve(&light_colors())
1256            .backdrop_effect(
1257                1.0,
1258                GlassDynamics {
1259                    activity: Some(0.0),
1260                    resting_tint: Some(tint),
1261                    ..Default::default()
1262                },
1263            )
1264        else {
1265            panic!("resting surface must use the shared wcKSRD shader");
1266        };
1267        assert_eq!(
1268            &shader.uniforms()[GLASS_RESTING_TINT_UNIFORM..GLASS_RESTING_TINT_UNIFORM + 4],
1269            &[tint.r(), tint.g(), tint.b(), tint.a()]
1270        );
1271        assert_eq!(shader.uniforms()[GLASS_ACTIVITY_UNIFORM], 0.0);
1272    }
1273
1274    #[test]
1275    fn full_optical_activity_preserves_the_resolved_material() {
1276        let resolved = Glass::lens()
1277            .refraction_depth(0.72)
1278            .refraction_curve(0.8)
1279            .dispersion(0.42)
1280            .blur_radius(3.0)
1281            .resolve(&light_colors());
1282        let shader = terminal_shader(resolved.backdrop_effect(
1283            2.0,
1284            GlassDynamics {
1285                activity: Some(1.0),
1286                ..Default::default()
1287            },
1288        ));
1289        let uniforms = shader.uniforms();
1290        assert_eq!(uniforms[GLASS_ACTIVITY_UNIFORM], 1.0);
1291        assert_eq!(uniforms[9], 0.72);
1292        assert_eq!(uniforms[GLASS_REFRACTION_CURVE_UNIFORM], 0.8);
1293        assert_eq!(uniforms[GLASS_DISPERSION_UNIFORM], 0.42);
1294        assert_eq!(uniforms[GLASS_TRANSMISSION_REFRACTION_UNIFORM], 1.0);
1295        assert_eq!(
1296            uniforms[GLASS_BLUR_RADIUS_UNIFORM],
1297            WCKSRD_OPTICAL_BLUR_RADIUS_PX
1298        );
1299    }
1300
1301    #[test]
1302    fn morph_geometry_and_incompressible_strain_are_packed() {
1303        let deformation = GlassDeformation::incompressible((0.0, 2.0), 1.25);
1304        let morph = GlassMorph {
1305            node_size: (78.0, 59.0),
1306            primary: (39.0, 29.5, 58.0, 39.0, -1.0),
1307            shapes: vec![(70.0, 29.5, 40.0, 40.0, -1.0)],
1308            glue: 8.0,
1309            wobble_amplitude: 1.0,
1310            wobble_phase: 0.5,
1311            bulge_amplitude: 2.0,
1312            bulge_direction: 0.25,
1313            ellipse_blend: 0.3,
1314            deformation: Some(deformation),
1315            zoom_anchor: (0.0, 0.0),
1316        };
1317        let RenderEffect::Shader { shader } = Glass::lens()
1318            .no_clip()
1319            .resolve(&light_colors())
1320            .backdrop_effect(
1321                1.0,
1322                GlassDynamics {
1323                    morph: Some(morph),
1324                    ..Default::default()
1325                },
1326            )
1327        else {
1328            panic!("morph must use the shared wcKSRD shader");
1329        };
1330        let uniforms = shader.uniforms();
1331        assert_eq!(&uniforms[0..6], &[78.0, 59.0, 39.0, 29.5, 58.0, 39.0]);
1332        assert_eq!(uniforms[30], 1.0);
1333        assert_eq!(&uniforms[106..110], &[0.0, 1.0, 1.25, 0.8]);
1334        assert_eq!(uniforms[110], 0.3);
1335        assert!(shader.output_padding() > 0.0);
1336    }
1337}