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