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

commit66232ed7c7518333e2f58f36e647cc25a1981f08
parent91e08ebbd1
authorLucas Galante <[email protected]>
date2026-09-04 10:22
perf(pan): freeze blur caches during camera motion, fixed-size grid patches, prefetch by velocity

Three per-pan costs, found by reading the pan frame path and the live
log (13 grid patches in 30s of navigation, each ~9160x5676 buffer px at
output scale 2 — ~200MB per swapchain image — and a 2.5GB peak in the
grid service):

- scenefx marks every optimized-blur node dirty whenever a node below it
  moves, and the screen-sized backdrop moves on every pan frame, so every
  blurred window re-baked its full blur every frame of a pan. The scene
  gains a blur_frozen flag (river_scene_set_blur_frozen): set when motion
  starts, cleared at the viewport settle — which marks all blurs dirty
  once so the settled frame re-bakes against the final backdrop.

- Grid patches were period-aligned OUTWARD from a varying union rect, so
  consecutive patches differed by up to two periods (and overran the
  buffer cap by the same). Every size change rebuilt the client's
  swapchain and dropped a frame mid-gesture. A patch is now the largest
  whole-period rect within the cap, centered on the union: identical
  extents from patch to patch, so the swapchain survives the swap. The
  legacy exact-fit alignment remains for a union that nearly fills the
  cap (an overview flight).

- Patches anticipated only explicit camera targets. A kinetic coast now
  predicts its landing (v/friction) and a live finger gesture ~0.3s of
  its velocity (pan_finger_v, fed from the cursor), so the next patch is
  issued toward where the pan is heading while the current one still
  covers the viewport. And a pure pan now counts as a flight for the
  fallback cells, so a gesture that outruns its patch shows the lattice
  at the leading edge instead of bare backdrop.

Verified in a headless shadow: repeated pans and a zoom flight issue
patches of one constant size (3664x3870 @1 at 1280x720) where they varied
before, the grid and windows render, unit tests pass. Frame timing needs
the live session (headless renders no pan frames).

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

 scenefx/include/scenefx/types/wlr_scene.h | 10 ++++
 scenefx/types/scene/wlr_scene.c           |  5 +-
 src/server/cursor.rs                      |  2 +
 src/server/window_manager.rs              | 83 ++++++++++++++++++++++++++-----
 src/server/wlroots_log_wrapper.c          | 14 ++++++
 wrapper.h                                 |  1 +
 6 files changed, 101 insertions(+), 14 deletions(-)

