GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git
fix: a grouped carve shades as its overlay does
A Recess/Boss grouped into its plate as a CSG feature drew a doubled
outline — two black lines down its shadowed wall, two bright ones down
its lit wall — where the same carve as an overlay drew one soft edge.
The plate path subtracted roll_shade_line(sv) with sv the roll's slope
plus every carve's: the lobe sits at the half-vector's tilt, a carve
wall's tilt passes through that angle twice, and each hit, subtracted
from a dark face, went to black. And it multiplied the carves' diffuse
and curvature into the face while adding their glint whole, so the
terms no longer cancelled as in the overlay's one signed value.
The plate now lights its roll alone (shade line included) and
composites what the carves add — the summed normal's diffuse and glint
less the roll's, plus their curvature — as an overlay is blended
(carve_over). On the face that is the overlay term for term; across the
roll the normal is still the summed one. A plate with no carves is
unchanged to the bit.
It was never GPU-specific: NVIDIA and Intel render the 2D path
identically, before and after. In a cce-shadow the NVIDIA ICD only
loads with DISPLAY/XAUTHORITY set, so a "discrete" shadow run silently
fell back to Intel. examples/grouped_recess_probe.rs shows a grouped
column beside a forced-overlay one: 5462 px apart before, 0 after, on
both devices.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CLAUDE.md | 54 ++++++++++++++++++---
examples/grouped_recess_probe.rs | 100 +++++++++++++++++++++++++++++++++++++++
src/scene/relief_shade.rs | 23 ++++++++-
src/vk/shader2d.wgsl | 64 ++++++++++++++++++-------
4 files changed, 215 insertions(+), 26 deletions(-)
diff --git a/CLAUDE.md b/CLAUDE.md
index 43173a9..a3bb90a 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -386,13 +386,11 @@ explicit valley and `cce-relief`'s preview. `a_dropdown_trigger_wears_the_runs_e
`Prim::Field` whose seam is `FIELD_RUN_ONLY` px past its right end (`field_well_only`),
in `TextBox`'s own paint and in `ParametersBg::fields` for every text row without a
picker. It was a `Prim::Recess`, which the runner GROUPS into a live host plate as a CSG
-feature, and on the NVIDIA device (`CCE_VK_DEVICE=discrete`, the designer's `gpu
-"discrete"`) a grouped recess drew a doubled outline where the field beside it — never
-grouped, its own overlay — drew the single edge; on the integrated GPU the two are
-identical to the pixel, so the difference shows only on the discrete card. Other grouped
-recesses (a spinbox with no run, a colour well, a toggle's track) still group. Both
-fallbacks draw an all-well field as one plain recess. `a_text_box_is_a_field_that_is_all_well`
-is the test.
+feature, and a grouped recess drew a doubled outline where the field beside it — never
+grouped, its own overlay — drew the single edge. That was first put down to the NVIDIA
+device; it was not (see "A grouped carve shades as its overlay does" below, which fixed
+the cause the same day, so the two now agree whichever is used). Both fallbacks draw an
+all-well field as one plain recess. `a_text_box_is_a_field_that_is_all_well` is the test.
The pieces that feed it: `ParametersBg::fields` (the pane's list, drawn after its troughs,
hover-tinted like them; textpick rows and spinboxes with a run are in neither `reliefs`
@@ -405,6 +403,48 @@ are the tests. Until the same day the picker and the run were nested INSIDE a fu
well, their faces stopping at the base of its wall, so they never reached the edge a
dropdown's ▼ does.
+### A grouped carve shades as its overlay does (since 2026-10-02)
+
+A full-ring, untinted `Prim::Recess` / `Boss` emitted while a plate's grouping window is
+open becomes a CSG feature of that plate's one draw (`MODE_PLATE` in `shader2d.wgsl`);
+otherwise it is its own overlay (`MODE_RECESS` / `MODE_BOSS`). The two must look the same
+on the plate's face. They did not: the grouped one drew a **doubled outline**, two thin
+black lines down its shadowed wall and two bright ones down its lit wall, where the
+overlay drew one soft edge. Two causes, both in how the plate path applied the carves:
+
+- **The shade line took the carves' slope.** The plate's colour subtracted
+ `roll_shade_line(sv)` with `sv` the roll's slope PLUS every carve's. The shade line is
+ a narrow lobe at the half-vector's tilt (22.5° at the DE's 45° light); a carve wall's
+ tilt rises through that angle and falls back through it, so the lobe fired twice per
+ wall — subtracted in colour units from a face near 0.016 linear, both hits went to
+ black. The overlay path, and `relief_shade::carve_shade`, never had a shade line: it
+ exists for the raised roll. It is now `roll_shade_line(sv_rim)`, the roll's alone.
+- **Diffuse and curvature were a multiply on the face; the glint was added whole.** On a
+ dark face a multiply barely moves the pixel, so the overlay's lit shoulder vanished,
+ and the glint's two crossings (the same twice-through-the-angle as above) no longer
+ cancelled against the fillet's darkening as they do inside the overlay's one signed
+ value. The plate now lights its roll alone as before (multiply plus glint and shade
+ line), and composites what the carves ADD — the summed normal's diffuse and glint less
+ the roll's, plus their curvature — as an overlay is blended (`carve_over`: screen
+ toward white, multiply toward black). On the face that is the overlay's `v` term for
+ term; across the roll the normal is still the summed one, the junction grouping is
+ for. A plate with no carves composites `v = 0` and is unchanged to the bit.
+
+Measured with `examples/grouped_recess_probe.rs` (a plate whose recesses group beside the
+same recesses forced to overlay by a transparent quad): grouped and overlay columns
+5,462 px apart before, 0 after; in the designer with grouped text wells, 0 px from the
+`well_field` rendering.
+
+**It was never the GPU.** The report was "doubled on the NVIDIA card, single on the
+Iris Xe"; the 2D path is identical to the pixel on both, before the fix and after. Two
+things made it look GPU-specific. Grouping is decided per frame by what is painted
+between a plate and its carves, so two captures of "the same" pane can differ in what
+grouped. And inside a `cce-shadow` session the NVIDIA ICD does not load at all unless
+the process can reach an X display (`DISPLAY=:0` and `XAUTHORITY=$HOME/.Xauthority`):
+`vk_icdGetInstanceProcAddr` fails, the loader skips the ICD, and `CCE_VK_DEVICE=discrete`
+falls back to the Intel device SILENTLY. Check `grep -c nvidia /proc/<pid>/maps` before
+believing a "discrete" shadow capture.
+
### A row can open a submenu (since 2026-09-29)
`context_menu::set_row_submenu(idx, SubmenuSpec { options, header_count, sliders })`,
diff --git a/examples/grouped_recess_probe.rs b/examples/grouped_recess_probe.rs
new file mode 100644
index 0000000..848c092
--- /dev/null
+++ b/examples/grouped_recess_probe.rs
@@ -0,0 +1,100 @@
+//! A visual check that a recess GROUPED into its plate as a CSG feature
+//! shades like the same recess drawn as an overlay. Left plate: the recesses
+//! group (nothing else is painted between the plate and them). Right plate:
+//! the same recesses, but a transparent quad painted first closes the plate's
+//! grouping window, so each one falls back to the overlay. The two columns
+//! should read alike on every device — compare `CCE_VK_DEVICE=discrete`
+//! against the default. `CCE_PLATE_DEBUG=1` prints the grouping verdicts.
+//! Run inside a Wayland session (a cce-shadow instance works):
+//! `cargo run --release -p cce-ui --example grouped_recess_probe`.
+
+use cce_ui::engine::{Application, EngineState, LogicalPosition, LogicalSize, WindowSettings};
+use cce_ui::scene::layout::Rect;
+use cce_ui::scene::paint::{DisplayList, PaintCtx};
+use cce_ui::scene::Material;
+use cce_ui::widget::{ElementState, KeyEvent, MouseButton, MouseScrollDelta};
+use wayland_client::QueueHandle;
+
+struct ProbeApp;
+
+/// The probe's recesses, relative to a plate's top-left: (rect, radius,
+/// depth) at the sizes the parameter pane uses — a text box, a toggle's
+/// track, a colour well — and a couple of deeper ones.
+fn recesses(x: f32, y: f32) -> Vec<(Rect, f32, f32)> {
+ let w = cce_ui::layout::bevel_width();
+ let mut out = Vec::new();
+ let mut yy = y + 30.0;
+ for (h, wide) in [(20.0, 260.0), (20.0, 120.0), (28.0, 260.0), (40.0, 260.0), (70.0, 260.0)] {
+ let depth = w.min(h * 0.2);
+ out.push((Rect { x: x + 30.0, y: yy, width: wide, height: h }, (h * 0.5).min(8.0), depth));
+ yy += h + 24.0;
+ }
+ out
+}
+
+impl Application for ProbeApp {
+ type Message = ();
+
+ fn new(_qh: &QueueHandle<EngineState<Self>>, _sender: calloop::channel::Sender<()>) -> Self {
+ Self
+ }
+
+ fn settings(&self) -> WindowSettings {
+ WindowSettings {
+ title: "grouped recess probe".into(),
+ app_id: "cce-grouped-recess-probe".into(),
+ width: 720,
+ height: 400,
+ fullscreen: false,
+ min_size: None,
+ }
+ }
+
+ fn update(&mut self, _msg: (), _needs_rebuild: &mut bool, _exit: &mut bool) {}
+
+ fn tick(&mut self, _dt: f32, _needs_rebuild: &mut bool) {}
+
+ fn display_list(&mut self, size: LogicalSize, _scale: f64) -> Option<DisplayList> {
+ let mut pc = PaintCtx::new();
+ let (w, h) = (size.width as f32, size.height as f32);
+ let half = (w - 30.0) * 0.5;
+ let bevel = cce_ui::layout::bevel_width();
+ for (i, x) in [10.0, 20.0 + half].into_iter().enumerate() {
+ let plate = Rect { x, y: 10.0, width: half, height: h - 20.0 };
+ pc.plate(plate, (16.0, 16.0, 16.0, 16.0), &Material::pane(), bevel);
+ if i == 1 {
+ // Ordinary geometry between the plate and its carves closes
+ // the grouping window: every recess below is an overlay.
+ pc.quad(Rect { x: x + 1.0, y: 11.0, width: 1.0, height: 1.0 }, [0.0; 4]);
+ }
+ for (r, radius, depth) in recesses(x, 10.0) {
+ pc.recess(r, (radius, radius, radius, radius), depth);
+ }
+ // A boss, and a recess running into the plate's roll — the
+ // junction grouping exists for (the overlay fades out there
+ // instead, so the two columns differ by design at its right end).
+ let boss = Rect { x: x + 30.0, y: h - 60.0, width: 120.0, height: 30.0 };
+ pc.boss(boss, (8.0, 8.0, 8.0, 8.0), 6.0);
+ let edge = Rect { x: x + 180.0, y: h - 60.0, width: half - 180.0 - 2.0, height: 30.0 };
+ pc.recess(edge, (8.0, 8.0, 8.0, 8.0), 6.0);
+ }
+ Some(pc.finish())
+ }
+
+ fn display_list_text(&self) -> bool {
+ true
+ }
+
+ fn handle_pointer_move(&mut self, _pos: LogicalPosition, _needs_rebuild: &mut bool) {}
+ fn handle_mouse_input(&mut self, _b: MouseButton, _s: ElementState, _p: LogicalPosition, _r: &mut bool) -> Option<()> {
+ None
+ }
+ fn handle_mouse_wheel(&mut self, _d: &MouseScrollDelta, _p: LogicalPosition, _r: &mut bool) {}
+ fn handle_key_input(&mut self, _e: &KeyEvent, _r: &mut bool) -> Option<()> {
+ None
+ }
+}
+
+fn main() {
+ cce_ui::engine::run::<ProbeApp>();
+}
diff --git a/src/scene/relief_shade.rs b/src/scene/relief_shade.rs
index 4fb4411..5a834e2 100644
--- a/src/scene/relief_shade.rs
+++ b/src/scene/relief_shade.rs
@@ -3,7 +3,10 @@
//! sketching them. Both branches: the free carves ([`carve_shade`]) and the
//! plate's own perimeter roll ([`plate_surface`]), which do not composite the
//! same way — a carve is a translucent overlay, a plate is a multiply on its
-//! own fill plus an additive specular.
+//! own fill plus an additive specular. A carve GROUPED into a plate is both:
+//! the plate's roll as [`plate_surface`], then what the carve adds composited
+//! over it as [`carve_shade`]'s value is ([`composite`]) — on the face, exactly
+//! the free carve.
//!
//! This exists because `cce-relief` drew its cross-sections with a stand-in
//! (`lit = dot(normal, light) * 0.35`) that shares nothing with the shader but
@@ -271,6 +274,24 @@ mod tests {
assert_eq!(wgsl_const("PLATE_SHADE_LINE"), PLATE_SHADE_LINE);
}
+ /// A carve grouped into a plate shades as its overlay does: the plate path
+ /// takes the shade line from its own roll alone, and composites what the
+ /// carves add as `carve_shade`'s value is composited ([`composite`]). With
+ /// the summed slope the shade line fired twice down every carve wall and
+ /// drew a doubled outline (2026-10-02). WGSL has no test harness here, so
+ /// this reads the plate branch's source.
+ #[test]
+ fn a_grouped_carve_takes_no_shade_line() {
+ let plate = WGSL
+ .split("if (mode == MODE_PLATE) {")
+ .nth(1)
+ .and_then(|rest| rest.split("if (mode == MODE_ROLL) {").next())
+ .expect("the plate branch");
+ assert!(!plate.contains("roll_shade_line(sv)"), "a carve's slope reached the shade line");
+ assert!(plate.contains("roll_shade_line(sv_rim)"));
+ assert!(plate.contains("carve_over("), "the carves are composited as an overlay");
+ }
+
/// The crest is light-facing: at the silhouette the edge toward the light
/// shades brighter than the face and the edge away from it darker. Before
/// the weight, the flat crest left the far edge at or above the face.
diff --git a/src/vk/shader2d.wgsl b/src/vk/shader2d.wgsl
index 9cc9e0d..b4230ba 100644
--- a/src/vk/shader2d.wgsl
+++ b/src/vk/shader2d.wgsl
@@ -408,6 +408,17 @@ fn carve_slope(v: f32) -> f32 {
return 6.0 * v * (1.0 - v);
}
+// A carve's signed shading value `v` (a free carve's, or what the carves
+// grouped into a plate add to its roll) composited over colour `c` the way
+// the free-carve overlay is blended: positive screens toward white, negative
+// multiplies toward black, |v| the alpha. Exactly `c` at v = 0.
+fn carve_over(c: vec3f, v: f32) -> vec3f {
+ if (v >= 0.0) {
+ return mix(c, vec3f(1.0), min(v, 1.0));
+ }
+ return mix(c, vec3f(0.0), min(-v, 1.0));
+}
+
// Per-pixel lighting of a plate. The plate is one composite height field:
// the host's rolled-edge surface minus every carve's profile, with the carve
// depth measured RELATIVE to the local surface (a deboss/etch, not a flat
@@ -416,10 +427,11 @@ fn carve_slope(v: f32) -> f32 {
// subtract, so slope vectors ADD: the pixel's normal comes from the summed
// analytic slopes of every feature over it, and the junction where a carve's
// wall crosses the plate's perimeter roll is the smooth composite of both
-// tilts, ending in the rim notch a real groove leaves. One lighting
-// evaluation per pixel — features never blend in color space. Shading is
-// expressed relative to the flat face (shade ratio 1.0, specular delta 0.0)
-// so the face keeps exactly the app's chosen color.
+// tilts, ending in the rim notch a real groove leaves. One normal per pixel —
+// features never blend with each other. The roll is shaded relative to the
+// flat face (shade ratio 1.0, specular delta 0.0) so the face keeps exactly
+// the app's chosen color; what the carves add to it is composited as a free
+// carve's overlay is (`carve_over`), so a carve reads the same grouped or not.
fn plate_shade(frag: vec2f, vcol: vec4f) -> vec4f {
let l = rrect_clip.p_light.xyz;
let strength = rrect_clip.p_mat.x;
@@ -660,7 +672,10 @@ fn plate_shade(frag: vec2f, vcol: vec4f) -> vec4f {
if (vcol.a < 0.0) {
base = resolve_blur(frag, vcol, sv_rim * (refr * t), refr * f * f, k_plate, stride);
}
- var extra = PLATE_CREST * f * f * f * crest_weight(gd.xy);
+ // The roll's crest. The carves' shoulder/fillet terms are kept apart
+ // in `curv`: they composite as an overlay's do (below).
+ let crest = PLATE_CREST * f * f * f * crest_weight(gd.xy);
+ var curv = 0.0;
let f_off = u32(rrect_clip.p_host.x);
let f_cnt = u32(rrect_clip.p_host.y);
for (var i = 0u; i < f_cnt; i = i + 1u) {
@@ -676,11 +691,30 @@ fn plate_shade(frag: vec2f, vcol: vec4f) -> vec4f {
// shoulder/fillet ambient term flips with it (a boss's convex
// shoulder is at the top of its wall, not the bottom).
sv += -(feat.params.y / ft) * carve_slope(v) * fg.xy;
- extra += rrect_clip.p_mat.w * sin(v * TAU) * sign(feat.params.y);
+ curv += rrect_clip.p_mat.w * sin(v * TAU) * sign(feat.params.y);
}
+ // The roll alone, lit as a plate: a multiply on the face colour, plus
+ // the glint and its shade line in colour units.
+ let n_r = normalize(vec3f(sv_rim, 1.0));
+ let diff_r = PLATE_AMBIENT + (1.0 - PLATE_AMBIENT) * max(dot(n_r, l), 0.0);
+ let shade_r = 1.0 + (diff_r / flat_shade - 1.0 + crest) * strength;
+ let spec_r = roll_spec(sv_rim);
+ let dark_r = roll_shade_line(sv_rim);
+ // The carves, as what they ADD to the roll's lighting — the summed
+ // slope's diffuse and glint less the roll's own, plus their
+ // curvature — composited the way a free carve's overlay is: a white
+ // screen up, a black multiply down (`carve_over`). On the face this
+ // is the overlay's `v` term for term, so a carve reads the same
+ // grouped or not; across the roll the normal is still the summed one,
+ // which is the junction grouping exists for. Not a multiply on the
+ // face colour, and no shade line: on a dark face a multiply barely
+ // moves the shoulder while the glint is added whole, and the shade
+ // line — a lobe at the half-vector's tilt — fired twice down every
+ // wall, as the tilt rose through that angle and fell back, each time
+ // subtracted to black: a doubled outline (2026-10-02).
let n = normalize(vec3f(sv, 1.0));
let diff = PLATE_AMBIENT + (1.0 - PLATE_AMBIENT) * max(dot(n, l), 0.0);
- let shade = 1.0 + (diff / flat_shade - 1.0 + extra) * strength;
+ let v_c = ((diff - diff_r) / flat_shade + curv + roll_spec(sv) - spec_r) * strength;
// p_spec_tint.w = 1 marks an accent-tinted plate (the focused-pane
// treatment). Neutral plates (w = 0) take the plain return below,
// byte-identical.
@@ -706,14 +740,9 @@ fn plate_shade(frag: vec2f, vcol: vec4f) -> vec4f {
// carved into a focused plate — each parameter control on the
// designer's parameter pane — wore the accent too, reading as if
// every control were focused alongside the pane. The carves'
- // neutral shading is added back on top unchanged.
+ // neutral shading is composited over it unchanged.
let tint = rrect_clip.p_spec_tint.rgb;
- let n_r = normalize(vec3f(sv_rim, 1.0));
- let diff_r = PLATE_AMBIENT + (1.0 - PLATE_AMBIENT) * max(dot(n_r, l), 0.0);
- let crest = PLATE_CREST * f * f * f * crest_weight(gd.xy);
- let shade_r = 1.0 + (diff_r / flat_shade - 1.0 + crest) * strength;
- let rim = base.rgb * shade_r + vec3f((roll_spec(sv_rim) - roll_shade_line(sv_rim)) * strength);
- let full = base.rgb * shade + vec3f((roll_spec(sv) - roll_shade_line(sv)) * strength);
+ let rim = base.rgb * shade_r + vec3f((spec_r - dark_r) * strength);
// The alpha a carve would composite white (or black) at for the
// same change in luminance, then that alpha in the accent.
let W = vec3f(0.2126, 0.7152, 0.0722);
@@ -725,11 +754,10 @@ fn plate_shade(frag: vec2f, vcol: vec4f) -> vec4f {
} else {
lit = mix(base.rgb, tint * FOCUS_SHADOW, min(-dl / max(bl, 0.004) * FOCUS_GAIN, 1.0));
}
- return vec4f(lit + (full - rim), abs(base.a) * aa);
+ return vec4f(carve_over(lit, v_c), abs(base.a) * aa);
}
- let spec = roll_spec(sv);
- let dark = roll_shade_line(sv);
- return vec4f(base.rgb * shade + rrect_clip.p_spec_tint.rgb * (spec * strength) - vec3f(dark * strength), abs(base.a) * aa);
+ let rolled = base.rgb * shade_r + rrect_clip.p_spec_tint.rgb * (spec_r * strength) - vec3f(dark_r * strength);
+ return vec4f(carve_over(rolled, v_c), abs(base.a) * aa);
}
if (mode == MODE_ROLL) {