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

commit972f35e44b163fe4d9466df51d843353dbd3b313
parentf5bca914c7
authorLucas Galante <[email protected]>
date2026-08-01 23:41
feat: feathered-edge AA for lines, arcs, and circle silhouettes

Half-px alpha ramps along stroke long edges and arc rims (Gouraud edge
bands — the poor-man's AA the flat pipeline doesn't provide), circles
drawn as cover quads clipped by their own feathered circle clip, and
quads honoring an active circle clip like circles/arcs do. The shader's
circular clip folds a ~1px smoothstep feather into coverage instead of
a hard discard edge.

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

 src/backend/window_runner.rs | 155 +++++++++++++++++++++++++++++++------------
 src/vk/shader2d.wgsl         |   7 +-
 2 files changed, 117 insertions(+), 45 deletions(-)

diff --git a/src/backend/window_runner.rs b/src/backend/window_runner.rs
index 66cf2b2..8e8198e 100644
--- a/src/backend/window_runner.rs
+++ b/src/backend/window_runner.rs
@@ -530,18 +530,63 @@ pub fn vector_vertices(
             verts.push(Vertex { position: [ndc_w2x, ndc_w2y], color: c, clip_circle });
         }
         LineCap::Round => {
-            verts.extend_from_slice(&line_vertices(x1, y1, x2, y2, thickness, sw, sh, c));
+            push_feathered_line_vertices(x1, y1, x2, y2, thickness, sw, sh, c, &mut verts);
             let clip_circle = [0.0, 0.0, 0.0];
             verts.extend(circle_vertices(x2, y2, thickness / 2.0, sw, sh, c, 16, clip_circle));
         }
         LineCap::Flat => {
-            verts.extend_from_slice(&line_vertices(x1, y1, x2, y2, thickness, sw, sh, c));
+            push_feathered_line_vertices(x1, y1, x2, y2, thickness, sw, sh, c, &mut verts);
         }
     }
-    
+
     verts
 }
 
