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

commit1fff6133031f1c24952eed30b37b90dcce520a3b
parent68db40e5dd
authorLucas Galante <[email protected]>
date2026-09-04 10:45
fix(pan): sample frozen blur bakes at the desktop's screen delta — no more blur artifacts on blurred windows while panning

Freezing the optimized-blur caches during a pan stopped the per-frame
re-bake, but the shared cache is in screen space: a window that moved
with the desktop sampled whatever the cache held at its NEW position —
another window's bake, or nothing — and its blur smeared and tore
(cce-data-editor, cce-terminal). The blur was frozen in the wrong frame
of reference.

The scene now carries a freeze offset (river_scene_set_blur_freeze_offset),
the layout-px screen delta the desktop has moved since the freeze, and a
bottom-layer blur node draws the cached buffer shifted by it (a new
sample_offset on fx_render_blur_pass_options, applied only when the
cache is sampled directly). A window that moved with the desktop then
reads exactly its own pre-freeze bake, which is CORRECT for a pure pan —
the backdrop under it moved by the same delta — not merely stale. The
compositor records the last rendered pan when motion starts, updates
the offset every motion frame, and thaws (per-frame re-bake) for the
duration of a zoom, which no shift can compensate. Rotated outputs get
the unshifted cache.

Shadow: cce-data-editor and cce-terminal over the grid, screenshots at
rest, twice mid-pan, and settled — the blurred lattice through the
plate stays aligned with the grid outside it throughout.

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

 scenefx/include/scenefx/render/pass.h     |  7 +++++++
 scenefx/include/scenefx/types/wlr_scene.h |  8 ++++++++
 scenefx/render/fx_renderer/fx_pass.c      |  9 ++++++---
 scenefx/types/scene/wlr_scene.c           |  7 +++++++
 src/server/window_manager.rs              | 32 ++++++++++++++++++++++++++++---
 src/server/wlroots_log_wrapper.c          |  9 +++++++++
 wrapper.h                                 |  1 +
 7 files changed, 67 insertions(+), 6 deletions(-)

diff --git a/scenefx/include/scenefx/render/pass.h b/scenefx/include/scenefx/render/pass.h
index 2e66c4e..2c6b00d 100644
--- a/scenefx/include/scenefx/render/pass.h
+++ b/scenefx/include/scenefx/render/pass.h
@@ -164,6 +164,13 @@ struct fx_render_blur_pass_options {
 	float blur_strength;
 	struct fx_corner_fradii corners;
 	struct clipped_fregion clipped_region;
+	/**
+	 * Buffer-px offset at which the cached optimized-blur buffer is sampled
+	 * (only when it is sampled directly, i.e. optimized and full strength).
+	 * Zero normally; the scene's blur freeze sets it to the desktop's
+	 * screen delta so a moved window keeps reading its own bake.
+	 */
+	int sample_offset_x, sample_offset_y;
 };
 
 struct fx_gles_render_pass *fx_get_render_pass(struct wlr_render_pass *render_pass);
diff --git a/scenefx/include/scenefx/types/wlr_scene.h b/scenefx/include/scenefx/types/wlr_scene.h
index 1b0b85a..cc7c59f 100644
--- a/scenefx/include/scenefx/types/wlr_scene.h
+++ b/scenefx/include/scenefx/types/wlr_scene.h
@@ -126,6 +126,14 @@ 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;
diff --git a/scenefx/render/fx_renderer/fx_pass.c b/scenefx/render/fx_renderer/fx_pass.c
index cd9aa44..78e2319 100644
--- a/scenefx/render/fx_renderer/fx_pass.c
+++ b/scenefx/render/fx_renderer/fx_pass.c
@@ -1370,10 +1370,13 @@ void fx_render_pass_add_blur(struct fx_gles_render_pass *pass,
 		stencil_mask_close(true);
 	}
 
-	// Draw the blurred texture
+	// Draw the blurred texture. Sampled straight from the shared cache
+	// (optimized, full strength) it may be shifted by the scene's freeze
+	// offset; a freshly blurred buffer is always in place.
+	const bool direct_cache = fx_options->use_optimized_blur && !has_strength;
 	tex_options->base.dst_box = (struct wlr_box) {
-		.x = 0,
-		.y = 0,
+		.x = direct_cache ? fx_options->sample_offset_x : 0,
+		.y = direct_cache ? fx_options->sample_offset_y : 0,
 		.width = buffer->buffer->width,
 		.height = buffer->buffer->height,
 	};
diff --git a/scenefx/types/scene/wlr_scene.c b/scenefx/types/scene/wlr_scene.c
index d3d1d55..8009f60 100644
--- a/scenefx/types/scene/wlr_scene.c
+++ b/scenefx/types/scene/wlr_scene.c
@@ -2854,6 +2854,13 @@ 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,
 		};
 		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 badcb1d..5514c4b 100644
--- a/src/server/window_manager.rs
+++ b/src/server/window_manager.rs
@@ -181,6 +181,11 @@ 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
@@ -376,6 +381,7 @@ 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;
@@ -3032,6 +3038,8 @@ 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;
@@ -3059,9 +3067,26 @@ impl WindowManager {
             self.viewport_is_active = true;
             // 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. Frozen blurs go slightly stale
-            // during the gesture and re-bake once at settle.
-            ffi::river_scene_set_blur_frozen((*self.server).scene.wlr_scene, true);
+            // 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
+            // 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,
+                );
+            }
             for &window in self.windows.iter() {
                 if !window.is_null() {
                     (*window).render_viewport_update();
@@ -3147,6 +3172,7 @@ 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 5413aef..1cd2a48 100644
--- a/src/server/wlroots_log_wrapper.c
+++ b/src/server/wlroots_log_wrapper.c
@@ -1091,7 +1091,16 @@ void river_scene_set_blur_frozen(struct wlr_scene *scene, bool 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 68d244c..4787139 100644
--- a/wrapper.h
+++ b/wrapper.h
@@ -265,6 +265,7 @@ 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);