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

commit11f3bfd72f74d362ecfd9ffe6c290eb3e061407c
parentac01d76a3d
authorLucas Galante <[email protected]>
date2026-07-28 15:37
fix: full-repaint chokepoint in the output frame handler — zoom ghosts

The previous full-damage fix lived in update_viewport_local, but zoom
takes the dirty_windowing/manage path whenever the WM isn't idle (a
streaming client keeps it busy — the user's normal state), so those
frames still under-damaged and left stale slivers of the previous zoom
level around windows. Chasing every camera-mutation call site is
whack-a-mole; instead the output frame handler now compares the camera
against the last camera THIS OUTPUT rendered and damages the whole
output on any change — one chokepoint that catches wheel zoom, IPC,
keyed actions, edge-pan, and the pan animation identically, exactly
once per output per camera change. It runs before the needs-frame
early-out so a camera change with no other pending damage cannot skip
the frame. Verified in the nested harness: six rapid zoom changes over
a 30fps-streaming client, no artifacts.

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

 src/server/output.rs | 30 ++++++++++++++++++++++++++++++
 1 file changed, 30 insertions(+)

diff --git a/src/server/output.rs b/src/server/output.rs
index d2bc180..88b05bc 100644
--- a/src/server/output.rs
+++ b/src/server/output.rs
@@ -158,6 +158,11 @@ pub struct Output {
     pub rendering_current: RenderingState,
 
     // Cached grid parameters to avoid redrawing when unchanged
+    /// Camera state at this output's last rendered frame; any difference
+    /// forces a full-output repaint (see the frame chokepoint).
+    pub last_rendered_pan_x: f64,
+    pub last_rendered_pan_y: f64,
+    pub last_rendered_zoom: f64,
     pub last_grid_viewport_w: i32,
     pub last_grid_viewport_h: i32,
     pub last_grid_zoom: f64,
@@ -414,6 +419,9 @@ impl Output {
             sent_wl_output: false,
             rendering_requested: RenderingState { tearing: false },
             rendering_current: RenderingState { tearing: false },
+            last_rendered_pan_x: f64::NAN,
+            last_rendered_pan_y: f64::NAN,
+            last_rendered_zoom: f64::NAN,
             last_grid_viewport_w: 0,
             last_grid_viewport_h: 0,
             last_grid_zoom: 0.0,
@@ -485,6 +493,28 @@ impl Output {
             }
         };
 
+        // One chokepoint for every camera-mutation path (wheel zoom, IPC,
+        // keyed actions, edge-pan, the pan animation — whichever of
+        // update_viewport_local or the manage transaction carried it): if
+        // the camera changed since this output last rendered, per-node
+        // damage under-reports the whole-screen relayout (stale slivers of
+        // the previous zoom survive wherever idle content used to be), so
+        // force a full repaint. Must run BEFORE the needs-frame early-out —
+        // a camera change with no other pending damage would otherwise skip
+        // the frame entirely.
+        {
+            let wm = &(*self.server).wm;
+            let cam = (wm.desk_pan_x, wm.desk_pan_y, wm.desk_zoom);
+            if cam != (self.last_rendered_pan_x, self.last_rendered_pan_y, self.last_rendered_zoom) {
+                self.last_rendered_pan_x = cam.0;
+                self.last_rendered_pan_y = cam.1;
+                self.last_rendered_zoom = cam.2;
+                if !self.scene_output.is_null() {
+                    ffi::river_scene_output_damage_whole(self.scene_output);
+                }
+            }
+        }
+
         if pending_shot.is_none() && !ffi::wlr_scene_output_needs_frame(self.scene_output) {
             return Ok(());
         }