+/// `line_vertices` with a half-px alpha ramp along each long edge (the arc
+/// tessellator's poor-man's AA) — diagonal strokes resolve smoothly instead of
+/// stair-stepping. Axis-aligned strokes keep the crisp single-quad path:
+/// feathering a pixel-snapped hairline would only blur it.
+fn push_feathered_line_vertices(
+    x1: f32, y1: f32, x2: f32, y2: f32,
+    thickness: f32,
+    sw: f32, sh: f32,
+    c: [f32; 4],
+    out: &mut Vec<Vertex>,
+) {
+    let dx = x2 - x1;
+    let dy = y2 - y1;
+    let len = (dx * dx + dy * dy).sqrt();
+    if len < 0.001 || dx.abs() < 0.01 || dy.abs() < 0.01 {
+        out.extend_from_slice(&line_vertices(x1, y1, x2, y2, thickness, sw, sh, c));
+        return;
+    }
+    let (nx, ny) = (-dy / len, dx / len);
+    let f = 0.5f32.min(thickness * 0.25);
+    let half = thickness * 0.5;
+    // (offset at band start, offset at band end, alpha at start, alpha at end)
+    let bands = [
+        (-half - f, -half + f, 0.0, c[3]),
+        (-half + f, half - f, c[3], c[3]),
+        (half - f, half + f, c[3], 0.0),
+    ];
+    for &(oa, ob, aa, ab) in &bands {
+        let ca = [c[0], c[1], c[2], aa];
+        let cb = [c[0], c[1], c[2], ab];
+        let p = |x: f32, y: f32, o: f32| -> [f32; 2] {
+            [((x + nx * o) / sw) * 2.0 - 1.0, 1.0 - ((y + ny * o) / sh) * 2.0]
+        };
+        let clip_circle = [0.0, 0.0, 0.0];
+        let (a1, b1) = (p(x1, y1, oa), p(x1, y1, ob));
+        let (a2, b2) = (p(x2, y2, oa), p(x2, y2, ob));
+        out.push(Vertex { position: a1, color: ca, clip_circle });
+        out.push(Vertex { position: b1, color: cb, clip_circle });
+        out.push(Vertex { position: b2, color: cb, clip_circle });
+        out.push(Vertex { position: a1, color: ca, clip_circle });
+        out.push(Vertex { position: b2, color: cb, clip_circle });
+        out.push(Vertex { position: a2, color: ca, clip_circle });
+    }
+}
+
 pub fn rounded_rect_vertices_corners(
     x: f32, y: f32, ww: f32, h: f32,
     r: f32,
@@ -1510,7 +1555,9 @@ pub fn tessellate_display_list(
                 continue;
             }
             Prim::Quad { rect, color } => {
-                verts.extend(quad_vertices(rect.x, rect.y, rect.width, rect.height, sw, sh, *color));
+                // Quads honor an active circle clip like circles/arcs do (the
+                // Ramp's foam-cell fills draw as clipped strips).
+                verts.extend(quad_vertices_with_clip(rect.x, rect.y, rect.width, rect.height, sw, sh, *color, no));
             }
             Prim::RoundedRect { rect, radius, corners, color } => {
                 let radii = crate::widget::CornerRadii::new(
@@ -1759,7 +1806,19 @@ pub fn tessellate_display_list(
                 verts.extend(vector_vertices(*x1, *y1, *x2, *y2, *thickness, sw, sh, *color, lc));
             }
             Prim::Circle { cx, cy, radius, color } => {
-                verts.extend(circle_vertices(*cx, *cy, *radius, sw, sh, *color, segs(*radius), no));
+                if item.clip_circle.is_none() && *radius > 1.5 {
+                    // Cover quad with the disc itself as the (feathered) circle
+                    // clip: a per-pixel smooth silhouette instead of a hard-edged
+                    // fan. The quad overhangs by 1px for the feather. Only when
+                    // no ancestor clip holds the slot — then it's the fan path.
+                    let own = [cx * scale, cy * scale, radius * scale];
+                    let d = *radius + 1.0;
+                    verts.extend(quad_vertices_with_clip(
+                        cx - d, cy - d, 2.0 * d, 2.0 * d, sw, sh, *color, own,
+                    ));
+                } else {
+                    verts.extend(circle_vertices(*cx, *cy, *radius, sw, sh, *color, segs(*radius), no));
+                }
             }
             Prim::Sphere { cx, cy, radius, color } if shader_plates => {
                 // A hemisphere lit per pixel by the plate branch (mode 5): one
@@ -2198,45 +2257,6 @@ pub fn arc_background_vertices(
     verts
 }
 
-pub fn push_arc_background_vertices(
-    cx: f32, cy: f32, r: f32,
-    thickness: f32,
-    start_angle: f32, end_angle: f32,
-    sw: f32, sh: f32,
-    color: [f32; 4],
-    segments: usize,
-    clip_circle: [f32; 3],
-    out: &mut Vec<Vertex>,
-) {
-    for i in 0..segments {
-        let theta1 = start_angle + (i as f32) * (end_angle - start_angle) / (segments as f32);
-        let theta2 = start_angle + ((i + 1) as f32) * (end_angle - start_angle) / (segments as f32);
-        
-        let x0 = cx + (r - thickness) * theta1.cos();
-        let y0 = cy + (r - thickness) * theta1.sin();
-        let x1 = cx + r * theta1.cos();
-        let y1 = cy + r * theta1.sin();
-        
-        let x2 = cx + r * theta2.cos();
-        let y2 = cy + r * theta2.sin();
-        let x3 = cx + (r - thickness) * theta2.cos();
-        let y3 = cy + (r - thickness) * theta2.sin();
-        
-        let ndc_x0 = (x0 / sw) * 2.0 - 1.0; let ndc_y0 = 1.0 - (y0 / sh) * 2.0;
-        let ndc_x1 = (x1 / sw) * 2.0 - 1.0; let ndc_y1 = 1.0 - (y1 / sh) * 2.0;
-        let ndc_x2 = (x2 / sw) * 2.0 - 1.0; let ndc_y2 = 1.0 - (y2 / sh) * 2.0;
-        let ndc_x3 = (x3 / sw) * 2.0 - 1.0; let ndc_y3 = 1.0 - (y3 / sh) * 2.0;
-        
-        out.push(Vertex { position: [ndc_x0, ndc_y0], color, clip_circle });
-        out.push(Vertex { position: [ndc_x1, ndc_y1], color, clip_circle });
-        out.push(Vertex { position: [ndc_x2, ndc_y2], color, clip_circle });
-        
-        out.push(Vertex { position: [ndc_x0, ndc_y0], color, clip_circle });
-        out.push(Vertex { position: [ndc_x2, ndc_y2], color, clip_circle });
-        out.push(Vertex { position: [ndc_x3, ndc_y3], color, clip_circle });
-    }
-}
-
 /// A ring band with radial Gouraud shading: two sub-bands (inner rim → crest
 /// centerline, crest → outer rim) whose vertex colors interpolate across the
 /// stroke — the rounded-bevel profile — plus the half-px alpha feathers at
@@ -2289,6 +2309,53 @@ pub fn push_arc_shaded_vertices(
     }
 }
 
+pub fn push_arc_background_vertices(
+    cx: f32, cy: f32, r: f32,
+    thickness: f32,
+    start_angle: f32, end_angle: f32,
+    sw: f32, sh: f32,
+    color: [f32; 4],
+    segments: usize,
+    clip_circle: [f32; 3],
+    out: &mut Vec<Vertex>,
+) {
+    // The stroke band [r - thickness, r], with a half-px alpha ramp on each rim
+    // (Gouraud across thin edge bands) so curved edges resolve smoothly instead
+    // of hard-stepping — the poor-man's AA the flat pipeline doesn't provide.
+    let f = 0.5f32.min(thickness * 0.25);
+    let r_in = (r - thickness).max(0.0);
+    // (inner radius, outer radius, alpha at inner rim, alpha at outer rim)
+    let bands = [
+        ((r_in - f).max(0.0), r_in + f, 0.0, color[3]),
+        (r_in + f, r - f, color[3], color[3]),
+        (r - f, r + f, color[3], 0.0),
+    ];
+    for i in 0..segments {
+        let theta1 = start_angle + (i as f32) * (end_angle - start_angle) / (segments as f32);
+        let theta2 = start_angle + ((i + 1) as f32) * (end_angle - start_angle) / (segments as f32);
+        let (c1, s1) = (theta1.cos(), theta1.sin());
+        let (c2, s2) = (theta2.cos(), theta2.sin());
+        for &(ra, rb, aa, ab) in &bands {
+            if rb <= ra {
+                continue;
+            }
+            let ca = [color[0], color[1], color[2], aa];
+            let cb = [color[0], color[1], color[2], ab];
+            let p = |rad: f32, c: f32, s: f32| -> [f32; 2] {
+                [((cx + rad * c) / sw) * 2.0 - 1.0, 1.0 - ((cy + rad * s) / sh) * 2.0]
+            };
+            let (i1, o1) = (p(ra, c1, s1), p(rb, c1, s1));
+            let (i2, o2) = (p(ra, c2, s2), p(rb, c2, s2));
+            out.push(Vertex { position: i1, color: ca, clip_circle });
+            out.push(Vertex { position: o1, color: cb, clip_circle });
+            out.push(Vertex { position: o2, color: cb, clip_circle });
+            out.push(Vertex { position: i1, color: ca, clip_circle });
+            out.push(Vertex { position: o2, color: cb, clip_circle });
+            out.push(Vertex { position: i2, color: ca, clip_circle });
+        }
+    }
+}
+
 #[derive(Debug, Clone)]
 pub struct WindowSettings {
     pub title: String,
diff --git a/src/vk/shader2d.wgsl b/src/vk/shader2d.wgsl
index f757423..1751c8a 100644
--- a/src/vk/shader2d.wgsl
+++ b/src/vk/shader2d.wgsl
@@ -495,10 +495,15 @@ fn fs_main(in: VertexOutput) -> @location(0) vec4f {
     if (clip_cov <= 0.0) {
         discard;
     }
+    // Circular clip: ~1px feather folded into the coverage (mirroring the
+    // rounded-rect clip below) — a clipped edge doubles as the silhouette AA
+    // for circle prims drawn as cover quads.
     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 dist = sqrt(dx * dx + dy * dy);
+        clip_cov *= 1.0 - smoothstep(in.clip_circle.z - 0.5, in.clip_circle.z + 0.5, dist);
+        if (clip_cov <= 0.0) {
             discard;
         }
     }