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

commitd4c6ff243f5449b8f99a9cf80e30b495d07f465b
parent701aff0444
authorLucas Galante <[email protected]>
date2026-09-11 10:54
fix(scenefx): a blur node re-blurs only when the expanded damage actually reaches it

`apply_blur_region` decided whether a blur node needs re-rendering by
testing the return value of `pixman_region32_intersect`, which reports
allocation success, not a non-empty result. Every blur node therefore
counted as touched by every frame's damage: its whole box was unioned into
the frame damage and re-blurred, whenever anything anywhere on the output
changed. With nine blurred status segments that was ~1.3 ms of main-thread
CPU per frame for as long as any client animated — the bulk of the
compositor's steady 14% of a core live while the Claude app streamed.

Test the intersection for emptiness instead. Measured in a 3840x2400
shadow with four segments and a 60 fps vkcube far from the bar:
render_and_commit 1670 µs → 495 µs, blur renders per frame 4.0 → 0.45; a
cube placed in the bar's row still re-blurs all four every frame, as it
must (the blur samples 80 px beyond its box).

Two CCE_BLUR_DEBUG lines record each blur node render and each
compensation decision, which is how this was found.

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

 CLAUDE.md                       | 16 ++++++++++++++++
 scenefx/types/scene/wlr_scene.c | 30 +++++++++++++++++++++++++++++-
 2 files changed, 45 insertions(+), 1 deletion(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index b3224be..aec6b71 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -531,6 +531,22 @@ grid has them.
   wrappers borrow the app id rather than allocating; keep it that way, they
   run several times per pointer-motion event.
 
+  **Blur re-renders only where damage reaches** (scenefx `apply_blur_region`,
+  fixed 2026-09-11). `pixman_region32_intersect` returns allocation success,
+  not "non-empty"; the vendored code tested that return, so every blur node
+  counted as touched by every frame's damage and re-blurred — nine status
+  segments cost ~1.3 ms of CPU per frame whenever anything on screen moved
+  (measured: 1670 µs → 495 µs per frame with an animating client far from
+  the bar). A node whose box lies within the blur sample size (2^(passes+1) ×
+  radius = 80 px at the default 3/5) of the damage still re-blurs, as it must.
+  `CCE_BLUR_DEBUG=1` logs each blur node render (`blur entry …`) and each
+  compensation decision (`blur_region …`) — the tool for "why is this blur
+  re-rendering". Known, not fixed: the *optimized* (cached) blur behind a
+  translucent window is not re-baked when content beneath it changes, only on
+  explicit camera/grid dirtying, so a video under a blurred window shows a
+  frozen ghost; buffer commits never pass through `scene_node_update` with
+  damage in this scenefx, which is the path the cache's dirtying hangs off.
+
   **`backdrop` is the one per-subscriber topic** — it names the asking segment,
   because the whole point is that the two ends of a bar sit over different things.
   Lines are `<luma> <spread>` (0-100 each) or `unknown`. It answers a question a
diff --git a/scenefx/types/scene/wlr_scene.c b/scenefx/types/scene/wlr_scene.c
index bb1aa42..edb7376 100644
--- a/scenefx/types/scene/wlr_scene.c
+++ b/scenefx/types/scene/wlr_scene.c
@@ -19,6 +19,8 @@
 #include <wlr/util/region.h>
 #include <wlr/util/transform.h>
 
+static bool cce_scene_blur_debug(void);
+
 #include "render/color.h"
 #include "render/tracy.h"
 #include "scenefx/render/fx_renderer/fx_offscreen_buffers.h"
@@ -2632,6 +2634,17 @@ static void scene_entry_render(struct render_list_entry *entry, const struct ren
 		return;
 	}
 
+	// CCE_BLUR_DEBUG: why is this blur node rendering — its box, the slice
+	// inside this frame's damage, and the damage extents.
+	if (node->type == WLR_SCENE_NODE_BLUR && cce_scene_blur_debug()) {
+		const pixman_box32_t *v = pixman_region32_extents(&node->visible);
+		const pixman_box32_t *r = pixman_region32_extents(&render_region);
+		const pixman_box32_t *d = pixman_region32_extents(&data->damage);
+		wlr_log(WLR_INFO, "[scenefx] blur entry visible=(%d,%d)-(%d,%d) render=(%d,%d)-(%d,%d) damage=(%d,%d)-(%d,%d) rects=%d",
+			v->x1, v->y1, v->x2, v->y2, r->x1, r->y1, r->x2, r->y2,
+			d->x1, d->y1, d->x2, d->y2, pixman_region32_n_rects(&data->damage));
+	}
+
 	int x = entry->x - data->logical.x;
 	int y = entry->y - data->logical.y;
 
@@ -3777,8 +3790,23 @@ static bool apply_blur_region(struct wlr_scene_node *node, struct blur_data *blu
 
 	pixman_region32_t intersection;
 	pixman_region32_init(&intersection);
-	if (pixman_region32_intersect(&intersection, &expanded_damage, &node_visible_region)) {
+	pixman_region32_intersect(&intersection, &expanded_damage, &node_visible_region);
+	// pixman_region32_intersect's return value reports allocation success,
+	// not a non-empty result — testing it here made EVERY blur node count as
+	// touched by EVERY frame's damage, so all of them re-blurred (and pulled
+	// their whole box into the damage) whenever anything on screen changed:
+	// one animating window cost a full re-blur of every status segment and
+	// translucent window each frame. Only a node the expanded damage
+	// actually reaches has stale samples to worry about.
+	if (pixman_region32_not_empty(&intersection)) {
 		should_compensate_blur = true;
+		if (cce_scene_blur_debug()) {
+			const pixman_box32_t *nv = pixman_region32_extents(&node_visible_region);
+			const pixman_box32_t *od = pixman_region32_extents(original_damage);
+			wlr_log(WLR_INFO, "[scenefx] blur_region: type=%d sample=%d node=(%d,%d)-(%d,%d) orig=(%d,%d)-(%d,%d)",
+				node->type, sample_size, nv->x1, nv->y1, nv->x2, nv->y2,
+				od->x1, od->y1, od->x2, od->y2);
+		}
 
 		// Re-render the node's entire blur region, not just the damaged
 		// sliver: the blur samples the framebuffer up to sample_size beyond