Wayland compositor (wlroots)
git clone https://git.lucas.co/cce-compositor.git
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);