Wayland compositor (wlroots)
git clone https://git.lucas.co/cce-compositor.git
grid: native-res patches on scaled outputs; flat fallback cells; re-bake optimized blur when backdrop content changes
Three coordinated fixes for the client grid at output scale 2:
- The patch quantizer folded only zoom into the patch scale, so a
buffer px was a LOGICAL px — magnified 2x on a scale-2 output into
staircase blocks on every cell edge. q now carries the output scale
(a buffer px is a native px at the quantized zoom); the covers()
resolution-drift window measures native px per buffer px and the
buffer cap scales with the output so the margin never shrinks below
the comfort band covers() demands (which would repatch forever).
The cce-ui runner pins grid surfaces to buffer scale 1 to match.
- Fallback cells drop their lit chamfer: the cce-grid client draws
flat cells now, and the swap must not change the grid's material.
- scenefx's optimized-blur cache (the shared blurred backdrop) is only
invalidated by blur-parameter setters and blur-node RESIZES, so
backdrop content that changes without either — a grid patch latching
after the viewport settled, the client<->fallback swap, a fallback
spec change — left every translucent window showing a blur of the
pre-change desktop (stale, misanchored grid lines). New
river_scene_mark_optimized_blur_dirty walks the scene and re-bakes;
called from all three sites. Verified: the blurred gap band through a
translucent window lands within 1px of the latched grid's band.
Co-Authored-By: Claude Fable 5 <[email protected]>
src/server/output.rs | 63 +++++++---------------------------------
src/server/window_manager.rs | 43 +++++++++++++++++++--------
src/server/wlroots_log_wrapper.c | 25 ++++++++++++++++
src/server/xdg_toplevel.rs | 6 ++++
wrapper.h | 2 ++
5 files changed, 75 insertions(+), 64 deletions(-)
diff --git a/src/server/output.rs b/src/server/output.rs
index 01d7ca8..d2ce55b 100644
--- a/src/server/output.rs
+++ b/src/server/output.rs
@@ -804,6 +804,10 @@ impl Output {
|| self.last_grid_bevel != Some(bevel_key);
if structure_changed {
self.grid_force_redraw_frames = 3;
+ // Fallback-grid structure (spec/zoom/viewport) is backdrop
+ // content in the optimized-blur capture set — same staleness
+ // rule as the client-grid latch.
+ ffi::river_scene_mark_optimized_blur_dirty((*self.server).scene.wlr_scene);
}
let force = self.grid_force_redraw_frames > 0;
if force {
@@ -826,48 +830,13 @@ impl Output {
let pool = &mut self.grid_rect_pool;
let mut pool_idx = 0;
- let layout = &wm.layout;
- let bevel_on = layout.bevel_enabled;
- // Light normalized exactly like the window bevels — the grid is lit
- // by the same lamp.
- let (bevel_lx, bevel_ly) = {
- let (lx, ly) = (layout.bevel_light_x, layout.bevel_light_y);
- let len = (lx * lx + ly * ly).sqrt();
- if len > 1e-6 { (lx / len, ly / len) } else { (-0.7071, -0.7071) }
- };
- let bevel_tree = self.grid_bevel_tree;
+ // The cells are deliberately FLAT — no lit chamfer. The window-bevel
+ // treatment on grid cells fought the windows' own relief, and the
+ // cce-grid client draws its cells flat, so the fallback must too or
+ // the client latch visibly swaps the grid's material. The bevel pool
+ // survives only to disable nodes left by older sessions.
let bevel_pool = &mut self.grid_bevel_pool;
- let mut bevel_idx = 0;
-
- let mut get_bevel = |w: i32, h: i32, x: i32, y: i32, radius: i32, thickness: f32| {
- let bevel = if bevel_idx < bevel_pool.len() {
- let node = bevel_pool[bevel_idx];
- ffi::wlr_scene_node_set_enabled(&mut (*node).node as *mut ffi::wlr_scene_node, true);
- ffi::wlr_scene_bevel_set_size(node, w, h);
- node
- } else {
- let node = ffi::wlr_scene_bevel_create(bevel_tree, w, h, 0, 0.0, layout.bevel_color.as_ptr());
- if !node.is_null() {
- bevel_pool.push(node);
- }
- node
- };
- if !bevel.is_null() {
- ffi::wlr_scene_node_set_position(&mut (*bevel).node as *mut ffi::wlr_scene_node, x, y);
- ffi::wlr_scene_bevel_set_corner_radius(bevel, radius);
- ffi::wlr_scene_bevel_set_thickness(bevel, thickness.max(1.0));
- ffi::wlr_scene_bevel_set_light(
- bevel,
- bevel_lx,
- bevel_ly,
- layout.bevel_light_intensity,
- layout.bevel_shade_intensity,
- );
- ffi::wlr_scene_bevel_set_shoulder(bevel, layout.bevel_shoulder);
- ffi::wlr_scene_bevel_set_color(bevel, layout.bevel_color.as_ptr());
- }
- bevel_idx += 1;
- };
+ let bevel_idx = 0;
// Helper closure to manage/reuse the pool of wlr_scene_rect elements.
let mut get_rect = |w: i32, h: i32, color_ptr: *const f32, x: i32, y: i32, corner_r: i32, fade_i: i32| -> *mut ffi::wlr_scene_rect {
@@ -940,13 +909,10 @@ impl Output {
// at corner_shape > 2 the superellipse hugs the
// corner, so the raw radius reads nearly square —
// and a tiled window's (widened) arc must land on
- // the cell's arc. The rim reaches as far as the
- // radius: the fillet is a full quarter-sweep a
- // window corner nests into.
+ // the cell's arc.
let cell_radius = crate::window::widen_corner_radius(
cells.corner_radius_px, cells.cell_px, cells.cell_px,
);
- let cell_bevel_thickness = cell_radius as f32;
// Cell positions from the EXACT period, rounded per
// cell: a rounded-period spacing drifts from the
// world-anchored windows at fractional zooms (the
@@ -957,13 +923,6 @@ impl Output {
for row in 0..=cells.rows {
let rel_y = (row as f64 * frame.period_px_exact).round() as i32;
get_rect(cells.cell_px, cells.cell_px, cells.color.0.as_ptr(), rel_x, rel_y, cell_radius, inset_scaled);
- // The lit chamfer descending from the grid
- // lines into the cell — it wraps the corner
- // arcs, filling the corner cutout with the
- // fillet instead of flat gap color.
- if bevel_on && cell_radius > 0 {
- get_bevel(cells.cell_px, cells.cell_px, rel_x, rel_y, cell_radius, cell_bevel_thickness);
- }
}
}
}
diff --git a/src/server/window_manager.rs b/src/server/window_manager.rs
index 5bfa484..d9f7b06 100644
--- a/src/server/window_manager.rs
+++ b/src/server/window_manager.rs
@@ -1941,36 +1941,44 @@ impl WindowManager {
/// window; a failed send (no toplevel resource yet, old client) simply
/// retries on a later pass.
pub unsafe fn update_grid_patches(&mut self) {
- let mut out_box: Option<ffi::wlr_box> = None;
+ let mut out_box: Option<(ffi::wlr_box, f64)> = None;
let outputs_list = &(*self.server).om.outputs as *const ffi::wl_list as *mut WlList;
let mut curr_out = (*outputs_list).next;
while curr_out != outputs_list {
let output = crate::container_of!(curr_out, crate::output::Output, link);
if (*output).sent.state == crate::output::OutputStateValue::Enabled {
- out_box = Some((*output).sent.box_layout());
+ out_box = Some(((*output).sent.box_layout(), (*output).sent.scale.max(1.0) as f64));
break;
}
curr_out = (*curr_out).next;
}
- let Some(out) = out_box else { return };
+ let Some((out, out_scale)) = out_box else { return };
let zoom = crate::policy::background::sanitized_zoom(self.desk_zoom);
let vw = out.width as f64 / zoom;
let vh = out.height as f64 / zoom;
let (vx, vy) = (self.desk_pan_x, self.desk_pan_y);
// Buffer px per virtual unit: zoom quantized to a power of two so
- // small zoom wobbles don't re-render the world.
- let q = (2f64).powf(zoom.log2().round()).clamp(0.125, 2.0);
+ // small zoom wobbles don't re-render the world, times the output
+ // scale so a buffer px is a NATIVE px at the quantized zoom — at
+ // scale 2 an unscaled patch is magnified 2x on screen, which turns
+ // every cell edge into 2px staircase blocks.
+ let q = (2f64).powf(zoom.log2().round()).clamp(0.125, 2.0) * out_scale;
let period = self.layout.desktop_grid_scale
+ (self.layout.desktop_gap_width as f64).max(0.0);
let covers = |p: &crate::policy::api::GridPatch| -> bool {
let mx = vw * 0.15;
let my = vh * 0.15;
+ // Resolution drift is native px per buffer px, so the output
+ // scale belongs on the zoom side — measuring against zoom
+ // alone would reject every native-res patch on a scaled
+ // output (display factor 0.5 at scale 2) and repatch forever.
+ let disp = zoom * out_scale / p.scale;
p.x <= vx - mx
&& p.y <= vy - my
&& p.x + p.w >= vx + vw + mx
&& p.y + p.h >= vy + vh + my
- && (zoom / p.scale) > 0.5
- && (zoom / p.scale) < 2.01
+ && disp > 0.5
+ && disp < 2.01
};
for &w in self.windows.iter() {
if w.is_null() || (*w).closed || !(*w).is_grid() {
@@ -1995,14 +2003,21 @@ impl WindowManager {
// Half a viewport of margin per side, shrunk if the buffer
// would exceed the cap; then period-aligned outward so the
// client draws whole cells. Margin and cap are a MEMORY knob:
- // at output scale 2 the client's framebuffer is
- // (patch * q * 2)^2 * 4B per swapchain image — the original
+ // the client's framebuffer is (patch * q)^2 * 4B per swapchain
+ // image (q carries the output scale) — the original
// 3x3-viewport margin cost ~340MB per image (gigabytes with
// swapchain + staging), for scroll headroom that the 0.15
// comfort margin above rarely used.
- const MAX_BUF: f64 = 4096.0;
- let m = (((MAX_BUF / q) - vw) / (2.0 * vw)).clamp(0.0, 0.5)
- .min((((MAX_BUF / q) - vh) / (2.0 * vh)).clamp(0.0, 0.5));
+ // Scale-aware: at output scale 2 a native-res patch has 2x the
+ // buffer px per virtual unit, and a cap that ignored that would
+ // shrink the margin below the 0.15-viewport comfort band that
+ // covers() demands — a patch that can never cover is a repatch
+ // every arrange pass. 4096 * scale keeps the same VIRTUAL
+ // coverage (≈ 2x2 viewports) at every scale; the memory cost is
+ // the native-resolution pixels themselves.
+ let max_buf: f64 = 4096.0 * out_scale;
+ let m = (((max_buf / q) - vw) / (2.0 * vw)).clamp(0.0, 0.5)
+ .min((((max_buf / q) - vh) / (2.0 * vh)).clamp(0.0, 0.5));
let x0 = ((vx - m * vw) / period).floor() * period;
let y0 = ((vy - m * vh) / period).floor() * period;
let x1 = ((vx + (1.0 + m) * vw) / period).ceil() * period;
@@ -2208,6 +2223,10 @@ impl WindowManager {
for &output in &active_outputs {
(*output).grid_force_redraw_frames = 3;
}
+ // The swap changes backdrop content under the optimized-blur
+ // capture set without any blur-node resize — re-bake or
+ // translucent windows keep blurring the pre-swap grid.
+ ffi::river_scene_mark_optimized_blur_dirty((*self.server).scene.wlr_scene);
}
for (&win_ptr, wp) in win_ptrs.iter().zip(plan.windows.iter()) {
diff --git a/src/server/wlroots_log_wrapper.c b/src/server/wlroots_log_wrapper.c
index 4d6857e..dba2fcb 100644
--- a/src/server/wlroots_log_wrapper.c
+++ b/src/server/wlroots_log_wrapper.c
@@ -1049,3 +1049,28 @@ struct wlr_buffer *river_data_buffer_create(int width, int height,
wlr_buffer_init(&buf->base, &cce_data_buffer_impl, width, height);
return &buf->base;
}
+
+/* Mark every optimized-blur node in the scene dirty, forcing a re-bake of the
+ * shared blurred-backdrop cache on the next frame. The cache is otherwise
+ * invalidated only by blur-parameter setters and window blur RESIZES
+ * (optimized_blur_set_size marks dirty; a same-size re-enable does not), so
+ * backdrop CONTENT that changes without either — the grid client latching a
+ * new patch after the viewport has settled, or the client<->fallback grid
+ * swap — leaves every translucent window showing a stale bake. */
+static void mark_optimized_blur_dirty_rec(struct wlr_scene_node *node) {
+ if (node->type == WLR_SCENE_NODE_OPTIMIZED_BLUR) {
+ wlr_scene_optimized_blur_mark_dirty(wlr_scene_optimized_blur_from_node(node));
+ return;
+ }
+ if (node->type == WLR_SCENE_NODE_TREE) {
+ struct wlr_scene_tree *tree = wlr_scene_tree_from_node(node);
+ struct wlr_scene_node *child;
+ wl_list_for_each(child, &tree->children, link) {
+ mark_optimized_blur_dirty_rec(child);
+ }
+ }
+}
+
+void river_scene_mark_optimized_blur_dirty(struct wlr_scene *scene) {
+ mark_optimized_blur_dirty_rec(&scene->tree.node);
+}
diff --git a/src/server/xdg_toplevel.rs b/src/server/xdg_toplevel.rs
index 0959fd4..9dabf85 100644
--- a/src/server/xdg_toplevel.rs
+++ b/src/server/xdg_toplevel.rs
@@ -538,6 +538,12 @@ unsafe extern "C" fn handle_commit(listener: *mut ffi::wl_listener, _data: *mut
if let Some((serial, patch)) = (*window).grid_patch_acked.take() {
log::info!("[Grid] latched patch #{serial} on commit");
(*window).grid_patch_current = Some(patch);
+ // The grid sits in the optimized-blur capture set (backdrop layers):
+ // new patch content invalidates the shared blurred-backdrop cache,
+ // which nothing else re-bakes when the latch lands after the
+ // viewport has settled — translucent windows keep showing a blur of
+ // the pre-latch desktop.
+ ffi::river_scene_mark_optimized_blur_dirty((*(*window).server).scene.wlr_scene);
(*(*window).server).wm.dirty_windowing();
}
let base = ffi::river_wlr_xdg_toplevel_get_base((*toplevel).wlr_toplevel);
diff --git a/wrapper.h b/wrapper.h
index 41aa44b..8c489d8 100644
--- a/wrapper.h
+++ b/wrapper.h
@@ -262,6 +262,8 @@ void river_wlr_keyboard_init(struct wlr_keyboard *keyboard, void (*led_update)(s
void river_scene_node_enable_blur(struct wlr_scene_node *node, bool enabled, bool optimized, bool ignore_transparent, int x, int y, int width, int height, int corner_radius);
+void river_scene_mark_optimized_blur_dirty(struct wlr_scene *scene);
+
void river_scene_node_set_opacity(struct wlr_scene_node *node, float opacity);
void river_scene_node_set_corner_radius(struct wlr_scene_node *node, int radius);