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

commit900ae50c0c1956d2eeca1c01d82fd0c8df73f261
parentd6892c834e
authorLucas Galante <[email protected]>
date2026-07-24 14:13
feat: custom edge profile — a second ramp LUT for the plate roll

set_roll_profile_keys installs a descent-progress curve (face join 0 →
silhouette 1) sampled into a slope LUT the shader's roll_slope reads in
place of the analytic superellipse quadrant, exactly as carve_slope does
for recess/boss walls. WindowInfo grows a roll meta + LUT slot (160 →
304 bytes) with its own generation counter; the face-end taper keeps
interior pixels flat. No profile installed = the analytic (truncated)
quadrant, unchanged.

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

 src/layout.rs        | 53 ++++++++++++++++++++++++++++++++++++++++++++++++++--
 src/vk/renderer.rs   | 21 +++++++++++++++++----
 src/vk/shader2d.wgsl | 22 ++++++++++++++++++++++
 3 files changed, 90 insertions(+), 6 deletions(-)

diff --git a/src/layout.rs b/src/layout.rs
index 126a9ae..84dd779 100644
--- a/src/layout.rs
+++ b/src/layout.rs
@@ -1553,6 +1553,16 @@ static BEVEL_PROFILE: std::sync::RwLock<Option<[f32; BEVEL_PROFILE_SAMPLES]>> =
 /// Bumped on every profile change so renderers know to re-upload their LUT.
 static BEVEL_PROFILE_GEN: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
 
+/// The custom EDGE (plate roll) profile — same slope-LUT encoding as
+/// [`BEVEL_PROFILE`], but read by the shader's `roll_slope` for the perimeter
+/// roll of widget-scale plates: `v` runs 0 at the face join → 1 at the
+/// silhouette, and the curve is the roll's descent progress (0 = face height,
+/// 1 = fully dropped), so the identity curve is a straight chamfer and `None`
+/// is the analytic superellipse quadrant.
+static ROLL_PROFILE: std::sync::RwLock<Option<[f32; BEVEL_PROFILE_SAMPLES]>> =
+    std::sync::RwLock::new(None);
+static ROLL_PROFILE_GEN: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
+
 /// Evaluate a ramp key list at `t` — the same piecewise interpolation
 /// `cce_ui::widget::Ramp::get_interpolated_value` draws, so the bevel renders
 /// exactly the curve the ramp widget shows (`smooth` = the widget's Bezier line
@@ -1593,6 +1603,13 @@ pub fn set_bevel_profile_keys(keys: &[(f32, f32)], smooth: bool) {
         clear_bevel_profile();
         return;
     }
