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

commitc68684c91bf7d66743fcee2a6782a036ddf6d696
parentc674a2772a
authorLucas Galante <[email protected]>
date2026-09-20 01:30
feat(plate): rim refraction — the roll bends what you see through it

style.surface.plate.refraction (0..1, default 0). The other half of the
glass question: not "can I read this" but "is this an object". A plate
whose body has been flattened toward its tint by backdrop_compression
reads as a scrim; the rim is where the material can still say what it
is, and on a dark desktop it is the ONLY place, since there is barely
any luminance variation behind the plate to show through in the first
place.

The roll is a real surface with a real tilt and sv_rim IS that tilt --
the unnormalized normal's horizontal part, already computed for the
specular -- so displacing the backdrop sample along it is what a curved
edge does to what you see through it. Scaled by the roll width, so a
12px bevel bends more than a 2px one and the effect tracks the plate's
own geometry.

It samples the CLEAN backdrop, cross-fading to the frosted body on f*f.
Refraction must bend something with STRUCTURE or it is invisible:
displacing a field already blurred to sigma ~11px moves smooth values
around and reads as nothing. A thin edge also scatters over a shorter
path than a thick middle, which is what the droplet branch already
trades on. One extra tap, not three -- per-channel dispersion inside a
band this narrow is invisible once the body is 49 taps, and would
triple the most expensive path in this shader to be erased.

The clear rim is exempt from backdrop_compression in proportion to how
clear it is. Compression is a legibility control and the rim carries no
text; tone-mapping it pulls the refracted view back toward the plate's
own key, which is the exact contrast the rim exists to show. Measured,
the two fighting made the effect nearly invisible: exempting the rim
made it 5.9x stronger at the same setting (rim pixel change 1.50 ->
8.86 of 255 at 0.3) with the body still under 0.4.

Verified by sweeping 0/0.3/0.6/1.0 in a headless shadow and diffing the
roll band against the body: the change scales linearly with the setting
and stays in the roll. Useful range ~0.3-0.6.

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

 CLAUDE.md            | 26 +++++++++++++++++++++
 src/color.rs         | 38 ++++++++++++++++++++++++++++++
 src/vk/renderer.rs   | 12 ++++++++++
 src/vk/shader2d.wgsl | 66 +++++++++++++++++++++++++++++++++++++++++++---------
 4 files changed, 131 insertions(+), 11 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index cc12b61..bb879c8 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -353,6 +353,32 @@ What this buys, and where the code is heading:
   dark backdrop at EVERY k: there is little luminance variation behind the
   plate there to begin with, so "glass" on a dark desktop is carried by the rim
   and bevel, not by the backdrop.
+- **`style.surface.plate.refraction` (0..1, default 0) is the rim, and it buys
+  no legibility.** It is the answer to the other half of the question — not
+  "can I read this" but "is this an object". The roll is a real surface with a
+  real tilt, and `sv_rim` IS that tilt (the unnormalized normal's horizontal
+  part, already computed for the specular), so displacing the backdrop sample
+  along it is what a curved edge does to what you see through it. Scaled by the
+  roll width, so a 12px bevel bends more than a 2px one.
+
+  **It samples the CLEAN backdrop, not the blurred one**, cross-fading to the
+  frosted body on `f*f`. Refraction has to bend something with STRUCTURE or it
+  is invisible: displacing a field already blurred to sigma ~11px just moves
+  smooth values around. A thin edge scattering over a shorter path than a thick
+  middle is also what a real slab does — the droplet branch trades on the same
+  thing ("thin edges are clearer water"). One extra tap, not three: per-channel
+  dispersion inside a band this narrow is invisible once the body is 49 taps,
+  and paying for it would triple the most expensive path in this shader to be
+  erased.
+
+  **The clear rim is exempt from `backdrop_compression`**, in proportion to how
+  clear it is. Compression is a legibility control and the rim carries no text;
+  tone-mapping it pulls the refracted view back toward the plate's own key,
+  which is the exact contrast the rim exists to show. Measured, the two
+  fighting made the effect nearly invisible — exempting the rim made it **5.9x
+  stronger** at the same setting (rim pixel change 1.50 -> 8.86 of 255 at 0.3),
+  with the body still under 0.4. Useful range is ~0.3-0.6; the effect is in the
+  roll and stays there.
 
 ## The `scene/` core rebuild (read `docs/rfc-core-rebuild.md` before touching it)
 
