git.lucas.co / cce-compositor
Wayland compositor (wlroots)
git clone https://git.lucas.co/cce-compositor.git

commit93e76978485704979471e21e11ff8811053e7629
parent1fff613303
authorLucas Galante <[email protected]>
date2026-09-04 10:59
fix(blur): anchor each frozen blur to where its bake lives — no more offset ghosts mid-pan

The freeze offset was one scene-wide number rounded per frame, while
each window's screen position rounds from its own fractional virtual
position, so the blurred backdrop jittered a pixel against windows. Worse,
two frames into every pan the fallback-cells toggle marks every optimized
blur dirty, so the bakes re-ran at the moved positions while the
scene-wide offset still assumed the pre-move ones: window-shaped
rectangles of the wrong bake showed through translucent plates
(cce-data-editor, cce-terminal).

Each optimized-blur node now records the coordinates it last baked at,
and while the scene is frozen its sibling blur samples the shared cache
shifted by its own travel since that bake. That is per node, honors each
window's own rounding, and self-corrects when a bake re-runs mid-freeze.
The wrapper snapshot and the scene-wide offset are gone.

Verified headless with cce-data-editor and cce-terminal over the grid:
mid-pan screenshots (vertical and diagonal) show a uniform blur matching
the lattice outside the plate, where the previous build showed offset
rectangles; settled frames unchanged.

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

 scenefx/include/scenefx/types/wlr_scene.h | 20 ++++++++------
 scenefx/types/scene/wlr_scene.c           | 43 ++++++++++++++++++++++++++-----
 src/server/window_manager.rs              | 32 +++++------------------
 src/server/wlroots_log_wrapper.c          | 13 +++-------
 wrapper.h                                 |  1 -
 5 files changed, 58 insertions(+), 51 deletions(-)

