GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git
feat: rounded-rect clipping so plate children cut off at rounded corners
New PaintCtx::clip_rounded (push_clip_rounded / pop_clip_rounded): pushes
the rect as a scissor and records a rounded-rect SDF clip
[cx, cy, bx, by, r] on emitted items. Geometry applies it per draw batch
via push constants on the 2D pipeline (fragments outside the SDF
discard); DlBatch/Batch2D carry the clip and batches split on it. Text
applies it per glyph: TextSpan/GlyphVertex gain clip_extents, and
glyph.wgsl's circle test generalizes to the rounded-rect SDF (zero
extents = the old circle, so the circular pane is unchanged). TextItem
carries the paint item's clip_rrect into the glyph pass.
naga validation now enables PUSH_CONSTANT; the 2D pipeline layout
declares a 32-byte fragment push-constant range, cleared before the
overlay draw.
TextSpan gains a required clip_extents field — consumers constructing
spans directly add clip_extents: [0.0; 2].
Co-Authored-By: Claude Fable 5 <[email protected]>
src/backend/window_runner.rs | 34 ++++++++++++++++++--------
src/scene/paint.rs | 54 ++++++++++++++++++++++++++++++++++++++---
src/vk/glyph.wgsl | 15 ++++++++----
src/vk/renderer.rs | 40 +++++++++++++++++++++++++++---
src/vk/shader2d.wgsl | 16 ++++++++++++
src/vk/text.rs | 20 ++++++++++++---
src/widget/display/label.rs | 1 +
src/widget/display/list_item.rs | 5 +++-
8 files changed, 157 insertions(+), 28 deletions(-)
diff --git a/src/backend/window_runner.rs b/src/backend/window_runner.rs
index 44efdc4..85e8b76 100644
--- a/src/backend/window_runner.rs
+++ b/src/backend/window_runner.rs
@@ -1003,10 +1003,13 @@ pub fn push_widget_vertices(w: &dyn crate::widget::WidgetHost, sw: f32, sh: f32,
}
}
-/// A contiguous run of vertices sharing one scissor rect (Phase 3 single paint path). `scissor` is
-/// a logical-pixel clip (`None` = unclipped); `start..end` indexes the flat vertex buffer.
+/// A contiguous run of vertices sharing one scissor rect (Phase 3 single paint path) and one
+/// rounded-rect clip. `scissor` is a logical-pixel clip (`None` = unclipped); `clip_rrect` is
+/// the paint walk's `[cx, cy, bx, by, r]` rounded clip in logical px (`None` = unclipped),
+/// applied as per-draw push-constant state; `start..end` indexes the flat vertex buffer.
pub struct DlBatch {
pub scissor: Option<crate::scene::layout::Rect>,
+ pub clip_rrect: Option<[f32; 5]>,
pub start: u32,
pub end: u32,
}
@@ -1116,14 +1119,14 @@ pub fn tessellate_display_list(
v.clip_circle = no;
}
}
- // Merge into the previous batch if it shares this clip and is contiguous.
+ // Merge into the previous batch if it shares this clip pair and is contiguous.
if let Some(last) = batches.last_mut() {
- if last.scissor == item.clip && last.end == start {
+ if last.scissor == item.clip && last.clip_rrect == item.clip_rrect && last.end == start {
last.end = end;
continue;
}
}
- batches.push(DlBatch { scissor: item.clip, start, end });
+ batches.push(DlBatch { scissor: item.clip, clip_rrect: item.clip_rrect, start, end });
}
(verts, batches, images)
@@ -1893,6 +1896,7 @@ impl<A: Application> EngineState<A> {
),
bounds: merged,
clip_circle: item.clip_circle,
+ clip_rrect: item.clip_rrect,
});
}
}
@@ -1903,7 +1907,7 @@ impl<A: Application> EngineState<A> {
let pre_custom = verts.len() as u32;
self.inner.as_mut().unwrap().custom_vertices(&mut verts, LogicalSize::new(logical_w, logical_h), scale_factor);
if (verts.len() as u32) > pre_custom {
- dl_batches.push(DlBatch { scissor: None, start: pre_custom, end: verts.len() as u32 });
+ dl_batches.push(DlBatch { scissor: None, clip_rrect: None, start: pre_custom, end: verts.len() as u32 });
}
// 1b. Overlay quads (drawn after the text pass).
@@ -1979,10 +1983,17 @@ impl<A: Application> EngineState<A> {
ti.color.a() as f32 / 255.0,
],
rotation: None,
- clip_circle: ti
- .clip_circle
- .map(|c| [c[0] * scale_f32, c[1] * scale_f32, c[2] * scale_f32])
- .unwrap_or([0.0; 3]),
+ // Circle wins when both are set (the circular pane's innermost clip);
+ // otherwise a rounded-rect clip rides as center+radius with extents.
+ clip_circle: match (ti.clip_circle, ti.clip_rrect) {
+ (Some(c), _) => [c[0] * scale_f32, c[1] * scale_f32, c[2] * scale_f32],
+ (None, Some(rr)) => [rr[0] * scale_f32, rr[1] * scale_f32, rr[4] * scale_f32],
+ (None, None) => [0.0; 3],
+ },
+ clip_extents: match (ti.clip_circle, ti.clip_rrect) {
+ (None, Some(rr)) => [rr[2] * scale_f32, rr[3] * scale_f32],
+ _ => [0.0; 2],
+ },
});
}
@@ -2029,6 +2040,9 @@ impl<A: Application> EngineState<A> {
(clip.height * scale_f32) as u32,
)
}),
+ clip_rrect: batch
+ .clip_rrect
+ .map(|c| [c[0] * scale_f32, c[1] * scale_f32, c[2] * scale_f32, c[3] * scale_f32, c[4] * scale_f32]),
start: batch.start,
end: batch.end,
})
diff --git a/src/scene/paint.rs b/src/scene/paint.rs
index 0fadce3..43b3cc3 100644
--- a/src/scene/paint.rs
+++ b/src/scene/paint.rs
@@ -104,16 +104,19 @@ pub struct TextAttrs {
pub weight: Option<u16>,
}
-/// A primitive plus the scissor rect it must be clipped to (`None` = unclipped), and an
+/// A primitive plus the scissor rect it must be clipped to (`None` = unclipped), an
/// optional circular clip `[cx, cy, r]` in logical pixels (`None` = unclipped) — the
/// per-vertex circle clip the tessellators already support, for round panes (the designer's
-/// circular network pane). Both clips compose: the scissor is GPU state, the circle rides
-/// the vertices.
+/// circular network pane) — and an optional rounded-rect clip `[cx, cy, bx, by, r]`
+/// (center, SDF half-extents = half-size minus radius, corner radius; logical px) so a
+/// plate's children cut off at its rounded corners. The clips compose: the scissor is GPU
+/// state, the circle rides the vertices, the rounded rect is per-draw-batch state.
#[derive(Clone, Debug, PartialEq)]
pub struct PaintItem {
pub prim: Prim,
pub clip: Option<Rect>,
pub clip_circle: Option<[f32; 3]>,
+ pub clip_rrect: Option<[f32; 5]>,
}
/// An ordered list of clipped primitives — the single source of truth for a frame's geometry.
@@ -155,6 +158,8 @@ pub struct PaintCtx {
/// Active circular clips; primitives record the innermost (`last`). Circles don't
/// intersect analytically like rects, so nesting keeps the innermost only.
clip_circle_stack: Vec<[f32; 3]>,
+ /// Active rounded-rect clips `[cx, cy, bx, by, r]`; innermost wins, like circles.
+ clip_rrect_stack: Vec<[f32; 5]>,
/// Saved offsets for nesting; `offset` is the current cumulative translation.
offset_stack: Vec<(f32, f32)>,
offset: (f32, f32),
@@ -172,6 +177,7 @@ impl PaintCtx {
list: DisplayList::new(),
clip_stack: Vec::new(),
clip_circle_stack: Vec::new(),
+ clip_rrect_stack: Vec::new(),
offset_stack: Vec::new(),
offset: (0.0, 0.0),
}
@@ -225,6 +231,43 @@ impl PaintCtx {
out
}
+ /// Push a rounded-rect clip: `rect` (current local space) with corner radius `radius`,
+ /// so children of a rounded plate cut off at its corners. Pushes the rect as a scissor
+ /// too — the scissor handles the straight edges (and keeps batching), the SDF trims the
+ /// corners. A radius of zero degenerates to the plain rect clip. Pair with
+ /// [`pop_clip_rounded`](PaintCtx::pop_clip_rounded), or prefer
+ /// [`clip_rounded`](PaintCtx::clip_rounded).
+ pub fn push_clip_rounded(&mut self, rect: Rect, radius: f32) {
+ self.push_clip(rect);
+ let r = radius.max(0.0);
+ if r > 0.0 {
+ let abs = self.apply_offset(rect);
+ self.clip_rrect_stack.push([
+ abs.x + abs.width / 2.0,
+ abs.y + abs.height / 2.0,
+ (abs.width / 2.0 - r).max(0.0),
+ (abs.height / 2.0 - r).max(0.0),
+ r,
+ ]);
+ } else {
+ // Keep push/pop balanced regardless of radius.
+ self.clip_rrect_stack.push([0.0; 5]);
+ }
+ }
+
+ pub fn pop_clip_rounded(&mut self) {
+ self.clip_rrect_stack.pop();
+ self.pop_clip();
+ }
+
+ /// Run `f` with `rect` (radius `radius`) pushed as a rounded clip, popping it afterward.
+ pub fn clip_rounded<R>(&mut self, rect: Rect, radius: f32, f: impl FnOnce(&mut Self) -> R) -> R {
+ self.push_clip_rounded(rect, radius);
+ let out = f(self);
+ self.pop_clip_rounded();
+ out
+ }
+
/// Run `f` with an additional translation applied to all emitted coordinates.
pub fn translate<R>(&mut self, dx: f32, dy: f32, f: impl FnOnce(&mut Self) -> R) -> R {
self.offset_stack.push(self.offset);
@@ -241,7 +284,10 @@ impl PaintCtx {
fn push(&mut self, prim: Prim) {
let clip = self.current_clip();
let clip_circle = self.clip_circle_stack.last().copied();
- self.list.items.push(PaintItem { prim, clip, clip_circle });
+ // r == 0 entries are balance placeholders (a zero-radius rounded clip is just its
+ // scissor rect) — record no rounded clip so batches keep merging.
+ let clip_rrect = self.clip_rrect_stack.last().copied().filter(|c| c[4] > 0.0);
+ self.list.items.push(PaintItem { prim, clip, clip_circle, clip_rrect });
}
pub fn quad(&mut self, rect: Rect, color: [f32; 4]) {
diff --git a/src/vk/glyph.wgsl b/src/vk/glyph.wgsl
index 8cd26e0..e85478f 100644
--- a/src/vk/glyph.wgsl
+++ b/src/vk/glyph.wgsl
@@ -10,6 +10,7 @@ struct VertexOutput {
@location(0) uv: vec2f,
@location(1) color: vec4f,
@location(2) clip_circle: vec3f,
+ @location(3) clip_extents: vec2f,
}
@vertex
@@ -18,23 +19,27 @@ fn vs_main(
@location(1) uv: vec2f,
@location(2) color: vec4f,
@location(3) clip_circle: vec3f,
+ @location(4) clip_extents: vec2f,
) -> VertexOutput {
var out: VertexOutput;
out.clip_position = vec4f(position, 0.0, 1.0);
out.uv = uv;
out.color = color;
out.clip_circle = clip_circle;
+ out.clip_extents = clip_extents;
return out;
}
@fragment
fn fs_main(in: VertexOutput) -> @location(0) vec4f {
- // Same circle clip as shader.wgsl (framebuffer px): used by the circular
- // network pane's curved rim labels.
+ // Rounded-rect SDF clip in framebuffer px: center clip_circle.xy, corner radius
+ // clip_circle.z, inner-box half-size clip_extents. Zero extents degenerate to the
+ // plain circle clip (the circular network pane's curved rim labels); a non-zero
+ // box clips plate children at the plate's rounded corners.
if (in.clip_circle.z > 0.0) {
- let dx = in.clip_position.x - in.clip_circle.x;
- let dy = in.clip_position.y - in.clip_circle.y;
- if (dx * dx + dy * dy > in.clip_circle.z * in.clip_circle.z) {
+ let q = abs(in.clip_position.xy - in.clip_circle.xy) - in.clip_extents;
+ let d = length(max(q, vec2f(0.0))) - in.clip_circle.z;
+ if (d > 0.0) {
discard;
}
}
diff --git a/src/vk/renderer.rs b/src/vk/renderer.rs
index 216a166..c4f2738 100644
--- a/src/vk/renderer.rs
+++ b/src/vk/renderer.rs
@@ -21,9 +21,13 @@ use super::scene::{MeshId, SceneDraw, SceneStage, Vertex3D};
use super::text::{TextSpan, TextStage};
/// One scissored draw range of a 2D frame. `scissor` is (x, y, w, h) in
-/// physical pixels; None draws with the full-surface scissor.
+/// physical pixels; None draws with the full-surface scissor. `clip_rrect` is an
+/// optional rounded-rect clip `[cx, cy, bx, by, r]` (center, SDF half-extents, corner
+/// radius; physical px) applied via push constants — fragments outside it discard, so a
+/// plate's children cut off at its rounded corners.
pub struct Batch2D {
pub scissor: Option<(u32, u32, u32, u32)>,
+ pub clip_rrect: Option<[f32; 5]>,
pub start: u32,
pub end: u32,
}
@@ -175,7 +179,7 @@ pub(crate) fn compile_wgsl(source: &str) -> Vec<u32> {
let module = naga::front::wgsl::parse_str(source).expect("WGSL parse failed");
let info = naga::valid::Validator::new(
naga::valid::ValidationFlags::all(),
- naga::valid::Capabilities::empty(),
+ naga::valid::Capabilities::PUSH_CONSTANT,
)
.validate(&module)
.expect("WGSL validation failed");
@@ -450,9 +454,17 @@ impl VkRenderer {
.expect("Failed to create descriptor set layout");
let set_layouts = [descriptor_set_layout];
+ // Push constants: the per-batch rounded-rect clip (two vec4s — [cx, cy, bx, by]
+ // and [r, enabled, 0, 0]) read by shader2d's fragment stage.
+ let push_ranges = [vk::PushConstantRange::default()
+ .stage_flags(vk::ShaderStageFlags::FRAGMENT)
+ .offset(0)
+ .size(32)];
let pipeline_layout = device
.create_pipeline_layout(
- &vk::PipelineLayoutCreateInfo::default().set_layouts(&set_layouts),
+ &vk::PipelineLayoutCreateInfo::default()
+ .set_layouts(&set_layouts)
+ .push_constant_ranges(&push_ranges),
None,
)
.expect("Failed to create pipeline layout");
@@ -1287,7 +1299,7 @@ impl VkRenderer {
let mut img_i = 0usize;
let default_batch =
- [Batch2D { scissor: None, start: 0, end: frame.vertex_count }];
+ [Batch2D { scissor: None, clip_rrect: None, start: 0, end: frame.vertex_count }];
let batches: &[Batch2D] =
if frame2d.batches.is_empty() { &default_batch } else { frame2d.batches };
@@ -1351,6 +1363,17 @@ impl VkRenderer {
self.core.device
.cmd_bind_vertex_buffers(cmd, 0, &[frame.vertex.buffer], &[0]);
self.core.device.cmd_set_scissor(cmd, 0, &[scissor]);
+ // Per-batch rounded-rect clip (fragments outside discard).
+ let rr = batch.clip_rrect.unwrap_or([0.0; 5]);
+ let enabled = if batch.clip_rrect.is_some() { 1.0f32 } else { 0.0 };
+ let pc = [rr[0], rr[1], rr[2], rr[3], rr[4], enabled, 0.0, 0.0];
+ self.core.device.cmd_push_constants(
+ cmd,
+ self.pipeline_layout,
+ vk::ShaderStageFlags::FRAGMENT,
+ 0,
+ bytemuck::cast_slice(&pc),
+ );
self.core.device.cmd_draw(cmd, upto - cursor, 1, cursor, 0);
}
cursor = upto;
@@ -1391,6 +1414,15 @@ impl VkRenderer {
);
self.core.device
.cmd_bind_vertex_buffers(cmd, 0, &[frame.vertex.buffer], &[0]);
+ // Push constants persist across binds — clear any batch's rounded clip.
+ let pc = [0.0f32; 8];
+ self.core.device.cmd_push_constants(
+ cmd,
+ self.pipeline_layout,
+ vk::ShaderStageFlags::FRAGMENT,
+ 0,
+ bytemuck::cast_slice(&pc),
+ );
self.core.device
.cmd_draw(cmd, frame.overlay_count, 1, frame.overlay_start, 0);
}
diff --git a/src/vk/shader2d.wgsl b/src/vk/shader2d.wgsl
index edf97ef..5cd6199 100644
--- a/src/vk/shader2d.wgsl
+++ b/src/vk/shader2d.wgsl
@@ -55,6 +55,14 @@ fn is_outside_window_corners(pos: vec2<f32>) -> bool {
return false;
}
+// Per-batch rounded-rect clip: rect0 = [cx, cy, bx, by] (center + SDF half-extents),
+// rect1 = [corner radius, enabled flag, 0, 0]. Physical pixels, like clip_position.
+struct RRectClip {
+ rect0: vec4f,
+ rect1: vec4f,
+}
+var<push_constant> rrect_clip: RRectClip;
+
struct VertexOutput {
@builtin(position) clip_position: vec4f,
@location(0) color: vec4f,
@@ -119,6 +127,14 @@ fn fs_main(in: VertexOutput) -> @location(0) vec4f {
discard;
}
}
+ // Rounded-rect clip (per-batch): SDF of the round-cornered box; outside discards.
+ if (rrect_clip.rect1.y > 0.5) {
+ let q = abs(in.clip_position.xy - rrect_clip.rect0.xy) - rrect_clip.rect0.zw;
+ let d = length(max(q, vec2f(0.0))) - rrect_clip.rect1.x;
+ if (d > 0.0) {
+ discard;
+ }
+ }
// Blur-behind plate: negative alpha mixes the (blurred) backdrop with the
// plate color at |alpha| opacity.
diff --git a/src/vk/text.rs b/src/vk/text.rs
index 8403e24..9dbec40 100644
--- a/src/vk/text.rs
+++ b/src/vk/text.rs
@@ -48,6 +48,11 @@ pub struct TextSpan<'a> {
/// Fragment circle clip (center_x, center_y, radius) in physical pixels;
/// zero radius disables (matches shader.wgsl's clip_circle).
pub clip_circle: [f32; 3],
+ /// Rounded-rect clip half-extents (physical px). Zero keeps `clip_circle` a plain
+ /// circle; non-zero reinterprets it as a rounded-rect SDF clip — center
+ /// `clip_circle.xy`, corner radius `clip_circle.z`, inner box half-size
+ /// `clip_extents` — so plate children (labels included) cut off at rounded corners.
+ pub clip_extents: [f32; 2],
}
#[repr(C)]
@@ -57,6 +62,7 @@ struct GlyphVertex {
uv: [f32; 2],
color: [f32; 4],
clip_circle: [f32; 3],
+ clip_extents: [f32; 2],
}
#[derive(Clone, Copy)]
@@ -210,6 +216,11 @@ impl TextStage {
.binding(0)
.format(vk::Format::R32G32B32_SFLOAT)
.offset(32),
+ vk::VertexInputAttributeDescription::default()
+ .location(4)
+ .binding(0)
+ .format(vk::Format::R32G32_SFLOAT)
+ .offset(44),
];
let vertex_input = vk::PipelineVertexInputStateCreateInfo::default()
.vertex_binding_descriptions(&vertex_bindings)
@@ -586,10 +597,11 @@ impl TextStage {
};
let uv = |u: f32, v: f32| [u / ATLAS_SIZE as f32, v / ATLAS_SIZE as f32];
let clip_circle = span.clip_circle;
- let tl = GlyphVertex { position: ndc(corners[0]), uv: uv(u0, v0), color, clip_circle };
- let tr = GlyphVertex { position: ndc(corners[1]), uv: uv(u1, v0), color, clip_circle };
- let bl = GlyphVertex { position: ndc(corners[2]), uv: uv(u0, v1), color, clip_circle };
- let br = GlyphVertex { position: ndc(corners[3]), uv: uv(u1, v1), color, clip_circle };
+ let clip_extents = span.clip_extents;
+ let tl = GlyphVertex { position: ndc(corners[0]), uv: uv(u0, v0), color, clip_circle, clip_extents };
+ let tr = GlyphVertex { position: ndc(corners[1]), uv: uv(u1, v0), color, clip_circle, clip_extents };
+ let bl = GlyphVertex { position: ndc(corners[2]), uv: uv(u0, v1), color, clip_circle, clip_extents };
+ let br = GlyphVertex { position: ndc(corners[3]), uv: uv(u1, v1), color, clip_circle, clip_extents };
self.pending_vertices.extend([tl, tr, bl, tr, br, bl]);
}
}
diff --git a/src/widget/display/label.rs b/src/widget/display/label.rs
index 8443e27..50ee8ae 100644
--- a/src/widget/display/label.rs
+++ b/src/widget/display/label.rs
@@ -245,6 +245,7 @@ impl StyledLabel {
color: self.g_color,
bounds: None,
clip_circle: None,
+ clip_rrect: None,
});
w
}
diff --git a/src/widget/display/list_item.rs b/src/widget/display/list_item.rs
index 830a734..9f98b82 100644
--- a/src/widget/display/list_item.rs
+++ b/src/widget/display/list_item.rs
@@ -17,6 +17,9 @@ pub struct TextItem {
/// Optional circular clip `[cx, cy, r]` in logical pixels (a display-list item's
/// `clip_circle` carried through to the glyph pass). `None` for ordinary labels.
pub clip_circle: Option<[f32; 3]>,
+ /// Optional rounded-rect clip `[cx, cy, bx, by, r]` in logical pixels (a display-list
+ /// item's `clip_rrect` carried through to the glyph pass). `None` for ordinary labels.
+ pub clip_rrect: Option<[f32; 5]>,
}
impl TextItem {
@@ -31,7 +34,7 @@ impl TextItem {
bounds: Option<[f32; 4]>,
) -> Self {
let buffer = crate::backend::window_runner::get_text_buffer(fs, text, size, font);
- Self { buffer, x, y, color, bounds, clip_circle: None }
+ Self { buffer, x, y, color, bounds, clip_circle: None, clip_rrect: None }
}
}