diff --git a/src/color.rs b/src/color.rs
index 97beb38..af48fda 100644
--- a/src/color.rs
+++ b/src/color.rs
@@ -651,6 +651,9 @@ fn parse_and_set_colors(content: &str) {
     {
         if let Ok(mut lock) = PLATE_BACKDROP_COMPRESSION.write() { *lock = (c as f32).clamp(0.0, 1.0); }
     }
+    if let Some(r) = val.pointer("/style/surface/plate/refraction").and_then(|v| v.as_f64()) {
+        if let Ok(mut lock) = PLATE_REFRACTION.write() { *lock = (r as f32).clamp(0.0, 1.0); }
+    }
     if let Some(blur) = val.pointer("/style/surface/plate/blur").and_then(|v| v.as_bool()) {
         if let Ok(mut lock) = PLATE_BLUR.write() { *lock = blur; }
     } else if let Some(blur_val) = val.pointer("/style/surface/plate/blur").and_then(|v| v.as_f64()) {
@@ -1730,6 +1733,29 @@ pub fn set_plate_backdrop_compression(c: f32) {
     style_write(&PLATE_BACKDROP_COMPRESSION, c.clamp(0.0, 1.0));
 }
 
+/// How far a frosted plate's roll bends what it samples, and how much clearer
+/// its rim is than its frosted body: 0 = a flat window, 1 = full.
+///
+/// This buys no legibility and is not meant to — see
+/// [`plate_backdrop_compression`] for that. What it buys is the plate reading
+/// as an OBJECT: a curved edge displaces the view through it, so the
+/// silhouette stops being where the haze ends and becomes where a slab with a
+/// thickness begins. The two are complementary, and on a dark desktop
+/// especially: compression flattens the body toward the tint, which leaves the
+/// rim as the only place the material can still say what it is.
+///
+/// Default 0.0 — no existing config changes appearance.
+static PLATE_REFRACTION: RwLock<f32> = RwLock::new(0.0);
+
+pub fn plate_refraction() -> f32 {
+    load_colors_once();
+    style_read(&PLATE_REFRACTION)
+}
+
+pub fn set_plate_refraction(r: f32) {
+    style_write(&PLATE_REFRACTION, r.clamp(0.0, 1.0));
+}
+
 static PLATE_BLUR: RwLock<bool> = RwLock::new(false);
 
 pub fn plate_blur() -> bool {
@@ -1937,6 +1963,18 @@ mod tests {
         assert_eq!(parse_hex_rgba_linear("#050508").unwrap()[3], 1.0);
     }
 
+    /// Rim refraction defaults OFF and clamps, like its neighbour.
+    #[test]
+    fn plate_refraction_defaults_off_and_clamps() {
+        assert_eq!(plate_refraction(), 0.0, "off unless a config asks");
+        set_plate_refraction(0.6);
+        assert_eq!(plate_refraction(), 0.6);
+        set_plate_refraction(9.0);
+        assert_eq!(plate_refraction(), 1.0);
+        set_plate_refraction(-0.5);
+        assert_eq!(plate_refraction(), 0.0);
+    }
+
     /// The plate's backdrop compression defaults OFF and clamps.
     ///
     /// Off is load-bearing: it is the behaviour every config already in the
diff --git a/src/vk/renderer.rs b/src/vk/renderer.rs
index 5d96385..05b2e54 100644
--- a/src/vk/renderer.rs
+++ b/src/vk/renderer.rs
@@ -260,6 +260,7 @@ pub struct VkRenderer {
     /// them straight into the style registry with no generation counter.
     relief_uploaded: (f32, f32),
     compression_uploaded: f32,
+    refraction_uploaded: f32,
     /// Same for the edge (roll) profile LUT.
     roll_profile_gen: u64,
     plate_features: AllocatedBuffer,
@@ -877,6 +878,7 @@ impl VkRenderer {
             profile_gen: 0,
             relief_uploaded: (0.0, 0.0),
             compression_uploaded: 0.0,
+            refraction_uploaded: 0.0,
             roll_profile_gen: 0,
             plate_features,
             frames,
@@ -920,6 +922,12 @@ impl VkRenderer {
         crate::color::plate_backdrop_compression()
     }
 
+    /// How far a plate's roll refracts its backdrop — tracked for re-upload
+    /// beside the compression, being live-editable config the same way.
+    fn refraction(&self) -> f32 {
+        crate::color::plate_refraction()
+    }
+
     /// The pinned relief heights in physical px, 0 = follow the width.
     fn relief_px(&self) -> (f32, f32) {
         let s = crate::scale::scale_factor().max(0.001);
@@ -956,6 +964,9 @@ impl VkRenderer {
         let compression = self.backdrop_compression();
         data[80] = compression;
         self.compression_uploaded = compression;
+        let refraction = self.refraction();
+        data[81] = refraction;
+        self.refraction_uploaded = refraction;
         self.profile_gen = crate::layout::bevel_profile_generation();
         self.roll_profile_gen = crate::layout::roll_profile_generation();
         if let Some(allocation) = self.window_info.allocation.as_mut() {
@@ -1580,6 +1591,7 @@ impl VkRenderer {
                 || self.roll_profile_gen != crate::layout::roll_profile_generation()
                 || self.relief_uploaded != self.relief_px()
                 || self.compression_uploaded != self.backdrop_compression()
+                || self.refraction_uploaded != self.refraction()
             {
                 self.write_window_info();
             }
diff --git a/src/vk/shader2d.wgsl b/src/vk/shader2d.wgsl
index 093558d..e528c97 100644
--- a/src/vk/shader2d.wgsl
+++ b/src/vk/shader2d.wgsl
@@ -35,8 +35,10 @@ struct WindowInfo {
     // the same geometry whatever wall it is cut with.
     relief_meta: vec4f,
     // x = how hard a frosted plate pulls its backdrop's luminance toward its
-    // own key (0 = untouched, 1 = flat). See `resolve_blur`. Appended last so
-    // the established offsets above keep their indices.
+    // own key (0 = untouched, 1 = flat). See `resolve_blur`.
+    // y = rim refraction: how far the plate's roll displaces what it samples,
+    // and how much CLEARER the rim is than the frosted body. See MODE_PLATE.
+    // Appended last so the established offsets above keep their indices.
     backdrop_meta: vec4f,
 }
 
@@ -501,7 +503,7 @@ fn plate_shade(frag: vec2f, vcol: vec4f) -> vec4f {
         }
         var base = vcol;
         if (vcol.a < 0.0) {
-            base = resolve_blur(frag, vcol);
+            base = resolve_blur(frag, vcol, vec2f(0.0), 0.0);
         }
         let t2 = max(rrect_clip.p_light.w, 0.001);
         let u = clamp(din / t2, 0.0, 1.0);
@@ -541,10 +543,6 @@ fn plate_shade(frag: vec2f, vcol: vec4f) -> vec4f {
         if (aa <= 0.0) {
             discard;
         }
-        var base = vcol;
-        if (vcol.a < 0.0) {
-            base = resolve_blur(frag, vcol);
-        }
         let u = clamp(d / t, 0.0, 1.0);
         let f = 1.0 - u;
         // Host roll slope vector: vertical at the silhouette, flat where the
@@ -555,6 +553,27 @@ fn plate_shade(frag: vec2f, vcol: vec4f) -> vec4f {
         // focused plate's accent ring traces this alone (see the tinted
         // branch), so the wells carved into it never wear the ring too.
         let sv_rim = sv;
+
+        // Rim refraction, resolved BEFORE the shading below because the
+        // backdrop it bends is `base`.
+        //
+        // The roll is a real surface with a real tilt — `sv_rim` IS that tilt
+        // (the horizontal part of the unnormalized normal), already computed
+        // for the specular. Displacing the backdrop sample along it is what a
+        // curved edge does to what you see through it: the view compresses
+        // toward the silhouette and the plate stops being a rectangle of haze
+        // and starts being a slab with a thickness.
+        //
+        // Scaled by the roll width `t`, so a 12px bevel bends more than a 2px
+        // one and the effect tracks the plate's own geometry rather than
+        // drifting off it at another radius. The clarity ramp is f*f — the
+        // clear window belongs to the outer third of the roll, and the face
+        // must reach zero exactly or the whole plate unfrosts.
+        let refr = clamp(window_info.backdrop_meta.y, 0.0, 1.0);
+        var base = vcol;
+        if (vcol.a < 0.0) {
+            base = resolve_blur(frag, vcol, sv_rim * (refr * t), refr * f * f);
+        }
         var extra = PLATE_CREST * f * f * f;
         let f_off = u32(rrect_clip.p_host.x);
         let f_cnt = u32(rrect_clip.p_host.y);
@@ -940,7 +959,8 @@ fn fs_main(in: VertexOutput) -> @location(0) vec4f {
     // Blur-behind plate: negative alpha mixes the (blurred) backdrop with the
     // plate color at |alpha| opacity.
     if (in.color.a < 0.0) {
-        let c = resolve_blur(in.clip_position.xy, in.color);
+        // A plain blur-behind quad has no roll to refract through.
+        let c = resolve_blur(in.clip_position.xy, in.color, vec2f(0.0), 0.0);
         return vec4f(c.rgb, c.a * clip_cov);
     }
 
@@ -951,7 +971,7 @@ fn fs_main(in: VertexOutput) -> @location(0) vec4f {
 // FULLY blurred backdrop is the base (no clean-backdrop passthrough; mixing
 // the clean sample back in at plate opacity left translucent plates barely
 // blurred), tinted by the plate color at |alpha| opacity.
-fn resolve_blur(pos: vec2f, color: vec4f) -> vec4f {
+fn resolve_blur(pos: vec2f, color: vec4f, refract: vec2f, clarity: f32) -> vec4f {
     let tex_size = vec2f(textureDimensions(t_backdrop));
 
     var blurred = vec4f(0.0);
@@ -972,7 +992,26 @@ fn resolve_blur(pos: vec2f, color: vec4f) -> vec4f {
         }
     }
 
-    let backdrop_color = blurred / total_weight;
+    var backdrop_color = blurred / total_weight;
+
+    // The rim's clear window onto the backdrop.
+    //
+    // Refraction has to sample something with STRUCTURE or it is invisible:
+    // displacing a field that has already been blurred to sigma ~11px moves
+    // smooth values around and reads as nothing at all. So the rim takes a
+    // CLEAN sample, displaced by the roll's tilt, and cross-fades to the
+    // frosted body — which is also what a real slab does, its thin edge
+    // scattering over a shorter path than its thick middle (the droplet
+    // branch already trades on that: "thin edges are clearer water").
+    //
+    // One extra tap, not three: per-channel dispersion inside a band this
+    // narrow is invisible once the body blur is 49 taps, and paying for it
+    // would triple the most expensive path in this shader to be erased.
+    if (clarity > 0.001) {
+        let clean = textureSample(t_backdrop, s_backdrop, (pos + refract) / tex_size);
+        backdrop_color = mix(backdrop_color, clean, clamp(clarity, 0.0, 1.0));
+    }
+
     let opacity = -color.a;
 
     // Luminance-range compression, the plate's legibility control.
@@ -990,7 +1029,12 @@ fn resolve_blur(pos: vec2f, color: vec4f) -> vec4f {
     // SYMMETRIC, pulling a bright backdrop down and a dark one UP, so what it
     // removes is the plate's swing through the ink's luminance rather than
     // the view through it. Hue, chroma and movement all still read.
-    let k = clamp(window_info.backdrop_meta.x, 0.0, 1.0);
+    // Compression is a LEGIBILITY control and the rim carries no text, so the
+    // clear window opened there is exempt in proportion to how clear it is.
+    // Tone-mapping it would pull the refracted view back toward the plate's
+    // own key — the exact contrast the rim exists to show — and the effect
+    // measured nearly invisible with the two fighting.
+    let k = clamp(window_info.backdrop_meta.x, 0.0, 1.0) * (1.0 - clamp(clarity, 0.0, 1.0));
     let W = vec3f(0.2126, 0.7152, 0.0722);
     let bl = dot(backdrop_color.rgb, W);
     let key = dot(color.rgb, W);