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

commit7d280c901f9708f9c611735c3811919b50e38d6d
parentaf780e16d2
authorLucas Galante <[email protected]>
date2026-08-28 10:27
feat: Prim::DropletScrim — the droplet silhouette, filled flat and feathered

A caller drawing text on a droplet needs a legible ground under it. A
rounded-rect glow inset in the drop's box is an approximation that shows:
the drop has a flat attach line, a bowed bottom and superellipse joins,
and a pill does not.

So the scrim rides the droplet's own shader path. Mode 12 shares mode 10's
SDF construction verbatim — sheet smin belly, smax bow — and returns right
after it with a flat fill whose alpha ramps from nothing at the rim to
full `feather` px inside. Sharing the branch rather than copying it is the
point: the drop and the vignette inside it cannot disagree about where the
edge is. The spec-to-push resolution moved into `droplet_geom` for the same
reason, since both prims now need it.

No dome, rim, gleam or contact shadow: this is a ground, not a second lit
body, so its cover quad is exactly the box rather than overhanging it.
p_light.w carries the feather in place of the shading band, which mode 12
returns before ever reading. On the legacy (bevel_shader 0) path it
degrades to the same flat rounded-rect outline Prim::Droplet falls back to,
rather than vanishing.

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

 src/backend/window_runner.rs | 126 +++++++++++++++++++++++++++++++++----------
 src/scene/paint.rs           |  22 ++++++++
 src/vk/shader2d.wgsl         |  21 +++++++-
 3 files changed, 139 insertions(+), 30 deletions(-)

diff --git a/src/backend/window_runner.rs b/src/backend/window_runner.rs
index 48e648f..9fc2b2d 100644
--- a/src/backend/window_runner.rs
+++ b/src/backend/window_runner.rs
@@ -60,6 +60,64 @@ std::thread_local! {
     static BUFFER_CACHE: std::cell::RefCell<std::collections::HashMap<BufferCacheKey, CachedBuffer>> = std::cell::RefCell::new(std::collections::HashMap::new());
 }
 
