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