git.lucas.co / cce-ui
GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git

commita8ee3bb9581a43735ff4947d5569762073c2d725
parentace940dcf0
authorLucas Galante <[email protected]>
date2026-08-25 12:32
droplet: contact shadow (DropletSpec::shadow)

A soft dark falloff cast below the drop's lower arc, outside the
silhouette — weighted by the outward gradient's downward component so
the attach line and sides stay clean. The cover quad overhangs the box
sideways and below by the reach; strength and reach ride the two
remaining spec-tint slots. Hosts leave room beneath the box via
DropletSpec::shadow_gap (one place, so the bar's reserved gap and the
shader's falloff stay proportioned). Default strength 0.35; 0 disables
and the box reclaims the gap.

Co-Authored-By: Claude Fable 5 <[email protected]>

 src/backend/window_runner.rs | 22 +++++++++++++++++++---
 src/scene/paint.rs           | 19 +++++++++++++++++++
 src/vk/shader2d.wgsl         | 23 ++++++++++++++++++++---
 3 files changed, 58 insertions(+), 6 deletions(-)

diff --git a/src/backend/window_runner.rs b/src/backend/window_runner.rs
index 2374aad..fb0440e 100644
--- a/src/backend/window_runner.rs
+++ b/src/backend/window_runner.rs
@@ -1981,7 +1981,17 @@ pub fn tessellate_display_list(
                 // fractions resolve against the concrete rect here, clamped so
                 // small or narrow boxes stay well-formed (a belly wider than
                 // the box would turn the SDF interior inside out).
-                verts.extend(quad_vertices(rect.x, rect.y, rect.width, rect.height, sw, sh, *color));
+                // The cover quad grows sideways and BELOW the box by the
+                // contact shadow's reach — shadow fragments live outside the
+                // silhouette, so they need covered pixels to shade.
+                let sh_reach = if spec.shadow > 0.0 { (0.18 * rect.height).max(2.0) } else { 0.0 };
+                verts.extend(quad_vertices(
+                    rect.x - sh_reach,
+                    rect.y,
+                    rect.width + 2.0 * sh_reach,
+                    rect.height + sh_reach,
+                    sw, sh, *color,
+                ));
                 let hx = rect.width * 0.5;
                 let hy = rect.height * 0.5;
                 let sag = spec.sag.clamp(0.0, 0.9) * rect.height;
@@ -2025,8 +2035,14 @@ pub fn tessellate_display_list(
                     material: [plate_mat[0], spec.gleam, spec.shine, spec.rim],
                     host: [sr * scale, spec.clarity.clamp(0.0, 1.0), spec.dome, ar * scale],
                     // Droplet glints are always white, so the tint RGB slots
-                    // carry droplet params instead: x = core density.
-                    specular_tint: [spec.core.clamp(0.0, 2.0), 1.0, 1.0, bow * scale],
+                    // carry droplet params instead: x = core density,
+                    // y = contact-shadow reach px, z = shadow strength.
+                    specular_tint: [
+                        spec.core.clamp(0.0, 2.0),
+                        sh_reach * scale,
+                        spec.shadow.clamp(0.0, 1.0),
+                        bow * scale,
+                    ],
                     mode: 10.0,
                     shape: spec.curve.clamp(2.0, 6.0),
                 });
diff --git a/src/scene/paint.rs b/src/scene/paint.rs
index e1a8c56..dda2714 100644
--- a/src/scene/paint.rs
+++ b/src/scene/paint.rs
@@ -130,6 +130,12 @@ pub struct DropletSpec {
     /// faint upside-down image of the scene a real hanging drop shows in its
     /// belly. Client-side ignored, like `refr`.
     pub ghost: f32,
+    /// Contact-shadow strength (0-1): a soft dark falloff cast below the
+    /// drop's lower arc, outside the silhouette — the volume cue of a bead
+    /// sitting proud of the surface. The host must leave room beneath the
+    /// drop box for it (the status bar insets the box by
+    /// [`DropletSpec::shadow_gap`]). 0 disables it.
+    pub shadow: f32,
 }
 
 impl DropletSpec {
@@ -169,6 +175,7 @@ impl DropletSpec {
                 "core" => spec.core = v,
                 "refr" => spec.refr = v,
                 "ghost" => spec.ghost = v,
+                "shadow" => spec.shadow = v,
                 _ => log::warn!("droplet spec: unknown key '{}' — skipped", key),
             }
         }
@@ -195,6 +202,17 @@ impl DropletSpec {
         let bow = (self.bow.clamp(0.0, 0.5) * h).min(hy * 0.9);
         (sr, ar, bow)
     }
+
+    /// Vertical room (logical px) a host should leave BELOW the drop box for
+    /// the contact shadow, given the full slot height. One place, so the
+    /// bar's reserved gap and the shader's falloff reach stay proportioned.
+    pub fn shadow_gap(&self, slot_h: f32) -> f32 {
+        if self.shadow > 0.0 {
+            (0.16 * slot_h).ceil()
+        } else {
+            0.0
+        }
+    }
 }
 
 impl Default for DropletSpec {
@@ -223,6 +241,7 @@ impl Default for DropletSpec {
             core: 0.35,
             refr: 0.0,
             ghost: 0.0,
+            shadow: 0.35,
         }
     }
 }
diff --git a/src/vk/shader2d.wgsl b/src/vk/shader2d.wgsl
index 76f080a..30c8109 100644
--- a/src/vk/shader2d.wgsl
+++ b/src/vk/shader2d.wgsl
@@ -354,8 +354,9 @@ fn plate_shade(frag: vec2f, vcol: vec4f) -> vec4f {
     //   p_radii = [sag, belly radius, belly half-width, blend k] px;
     //   p_host  = [sheet bottom-corner radius px, edge clarity 0-1, dome
     //              amplitude, attach (top-corner) radius px];
-    //   p_spec_tint = [core density, _, _, bottom-bow edge rise px] — a
-    //     droplet's glint is always white, so the tint RGB slots are free;
+    //   p_spec_tint = [core density, contact-shadow reach px, contact-shadow
+    //     strength, bottom-bow edge rise px] — a droplet's glint is always
+    //     white, so the tint RGB slots are free;
     //   p_mat.w = fresnel rim crest amplitude (droplets carve nothing, so the
     //             AO slot is free); p_light.w = shaded band width px.
     // The silhouette is the smooth union of a film SHEET attached to the top
@@ -420,7 +421,23 @@ fn plate_shade(frag: vec2f, vcol: vec4f) -> vec4f {
         let din = -d;
         let aa2 = clamp(din + 0.5, 0.0, 1.0);
         if (aa2 <= 0.0) {
-            discard;
+            // Outside the silhouette: the contact shadow — a soft dark
+            // falloff cast below the drop's lower arc (weighted by the
+            // outward gradient's downward component, so the attach line and
+            // sides stay clean). The cover quad overhangs the box by the
+            // reach to give these fragments pixels to land on.
+            let sh_reach = rrect_clip.p_spec_tint.y;
+            let sh_amp = rrect_clip.p_spec_tint.z;
+            if (sh_reach < 0.5 || sh_amp <= 0.0) {
+                discard;
+            }
+            let down_sh = clamp(g.y, 0.0, 1.0);
+            let sfall = 1.0 - clamp(d / sh_reach, 0.0, 1.0);
+            let sa = sh_amp * sfall * sfall * down_sh;
+            if (sa <= 0.004) {
+                discard;
+            }
+            return vec4f(0.0, 0.0, 0.0, sa);
         }
         var base = vcol;
         if (vcol.a < 0.0) {