diff --git a/scenefx/include/scenefx/types/wlr_scene.h b/scenefx/include/scenefx/types/wlr_scene.h
index cc7c59f..d6076ff 100644
--- a/scenefx/include/scenefx/types/wlr_scene.h
+++ b/scenefx/include/scenefx/types/wlr_scene.h
@@ -126,14 +126,6 @@ struct wlr_scene {
 	 * Explicit wlr_scene_optimized_blur_mark_dirty() calls still apply.
 	 */
 	bool blur_frozen;
-	/**
-	 * While frozen, the layout-px offset the frozen backdrop bake is
-	 * sampled at: the screen delta the desktop has moved since the freeze.
-	 * A window that moved with the desktop then samples exactly its own
-	 * pre-freeze bake, so the blur stays correct through a pure pan instead
-	 * of sliding into whatever the shared cache holds at its new position.
-	 */
-	int blur_freeze_dx, blur_freeze_dy;
 
 	struct {
 		struct wl_listener linux_dmabuf_v1_destroy;
@@ -283,6 +275,18 @@ struct wlr_scene_optimized_blur {
 	int width, height;
 
 	bool dirty;
+
+	/**
+	 * Layout coordinates the node had when it last baked into the shared
+	 * cache — where its bake lives. While the scene's blur is frozen (see
+	 * wlr_scene.blur_frozen) the sibling wlr_scene_blur samples the cache
+	 * at (baked - current), so a node that moved keeps reading exactly its
+	 * own bake, per node, honoring its own pixel rounding; and a bake that
+	 * does re-run mid-freeze (an explicit mark_dirty) simply re-anchors
+	 * itself. Unset until the first bake.
+	 */
+	bool baked;
+	int baked_x, baked_y;
 };
 
 struct wlr_scene_outputs_update_event {
diff --git a/scenefx/types/scene/wlr_scene.c b/scenefx/types/scene/wlr_scene.c
index 8009f60..41639a0 100644
--- a/scenefx/types/scene/wlr_scene.c
+++ b/scenefx/types/scene/wlr_scene.c
@@ -2772,6 +2772,12 @@ static void scene_entry_render(struct render_list_entry *entry, const struct ren
 			bool result = fx_render_pass_add_optimized_blur(fx_pass, &blur_options);
 			if (result) {
 				scene_blur->dirty = false;
+				int bx, by;
+				if (wlr_scene_node_coords(node, &bx, &by)) {
+					scene_blur->baked = true;
+					scene_blur->baked_x = bx;
+					scene_blur->baked_y = by;
+				}
 			}
 		}
 		break;
@@ -2834,6 +2840,30 @@ static void scene_entry_render(struct render_list_entry *entry, const struct ren
 		struct fx_corner_radii blur_corners = blur->corners;
 		fx_corner_radii_transform(node_transform, &blur_corners);
 
+		// Frozen scene: sample the shared cache where this node's own bake
+		// lives — the sibling optimized node's coordinates at its last bake
+		// — rather than at the node's current position.
+		int freeze_dx = 0, freeze_dy = 0;
+		if (scene->blur_frozen && blur->should_only_blur_bottom_layer &&
+				data->transform == WL_OUTPUT_TRANSFORM_NORMAL && node->parent) {
+			struct wlr_scene_node *sib;
+			wl_list_for_each(sib, &node->parent->children, link) {
+				if (sib->type != WLR_SCENE_NODE_OPTIMIZED_BLUR) {
+					continue;
+				}
+				struct wlr_scene_optimized_blur *opt = wlr_scene_optimized_blur_from_node(sib);
+				int cur_x, cur_y;
+				if (opt->baked && wlr_scene_node_coords(sib, &cur_x, &cur_y)) {
+					// The cache is drawn shifted by the node's travel since
+					// the bake: screen pixel s then shows cache pixel
+					// s - travel, i.e. the bake's own pixel for that spot.
+					freeze_dx = cur_x - opt->baked_x;
+					freeze_dy = cur_y - opt->baked_y;
+				}
+				break;
+			}
+		}
+
 		struct fx_render_blur_pass_options blur_options = {
 			.tex_options = {
 				.base = (struct wlr_render_texture_options) {
@@ -2854,13 +2884,12 @@ static void scene_entry_render(struct render_list_entry *entry, const struct ren
 			.blur_data = &scene->blur_data,
 			.ignore_transparent = mask != NULL,
 			.blur_strength = blur->strength,
-			// Frozen cache: sample at the desktop's screen delta since the
-			// freeze (layout px -> buffer px). Only meaningful for an
-			// untransformed output; a rotated one gets the unshifted cache.
-			.sample_offset_x = (scene->blur_frozen && data->transform == WL_OUTPUT_TRANSFORM_NORMAL)
-				? (int)round(scene->blur_freeze_dx * data->scale) : 0,
-			.sample_offset_y = (scene->blur_frozen && data->transform == WL_OUTPUT_TRANSFORM_NORMAL)
-				? (int)round(scene->blur_freeze_dy * data->scale) : 0,
+			// Frozen cache: sample at this node's own delta since the freeze
+			// (layout px -> buffer px), so it reads its own bake. Only
+			// meaningful for an untransformed output; a rotated one gets
+			// the unshifted cache.
+			.sample_offset_x = (int)round(freeze_dx * data->scale),
+			.sample_offset_y = (int)round(freeze_dy * data->scale),
 		};
 		fx_render_pass_add_blur(fx_pass, &blur_options);
 		break;
diff --git a/src/server/window_manager.rs b/src/server/window_manager.rs
index 5514c4b..88e7d2e 100644
--- a/src/server/window_manager.rs
+++ b/src/server/window_manager.rs
@@ -181,11 +181,6 @@ pub struct WindowManager {
     /// live: `step_camera_frame` advances it once per output frame, and the
     /// watchdog timer keeps frames coming while it is set.
     pub camera_anim_active: bool,
-    /// Camera pan the frozen blur bake is valid for (the last rendered
-    /// camera when motion began). The per-frame freeze offset is the screen
-    /// delta from it; a zoom change thaws instead, since a scale change
-    /// cannot be compensated by shifting the bake.
-    pub blur_freeze_base: Option<(f64, f64)>,
     /// Finger-pan motion (virtual units, `[x, y]`) queued since the last
     /// frame. Trackpad axis events used to relayout the desktop per event
     /// (twice per sample for a diagonal); they now accumulate here and are
@@ -381,7 +376,6 @@ impl WindowManager {
         self.pan_finger_v = [0.0, 0.0];
         self.camera_anim_active = false;
         self.pan_pending = [0.0, 0.0];
-        self.blur_freeze_base = None;
         self.animation_timer = std::ptr::null_mut();
         self.edge_pan_vx = 0.0;
         self.edge_pan_vy = 0.0;
@@ -3038,8 +3032,6 @@ impl WindowManager {
         let zoom_changed = self.desk_zoom != self.last_viewport_zoom;
         let pan_changed = self.desk_pan_x != self.last_viewport_pan_x || self.desk_pan_y != self.last_viewport_pan_y;
         let moved = zoom_changed || pan_changed;
-        // The camera the last frame rendered (and baked its blurs) with.
-        let prev_pan = (self.last_viewport_pan_x, self.last_viewport_pan_y);
 
         self.last_viewport_zoom = self.desk_zoom;
         self.last_viewport_pan_x = self.desk_pan_x;
@@ -3068,25 +3060,14 @@ impl WindowManager {
             // Every motion frame moves the screen-sized backdrop under every
             // blurred window; without this, scenefx re-bakes every optimized
             // blur every frame of the pan. Through a pure pan the bakes are
-            // frozen and sampled at the desktop's screen delta since the
-            // freeze, so each window keeps reading exactly its own bake —
-            // correct, not stale, because the backdrop moved with it. A zoom
-            // changes the scale under the window, which no shift can
-            // compensate: the caches thaw (re-bake per frame) for its
+            // frozen and each window samples the cache where its own bake
+            // lives (the coordinates it last baked at), reading exactly its
+            // own bake — correct, not stale, because the backdrop moved with
+            // it. A zoom changes the scale under the window, which no shift
+            // can compensate: the caches thaw (re-bake per frame) for its
             // duration and re-freeze on the next pure-pan frame.
             let scene = (*self.server).scene.wlr_scene;
-            if zoom_changed {
-                ffi::river_scene_set_blur_frozen(scene, false);
-                self.blur_freeze_base = None;
-            } else {
-                let (bx, by) = *self.blur_freeze_base.get_or_insert(prev_pan);
-                ffi::river_scene_set_blur_frozen(scene, true);
-                ffi::river_scene_set_blur_freeze_offset(
-                    scene,
-                    (-(self.desk_pan_x - bx) * self.desk_zoom).round() as i32,
-                    (-(self.desk_pan_y - by) * self.desk_zoom).round() as i32,
-                );
-            }
+            ffi::river_scene_set_blur_frozen(scene, !zoom_changed);
             for &window in self.windows.iter() {
                 if !window.is_null() {
                     (*window).render_viewport_update();
@@ -3172,7 +3153,6 @@ impl WindowManager {
         // Thaw the blur caches (marks them all dirty once) so the settled
         // frame re-bakes against the final backdrop.
         ffi::river_scene_set_blur_frozen((*self.server).scene.wlr_scene, false);
-        self.blur_freeze_base = None;
         for &window in self.windows.iter() {
             if !window.is_null() {
                 (*window).render_finish();
diff --git a/src/server/wlroots_log_wrapper.c b/src/server/wlroots_log_wrapper.c
index 1cd2a48..56504e8 100644
--- a/src/server/wlroots_log_wrapper.c
+++ b/src/server/wlroots_log_wrapper.c
@@ -1086,21 +1086,16 @@ void river_scene_mark_optimized_blur_dirty(struct wlr_scene *scene) {
  * invalidation for the duration of a camera pan. Thawing marks every
  * optimized blur dirty once so the settled frame re-bakes against the
  * final backdrop. */
+/* See wlr_scene.blur_frozen. While frozen each blur samples the shared
+ * cache where its own bake lives (wlr_scene_optimized_blur.baked_x/y), so
+ * nothing needs snapshotting here; thawing marks every bake dirty once so
+ * the settled frame re-bakes against the final backdrop. */
 void river_scene_set_blur_frozen(struct wlr_scene *scene, bool frozen) {
 	if (scene->blur_frozen == frozen) {
 		return;
 	}
 	scene->blur_frozen = frozen;
-	scene->blur_freeze_dx = 0;
-	scene->blur_freeze_dy = 0;
 	if (!frozen) {
 		mark_optimized_blur_dirty_rec(&scene->tree.node);
 	}
 }
-
-/* The screen delta (layout px) the desktop has moved since the freeze —
- * what every frozen blur samples its bake at. */
-void river_scene_set_blur_freeze_offset(struct wlr_scene *scene, int dx, int dy) {
-	scene->blur_freeze_dx = dx;
-	scene->blur_freeze_dy = dy;
-}
diff --git a/wrapper.h b/wrapper.h
index 4787139..68d244c 100644
--- a/wrapper.h
+++ b/wrapper.h
@@ -265,7 +265,6 @@ void river_scene_node_enable_blur(struct wlr_scene_node *node, bool enabled, boo
 
 void river_scene_mark_optimized_blur_dirty(struct wlr_scene *scene);
 void river_scene_set_blur_frozen(struct wlr_scene *scene, bool frozen);
-void river_scene_set_blur_freeze_offset(struct wlr_scene *scene, int dx, int dy);
 
 void river_scene_node_set_opacity(struct wlr_scene_node *node, float opacity);