+    *BEVEL_PROFILE.write().unwrap() = Some(ramp_slope_lut(keys, smooth));
+    BEVEL_PROFILE_GEN.fetch_add(1, std::sync::atomic::Ordering::Release);
+}
+
+/// A ramp key list sampled into the shader's slope LUT — slot `i` holds the
+/// curve's slope at `v = (i + 0.5) / N`.
+fn ramp_slope_lut(keys: &[(f32, f32)], smooth: bool) -> [f32; BEVEL_PROFILE_SAMPLES] {
     let n = BEVEL_PROFILE_SAMPLES;
     let mut slopes = [0.0f32; BEVEL_PROFILE_SAMPLES];
     for (i, slot) in slopes.iter_mut().enumerate() {
@@ -1600,8 +1617,40 @@ pub fn set_bevel_profile_keys(keys: &[(f32, f32)], smooth: bool) {
         let h1 = sample_ramp_keys(keys, smooth, (i + 1) as f32 / n as f32);
         *slot = (h1 - h0) * n as f32;
     }
-    *BEVEL_PROFILE.write().unwrap() = Some(slopes);
-    BEVEL_PROFILE_GEN.fetch_add(1, std::sync::atomic::Ordering::Release);
+    slopes
+}
+
+/// Install a custom EDGE profile for the plate perimeter roll from ramp keys —
+/// the [`set_bevel_profile_keys`] twin for [`ROLL_PROFILE`]. The curve is the
+/// roll's descent progress from the face join (0) to the silhouette (1); the
+/// shader's `roll_slope` samples it in place of the analytic superellipse
+/// quadrant. Empty or single-key lists clear back to the analytic roll.
+pub fn set_roll_profile_keys(keys: &[(f32, f32)], smooth: bool) {
+    if keys.len() < 2 {
+        clear_roll_profile();
+        return;
+    }
+    *ROLL_PROFILE.write().unwrap() = Some(ramp_slope_lut(keys, smooth));
+    ROLL_PROFILE_GEN.fetch_add(1, std::sync::atomic::Ordering::Release);
+}
+
+/// Drop the custom edge profile — plate rolls return to the analytic quadrant.
+pub fn clear_roll_profile() {
+    let mut guard = ROLL_PROFILE.write().unwrap();
+    if guard.is_some() {
+        *guard = None;
+        ROLL_PROFILE_GEN.fetch_add(1, std::sync::atomic::Ordering::Release);
+    }
+}
+
+/// The installed edge profile's slope LUT, if any — what the renderer uploads.
+pub fn roll_profile_slopes() -> Option<[f32; BEVEL_PROFILE_SAMPLES]> {
+    *ROLL_PROFILE.read().unwrap()
+}
+
+/// Change counter for [`roll_profile_slopes`].
+pub fn roll_profile_generation() -> u64 {
+    ROLL_PROFILE_GEN.load(std::sync::atomic::Ordering::Acquire)
 }
 
 /// Drop the custom bevel profile — walls return to the analytic smoothstep.
diff --git a/src/vk/renderer.rs b/src/vk/renderer.rs
index df9d81e..c1573f0 100644
--- a/src/vk/renderer.rs
+++ b/src/vk/renderer.rs
@@ -94,7 +94,8 @@ pub const MAX_PLATE_FEATURES: usize = 64;
 const PLATE_FEATURE_BYTES: usize = 48;
 /// shader2d's WindowInfo UBO: [size/clip vec4][bevel-profile meta vec4]
 /// [8 vec4 of profile slope samples].
-const WINDOW_INFO_BYTES: vk::DeviceSize = 160;
+// [size/clip vec4][carve profile meta + 8 vec4][roll profile meta + 8 vec4].
+const WINDOW_INFO_BYTES: vk::DeviceSize = 304;
 
 pub(crate) struct AllocatedBuffer {
     pub(crate) buffer: vk::Buffer,
@@ -214,6 +215,8 @@ pub struct VkRenderer {
     /// The bevel-profile generation `window_info` was last written with —
     /// `draw_frame_2d` rewrites the UBO when the layout global moves on.
     profile_gen: u64,
+    /// Same for the edge (roll) profile LUT.
+    roll_profile_gen: u64,
     plate_features: AllocatedBuffer,
 
     frames: Vec<Frame>,
@@ -825,6 +828,7 @@ impl VkRenderer {
             backdrop_sampler,
             window_info,
             profile_gen: 0,
+            roll_profile_gen: 0,
             plate_features,
             frames,
             frame_index: 0,
@@ -859,8 +863,9 @@ impl VkRenderer {
     }
 
     fn write_window_info(&mut self) {
-        // [size/clip vec4][profile meta vec4][8 vec4 of profile slope samples]
-        // — must stay in lockstep with shader2d's WindowInfo.
+        // [size/clip vec4][carve profile meta vec4][8 vec4 carve slopes]
+        // [roll profile meta vec4][8 vec4 roll slopes] — must stay in
+        // lockstep with shader2d's WindowInfo.
         let mut data = [0.0f32; WINDOW_INFO_BYTES as usize / 4];
         data[0] = self.extent.width as f32;
         data[1] = self.extent.height as f32;
@@ -871,7 +876,13 @@ impl VkRenderer {
             data[5] = crate::layout::BEVEL_PROFILE_SAMPLES as f32;
             data[8..8 + slopes.len()].copy_from_slice(&slopes);
         }
+        if let Some(slopes) = crate::layout::roll_profile_slopes() {
+            data[40] = 1.0;
+            data[41] = crate::layout::BEVEL_PROFILE_SAMPLES as f32;
+            data[44..44 + slopes.len()].copy_from_slice(&slopes);
+        }
         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() {
             allocation.mapped_slice_mut().unwrap()[..WINDOW_INFO_BYTES as usize]
                 .copy_from_slice(bytemuck::cast_slice(&data));
@@ -1430,7 +1441,9 @@ impl VkRenderer {
             // Re-upload the bevel-profile LUT when it changed (a live ramp
             // edit). The other in-flight frame may still read the old bytes —
             // both are valid profiles, so the one-frame mix is benign.
-            if self.profile_gen != crate::layout::bevel_profile_generation() {
+            if self.profile_gen != crate::layout::bevel_profile_generation()
+                || self.roll_profile_gen != crate::layout::roll_profile_generation()
+            {
                 self.write_window_info();
             }
 
diff --git a/src/vk/shader2d.wgsl b/src/vk/shader2d.wgsl
index 2ca4a0c..31e1e4d 100644
--- a/src/vk/shader2d.wgsl
+++ b/src/vk/shader2d.wgsl
@@ -21,6 +21,12 @@ struct WindowInfo {
     // carve floor / boss crest), sample i at v = (i + 0.5) / count, packed 4
     // per vec4. carve_slope reads these in place of its analytic smoothstep.
     profile: array<vec4f, 8>,
+    // Custom EDGE profile for the plate perimeter roll
+    // (cce_ui::layout::set_roll_profile_keys) — same encoding, read by
+    // roll_slope in place of the analytic superellipse quadrant. The curve is
+    // the roll's descent progress: 0 at the face join, 1 at the silhouette.
+    roll_meta: vec4f,
+    roll_profile: array<vec4f, 8>,
 }
 
 @group(0) @binding(2) var<uniform> window_info: WindowInfo;
@@ -198,6 +204,22 @@ fn roll_spec(sv: vec2f) -> f32 {
 const ROLL_CUT: f32 = 0.8;
 
 fn roll_slope(f: f32) -> f32 {
+    // Custom edge profile: sample the uploaded ramp LUT. Face pixels saturate
+    // at f = 0 (the roll band's interior end), so taper the slope to zero
+    // there or every face pixel would inherit the curve's start slope; the
+    // silhouette end keeps whatever slope the curve was drawn ending on.
+    if (window_info.roll_meta.x > 0.5) {
+        let n = window_info.roll_meta.y;
+        let fcl = clamp(f, 0.0, 1.0);
+        let x = clamp(fcl * n - 0.5, 0.0, n - 1.0);
+        let i0 = u32(floor(x));
+        let i1 = min(i0 + 1u, u32(n) - 1u);
+        let fr = x - floor(x);
+        let s0 = window_info.roll_profile[i0 >> 2u][i0 & 3u];
+        let s1 = window_info.roll_profile[i1 >> 2u][i1 & 3u];
+        let win = clamp(fcl * n * 0.667, 0.0, 1.0);
+        return mix(s0, s1, fr) * win;
+    }
     let shape = rrect_clip.rect1.w;
     let fc = f * ROLL_CUT;
     if (shape > 2.001) {