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