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