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    /// Drop shadow below the glass.
321    pub shadow: bool,
322    /// Per-surface shadow override.
323    pub shadow_style: Option<GlassShadow>,
324    /// Clip the layer to `shape`. Morphing glass disables this — coverage
325    /// comes entirely from the shader's SDF.
326    pub clip: bool,
327    /// Foreground color whose contrast the frost must protect. `None` uses
328    /// the theme label color.
329    pub foreground: Option<Color>,
330    /// Strength of backdrop+foreground frost adaptation.
331    pub adaptive_frost: f32,
332}
333
334impl Glass {
335    pub fn regular() -> Self {
336        Self {
337            variant: GlassVariant::Regular,
338            shape: LiquidShape::Capsule,
339            tint: None,
340            blur_radius: None,
341            saturation: None,
342            refraction_depth: 0.34,
343            refraction_curve: 0.25,
344            dispersion: 0.0,
345            transmission_refraction: 1.0,
346            meniscus_absorption: 1.0,
347            fold_depth: 0.0,
348            optical_zoom: 1.0,
349            rim_reflection: 1.0,
350            ink_recolor: None,
351            highlight: 0.9,
352            lift: None,
353            shadow: true,
354            shadow_style: None,
355            clip: true,
356            foreground: None,
357            adaptive_frost: 0.65,
358        }
359    }
360
361    pub fn clear() -> Self {
362        Self {
363            variant: GlassVariant::Clear,
364            ..Self::regular()
365        }
366    }
367
368    /// The interactive lens bubble (reference: the iOS toggle/tab-bar drag
369    /// lens): fully transparent, magnifying dome across the whole element,
370    /// strong rainbow rim.
371    pub fn lens() -> Self {
372        Self {
373            variant: GlassVariant::Lens,
374            shape: LiquidShape::Capsule,
375            tint: Some(Color::rgba(1.0, 1.0, 1.0, 0.07)),
376            blur_radius: None,
377            saturation: None,
378            refraction_depth: 0.34,
379            refraction_curve: 1.0,
380            dispersion: 0.30,
381            transmission_refraction: 1.0,
382            meniscus_absorption: 1.0,
383            fold_depth: 0.0,
384            optical_zoom: 1.0,
385            rim_reflection: 1.0,
386            ink_recolor: None,
387            highlight: 1.15,
388            lift: None,
389            shadow: true,
390            shadow_style: None,
391            clip: true,
392            foreground: None,
393            adaptive_frost: 0.0,
394        }
395    }
396
397    pub fn shape(mut self, shape: LiquidShape) -> Self {
398        self.shape = shape;
399        self
400    }
401
402    pub fn tint(mut self, tint: Color) -> Self {
403        self.tint = Some(tint);
404        self
405    }
406
407    pub fn blur_radius(mut self, radius_dp: f32) -> Self {
408        self.blur_radius = Some(radius_dp);
409        self
410    }
411
412    pub fn saturation(mut self, saturation: f32) -> Self {
413        self.saturation = Some(saturation);
414        self
415    }
416
417    pub fn refraction_depth(mut self, fraction: f32) -> Self {
418        self.refraction_depth = fraction.clamp(0.0, 2.0);
419        self
420    }
421
422    pub fn refraction_curve(mut self, curve: f32) -> Self {
423        self.refraction_curve = curve.clamp(0.05, 1.0);
424        self
425    }
426
427    pub fn dispersion(mut self, strength: f32) -> Self {
428        self.dispersion = strength.clamp(0.0, 1.0);
429        self
430    }
431
432    pub fn transmission_refraction(mut self, strength: f32) -> Self {
433        self.transmission_refraction = strength.clamp(0.0, 1.0);
434        self
435    }
436
437    pub fn meniscus_absorption(mut self, strength: f32) -> Self {
438        self.meniscus_absorption = strength.clamp(0.0, 1.0);
439        self
440    }
441
442    /// Sets the rim fold band depth in dp (zero disables the fold).
443    pub fn fold_depth(mut self, depth_dp: f32) -> Self {
444        self.fold_depth = depth_dp.max(0.0);
445        self
446    }
447
448    /// Sets the uniform face magnification of a riding lens (1.0 = none).
449    pub fn optical_zoom(mut self, zoom: f32) -> Self {
450        self.optical_zoom = zoom.max(1.0);
451        self
452    }
453
454    /// Sets the meniscus rim reflectivity (1.0 = full reference line).
455    pub fn rim_reflection(mut self, reflectivity: f32) -> Self {
456        self.rim_reflection = reflectivity.clamp(0.0, 2.0);
457        self
458    }
459
460    /// The lens recolors dark transmitted ink toward `color` at `strength`
461    /// (0..1) — the reference bubble's color-mask act as a material optic.
462    pub fn ink_recolor(mut self, color: Color, strength: f32) -> Self {
463        self.ink_recolor = Some((color, strength.clamp(0.0, 1.0)));
464        self
465    }
466
467    pub fn highlight(mut self, highlight: f32) -> Self {
468        self.highlight = highlight;
469        self
470    }
471
472    /// Overrides the screen-lift (how hard the glass brightens what it
473    /// shows; the bar lens uses a near-zero lift to stay transmissive).
474    pub fn lift(mut self, lift: f32) -> Self {
475        self.lift = Some(lift);
476        self
477    }
478
479    pub fn adaptive_frost(mut self, foreground: Color, strength: f32) -> Self {
480        self.foreground = Some(foreground);
481        self.adaptive_frost = strength.clamp(0.0, 1.0);
482        self
483    }
484
485    pub fn shadow(mut self, shadow: bool) -> Self {
486        self.shadow = shadow;
487        self
488    }
489
490    pub fn shadow_style(mut self, shadow: GlassShadow) -> Self {
491        self.shadow_style = Some(shadow);
492        self
493    }
494
495    /// Disables the layer clip: the shader's SDF coverage is the only shape
496    /// (required while morphing across geometry the clip can't follow).
497    pub fn no_clip(mut self) -> Self {
498        self.clip = false;
499        self
500    }
501
502    fn default_blur_radius(&self) -> f32 {
503        match self.variant {
504            GlassVariant::Regular => 8.0,
505            GlassVariant::Clear => 3.0,
506            GlassVariant::Lens => 0.0,
507        }
508    }
509
510    fn default_saturation(&self) -> f32 {
511        match self.variant {
512            GlassVariant::Regular => 1.5,
513            GlassVariant::Clear => 1.25,
514            GlassVariant::Lens => 1.0,
515        }
516    }
517
518    /// Theme-resolved material constants captured at composition time.
519    pub(crate) fn resolve(&self, colors: &LiquidColors) -> ResolvedGlass {
520        // Screen-lift keeps the blurred backdrop's colors alive while reading
521        // bright (the reference material is far whiter than an alpha tint
522        // could get without going milky).
523        // The reference menu/bar glass shows blurred content smudges through
524        // its body — lift bright but never opaque; the lens lightens what it
525        // magnifies noticeably (the pressed toggle's green reads lifted).
526        let lift = self.lift.unwrap_or(match (self.variant, colors.is_dark) {
527            (GlassVariant::Regular, false) => 0.42,
528            (GlassVariant::Regular, true) => -0.38,
529            (GlassVariant::Clear, false) => 0.12,
530            (GlassVariant::Clear, true) => -0.12,
531            (GlassVariant::Lens, false) => 0.10,
532            (GlassVariant::Lens, true) => -0.08,
533        });
534        let foreground = self.foreground.unwrap_or(colors.label);
535        let shadow = self.shadow_style.unwrap_or_else(|| {
536            GlassShadow::new(
537                Color::BLACK.with_alpha(match (self.variant, colors.is_dark) {
538                    (GlassVariant::Lens, false) => 0.14,
539                    (GlassVariant::Lens, true) => 0.28,
540                    (_, false) => 0.16,
541                    (_, true) => 0.5,
542                }),
543                if self.variant == GlassVariant::Lens {
544                    10.0
545                } else {
546                    22.0
547                },
548                if self.variant == GlassVariant::Lens {
549                    3.0
550                } else {
551                    8.0
552                },
553                if self.variant == GlassVariant::Lens {
554                    -6.0
555                } else {
556                    -2.0
557                },
558            )
559        });
560        ResolvedGlass {
561            shape: self.shape,
562            tint: self.tint.unwrap_or(colors.glass_tint),
563            blur_radius_dp: self
564                .blur_radius
565                .unwrap_or_else(|| self.default_blur_radius()),
566            saturation: self.saturation.unwrap_or_else(|| self.default_saturation()),
567            refraction_depth: self.refraction_depth,
568            refraction_curve: self.refraction_curve,
569            dispersion: self.dispersion,
570            transmission_refraction: self.transmission_refraction,
571            meniscus_absorption: self.meniscus_absorption,
572            fold_depth: self.fold_depth,
573            optical_zoom: self.optical_zoom,
574            rim_reflection: self.rim_reflection,
575            ink_recolor: self.ink_recolor,
576            highlight: self.highlight,
577            lift,
578            contrast: if self.variant == GlassVariant::Lens {
579                1.0
580            } else {
581                1.03
582            },
583            shadow: self.shadow,
584            clip: self.clip,
585            foreground_luma: 0.2126 * foreground.r()
586                + 0.7152 * foreground.g()
587                + 0.0722 * foreground.b(),
588            adaptive_frost: self.adaptive_frost,
589            rim_style: if self.variant == GlassVariant::Lens {
590                1.0
591            } else {
592                0.0
593            },
594            shadow_color: shadow.color,
595            shadow_radius: shadow.radius,
596            shadow_offset_y: shadow.offset_y,
597            shadow_spread: shadow.spread,
598        }
599    }
600}
601
602impl Default for Glass {
603    fn default() -> Self {
604        Self::regular()
605    }
606}
607
608/// A theme-resolved glass material; density and dynamics are applied per
609/// frame in the lazy graphics-layer resolver.
610#[derive(Clone, Debug, PartialEq)]
611pub(crate) struct ResolvedGlass {
612    pub shape: LiquidShape,
613    pub tint: Color,
614    pub blur_radius_dp: f32,
615    pub saturation: f32,
616    pub refraction_depth: f32,
617    pub refraction_curve: f32,
618    pub dispersion: f32,
619    pub transmission_refraction: f32,
620    pub meniscus_absorption: f32,
621    pub fold_depth: f32,
622    pub optical_zoom: f32,
623    pub rim_reflection: f32,
624    pub ink_recolor: Option<(Color, f32)>,
625    pub highlight: f32,
626    pub lift: f32,
627    pub contrast: f32,
628    pub shadow: bool,
629    pub clip: bool,
630    /// 0 = surface glass (soft white spec rim); 1 = interactive lens (thin
631    /// bright line + stronger dark outline, chroma does the color).
632    pub rim_style: f32,
633    pub foreground_luma: f32,
634    pub adaptive_frost: f32,
635    pub shadow_color: Color,
636    /// Variant-scaled drop shadow geometry: the lens bubble carries a tight
637    /// contact hint, large surfaces a soft wide ambient.
638    pub shadow_radius: f32,
639    pub shadow_offset_y: f32,
640    pub shadow_spread: f32,
641}
642
643impl ResolvedGlass {
644    /// Builds the wcKSRD shader for the current density and
645    /// per-frame dynamics. Cover mode keeps geometry in pixels with the
646    /// container uniform zeroed; the shader owns all backdrop samples.
647    pub(crate) fn backdrop_effect(&self, density: f32, dynamics: GlassDynamics) -> RenderEffect {
648        self.runtime_effect(density, dynamics, false)
649    }
650
651    fn content_mask_effect(&self, density: f32, dynamics: GlassDynamics) -> RenderEffect {
652        self.runtime_effect(density, dynamics, true)
653    }
654
655    fn runtime_effect(
656        &self,
657        density: f32,
658        dynamics: GlassDynamics,
659        content_mask: bool,
660    ) -> RenderEffect {
661        let density = density.max(f32::EPSILON);
662        let activity = dynamics
663            .activity
664            .filter(|value| value.is_finite())
665            .unwrap_or(1.0)
666            .clamp(0.0, 1.0);
667        let mut shader = RuntimeShader::new(LIQUID_GLASS_WGSL);
668        if let Some(morph) = dynamics.morph.as_ref() {
669            // Morph glass: the container carries the node size in dp and ALL
670            // geometry is dp — the shader divides the renderer-injected node
671            // pixel rect by the container, so the field lands correctly at
672            // any render scale (density-scaled packing broke every capture
673            // whose render scale differed from the platform density).
674            let (node_w, node_h) = morph.node_size;
675            let (cx, cy, w, h, radius) = morph.primary;
676            shader.set_float2(0, node_w.max(1.0), node_h.max(1.0));
677            shader.set_float2(2, cx, cy);
678            shader.set_float2(4, w, h);
679            shader.set_float(6, radius);
680            let count = morph.shapes.len().min(GlassMorph::MAX_SHAPES);
681            shader.set_float(30, count as f32);
682            for (index, (sx, sy, sw, sh, sr)) in morph.shapes.iter().take(count).enumerate() {
683                let base = 36 + index * 5;
684                shader.set_float(base, *sx);
685                shader.set_float(base + 1, *sy);
686                shader.set_float(base + 2, *sw);
687                shader.set_float(base + 3, *sh);
688                shader.set_float(base + 4, *sr);
689            }
690            shader.set_float(31, morph.glue);
691            shader.set_float(32, morph.wobble_amplitude * activity);
692            shader.set_float(33, morph.wobble_phase);
693            shader.set_float(26, morph.bulge_amplitude * activity);
694            shader.set_float(27, morph.bulge_direction);
695            shader.set_float(110, morph.ellipse_blend.clamp(0.0, 1.0) * activity);
696            if let Some(deformation) = morph.deformation {
697                let axis = deformation.axis();
698                let along = 1.0 + (deformation.along() - 1.0) * activity;
699                shader.set_float2(106, axis.0, axis.1);
700                shader.set_float(108, along);
701                shader.set_float(109, 1.0 / along);
702            } else {
703                shader.set_float2(106, 1.0, 0.0);
704                shader.set_float2(108, 1.0, 1.0);
705            }
706            // Bezel in dp: the shader scales by px-per-dp.
707        } else {
708            // Cover mode marker: container size stays zero. Geometry is px at
709            // the platform density (node size only known at render time).
710            shader.set_float2(0, 0.0, 0.0);
711            shader.set_float(6, self.shape.shader_radius_px(density));
712        }
713        shader.set_float(9, self.refraction_depth * activity);
714        shader.set_float(
715            GLASS_REFRACTION_CURVE_UNIFORM,
716            self.refraction_curve * activity,
717        );
718        shader.set_float(GLASS_DISPERSION_UNIFORM, self.dispersion * activity);
719        shader.set_float(
720            GLASS_TRANSMISSION_REFRACTION_UNIFORM,
721            self.transmission_refraction * activity,
722        );
723        shader.set_float(GLASS_MENISCUS_ABSORPTION_UNIFORM, self.meniscus_absorption);
724        // Always write optional channels — a conditional write leaves stale
725        // values behind if a shader instance is ever pooled or reused, and
726        // that leak class renders one surface with another's optics.
727        shader.set_float(GLASS_FOLD_DEPTH_UNIFORM, self.fold_depth.max(0.0));
728        shader.set_float(
729            GLASS_OPTICAL_ZOOM_UNIFORM,
730            1.0 + (self.optical_zoom - 1.0).max(0.0) * activity,
731        );
732        shader.set_float(121, self.rim_reflection.max(0.001));
733        let (ink_color, ink_strength) = self
734            .ink_recolor
735            .map(|(color, strength)| (color, strength * activity))
736            .unwrap_or((Color::TRANSPARENT, 0.0));
737        shader.set_float(124, ink_color.r());
738        shader.set_float(125, ink_color.g());
739        shader.set_float(126, ink_color.b());
740        shader.set_float(127, ink_strength);
741        let (light_x, light_y) = glass_light_direction();
742        shader.set_float(GLASS_LIGHT_DIRECTION_UNIFORM, light_x);
743        shader.set_float(GLASS_LIGHT_DIRECTION_UNIFORM + 1, light_y);
744        let (touch_x, touch_y, touch_intensity) = dynamics.touch.unwrap_or((0.0, 0.0, 0.0));
745        shader.set_float(118, touch_x);
746        shader.set_float(119, touch_y);
747        shader.set_float(120, touch_intensity.clamp(0.0, 1.0));
748        shader.set_float(GLASS_EFFECT_DENSITY_UNIFORM, density);
749        shader.set_float(
750            11,
751            (self.highlight + dynamics.highlight_boost).clamp(0.0, 2.0) * activity,
752        );
753        let dynamic_tint = boost_tint_saturation(self.tint, dynamics.saturation_boost);
754        let dynamic_tint_alpha = (dynamic_tint.a()
755            * dynamics
756                .tint_alpha_multiplier
757                .unwrap_or(1.0)
758                .clamp(0.0, 2.0)
759            * activity)
760            .clamp(0.0, 1.0);
761        shader.set_float4(
762            14,
763            dynamic_tint.r(),
764            dynamic_tint.g(),
765            dynamic_tint.b(),
766            dynamic_tint_alpha,
767        );
768        let saturation = (self.saturation + dynamics.saturation_boost).max(0.0);
769        shader.set_float(18, 1.0 + (saturation - 1.0) * activity);
770        shader.set_float(20, self.lift * activity);
771        shader.set_float(21, 0.5 * activity);
772        shader.set_float2(22, 0.0, 1.0);
773        shader.set_float(24, 1.0 + (self.contrast - 1.0) * activity);
774        shader.set_float(28, self.rim_style * activity);
775        let requested_blur_radius_px = if content_mask {
776            0.0
777        } else {
778            self.blur_radius_dp * density * activity
779        };
780        let wcksrd_blur_radius = requested_blur_radius_px.min(WCKSRD_OPTICAL_BLUR_RADIUS_PX);
781        let gaussian_blur_radius = (requested_blur_radius_px - wcksrd_blur_radius).max(0.0);
782        shader.set_float(GLASS_BLUR_RADIUS_UNIFORM, wcksrd_blur_radius);
783        shader.set_float(GLASS_ACTIVITY_UNIFORM, activity);
784        let resting_tint = dynamics.resting_tint.unwrap_or(Color::TRANSPARENT);
785        shader.set_float4(
786            GLASS_RESTING_TINT_UNIFORM,
787            resting_tint.r(),
788            resting_tint.g(),
789            resting_tint.b(),
790            resting_tint.a(),
791        );
792        shader.set_float(112, if content_mask { 1.0 } else { 0.0 });
793        shader.set_float(91, self.adaptive_frost * activity);
794        shader.set_float(97, self.foreground_luma);
795        let dynamic_shadow = !self.clip && self.shadow;
796        shader.set_float(
797            102,
798            if dynamic_shadow {
799                self.shadow_color.a() * 0.55 * activity
800            } else {
801                0.0
802            },
803        );
804        shader.set_float(103, self.shadow_radius);
805        shader.set_float(104, self.shadow_offset_y);
806        shader.set_float(105, self.shadow_spread);
807        // Morph padding: wobble reach plus how far any scene shape (plus its
808        // glue neck) extends beyond the primary rect — the capture and the
809        // composite surface must cover the whole glued field.
810        let morph_pad = dynamics
811            .morph
812            .as_ref()
813            .map(|morph| {
814                let (px, py, pw, ph, _) = morph.primary;
815                let (left, top) = (px - pw * 0.5, py - ph * 0.5);
816                let (right, bottom) = (px + pw * 0.5, py + ph * 0.5);
817                let mut shape_reach = 0.0f32;
818                for (sx, sy, sw, sh, _) in &morph.shapes {
819                    let reach_x = ((sx + sw * 0.5) - right)
820                        .max(left - (sx - sw * 0.5))
821                        .max(0.0);
822                    let reach_y = ((sy + sh * 0.5) - bottom)
823                        .max(top - (sy - sh * 0.5))
824                        .max(0.0);
825                    shape_reach = shape_reach.max(reach_x.max(reach_y));
826                }
827                let glue_pad = if morph.shapes.is_empty() {
828                    0.0
829                } else {
830                    morph.glue * 2.0
831                };
832                morph.wobble_amplitude * 2.0 + morph.bulge_amplitude + shape_reach + glue_pad
833            })
834            .unwrap_or(0.0);
835        // Paddings are consumed in LOGICAL units by the backdrop capture and
836        // output rects — dp, never density-scaled.
837        shader.set_input_padding(self.input_padding() + morph_pad + wcksrd_blur_radius / density);
838        // Morphing glass WRITES outside the node rect (wobble, bulge, glued
839        // neighbors, plus the ~2px antialiased rim); declare it so the
840        // composite scissor doesn't clip the field at the node edge.
841        if dynamics.morph.is_some() {
842            let shadow_reach = if dynamic_shadow {
843                self.shadow_radius + self.shadow_offset_y.abs() + self.shadow_spread.max(0.0)
844            } else {
845                0.0
846            };
847            shader.set_output_padding(morph_pad + shadow_reach + 4.0);
848        }
849
850        let optical_effect = RenderEffect::runtime_shader(shader);
851        if gaussian_blur_radius > f32::EPSILON {
852            // Mirror at the capture boundary: a backdrop capture is clipped
853            // at the surface's own edge (a top nav band's capture cannot
854            // extend above the page), and clamp-to-edge there stretches a
855            // single jittering content row across half the kernel — the
856            // band's top pixels pulse ~12 gray levels per scroll step
857            // (measured live). Mirroring keeps the edge statistics stable.
858            RenderEffect::blur_with_edge_treatment(gaussian_blur_radius, TileMode::Mirror)
859                .then(optical_effect)
860        } else {
861            optical_effect
862        }
863    }
864
865    /// Backdrop capture padding (px) covering the largest refracted sample
866    /// (see `liquid_glass_input_padding` for the explicit-rect twin). Padded
867    /// for tilt up to ±1 per axis so per-frame tilt never outruns the capture.
868    fn input_padding(&self) -> f32 {
869        // wcKSRD maps every refracted coordinate toward the center of the
870        // already-captured backdrop. Only its minimum 9x9 sample footprint
871        // extends beyond that segment; blur and morph reach are added by the
872        // caller from their actual runtime values.
873        2.0
874    }
875}
876
877/// Modifier extension installing the Liquid Glass material.
878pub trait LiquidModifierExt {
879    /// Applies the glass material to this composable's bounds: backdrop blur +
880    /// lens shader, clipped to `glass.shape`, with a soft drop shadow.
881    ///
882    /// Must be called in composable context (the material resolves theme
883    /// colors at the call site).
884    fn glass_effect(self, glass: Glass) -> Modifier;
885
886    /// [`glass_effect`](Self::glass_effect) with per-frame motion inputs; the
887    /// closure is read at scene-build time, so animating tilt or highlight
888    /// does not recompose.
889    fn glass_effect_with(
890        self,
891        glass: Glass,
892        dynamics: impl Fn() -> GlassDynamics + 'static,
893    ) -> Modifier;
894}
895
896impl LiquidModifierExt for Modifier {
897    fn glass_effect(self, glass: Glass) -> Modifier {
898        self.glass_effect_with(glass, GlassDynamics::default)
899    }
900
901    fn glass_effect_with(
902        self,
903        glass: Glass,
904        dynamics: impl Fn() -> GlassDynamics + 'static,
905    ) -> Modifier {
906        let colors = crate::theme::liquid_colors();
907        let resolved = Rc::new(glass.resolve(&colors));
908        let shape = resolved.shape;
909
910        let mut modifier = self;
911        if resolved.shadow && resolved.clip {
912            let shadow_color = resolved.shadow_color;
913            let (radius, offset_y, spread) = (
914                resolved.shadow_radius,
915                resolved.shadow_offset_y,
916                resolved.shadow_spread,
917            );
918            modifier = modifier.drop_shadow(shape.layer_shape(), move |scope| {
919                scope.radius = radius;
920                scope.spread = spread;
921                scope.offset.y = offset_y;
922                scope.color = shadow_color;
923                // Glass samples the backdrop behind itself — knock the shape
924                // out of its own shadow so the material stays bright.
925                scope.cutout = true;
926            });
927        }
928
929        let layer_resolved = Rc::clone(&resolved);
930        let clip = resolved.clip;
931        modifier.graphics_layer(move || {
932            let density = current_density();
933            let frame = dynamics();
934            let render_effect = (!clip && frame.morph.is_some())
935                .then(|| layer_resolved.content_mask_effect(density, frame.clone()));
936            GraphicsLayer {
937                backdrop_effect: Some(layer_resolved.backdrop_effect(density, frame)),
938                render_effect,
939                shape: shape.layer_shape(),
940                clip,
941                ..Default::default()
942            }
943        })
944    }
945}
946
947#[cfg(test)]
948mod tests {
949    use super::*;
950
951    fn light_colors() -> LiquidColors {
952        LiquidColors::light(Color::from_rgb_u8(0, 122, 255))
953    }
954
955    fn terminal_shader(effect: RenderEffect) -> RuntimeShader {
956        match effect {
957            RenderEffect::Shader { shader } => shader,
958            RenderEffect::Chain { second, .. } => terminal_shader(*second),
959            effect => panic!("expected runtime shader, got {effect:?}"),
960        }
961    }
962
963    #[test]
964    fn glass_light_direction_defaults_overhead_and_reaches_the_shader() {
965        assert_eq!(glass_light_direction(), (0.0, 1.0));
966        let resolved = Glass::regular().resolve(&light_colors());
967        let effect = resolved.backdrop_effect(2.0, GlassDynamics::default());
968        let shader = terminal_shader(effect);
969        let u = shader.uniforms();
970        assert_eq!(u[GLASS_LIGHT_DIRECTION_UNIFORM], 0.0);
971        assert_eq!(u[GLASS_LIGHT_DIRECTION_UNIFORM + 1], 1.0);
972
973        // Rotating the environment (device attitude) re-lights the next
974        // resolved effect.
975        set_glass_light_direction((1.0, 0.0));
976        let effect = resolved.backdrop_effect(2.0, GlassDynamics::default());
977        let u_rotated = terminal_shader(effect);
978        let u_rotated = u_rotated.uniforms();
979        assert_eq!(u_rotated[GLASS_LIGHT_DIRECTION_UNIFORM], 1.0);
980        assert_eq!(u_rotated[GLASS_LIGHT_DIRECTION_UNIFORM + 1], 0.0);
981        set_glass_light_direction((0.0, 1.0));
982    }
983
984    #[test]
985    fn liquid_shape_builds_matching_clip_and_layer_shapes() {
986        for shape in [
987            LiquidShape::Capsule,
988            LiquidShape::Circle,
989            LiquidShape::RoundedRect(12.0),
990        ] {
991            assert_eq!(shape.layer_shape(), LayerShape::Rounded(shape.clip_shape()));
992        }
993    }
994
995    #[test]
996    fn glass_shadow_clamps_negative_radius() {
997        let shadow = GlassShadow::new(Color::BLACK, -2.0, 3.0, -1.0);
998        assert_eq!(shadow.radius, 0.0);
999        assert_eq!(shadow.offset_y, 3.0);
1000        assert_eq!(shadow.spread, -1.0);
1001    }
1002
1003    #[test]
1004    fn incompressible_deformation_normalizes_axis_and_conserves_area() {
1005        let deformation = GlassDeformation::incompressible((3.0, 4.0), 1.25);
1006        assert_eq!(deformation.axis(), (0.6, 0.8));
1007        assert_eq!(deformation.along(), 1.25);
1008        assert!((deformation.along() * deformation.across() - 1.0).abs() < 1.0e-6);
1009        assert_eq!(
1010            GlassDeformation::incompressible((0.0, 0.0), 0.0).axis(),
1011            (1.0, 0.0)
1012        );
1013    }
1014
1015    #[test]
1016    fn glass_builders_clamp_physical_inputs() {
1017        let glass = Glass::lens()
1018            .shape(LiquidShape::Circle)
1019            .tint(Color::BLACK)
1020            .blur_radius(-2.0)
1021            .saturation(1.2)
1022            .refraction_depth(3.0)
1023            .refraction_curve(2.0)
1024            .dispersion(2.0)
1025            .transmission_refraction(2.0)
1026            .meniscus_absorption(2.0)
1027            .highlight(0.4)
1028            .lift(-0.2)
1029            .adaptive_frost(Color::WHITE, 2.0)
1030            .shadow(false)
1031            .no_clip();
1032        assert_eq!(glass.shape, LiquidShape::Circle);
1033        assert_eq!(glass.tint, Some(Color::BLACK));
1034        assert_eq!(glass.blur_radius, Some(-2.0));
1035        assert_eq!(glass.saturation, Some(1.2));
1036        assert_eq!(glass.refraction_depth, 2.0);
1037        assert_eq!(glass.refraction_curve, 1.0);
1038        assert_eq!(glass.dispersion, 1.0);
1039        assert_eq!(glass.transmission_refraction, 1.0);
1040        assert_eq!(glass.meniscus_absorption, 1.0);
1041        assert_eq!(glass.highlight, 0.4);
1042        assert_eq!(glass.lift, Some(-0.2));
1043        assert_eq!(glass.adaptive_frost, 1.0);
1044        assert!(!glass.shadow);
1045        assert!(!glass.clip);
1046    }
1047
1048    #[test]
1049    fn material_variants_resolve_distinct_frost_levels() {
1050        let regular = Glass::regular().resolve(&light_colors());
1051        let clear = Glass::clear().resolve(&light_colors());
1052        let lens = Glass::lens().resolve(&light_colors());
1053        assert!(regular.blur_radius_dp > clear.blur_radius_dp);
1054        assert!(clear.blur_radius_dp > lens.blur_radius_dp);
1055        assert!(regular.saturation > clear.saturation);
1056        assert_eq!(lens.rim_style, 1.0);
1057        assert_eq!(regular.refraction_curve, 0.25);
1058        assert_eq!(lens.refraction_curve, 1.0);
1059        assert_eq!(regular.dispersion, 0.0);
1060        assert_eq!(lens.dispersion, 0.30);
1061    }
1062
1063    #[test]
1064    fn neutral_surface_helpers_follow_foreground_polarity() {
1065        assert_eq!(
1066            neutral_surface_tint(Color::BLACK, 0.08, 0.10),
1067            Color::BLACK.with_alpha(0.08)
1068        );
1069        assert_eq!(
1070            neutral_surface_tint(Color::WHITE, 0.08, 0.10),
1071            Color::WHITE.with_alpha(0.10)
1072        );
1073        assert_eq!(neutral_surface_lift(Color::BLACK, 0.7, -0.3), 0.7);
1074        assert_eq!(neutral_surface_lift(Color::WHITE, 0.7, -0.3), -0.3);
1075    }
1076
1077    #[test]
1078    fn dynamic_saturation_reaches_the_material_tint() {
1079        let resting = Color::from_rgb_u8(0, 199, 208);
1080        let raised = boost_tint_saturation(resting, 0.55);
1081        assert_eq!(raised.r(), 0.0);
1082        assert!(raised.g() > resting.g());
1083        assert!(raised.b() > resting.b());
1084        assert_eq!(raised.a(), resting.a());
1085    }
1086
1087    #[test]
1088    fn resolved_material_packs_wcksrd_and_dynamic_tint() {
1089        let resolved = Glass::lens()
1090            .refraction_depth(0.72)
1091            .refraction_curve(0.8)
1092            .dispersion(0.42)
1093            .transmission_refraction(0.35)
1094            .meniscus_absorption(0.3)
1095            .blur_radius(3.0)
1096            .tint(Color::BLACK.with_alpha(0.8))
1097            .resolve(&light_colors());
1098        let effect = resolved.backdrop_effect(
1099            2.0,
1100            GlassDynamics {
1101                highlight_boost: 0.2,
1102                saturation_boost: 0.35,
1103                tint_alpha_multiplier: Some(0.25),
1104                ..Default::default()
1105            },
1106        );
1107        let RenderEffect::Chain { first, .. } = &effect else {
1108            panic!("macroscopic frost must precede the wcKSRD optical pass");
1109        };
1110        assert!(matches!(
1111            first.as_ref(),
1112            RenderEffect::Blur {
1113                radius_x: 4.0,
1114                radius_y: 4.0,
1115                ..
1116            }
1117        ));
1118        let shader = terminal_shader(effect);
1119        assert_eq!(shader.uniforms()[9], 0.72);
1120        assert_eq!(shader.uniforms()[GLASS_REFRACTION_CURVE_UNIFORM], 0.8);
1121        assert_eq!(shader.uniforms()[GLASS_DISPERSION_UNIFORM], 0.42);
1122        assert_eq!(
1123            shader.uniforms()[GLASS_TRANSMISSION_REFRACTION_UNIFORM],
1124            0.35
1125        );
1126        assert_eq!(shader.uniforms()[GLASS_MENISCUS_ABSORPTION_UNIFORM], 0.3);
1127        assert_eq!(shader.uniforms()[11], resolved.highlight + 0.2);
1128        assert_eq!(shader.uniforms()[18], resolved.saturation + 0.35);
1129        assert!((shader.uniforms()[17] - 0.2).abs() < 1.0e-6);
1130        assert_eq!(
1131            shader.uniforms()[GLASS_BLUR_RADIUS_UNIFORM],
1132            WCKSRD_OPTICAL_BLUR_RADIUS_PX
1133        );
1134        assert_eq!(shader.uniforms()[GLASS_EFFECT_DENSITY_UNIFORM], 2.0);
1135    }
1136
1137    #[test]
1138    fn raised_tint_density_stays_premultiplied_alpha_safe() {
1139        let effect = Glass::lens()
1140            .tint(Color::WHITE.with_alpha(0.8))
1141            .resolve(&light_colors())
1142            .backdrop_effect(
1143                1.0,
1144                GlassDynamics {
1145                    tint_alpha_multiplier: Some(2.0),
1146                    ..Default::default()
1147                },
1148            );
1149        assert_eq!(terminal_shader(effect).uniforms()[17], 1.0);
1150    }
1151
1152    #[test]
1153    fn optical_activity_reaches_identity_without_removing_the_glass_geometry() {
1154        let resolved = Glass::lens()
1155            .refraction_depth(0.72)
1156            .refraction_curve(0.8)
1157            .dispersion(0.42)
1158            .blur_radius(3.0)
1159            .saturation(1.4)
1160            .highlight(0.6)
1161            .lift(0.2)
1162            .tint(Color::BLACK.with_alpha(0.8))
1163            .no_clip()
1164            .resolve(&light_colors());
1165        let morph = GlassMorph {
1166            node_size: (120.0, 72.0),
1167            primary: (60.0, 36.0, 96.0, 52.0, -1.0),
1168            wobble_amplitude: 3.0,
1169            bulge_amplitude: 4.0,
1170            deformation: Some(GlassDeformation::incompressible((1.0, 0.0), 1.25)),
1171            ..Default::default()
1172        };
1173
1174        let RenderEffect::Shader { shader } = resolved.backdrop_effect(
1175            2.0,
1176            GlassDynamics {
1177                activity: Some(0.0),
1178                morph: Some(morph),
1179                ..Default::default()
1180            },
1181        ) else {
1182            panic!("material must resolve to the shared wcKSRD shader");
1183        };
1184        let uniforms = shader.uniforms();
1185        assert_eq!(uniforms[GLASS_ACTIVITY_UNIFORM], 0.0);
1186        assert_eq!(uniforms[9], 0.0);
1187        assert_eq!(uniforms[GLASS_REFRACTION_CURVE_UNIFORM], 0.0);
1188        assert_eq!(uniforms[GLASS_DISPERSION_UNIFORM], 0.0);
1189        assert_eq!(uniforms[GLASS_TRANSMISSION_REFRACTION_UNIFORM], 0.0);
1190        assert_eq!(uniforms[11], 0.0);
1191        assert_eq!(uniforms[17], 0.0);
1192        assert_eq!(uniforms[18], 1.0);
1193        assert_eq!(uniforms[20], 0.0);
1194        assert_eq!(uniforms[21], 0.0);
1195        assert_eq!(uniforms[24], 1.0);
1196        assert_eq!(uniforms[28], 0.0);
1197        assert_eq!(uniforms[GLASS_BLUR_RADIUS_UNIFORM], 0.0);
1198        assert_eq!(uniforms[91], 0.0);
1199        assert_eq!(uniforms[102], 0.0);
1200        assert_eq!(
1201            &uniforms[GLASS_RESTING_TINT_UNIFORM..GLASS_RESTING_TINT_UNIFORM + 4],
1202            &[0.0, 0.0, 0.0, 0.0]
1203        );
1204        assert_eq!(uniforms[32], 0.0);
1205        assert_eq!(uniforms[26], 0.0);
1206        assert_eq!(&uniforms[108..110], &[1.0, 1.0]);
1207        assert_eq!(&uniforms[2..6], &[60.0, 36.0, 96.0, 52.0]);
1208    }
1209
1210    #[test]
1211    fn resting_surface_tint_survives_zero_optical_activity() {
1212        let tint = Color::BLACK.with_alpha(0.11);
1213        let RenderEffect::Shader { shader } = Glass::lens()
1214            .no_clip()
1215            .resolve(&light_colors())
1216            .backdrop_effect(
1217                1.0,
1218                GlassDynamics {
1219                    activity: Some(0.0),
1220                    resting_tint: Some(tint),
1221                    ..Default::default()
1222                },
1223            )
1224        else {
1225            panic!("resting surface must use the shared wcKSRD shader");
1226        };
1227        assert_eq!(
1228            &shader.uniforms()[GLASS_RESTING_TINT_UNIFORM..GLASS_RESTING_TINT_UNIFORM + 4],
1229            &[tint.r(), tint.g(), tint.b(), tint.a()]
1230        );
1231        assert_eq!(shader.uniforms()[GLASS_ACTIVITY_UNIFORM], 0.0);
1232    }
1233
1234    #[test]
1235    fn full_optical_activity_preserves_the_resolved_material() {
1236        let resolved = Glass::lens()
1237            .refraction_depth(0.72)
1238            .refraction_curve(0.8)
1239            .dispersion(0.42)
1240            .blur_radius(3.0)
1241            .resolve(&light_colors());
1242        let shader = terminal_shader(resolved.backdrop_effect(
1243            2.0,
1244            GlassDynamics {
1245                activity: Some(1.0),
1246                ..Default::default()
1247            },
1248        ));
1249        let uniforms = shader.uniforms();
1250        assert_eq!(uniforms[GLASS_ACTIVITY_UNIFORM], 1.0);
1251        assert_eq!(uniforms[9], 0.72);
1252        assert_eq!(uniforms[GLASS_REFRACTION_CURVE_UNIFORM], 0.8);
1253        assert_eq!(uniforms[GLASS_DISPERSION_UNIFORM], 0.42);
1254        assert_eq!(uniforms[GLASS_TRANSMISSION_REFRACTION_UNIFORM], 1.0);
1255        assert_eq!(
1256            uniforms[GLASS_BLUR_RADIUS_UNIFORM],
1257            WCKSRD_OPTICAL_BLUR_RADIUS_PX
1258        );
1259    }
1260
1261    #[test]
1262    fn morph_geometry_and_incompressible_strain_are_packed() {
1263        let deformation = GlassDeformation::incompressible((0.0, 2.0), 1.25);
1264        let morph = GlassMorph {
1265            node_size: (78.0, 59.0),
1266            primary: (39.0, 29.5, 58.0, 39.0, -1.0),
1267            shapes: vec![(70.0, 29.5, 40.0, 40.0, -1.0)],
1268            glue: 8.0,
1269            wobble_amplitude: 1.0,
1270            wobble_phase: 0.5,
1271            bulge_amplitude: 2.0,
1272            bulge_direction: 0.25,
1273            ellipse_blend: 0.3,
1274            deformation: Some(deformation),
1275        };
1276        let RenderEffect::Shader { shader } = Glass::lens()
1277            .no_clip()
1278            .resolve(&light_colors())
1279            .backdrop_effect(
1280                1.0,
1281                GlassDynamics {
1282                    morph: Some(morph),
1283                    ..Default::default()
1284                },
1285            )
1286        else {
1287            panic!("morph must use the shared wcKSRD shader");
1288        };
1289        let uniforms = shader.uniforms();
1290        assert_eq!(&uniforms[0..6], &[78.0, 59.0, 39.0, 29.5, 58.0, 39.0]);
1291        assert_eq!(uniforms[30], 1.0);
1292        assert_eq!(&uniforms[106..110], &[0.0, 1.0, 1.25, 0.8]);
1293        assert_eq!(uniforms[110], 0.3);
1294        assert!(shader.output_padding() > 0.0);
1295    }
1296}