GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git
feat: sphere-lit slider thumb — Prim::Sphere, shader mode 5
A new Sphere prim shades a disc per pixel as a hemisphere under the DE's
plate light: ambient floor, diffuse off the sphere normal, and the
decoupled roll specular, all relative to the flat face so the lit center
keeps the app's thumb color exactly. One cover quad in its own plate
batch (p_rect carries center+radius); legacy bevel_shader-0 path draws
the flat circle unchanged. The slider thumb is the first consumer.
Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01XxjKyZK55xkRz43M8Fecsf
src/backend/window_runner.rs | 22 ++++++++++++++++++++++
src/scene/paint.rs | 13 +++++++++++++
src/vk/shader2d.wgsl | 30 +++++++++++++++++++++++++++---
src/widget/input/slider.rs | 2 +-
src/widget/model.rs | 1 +
5 files changed, 64 insertions(+), 4 deletions(-)
diff --git a/src/backend/window_runner.rs b/src/backend/window_runner.rs
index 7dde235..84fda88 100644
--- a/src/backend/window_runner.rs
+++ b/src/backend/window_runner.rs
@@ -1723,6 +1723,28 @@ pub fn tessellate_display_list(
Prim::Circle { cx, cy, radius, color } => {
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
+ // cover quad, its own never-merged batch. The quad overhangs
+ // the disc by 1px for the shader's silhouette anti-aliasing.
+ let d = *radius + 1.0;
+ verts.extend(quad_vertices(cx - d, cy - d, 2.0 * d, 2.0 * d, sw, sh, *color));
+ plate = Some(crate::vk::PlatePush {
+ // Center + radius in physical px; the SDF box machinery is
+ // unused in this mode, so .w is free.
+ rect: [cx * scale, cy * scale, radius * scale, 0.0],
+ radii: [0.0; 4],
+ light: [plate_light[0], plate_light[1], plate_light[2], 0.0],
+ material: plate_mat,
+ host: [0.0; 4],
+ mode: 5.0,
+ shape: 2.0,
+ });
+ }
+ Prim::Sphere { cx, cy, radius, color } => {
+ // Legacy path: the flat disc, exactly a Circle.
+ verts.extend(circle_vertices(*cx, *cy, *radius, sw, sh, *color, segs(*radius), no));
+ }
}
let end = verts.len() as u32;
if end == start {
diff --git a/src/scene/paint.rs b/src/scene/paint.rs
index 57cf3b2..cc28339 100644
--- a/src/scene/paint.rs
+++ b/src/scene/paint.rs
@@ -81,6 +81,13 @@ pub enum Prim {
Arc { cx: f32, cy: f32, radius: f32, thickness: f32, start: f32, end: f32, color: [f32; 4] },
Vector { x1: f32, y1: f32, x2: f32, y2: f32, thickness: f32, color: [f32; 4], cap: Cap },
Circle { cx: f32, cy: f32, radius: f32, color: [f32; 4] },
+ /// A `Circle` lit as a ball: the disc is shaded per pixel as a hemisphere
+ /// under the DE's plate light (same ambient/diffuse/specular model), so it
+ /// reads as a sphere sitting on the surface — the slider thumb's look. The
+ /// color is the sphere's face color exactly at the lit center, like a
+ /// plate's face keeps the app's color. Falls back to a flat circle on the
+ /// legacy (`bevel_shader 0`) path.
+ Sphere { cx: f32, cy: f32, radius: f32, color: [f32; 4] },
/// Text in sRGB u8 (the `TextLabel` convention). `font` is a font string for
/// `get_text_buffer` (family, or "family:size"); `bounds` is a logical `[l, t, r, b]` clip
/// for the glyph pass (Phase 6: the backend renders these through glyphon when the app
@@ -351,6 +358,12 @@ impl PaintCtx {
self.push(Prim::Circle { cx: cx + ox, cy: cy + oy, radius, color });
}
+ /// A sphere-lit circle — see `Prim::Sphere`.
+ pub fn sphere(&mut self, cx: f32, cy: f32, radius: f32, color: [f32; 4]) {
+ let (ox, oy) = self.offset;
+ self.push(Prim::Sphere { cx: cx + ox, cy: cy + oy, radius, color });
+ }
+
pub fn border(&mut self, rect: Rect, radii: Radii, fill: [f32; 4], border: [f32; 4], thickness: f32) {
let rect = self.apply_offset(rect);
self.push(Prim::Border { rect, radii, fill, border, thickness });
diff --git a/src/vk/shader2d.wgsl b/src/vk/shader2d.wgsl
index b736f02..b56b8d3 100644
--- a/src/vk/shader2d.wgsl
+++ b/src/vk/shader2d.wgsl
@@ -220,13 +220,37 @@ fn carve_slope(v: f32) -> f32 {
// expressed relative to the flat face (shade ratio 1.0, specular delta 0.0)
// so the face keeps exactly the app's chosen color.
fn plate_shade(frag: vec2f, vcol: vec4f) -> vec4f {
- let gd = rr_sdf_grad(frag, rrect_clip.p_rect, rrect_clip.p_radii);
- let d = -gd.z; // positive inside the plate, in px
- let t = max(rrect_clip.p_light.w, 0.001);
let l = rrect_clip.p_light.xyz;
let strength = rrect_clip.p_mat.x;
let flat_shade = PLATE_AMBIENT + (1.0 - PLATE_AMBIENT) * l.z;
+ // Mode 5: a sphere-lit disc (the slider thumb). p_rect.xy is the center,
+ // p_rect.z the radius, physical px. The disc is shaded as a hemisphere
+ // under the same light/material as the plates — ambient floor, diffuse off
+ // the sphere normal, the decoupled roll specular (its glint lands where
+ // the surface tilt meets the half-vector, ~a third of the way out toward
+ // the light) — and, like a plate face, the shade is expressed relative to
+ // the flat face so the color at the lit center is exactly the app's.
+ if (rrect_clip.rect1.z > 4.5) {
+ let c = frag - rrect_clip.p_rect.xy;
+ let r = max(rrect_clip.p_rect.z, 0.001);
+ let dist = length(c);
+ let aa = clamp(r - dist + 0.5, 0.0, 1.0); // 1px silhouette anti-aliasing
+ if (aa <= 0.0) {
+ discard;
+ }
+ let h = sqrt(max(r * r - dist * dist, 1e-3));
+ let n = vec3f(c / r, h / r); // unit on the sphere's surface
+ let diff = PLATE_AMBIENT + (1.0 - PLATE_AMBIENT) * max(dot(n, l), 0.0);
+ let shade = 1.0 + (diff / flat_shade - 1.0) * strength;
+ let spec = roll_spec(c / h);
+ return vec4f(vcol.rgb * shade + vec3f(spec * strength), vcol.a * aa);
+ }
+
+ let gd = rr_sdf_grad(frag, rrect_clip.p_rect, rrect_clip.p_radii);
+ let d = -gd.z; // positive inside the plate, in px
+ let t = max(rrect_clip.p_light.w, 0.001);
+
if (rrect_clip.rect1.z < 1.5) {
let aa = clamp(d + 0.5, 0.0, 1.0); // 1px silhouette anti-aliasing
if (aa <= 0.0) {
diff --git a/src/widget/input/slider.rs b/src/widget/input/slider.rs
index d34a0b7..5e17aff 100644
--- a/src/widget/input/slider.rs
+++ b/src/widget/input/slider.rs
@@ -305,7 +305,7 @@ impl Paint for Slider {
// drawn size only; the drag/hit geometry keeps the full thumb_size.
let diameter =
if self.recessed { g.h - recess_t } else { g.thumb_size };
- ctx.circle(
+ ctx.sphere(
thumb_x + g.thumb_size / 2.0,
thumb_y + g.thumb_size / 2.0,
diameter / 2.0,
diff --git a/src/widget/model.rs b/src/widget/model.rs
index 54ae2f1..5304bd7 100644
--- a/src/widget/model.rs
+++ b/src/widget/model.rs
@@ -1242,6 +1242,7 @@ impl<W: Layout + Paint + Input + 'static> WidgetHost for Adapted<W> {
Prim::Arc { cx, cy, radius, thickness, start, end, color } => ctx.arc(cx, cy, radius, thickness, start, end, color),
Prim::Vector { x1, y1, x2, y2, thickness, color, cap } => ctx.vector(x1, y1, x2, y2, thickness, color, cap),
Prim::Circle { cx, cy, radius, color } => ctx.circle(cx, cy, radius, color),
+ Prim::Sphere { cx, cy, radius, color } => ctx.sphere(cx, cy, radius, color),
Prim::Image { image, rect, alpha } => ctx.image(image, rect, alpha),
}
}