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