diff --git a/scenefx/include/scenefx/types/wlr_scene.h b/scenefx/include/scenefx/types/wlr_scene.h
index 4bc38e9..1b0b85a 100644
--- a/scenefx/include/scenefx/types/wlr_scene.h
+++ b/scenefx/include/scenefx/types/wlr_scene.h
@@ -117,6 +117,16 @@ struct wlr_scene {
 
 	bool restack_xwayland_surfaces;
 
+	/**
+	 * While set, a node moving underneath an optimized-blur node does NOT
+	 * mark that blur dirty. The compositor sets it for the duration of a
+	 * camera pan (every frame moves the screen-sized backdrop under every
+	 * blurred window, which otherwise re-bakes every blur every frame) and
+	 * clears it — marking all blurs dirty once — when the camera settles.
+	 * Explicit wlr_scene_optimized_blur_mark_dirty() calls still apply.
+	 */
+	bool blur_frozen;
+
 	struct {
 		struct wl_listener linux_dmabuf_v1_destroy;
 		struct wl_listener gamma_control_manager_v1_destroy;
diff --git a/scenefx/types/scene/wlr_scene.c b/scenefx/types/scene/wlr_scene.c
index 91b5259..d3d1d55 100644
--- a/scenefx/types/scene/wlr_scene.c
+++ b/scenefx/types/scene/wlr_scene.c
@@ -438,6 +438,7 @@ struct scene_update_data {
 #if WLR_HAS_XWAYLAND
 	struct wlr_xwayland_surface *restack_above;
 #endif
+	bool blur_frozen;
 };
 
 static uint32_t region_area(const pixman_region32_t *region) {
@@ -778,7 +779,8 @@ static bool scene_node_update_iterator(struct wlr_scene_node *node,
 
 	if (node->type == WLR_SCENE_NODE_OPTIMIZED_BLUR) {
 		struct wlr_scene_optimized_blur *scene_blur = wlr_scene_optimized_blur_from_node(node);
-		if (data->updated_node && scene_node_is_below(data->updated_node, node)) {
+		if (data->updated_node && !data->blur_frozen &&
+				scene_node_is_below(data->updated_node, node)) {
 			scene_blur->dirty = true;
 		}
 		if (scene_blur->dirty) {
@@ -875,6 +877,7 @@ static void scene_update_region(struct wlr_scene *scene,
 		.outputs = &scene->outputs,
 		.calculate_visibility = scene->calculate_visibility,
 		.restack_xwayland_surfaces = scene->restack_xwayland_surfaces,
+		.blur_frozen = scene->blur_frozen,
 	};
 
 	// update node visibility and output enter/leave events
diff --git a/src/server/cursor.rs b/src/server/cursor.rs
index 9193770..0bbf9f5 100644
--- a/src/server/cursor.rs
+++ b/src/server/cursor.rs
@@ -2068,6 +2068,7 @@ unsafe extern "C" fn handle_axis(listener: *mut ffi::wl_listener, data: *mut std
                     cursor.pan_vel[axis] * 0.65 + sample * 0.35
                 };
                 cursor.pan_last_msec[axis] = now_ms;
+                wm.pan_finger_v[axis] = cursor.pan_vel[axis];
                 if matches!(wm.state, crate::window_manager::WindowManagerState::Idle) {
                     wm.update_viewport_local();
                 } else {
@@ -2089,6 +2090,7 @@ unsafe extern "C" fn handle_axis(listener: *mut ffi::wl_listener, data: *mut std
                 }
                 cursor.pan_vel = [0.0, 0.0];
                 cursor.pan_last_msec = [0, 0];
+                wm.pan_finger_v = [0.0, 0.0];
                 if wm.kinetic_scroll() && (vx != 0.0 || vy != 0.0) {
                     wm.pan_coast_vx = vx;
                     wm.pan_coast_vy = vy;
diff --git a/src/server/window_manager.rs b/src/server/window_manager.rs
index ab9ac1e..5a943cf 100644
--- a/src/server/window_manager.rs
+++ b/src/server/window_manager.rs
@@ -173,6 +173,10 @@ pub struct WindowManager {
     /// wheel zoom): each step re-derives the pan so the virtual point under
     /// the cursor stays put throughout, not just at the end.
     pub zoom_anchor: Option<(f64, f64)>,
+    /// Live finger-pan velocity (virtual units/s, `[x, y]`) while a trackpad
+    /// gesture is panning the desktop; zero otherwise. Grid patches use it
+    /// to prefetch toward where the gesture is heading.
+    pub pan_finger_v: [f64; 2],
     pub animation_timer: *mut ffi::wl_event_source,
     /// Edge auto-pan velocity during an interactive move/resize, in SCREEN
     /// px/s (the tick divides by zoom). Written by `Seat::update_edge_pan`
@@ -359,6 +363,7 @@ impl WindowManager {
         self.pan_coast_vx = 0.0;
         self.pan_coast_vy = 0.0;
         self.zoom_anchor = None;
+        self.pan_finger_v = [0.0, 0.0];
         self.animation_timer = std::ptr::null_mut();
         self.edge_pan_vx = 0.0;
         self.edge_pan_vy = 0.0;
@@ -2272,6 +2277,26 @@ impl WindowManager {
                 } else {
                     None
                 }
+            })
+            .or_else(|| {
+                // No explicit destination: predict one from the kinetic
+                // coast (an exponential decay travels v/friction more) or a
+                // live finger gesture (~0.3s of its current velocity), so the
+                // patch is issued toward where the pan is heading before the
+                // viewport reaches the current patch's edge.
+                let (vx_, vy_) = if self.pan_coast_vx != 0.0 || self.pan_coast_vy != 0.0 {
+                    let f = self.scroll_friction();
+                    (self.pan_coast_vx / f, self.pan_coast_vy / f)
+                } else if self.pan_finger_v != [0.0, 0.0] {
+                    (self.pan_finger_v[0] * 0.3, self.pan_finger_v[1] * 0.3)
+                } else {
+                    return None;
+                };
+                Some(crate::policy::camera::Camera {
+                    pan_x: self.desk_pan_x + vx_,
+                    pan_y: self.desk_pan_y + vy_,
+                    zoom: crate::policy::background::sanitized_zoom(self.desk_zoom),
+                })
             });
         let in_flight = self.viewport_is_active || target_cam.is_some();
 
@@ -2441,19 +2466,39 @@ impl WindowManager {
                 // viewport shrinks toward the destination.
                 continue;
             }
-            let m = (((max_buf / q) - uw) / (2.0 * uw)).clamp(0.0, 0.5)
-                .min((((max_buf / q) - uh) / (2.0 * uh)).clamp(0.0, 0.5));
-            let x0 = ((ux0 - m * uw) / period_x).floor() * period_x;
-            let y0 = ((uy0 - m * uh) / period_y).floor() * period_y;
-            let x1 = ((ux1 + m * uw) / period_x).ceil() * period_x;
-            let y1 = ((uy1 + m * uh) / period_y).ceil() * period_y;
-            let patch = crate::policy::api::GridPatch {
-                x: x0,
-                y: y0,
-                w: x1 - x0,
-                h: y1 - y0,
-                scale: q,
+            // The patch is a FIXED size for a given resolution: the largest
+            // whole-period rect within the buffer cap, centered on the union
+            // and period-aligned. Consecutive patches during a pan then have
+            // identical buffer extents, so the client's swapchain survives
+            // the swap — the old outward-aligned rect varied by a couple of
+            // periods between patches, and every size change rebuilt a
+            // 200MB swapchain and dropped a frame mid-gesture. (It also
+            // overran the cap by up to two periods.)
+            let fw = ((max_buf / q) / period_x).floor().max(1.0) * period_x;
+            let fh = ((max_buf / q) / period_y).floor().max(1.0) * period_y;
+            let place = |u0: f64, u1: f64, f: f64, period: f64| -> Option<f64> {
+                let center = (u0 + u1) * 0.5;
+                let mut p0 = ((center - f * 0.5) / period).floor() * period;
+                if p0 + f < u1 {
+                    p0 += period;
+                }
+                (p0 <= u0 && p0 + f >= u1).then_some(p0)
+            };
+            let (x0, y0, pw, ph) = match (place(ux0, ux1, fw, period_x), place(uy0, uy1, fh, period_y)) {
+                (Some(x0), Some(y0)) => (x0, y0, fw, fh),
+                _ => {
+                    // The union nearly fills the cap (an overview flight's
+                    // union): the legacy outward alignment, exact-fit.
+                    let m = (((max_buf / q) - uw) / (2.0 * uw)).clamp(0.0, 0.5)
+                        .min((((max_buf / q) - uh) / (2.0 * uh)).clamp(0.0, 0.5));
+                    let x0 = ((ux0 - m * uw) / period_x).floor() * period_x;
+                    let y0 = ((uy0 - m * uh) / period_y).floor() * period_y;
+                    let x1 = ((ux1 + m * uw) / period_x).ceil() * period_x;
+                    let y1 = ((uy1 + m * uh) / period_y).ceil() * period_y;
+                    (x0, y0, x1 - x0, y1 - y0)
+                }
             };
+            let patch = crate::policy::api::GridPatch { x: x0, y: y0, w: pw, h: ph, scale: q };
             (*w).grid_patch_serial = (*w).grid_patch_serial.wrapping_add(1);
             let serial = (*w).grid_patch_serial;
             if (*self.server)
@@ -2666,9 +2711,13 @@ impl WindowManager {
         // with real cells for the frame or two the client needs to render
         // the flight's replacement patch (backdrop-only exposure was the
         // "cells at the bottom appear late" gap).
+        // A pure pan counts too: a fast trackpad flick or wheel run can
+        // outrun the client's patch, and without the fallback the leading
+        // edge showed bare backdrop until the next patch latched.
         let cells_wanted = plan.grid_cells_enabled
             || self.camera_ramp_anim.is_some()
-            || self.target_desk_zoom.is_some();
+            || self.target_desk_zoom.is_some()
+            || self.viewport_is_active;
         if self.grid_cells_enabled != cells_wanted {
             self.grid_cells_enabled = cells_wanted;
             // The cell pools redraw only on structure changes; force one so
@@ -2823,6 +2872,11 @@ impl WindowManager {
         // was the flicker of the blurred desktop grid behind transparent windows.
         if moved {
             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);
             for &window in self.windows.iter() {
                 if !window.is_null() {
                     (*window).render_viewport_update();
@@ -2905,6 +2959,9 @@ impl WindowManager {
             return;
         }
         self.viewport_is_active = false;
+        // 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);
         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 a7ba069..5413aef 100644
--- a/src/server/wlroots_log_wrapper.c
+++ b/src/server/wlroots_log_wrapper.c
@@ -1081,3 +1081,17 @@ static void mark_optimized_blur_dirty_rec(struct wlr_scene_node *node) {
 void river_scene_mark_optimized_blur_dirty(struct wlr_scene *scene) {
 	mark_optimized_blur_dirty_rec(&scene->tree.node);
 }
+
+/* See wlr_scene.blur_frozen: suspend the moved-node-below blur
+ * 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. */
+void river_scene_set_blur_frozen(struct wlr_scene *scene, bool frozen) {
+	if (scene->blur_frozen == frozen) {
+		return;
+	}
+	scene->blur_frozen = frozen;
+	if (!frozen) {
+		mark_optimized_blur_dirty_rec(&scene->tree.node);
+	}
+}
diff --git a/wrapper.h b/wrapper.h
index c1b74e4..68d244c 100644
--- a/wrapper.h
+++ b/wrapper.h
@@ -264,6 +264,7 @@ 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_set_blur_frozen(struct wlr_scene *scene, bool frozen);
 
 void river_scene_node_set_opacity(struct wlr_scene_node *node, float opacity);