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

commit04347c02a90210edfcc8802450114e588a4542e3
parent93e7697848
authorLucas Galante <[email protected]>
date2026-09-04 11:11
perf(blur): no re-bake at pan start or end — bakes stay anchored, thaw re-bakes only what a pan could break

Two sources re-baked every blurred window at both ends of every pan.
The pan-only fallback-cells enable (added with the patch prefetch)
forced a three-frame rect enable/redraw beneath every window, which
the scene reads as content changing below; it is gone — with fixed-size
prefetched patches a pan rarely outruns its patch, and when it does the
leading edge briefly shows backdrop. And the thaw marked every bake
dirty on the assumption that a frozen bake was stale.

It is not: a blur now samples the shared cache where its own bake lives
(the optimized node's coordinates at its last bake) whether the scene is
frozen or not, so after a pure pan every bake is still exactly right.
The freeze now only suppresses invalidation for nodes MOVING below;
content changing below (a surface commit) still re-bakes live, so
nothing goes stale during a pan either. The thaw re-bakes only bakes a
pan can actually break: one that never happened, one that did not cover
the whole node because it lay partly off the output, or one another bake
landed on (overlapping blurred windows, flagged at bake time). Unfrozen,
a node that moved away from a partial or other-output bake re-bakes
once; a fixed-position one never churns.

Measured with CCE_BLUR_DEBUG in a headless shadow: a fully on-screen
blurred window bakes zero times across two pans, start and end
included; a window hanging off the output edge and an overlapping pair
re-bake once each at settle, none at start. Mid-pan and settled
screenshots identical.

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

 scenefx/include/scenefx/types/wlr_scene.h |  36 +++++++----
 scenefx/types/scene/wlr_scene.c           | 100 ++++++++++++++++++++++++++----
 src/server/window_manager.rs              |  11 ++--
 src/server/wlroots_log_wrapper.c          |  31 +++++++--
 4 files changed, 142 insertions(+), 36 deletions(-)

diff --git a/scenefx/include/scenefx/types/wlr_scene.h b/scenefx/include/scenefx/types/wlr_scene.h
index d6076ff..794862b 100644
--- a/scenefx/include/scenefx/types/wlr_scene.h
+++ b/scenefx/include/scenefx/types/wlr_scene.h
@@ -118,12 +118,15 @@ 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.
+	 * While set, a node MOVING underneath an optimized-blur node does NOT
+	 * mark that blur dirty (its content changing still does). The
+	 * compositor sets it for the duration of a camera pan — every frame
+	 * moves the screen-sized backdrop under every blurred window, which
+	 * would otherwise re-bake every blur every frame — and clears it when
+	 * the camera settles. Blurs keep sampling their existing bakes at the
+	 * node's travel since the bake (see wlr_scene_optimized_blur), so a
+	 * pure pan needs no re-bake at all; the thaw re-bakes only bakes that
+	 * cannot be trusted (partial, overwritten, or never made).
 	 */
 	bool blur_frozen;
 
@@ -277,16 +280,23 @@ struct wlr_scene_optimized_blur {
 	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.
+	 * Where the node's bake lives in the shared per-output cache: the
+	 * layout coordinates it had, and the output it rendered to, when it
+	 * last baked. The sibling wlr_scene_blur samples the cache shifted by
+	 * the node's travel since then, so a node that moved with its backdrop
+	 * (a desktop pan) keeps reading exactly its own bake — per node, so
+	 * each window's own pixel rounding is honored — and a bake that re-runs
+	 * simply re-anchors. `baked_full` records that the whole box lay inside
+	 * the output, i.e. the bake covers the node wherever it travels; a
+	 * partial bake is re-baked once the scene thaws. `overwritten` is set
+	 * when a later bake landed on this bake's cache region (overlapping
+	 * windows), which also earns a re-bake at thaw.
 	 */
 	bool baked;
 	int baked_x, baked_y;
+	bool baked_full;
+	bool overwritten;
+	struct wlr_scene_output *baked_output;
 };
 
 struct wlr_scene_outputs_update_event {
diff --git a/scenefx/types/scene/wlr_scene.c b/scenefx/types/scene/wlr_scene.c
index 41639a0..d5f8a6c 100644
--- a/scenefx/types/scene/wlr_scene.c
+++ b/scenefx/types/scene/wlr_scene.c
@@ -439,6 +439,7 @@ struct scene_update_data {
 	struct wlr_xwayland_surface *restack_above;
 #endif
 	bool blur_frozen;
+	bool content_update;
 };
 
 static uint32_t region_area(const pixman_region32_t *region) {
@@ -718,6 +719,15 @@ static void restack_xwayland_surface(struct wlr_scene_node *node,
 }
 #endif
 
+static bool cce_scene_blur_debug(void) {
+	static int enabled = -1;
+	if (enabled < 0) {
+		const char *v = getenv("CCE_BLUR_DEBUG");
+		enabled = v && *v && strcmp(v, "0") != 0;
+	}
+	return enabled;
+}
+
 static bool scene_node_is_below(struct wlr_scene_node *a, struct wlr_scene_node *b) {
 	if (a == b) {
 		return false;
@@ -779,8 +789,15 @@ 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 && !data->blur_frozen &&
+		// A frozen scene ignores nodes MOVING below (the backdrop travelling
+		// with a pan); content changing below still re-bakes.
+		if (data->updated_node && (!data->blur_frozen || data->content_update) &&
 				scene_node_is_below(data->updated_node, node)) {
+			if (data->blur_frozen && cce_scene_blur_debug()) {
+				wlr_log(WLR_INFO, "[scenefx] frozen: content dirty from node type %d (%dx%d) below opt %dx%d",
+					data->updated_node->type, data->update_box.width, data->update_box.height,
+					scene_blur->width, scene_blur->height);
+			}
 			scene_blur->dirty = true;
 		}
 		if (scene_blur->dirty) {
@@ -858,7 +875,7 @@ static void scene_node_bounds(struct wlr_scene_node *node,
 
 static void scene_update_region(struct wlr_scene *scene,
 		struct wlr_scene_node *updated_node,
-		const pixman_region32_t *update_region) {
+		const pixman_region32_t *update_region, bool content_update) {
 	pixman_region32_t visible;
 	pixman_region32_init(&visible);
 	pixman_region32_copy(&visible, update_region);
@@ -878,6 +895,7 @@ static void scene_update_region(struct wlr_scene *scene,
 		.calculate_visibility = scene->calculate_visibility,
 		.restack_xwayland_surfaces = scene->restack_xwayland_surfaces,
 		.blur_frozen = scene->blur_frozen,
+		.content_update = content_update,
 	};
 
 	// update node visibility and output enter/leave events
@@ -934,6 +952,9 @@ static void scene_node_cleanup_when_disabled(struct wlr_scene_node *node,
 static void scene_node_update(struct wlr_scene_node *node,
 		pixman_region32_t *damage) {
 	struct wlr_scene *scene = scene_node_get_root(node);
+	// Explicit damage means the node's CONTENT changed (a buffer commit, a
+	// disable); NULL means a property or position change.
+	bool content_update = damage != NULL;
 
 	int x, y;
 	if (!wlr_scene_node_coords(node, &x, &y)) {
@@ -942,7 +963,7 @@ static void scene_node_update(struct wlr_scene_node *node,
 		if (damage) {
 			scene_node_cleanup_when_disabled(node, scene->restack_xwayland_surfaces, &scene->outputs);
 
-			scene_update_region(scene, node, damage);
+			scene_update_region(scene, node, damage, content_update);
 			scene_damage_outputs(scene, damage);
 			pixman_region32_fini(damage);
 		}
@@ -962,7 +983,7 @@ static void scene_node_update(struct wlr_scene_node *node,
 	pixman_region32_copy(&update_region, damage);
 	scene_node_bounds(node, x, y, &update_region);
 
-	scene_update_region(scene, node, &update_region);
+	scene_update_region(scene, node, &update_region, content_update);
 	pixman_region32_fini(&update_region);
 
 	scene_node_visibility(node, damage);
@@ -1893,7 +1914,7 @@ void wlr_scene_buffer_set_opaque_region(struct wlr_scene_buffer *scene_buffer,
 	pixman_region32_t update_region;
 	pixman_region32_init(&update_region);
 	scene_node_bounds(&scene_buffer->node, x, y, &update_region);
-	scene_update_region(scene_node_get_root(&scene_buffer->node), &scene_buffer->node, &update_region);
+	scene_update_region(scene_node_get_root(&scene_buffer->node), &scene_buffer->node, &update_region, true);
 	pixman_region32_fini(&update_region);
 }
 
@@ -2424,6 +2445,36 @@ static float get_luminance_multiplier(const struct wlr_color_luminances *src_lum
 	return (dst_lum->reference / src_lum->reference) * (src_lum->max / dst_lum->max);
 }
 
+static void optimized_blur_flag_overwritten_rec(struct wlr_scene_node *node,
+		struct wlr_scene_optimized_blur *self, const struct wlr_box *box) {
+	if (node->type == WLR_SCENE_NODE_OPTIMIZED_BLUR) {
+		struct wlr_scene_optimized_blur *other = wlr_scene_optimized_blur_from_node(node);
+		if (other != self && other->baked && other->baked_output == self->baked_output) {
+			struct wlr_box theirs = {
+				.x = other->baked_x, .y = other->baked_y,
+				.width = other->width, .height = other->height,
+			};
+			struct wlr_box overlap;
+			if (wlr_box_intersection(&overlap, &theirs, box)) {
+				other->overwritten = true;
+			}
+		}
+		return;
+	}
+	if (node->type == WLR_SCENE_NODE_TREE) {
+		struct wlr_scene_tree *tree = wlr_scene_tree_from_node(node);
+		struct wlr_scene_node *child;
+		wl_list_for_each(child, &tree->children, link) {
+			optimized_blur_flag_overwritten_rec(child, self, box);
+		}
+	}
+}
+
+static void optimized_blur_flag_overwritten(struct wlr_scene *scene,
+		struct wlr_scene_optimized_blur *self, const struct wlr_box *box) {
+	optimized_blur_flag_overwritten_rec(&scene->tree.node, self, box);
+}
+
 static void scene_entry_render(struct render_list_entry *entry, const struct render_data *data) {
 	struct wlr_scene_node *node = entry->node;
 	struct fx_gles_render_pass *fx_pass = fx_get_render_pass(data->render_pass);
@@ -2774,9 +2825,19 @@ static void scene_entry_render(struct render_list_entry *entry, const struct ren
 				scene_blur->dirty = false;
 				int bx, by;
 				if (wlr_scene_node_coords(node, &bx, &by)) {
+					struct wlr_box box = {
+						.x = bx, .y = by,
+						.width = scene_blur->width, .height = scene_blur->height,
+					};
 					scene_blur->baked = true;
 					scene_blur->baked_x = bx;
 					scene_blur->baked_y = by;
+					scene_blur->baked_output = data->output;
+					scene_blur->baked_full = wlr_box_contains_box(&data->logical, &box);
+					scene_blur->overwritten = false;
+					// Any other bake this one landed on top of in the cache
+					// now reads wrong under its owner: flag it for the thaw.
+					optimized_blur_flag_overwritten(scene, scene_blur, &box);
 				}
 			}
 		}
@@ -2840,11 +2901,15 @@ 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.
+		// 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: the cache is drawn shifted
+		// by the node's travel since the bake, so screen pixel s shows
+		// cache pixel s - travel, the bake's own pixel for that spot. Only
+		// a bake made on THIS output and covering the whole node is usable
+		// that way; any other (while the scene is not frozen) is re-baked.
 		int freeze_dx = 0, freeze_dy = 0;
-		if (scene->blur_frozen && blur->should_only_blur_bottom_layer &&
+		if (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) {
@@ -2853,12 +2918,21 @@ static void scene_entry_render(struct render_list_entry *entry, const struct ren
 				}
 				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.
+				if (!wlr_scene_node_coords(sib, &cur_x, &cur_y)) {
+					break;
+				}
+				bool moved = opt->baked && (cur_x != opt->baked_x || cur_y != opt->baked_y);
+				if (opt->baked && opt->baked_output == data->output &&
+						(opt->baked_full || scene->blur_frozen)) {
+					// Anchored: a full bake wherever it went; a partial one
+					// only while frozen (its covered part stays right, and
+					// the thaw re-bakes it).
 					freeze_dx = cur_x - opt->baked_x;
 					freeze_dy = cur_y - opt->baked_y;
+				} else if (!scene->blur_frozen && moved && !opt->dirty) {
+					// Unfrozen, moved since a bake that cannot follow it
+					// (partial, or from another output): bake once here.
+					wlr_scene_optimized_blur_mark_dirty(opt);
 				}
 				break;
 			}
diff --git a/src/server/window_manager.rs b/src/server/window_manager.rs
index 88e7d2e..23a48e9 100644
--- a/src/server/window_manager.rs
+++ b/src/server/window_manager.rs
@@ -2888,13 +2888,14 @@ 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.
+        // NOT a pure pan: enabling the cell pool is a three-frame rect
+        // enable/redraw below every window, which reads to the scene as
+        // content changing and re-bakes every blur at the start and end of
+        // every pan. A pan that outruns its (prefetched, fixed-size) patch
+        // briefly shows bare backdrop at the leading edge instead.
         let cells_wanted = plan.grid_cells_enabled
             || self.camera_ramp_anim.is_some()
-            || self.target_desk_zoom.is_some()
-            || self.viewport_is_active;
+            || self.target_desk_zoom.is_some();
         if self.grid_cells_enabled != cells_wanted {
             self.grid_cells_enabled = cells_wanted;
             // The cell pools redraw only on structure changes; force one so
diff --git a/src/server/wlroots_log_wrapper.c b/src/server/wlroots_log_wrapper.c
index 56504e8..1e9ce4a 100644
--- a/src/server/wlroots_log_wrapper.c
+++ b/src/server/wlroots_log_wrapper.c
@@ -1086,16 +1086,37 @@ 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. */
+/* Thaw: re-bake only what a pan could have left untrustworthy — a bake
+ * that never happened, one that did not cover the whole node (it lay
+ * partly off the output), or one another bake landed on top of. Everything
+ * else keeps sampling its own bake at its travel, exactly as it did
+ * mid-pan. */
+static void thaw_optimized_blur_rec(struct wlr_scene_node *node) {
+	if (node->type == WLR_SCENE_NODE_OPTIMIZED_BLUR) {
+		struct wlr_scene_optimized_blur *opt = wlr_scene_optimized_blur_from_node(node);
+		if (!opt->baked || !opt->baked_full || opt->overwritten) {
+			wlr_scene_optimized_blur_mark_dirty(opt);
+		}
+		return;
+	}
+	if (node->type == WLR_SCENE_NODE_TREE) {
+		struct wlr_scene_tree *tree = wlr_scene_tree_from_node(node);
+		struct wlr_scene_node *child;
+		wl_list_for_each(child, &tree->children, link) {
+			thaw_optimized_blur_rec(child);
+		}
+	}
+}
+
+/* See wlr_scene.blur_frozen. Blurs sample the shared cache where their own
+ * bake lives, frozen or not, so a pure pan needs no re-bake at either end;
+ * the thaw only re-bakes what cannot be trusted. */
 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);
+		thaw_optimized_blur_rec(&scene->tree.node);
 	}
 }