+/// A droplet spec resolved against a concrete rect: the push-constant fields
+/// that define its SILHOUETTE, in logical px.
+///
+/// Shared by [`crate::scene::paint::Prim::Droplet`] and
+/// [`crate::scene::paint::Prim::DropletScrim`] so the lit drop and the vignette
+/// drawn inside it can never disagree about the shape — the whole reason the
+/// scrim rides the droplet's shader path instead of approximating the outline
+/// with a rounded rect.
+struct DropletGeom {
+    hx: f32,
+    hy: f32,
+    sag: f32,
+    br: f32,
+    bw: f32,
+    k: f32,
+    sr: f32,
+    ar: f32,
+    band: f32,
+    bow: f32,
+    /// How far the contact shadow reaches below/beside the box (0 when the
+    /// spec has no shadow). The lit drop's cover quad grows by this; a scrim
+    /// never draws outside the silhouette and ignores it.
+    sh_reach: f32,
+}
+
+fn droplet_geom(rect: &crate::scene::layout::Rect, spec: &crate::scene::paint::DropletSpec) -> DropletGeom {
+    let hx = rect.width * 0.5;
+    let hy = rect.height * 0.5;
+    let sag = spec.sag.clamp(0.0, 0.9) * rect.height;
+    // belly <= 0 disables the belly outright (the oval-dewdrop default) — the
+    // shader skips the smin when the radius is 0.
+    let (br, bw) = if spec.belly > 0.0 {
+        let br = (spec.belly.min(1.0) * rect.height).min(hy).min(hx);
+        (br, ((hx - br).max(0.0) * spec.belly_w.clamp(0.0, 1.0)).max(1.0))
+    } else {
+        (0.0, 0.0)
+    };
+    let k = (spec.blend.max(0.0) * rect.height).max(1.0);
+    let sheet_hy = hy - sag * 0.5;
+    // Bottom (sheet_r) and top (attach) corner radii: when the pair overfills
+    // the sheet height, scale both down proportionally — 0.5 + 0.5 is the
+    // fully continuous egg.
+    let mut sr = (spec.sheet_r.clamp(0.0, 1.0) * rect.height).min(hx);
+    let mut ar = (spec.attach.clamp(0.0, 1.0) * rect.height).min(hx);
+    let sheet_h = (2.0 * sheet_hy).max(0.0);
+    if sr + ar > sheet_h && sr + ar > 0.0 {
+        let f = sheet_h / (sr + ar);
+        sr *= f;
+        ar *= f;
+    }
+    let band = (spec.band.max(0.05) * rect.height).max(1.0);
+    // Bottom-bow edge rise; the shader derives the arc radius from it per drop
+    // (R = hx^2/2*rise).
+    let bow = (spec.bow.clamp(0.0, 0.5) * rect.height).min(hy * 0.9);
+    let sh_reach = if spec.shadow > 0.0 { (0.18 * rect.height).max(2.0) } else { 0.0 };
+    DropletGeom { hx, hy, sag, br, bw, k, sr, ar, band, bow, sh_reach }
+}
+
 fn find_cased_family(fs: &FontSystem, name: &str) -> Option<String> {
     let lower_name = name.to_lowercase();
     for face in fs.db().faces() {
@@ -1569,6 +1627,7 @@ fn prim_kind(p: &crate::scene::paint::Prim) -> &'static str {
         P::Arc { .. } => "Arc", P::ArcShaded { .. } => "ArcShaded",
         P::Vector { .. } => "Vector", P::Circle { .. } => "Circle",
         P::Sphere { .. } => "Sphere", P::Droplet { .. } => "Droplet",
+        P::DropletScrim { .. } => "DropletScrim",
         P::ConcaveFillet { .. } => "ConcaveFillet",
         P::Groove { .. } => "Groove", P::Glow { .. } => "Glow",
         P::Text { .. } => "Text", P::Image { .. } => "Image",
@@ -2094,6 +2153,31 @@ pub fn tessellate_display_list(
                 // Legacy path: the flat disc, exactly a Circle.
                 verts.extend(circle_vertices(*cx, *cy, *radius, sw, sh, *color, segs(*radius), no));
             }
+            Prim::DropletScrim { rect, color, spec, feather } if shader_plates => {
+                // Shader mode 12: the droplet's own SDF, filled flat and
+                // feathered inward. No contact shadow, so unlike the lit drop
+                // the cover quad is exactly the box — a scrim never draws
+                // outside the silhouette.
+                let g = droplet_geom(rect, spec);
+                verts.extend(quad_vertices(rect.x, rect.y, rect.width, rect.height, sw, sh, *color));
+                plate = Some(crate::vk::PlatePush {
+                    rect: [
+                        (rect.x + rect.width * 0.5) * scale,
+                        (rect.y + rect.height * 0.5) * scale,
+                        g.hx * scale,
+                        g.hy * scale,
+                    ],
+                    radii: [g.sag * scale, g.br * scale, g.bw * scale, g.k * scale],
+                    // p_light.w carries the FEATHER here; mode 12 returns
+                    // before the shading band it otherwise holds is read.
+                    light: [plate_light[0], plate_light[1], plate_light[2], feather.max(0.001) * scale],
+                    material: [plate_mat[0], 0.0, 0.0, 0.0],
+                    host: [g.sr * scale, 0.0, 0.0, g.ar * scale],
+                    specular_tint: [0.0, 0.0, 0.0, g.bow * scale],
+                    mode: 12.0,
+                    shape: spec.curve.clamp(2.0, 6.0),
+                });
+            }
             Prim::Droplet { rect, color, spec } if shader_plates => {
                 // A water droplet lit by shader mode 10: one cover quad; the
                 // shader owns silhouette (sheet ∪smin belly), dome shading,
@@ -2104,7 +2188,9 @@ pub fn tessellate_display_list(
                 // 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 };
+                let g = droplet_geom(rect, spec);
+                let (hx, hy, sag, br, bw, k, sr, ar, band, bow, sh_reach) =
+                    (g.hx, g.hy, g.sag, g.br, g.bw, g.k, g.sr, g.ar, g.band, g.bow, g.sh_reach);
                 verts.extend(quad_vertices(
                     rect.x - sh_reach,
                     rect.y,
@@ -2112,34 +2198,6 @@ pub fn tessellate_display_list(
                     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;
-                // belly ≤ 0 disables the belly outright (the oval-dewdrop
-                // default) — the shader skips the smin when the radius is 0.
-                let (br, bw) = if spec.belly > 0.0 {
-                    let br = (spec.belly.min(1.0) * rect.height).min(hy).min(hx);
-                    (br, ((hx - br).max(0.0) * spec.belly_w.clamp(0.0, 1.0)).max(1.0))
-                } else {
-                    (0.0, 0.0)
-                };
-                let k = (spec.blend.max(0.0) * rect.height).max(1.0);
-                let sheet_hy = hy - sag * 0.5;
-                // Bottom (sheet_r) and top (attach) corner radii: when the
-                // pair overfills the sheet height, scale both down
-                // proportionally — 0.5 + 0.5 is the fully continuous egg.
-                let mut sr = (spec.sheet_r.clamp(0.0, 1.0) * rect.height).min(hx);
-                let mut ar = (spec.attach.clamp(0.0, 1.0) * rect.height).min(hx);
-                let sheet_h = (2.0 * sheet_hy).max(0.0);
-                if sr + ar > sheet_h && sr + ar > 0.0 {
-                    let f = sheet_h / (sr + ar);
-                    sr *= f;
-                    ar *= f;
-                }
-                let band = (spec.band.max(0.05) * rect.height).max(1.0);
-                // Bottom-bow edge rise; the shader derives the arc radius
-                // from it per drop (R = hx²/2·rise).
-                let bow = (spec.bow.clamp(0.0, 0.5) * rect.height).min(hy * 0.9);
                 plate = Some(crate::vk::PlatePush {
                     rect: [
                         (rect.x + rect.width * 0.5) * scale,
@@ -2167,6 +2225,16 @@ pub fn tessellate_display_list(
                     shape: spec.curve.clamp(2.0, 6.0),
                 });
             }
+            Prim::DropletScrim { rect, color, spec, .. } => {
+                // Legacy banded path: no SDF to feather against, so the scrim
+                // degrades to the same flat outline the drop itself does —
+                // hard-edged, but present. A prim with no arm here VANISHES.
+                let cap = (rect.height * 0.5).min(rect.width * 0.5);
+                let sr = (spec.sheet_r.clamp(0.0, 1.0) * rect.height).min(cap);
+                let ar = (spec.attach.clamp(0.0, 1.0) * rect.height).min(cap);
+                let radii = crate::widget::CornerRadii::new(ar, ar, sr, sr);
+                push_rounded_rect_vertices_corners(rect.x, rect.y, rect.width, rect.height, radii, sw, sh, *color, no, None, &mut verts);
+            }
             Prim::Droplet { rect, color, spec } => {
                 // Legacy banded path: the flat drop outline — attach-tapered
                 // top, round bottom. Degrades the material but keeps the
diff --git a/src/scene/paint.rs b/src/scene/paint.rs
index 09e522f..866dfc7 100644
--- a/src/scene/paint.rs
+++ b/src/scene/paint.rs
@@ -445,6 +445,18 @@ pub enum Prim {
     /// 0`) path it degrades to the flat hanging capsule — square top, round
     /// bottom — rather than vanishing.
     Droplet { rect: Rect, color: [f32; 4], spec: DropletSpec },
+    /// The same silhouette as [`Prim::Droplet`] under the same [`DropletSpec`],
+    /// filled FLAT and feathered inward: opaque through the interior, fading
+    /// to nothing over `feather` px as it approaches the drop's edge. A
+    /// vignette shaped exactly like the drop, for grounding text drawn on top
+    /// of one — not a second lit body, so it carries no dome, rim, gleam or
+    /// contact shadow.
+    ///
+    /// It shares the droplet's shader path rather than approximating the
+    /// outline with a rounded rect, so the two can never disagree about where
+    /// the drop's edge is. On the legacy (`bevel_shader 0`) path it degrades
+    /// to the same flat rounded-rect outline `Prim::Droplet` falls back to.
+    DropletScrim { rect: Rect, color: [f32; 4], spec: DropletSpec, feather: f32 },
     /// A concave inside-corner fillet for composed carves: a quarter-arc wall
     /// whose centre `(cx, cy)` sits out in the corner's pocket, shaded with the
     /// same step profile as a `Recess`/`Boss` wall (`raised` flips the sign).
@@ -770,6 +782,13 @@ impl PaintCtx {
 
     /// A hanging water droplet clinging to `rect`'s top edge — see
     /// [`Prim::Droplet`] and [`DropletSpec`].
+    /// See [`Prim::DropletScrim`]. `feather` is how far in from the drop's
+    /// edge the fill reaches full opacity, in logical px.
+    pub fn droplet_scrim(&mut self, rect: Rect, color: [f32; 4], spec: DropletSpec, feather: f32) {
+        let rect = self.apply_offset(rect);
+        self.push(Prim::DropletScrim { rect, color, spec, feather });
+    }
+
     pub fn droplet(&mut self, rect: Rect, color: [f32; 4], spec: DropletSpec) {
         let rect = self.apply_offset(rect);
         self.push(Prim::Droplet { rect, color, spec });
@@ -833,6 +852,9 @@ impl PaintCtx {
             Prim::Sphere { cx, cy, radius, color } => self.sphere(cx, cy, radius, color),
             Prim::Glow { rect, radius, reach, color } => self.glow(rect, radius, reach, color),
             Prim::Droplet { rect, color, spec } => self.droplet(rect, color, spec),
+            Prim::DropletScrim { rect, color, spec, feather } => {
+                self.droplet_scrim(rect, color, spec, feather)
+            }
             Prim::ConcaveFillet { cx, cy, radius, depth, start, raised } => {
                 self.concave_fillet(cx, cy, radius, depth, start, raised)
             }
diff --git a/src/vk/shader2d.wgsl b/src/vk/shader2d.wgsl
index 2bac4df..3880d47 100644
--- a/src/vk/shader2d.wgsl
+++ b/src/vk/shader2d.wgsl
@@ -131,6 +131,7 @@ const MODE_GROOVE: i32 = 8;       // slab carve about an arbitrary line
 const MODE_TROUGH: i32 = 9;       // sunken valley straddling the boundary
 const MODE_DROPLET: i32 = 10;     // hanging water droplet clinging to the box top
 const MODE_ROLL: i32 = 11;        // fill-less rolled perimeter, composited as an overlay
+const MODE_DROPLET_SCRIM: i32 = 12; // flat feathered fill of the droplet silhouette
 // Fillet modes rejoin the shared free-carve path as their flat equivalents.
 const FILLET_TO_STEP: i32 = 4;    // 6 -> RECESS, 7 -> BOSS
 
@@ -388,7 +389,7 @@ fn plate_shade(frag: vec2f, vcol: vec4f) -> vec4f {
     // (f³, like PLATE_CREST but tunable) and a thin-edge clarity falloff on
     // the tint alpha, so the (compositor- or resolve_blur-) frosted backdrop
     // shows through clearer at the rim.
-    if (mode == MODE_DROPLET) {
+    if (mode == MODE_DROPLET || mode == MODE_DROPLET_SCRIM) {
         let c = rrect_clip.p_rect.xy;
         let hx = rrect_clip.p_rect.z;
         let hy = rrect_clip.p_rect.w;
@@ -440,6 +441,24 @@ fn plate_shade(frag: vec2f, vcol: vec4f) -> vec4f {
 
         let din = -d;
         let aa2 = clamp(din + 0.5, 0.0, 1.0);
+        // MODE_DROPLET_SCRIM: the same silhouette, filled flat and feathered
+        // inward — a vignette shaped exactly like the drop it sits in, for a
+        // caller that needs a legible ground under text without a second lit
+        // body. It shares this mode's SDF rather than approximating the shape
+        // with a rounded rect, which is the whole point: the two can never
+        // disagree about where the drop's edge is. p_light.w carries the
+        // feather (px) instead of the shading band, which is only read below.
+        if (mode == MODE_DROPLET_SCRIM) {
+            if (aa2 <= 0.0) {
+                discard;
+            }
+            let fth = max(rrect_clip.p_light.w, 0.001);
+            let sa2 = vcol.a * clamp(din / fth, 0.0, 1.0) * aa2;
+            if (sa2 <= 0.004) {
+                discard;
+            }
+            return vec4f(vcol.rgb, sa2);
+        }
         if (aa2 <= 0.0) {
             // Outside the silhouette: the contact shadow — a soft dark
             // falloff cast below the drop's lower arc (weighted by the