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