GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git
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