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

commit29a0b5b031176208f9a51f0e6ee5bdfc66107a97
parent1e1958b2f7
authorLucas Galante <[email protected]>
date2026-08-21 13:31
grid: flight-aware patches — union coverage, latch-time anchor, native fill

Overview transitions still glitched two ways after the target-zoom fix:
an occasional one-frame cell flicker at the exit swap, and cell-less
backdrop at the leading edge of an enter while the client rendered the
replacement patch. Four coordinated changes:

- Patches anticipate the whole flight: coverage spans the union of the
  current and destination viewports (destination +0.10 comfort), with a
  fit gate that simply retries while an exit union is still bigger than
  the buffer cap. One patch per flight direction, already correct when
  it lands (was 3 chasing patches per enter, 13-15 per exit once the
  re-send storms were exposed).

- The storms: mid-flight the current-viewport comfort demand (0.15)
  poked past a zero-margin exact-fit union, and the in-flight display
  floor rejected the destination-resolution patch it had just issued
  while the zoom was still below it. Comfort drops to 0.02 in flight,
  and the in-flight band has no floor — oversampling renders sharp; the
  rest band keeps its floor purely as the memory trigger.

- The latch applies the new anchor inline (position/scale/box mirrored
  from the arrange Grid arm): the commit swaps the scene buffer
  immediately, and waiting for the next arrange pass left up to a frame
  of the new buffer at the old anchor — the exit flicker.

- The native cell lattice draws during camera flights even with a patch
  latched: it renders below the grid client's surface, so it only shows
  where the viewport outruns the patch — real cells at the enter's
  leading edge for the frame or two the client needs.

CCE_GRID_DEBUG=1 logs why a patch fails covers() (the diagnostic that
found both storms). Shadow-verified at scale 2: one patch per direction,
no exposure frame in an enter series, exit settles at native resolution
with zero post-settle frame delta.

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

 src/server/window_manager.rs | 230 ++++++++++++++++++++++++++++++++-----------
 src/server/xdg_toplevel.rs   |  36 +++++++
 2 files changed, 208 insertions(+), 58 deletions(-)

diff --git a/src/server/window_manager.rs b/src/server/window_manager.rs
index 7bdbefc..a93dd44 100644
--- a/src/server/window_manager.rs
+++ b/src/server/window_manager.rs
@@ -2075,57 +2075,142 @@ impl WindowManager {
         let vw = out.width as f64 / zoom;
         let vh = out.height as f64 / zoom;
         let (vx, vy) = (self.desk_pan_x, self.desk_pan_y);
-        // Buffer px per virtual unit: zoom quantized to a power of two so
-        // small zoom wobbles don't re-render the world, times the output
-        // scale so a buffer px is a NATIVE px at the quantized zoom — at
-        // scale 2 an unscaled patch is magnified 2x on screen, which turns
-        // every cell edge into 2px staircase blocks.
-        //
-        // Quantized from the DESTINATION zoom while a camera flight is in
-        // progress: quantizing from the interpolated zoom issued the
-        // mid-flight patch at the flight's own resolution (overview exit
-        // crossed the drift ceiling near zoom 0.5 → a q=1 patch) which then
-        // RESTED at display factor 2.0 — just inside the permissive drift
-        // window — so the grid stayed 2x-magnified after every overview
-        // round trip, and the latch landing at the animation's end read as
-        // the grid "readjusting". A target-quantized patch still swaps
-        // mid-flight, but lands the final content once, and enter flights
-        // reach the overview resolution in one hop instead of two.
+
+        // The camera flight's destination, if one is running: the ramp
+        // animation's target (overview enter/exit), else the exponential
+        // pan/zoom targets. Patches anticipate the destination — coverage
+        // spans the union of the current and target viewports, and the
+        // resolution quantizes for the destination — so a flight needs ONE
+        // patch that is already correct when it lands, instead of chasing
+        // the interpolated camera (which exposed cell-less backdrop at the
+        // leading edge of an overview enter, and left overview exits
+        // resting 2x-magnified with the swap landing at the animation's
+        // end as a visible readjust).
+        let target_cam: Option<crate::policy::camera::Camera> = self
+            .camera_ramp_anim
+            .as_ref()
+            .map(|a| a.target)
+            .or_else(|| {
+                if self.target_desk_pan_x.is_some()
+                    || self.target_desk_pan_y.is_some()
+                    || self.target_desk_zoom.is_some()
+                {
+                    Some(crate::policy::camera::Camera {
+                        pan_x: self.target_desk_pan_x.unwrap_or(self.desk_pan_x),
+                        pan_y: self.target_desk_pan_y.unwrap_or(self.desk_pan_y),
+                        zoom: crate::policy::background::sanitized_zoom(
+                            self.target_desk_zoom.unwrap_or(self.desk_zoom),
+                        ),
+                    })
+                } else {
+                    None
+                }
+            });
+        let in_flight = self.viewport_is_active || target_cam.is_some();
+
+        // Buffer px per virtual unit: the DESTINATION zoom quantized to a
+        // power of two (small zoom wobbles don't re-render the world),
+        // times the output scale so a buffer px is a NATIVE px at that
+        // zoom — then raised in pow2 steps until the patch is also
+        // displayable at the CURRENT zoom without exceeding 2x
+        // magnification, so the swap moment never pops visibly soft.
         let q_zoom = crate::policy::background::sanitized_zoom(
-            self.camera_ramp_anim
-                .as_ref()
-                .map(|a| a.target.zoom)
-                .or(self.target_desk_zoom)
-                .unwrap_or(self.desk_zoom),
+            target_cam.map(|c| c.zoom).unwrap_or(self.desk_zoom),
         );
-        let q = (2f64).powf(q_zoom.log2().round()).clamp(0.125, 2.0) * out_scale;
+        let mut q = (2f64).powf(q_zoom.log2().round()).clamp(0.125, 2.0) * out_scale;
+        let q_max = 2.0 * out_scale;
+        while zoom * out_scale / q > 2.0 && q < q_max {
+            q = (q * 2.0).min(q_max);
+        }
         let period = self.layout.desktop_grid_scale
             + (self.layout.desktop_gap_width as f64).max(0.0);
+
+        // Target viewport (virtual units), for the union coverage below.
+        let target_rect = target_cam.map(|c| {
+            let tw = out.width as f64 / c.zoom;
+            let th = out.height as f64 / c.zoom;
+            (c.pan_x, c.pan_y, tw, th, c.zoom)
+        });
+
         // Resolution drift tolerance: permissive while the camera is moving
         // (a giant re-render per animation frame would be worse than a bit
-        // of scaling), but at REST the patch must sit within the pow2
-        // half-step band around exact — the safety net that re-patches any
-        // path that settles mis-resolved. The band includes 1.414 (a zoom
-        // exactly on a half-step boundary re-quantizes to the same q), so
-        // a settled repatch can never loop.
-        let in_flight = self.viewport_is_active
-            || self.camera_ramp_anim.is_some()
-            || self.target_desk_zoom.is_some();
-        let (disp_lo, disp_hi) = if in_flight { (0.5, 2.01) } else { (0.70, 1.42) };
+        // of scaling), but at REST the patch must sit within a band around
+        // exact — the safety net that re-patches any path that settles
+        // mis-resolved. The band includes 1.414 (a zoom exactly on a pow2
+        // half-step boundary re-quantizes to the same q, so a settled
+        // repatch can never loop) and reaches down to 0.45: an OVERSAMPLED
+        // patch renders sharp and is only a memory cost, so a flight's
+        // union patch may rest through a whole overview visit.
+        // No lower bound in flight: oversampling renders sharp (memory-only
+        // cost), and an exit-union patch is necessarily oversampled for
+        // most of the flight (issued at destination resolution while the
+        // zoom is still far out) — any in-flight floor re-sends the very
+        // patch it just issued, every animation frame, until the zoom
+        // crosses it. The rest band keeps a floor purely as the memory
+        // trigger that swaps an oversized flight patch for a right-sized
+        // one after landing somewhere it no longer suits.
+        let (disp_lo, disp_hi) = if in_flight { (0.0, 2.01) } else { (0.40, 1.42) };
         let covers = |p: &crate::policy::api::GridPatch| -> bool {
-            let mx = vw * 0.15;
-            let my = vh * 0.15;
+            // Mid-flight the comfort demand on the CURRENT viewport drops
+            // to near-bare: an exit union barely fits the buffer cap (zero
+            // slack margin), and a 0.15-viewport demand poking past it
+            // re-patched every animation frame — a 13-patch storm per
+            // overview exit. The destination side of the union carries its
+            // own comfort for the landing; full comfort applies at rest.
+            let (mx, my) = if in_flight {
+                (vw * 0.02, vh * 0.02)
+            } else {
+                (vw * 0.15, vh * 0.15)
+            };
             // Resolution drift is native px per buffer px, so the output
             // scale belongs on the zoom side — measuring against zoom
             // alone would reject every native-res patch on a scaled
             // output (display factor 0.5 at scale 2) and repatch forever.
             let disp = zoom * out_scale / p.scale;
-            p.x <= vx - mx
+            let now_ok = p.x <= vx - mx
                 && p.y <= vy - my
                 && p.x + p.w >= vx + vw + mx
                 && p.y + p.h >= vy + vh + my
                 && disp > disp_lo
-                && disp < disp_hi
+                && disp < disp_hi;
+            // Mid-flight the patch must also suit the DESTINATION (small
+            // 0.05 comfort — the union patch is sized with 0.10, so this
+            // demand always fits what was sent). A patch that fails only
+            // here keeps displaying while its replacement renders.
+            let target_ok = target_rect.map_or(true, |(tx, ty, tw, th, tz)| {
+                let tmx = tw * 0.05;
+                let tmy = th * 0.05;
+                let tdisp = tz * out_scale / p.scale;
+                // Wide lower bound: the issuance q may be raised well above
+                // the target's nominal for current-zoom displayability
+                // (deep overview enters), and a bound that rejects the
+                // patch we just issued is a re-send storm. Long-term
+                // oversampling is corrected once by the rest band.
+                p.x <= tx - tmx
+                    && p.y <= ty - tmy
+                    && p.x + p.w >= tx + tw + tmx
+                    && p.y + p.h >= ty + th + tmy
+                    && tdisp > 0.15
+                    && tdisp < 1.42
+            });
+            now_ok && target_ok
+        };
+        let explain = |tag: &str, p: &crate::policy::api::GridPatch| {
+            if std::env::var("CCE_GRID_DEBUG").is_err() {
+                return;
+            }
+            let mx = vw * 0.02;
+            let disp = zoom * out_scale / p.scale;
+            log::info!(
+                "[GridDbg] {tag} fails: z={zoom:.3} patch=({:.0},{:.0} {:.0}x{:.0} @{:.2}) now_area={} disp={disp:.2} tgt={:?}",
+                p.x, p.y, p.w, p.h, p.scale,
+                p.x <= vx - mx && p.x + p.w >= vx + vw + mx && p.y <= vy - vh * 0.02 && p.y + p.h >= vy + vh * 1.02,
+                target_rect.map(|(tx, ty, tw, th, tz)| {
+                    (p.x <= tx - tw * 0.05 && p.x + p.w >= tx + tw * 1.05
+                        && p.y <= ty - th * 0.05 && p.y + p.h >= ty + th * 1.05,
+                     tz * out_scale / p.scale)
+                }),
+            );
         };
         for &w in self.windows.iter() {
             if w.is_null() || (*w).closed || !(*w).is_grid() {
@@ -2137,38 +2222,57 @@ impl WindowManager {
             if (*w).grid_patch_current.as_ref().map_or(false, &covers) {
                 continue;
             }
+            if let Some(cur) = &(*w).grid_patch_current {
+                explain("current", cur);
+            }
             if let Some((_, pending)) = &(*w).grid_patch_pending {
                 if covers(pending) {
                     continue;
                 }
+                explain("pending", pending);
             }
             if let Some((serial, acked)) = &(*w).grid_patch_acked {
                 // Rendered but not yet committed: give it a frame.
                 let _ = (serial, acked);
                 continue;
             }
-            // Half a viewport of margin per side, shrunk if the buffer
-            // would exceed the cap; then period-aligned outward so the
-            // client draws whole cells. Margin and cap are a MEMORY knob:
-            // the client's framebuffer is (patch * q)^2 * 4B per swapchain
-            // image (q carries the output scale) — the original
-            // 3x3-viewport margin cost ~340MB per image (gigabytes with
-            // swapchain + staging), for scroll headroom that the 0.15
-            // comfort margin above rarely used.
-            // Scale-aware: at output scale 2 a native-res patch has 2x the
-            // buffer px per virtual unit, and a cap that ignored that would
-            // shrink the margin below the 0.15-viewport comfort band that
-            // covers() demands — a patch that can never cover is a repatch
-            // every arrange pass. 4096 * scale keeps the same VIRTUAL
-            // coverage (≈ 2x2 viewports) at every scale; the memory cost is
-            // the native-resolution pixels themselves.
+            // Coverage: the current viewport, unioned with the flight's
+            // destination viewport (+0.10 comfort) when one is known. Then
+            // the remaining buffer budget spreads as margin per side and
+            // the rect period-aligns outward so the client draws whole
+            // cells. Margin and cap are a MEMORY knob: the client's
+            // framebuffer is (patch * q)^2 * 4B per swapchain image (q
+            // carries the output scale) — the original 3x3-viewport margin
+            // cost ~340MB per image, for scroll headroom that the 0.15
+            // comfort margin rarely used. The cap is scale-aware (4096 *
+            // out_scale keeps the same VIRTUAL coverage at every scale) so
+            // margins can never shrink below what covers() demands.
+            let mut ux0 = vx;
+            let mut uy0 = vy;
+            let mut ux1 = vx + vw;
+            let mut uy1 = vy + vh;
+            if let Some((tx, ty, tw, th, _)) = target_rect {
+                ux0 = ux0.min(tx - 0.10 * tw);
+                uy0 = uy0.min(ty - 0.10 * th);
+                ux1 = ux1.max(tx + 1.10 * tw);
+                uy1 = uy1.max(ty + 1.10 * th);
+            }
+            let uw = ux1 - ux0;
+            let uh = uy1 - uy0;
             let max_buf: f64 = 4096.0 * out_scale;
-            let m = (((max_buf / q) - vw) / (2.0 * vw)).clamp(0.0, 0.5)
-                .min((((max_buf / q) - vh) / (2.0 * vh)).clamp(0.0, 0.5));
-            let x0 = ((vx - m * vw) / period).floor() * period;
-            let y0 = ((vy - m * vh) / period).floor() * period;
-            let x1 = ((vx + (1.0 + m) * vw) / period).ceil() * period;
-            let y1 = ((vy + (1.0 + m) * vh) / period).ceil() * period;
+            if uw * q > max_buf || uh * q > max_buf {
+                // The union doesn't fit yet (an overview exit while still
+                // zoomed far out needs a native-res patch bigger than the
+                // cap): keep displaying the old patch and retry as the
+                // 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).floor() * period;
+            let y0 = ((uy0 - m * uh) / period).floor() * period;
+            let x1 = ((ux1 + m * uw) / period).ceil() * period;
+            let y1 = ((uy1 + m * uh) / period).ceil() * period;
             let patch = crate::policy::api::GridPatch {
                 x: x0,
                 y: y0,
@@ -2363,8 +2467,18 @@ impl WindowManager {
                 ffi::wlr_scene_node_set_enabled((*output).background_rect as *mut ffi::wlr_scene_node, plan.background_rect_enabled);
             }
         }
-        if self.grid_cells_enabled != plan.grid_cells_enabled {
-            self.grid_cells_enabled = plan.grid_cells_enabled;
+        // During a camera flight the native cell lattice draws even while a
+        // client patch is latched: the fallback renders BELOW the grid
+        // client's surface, so it only shows through wherever the viewport
+        // outruns the patch — filling the leading edge of an overview enter
+        // 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).
+        let cells_wanted = plan.grid_cells_enabled
+            || self.camera_ramp_anim.is_some()
+            || 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
             // the swap (client grid <-> compositor cells) is immediate.
             for &output in &active_outputs {
diff --git a/src/server/xdg_toplevel.rs b/src/server/xdg_toplevel.rs
index 7ece495..c433766 100644
--- a/src/server/xdg_toplevel.rs
+++ b/src/server/xdg_toplevel.rs
@@ -538,6 +538,42 @@ unsafe extern "C" fn handle_commit(listener: *mut ffi::wl_listener, _data: *mut
     if let Some((serial, patch)) = (*window).grid_patch_acked.take() {
         log::info!("[Grid] latched patch #{serial} on commit");
         (*window).grid_patch_current = Some(patch);
+        // The commit swaps the BUFFER in the scene immediately, but the
+        // anchor (position/scale/box) otherwise waits for the next arrange
+        // pass — up to a frame of the new buffer drawn at the OLD patch's
+        // anchor, the occasional one-frame cell flicker at a patch swap.
+        // Apply the new anchor inline, mirroring the arrange Grid arm
+        // exactly (virtual_to_screen's truncating cast included); the next
+        // arrange re-affirms the same values.
+        {
+            let wm = &(*(*window).server).wm;
+            let zoom = crate::policy::background::sanitized_zoom(wm.desk_zoom);
+            let (mut ox, mut oy) = (0i32, 0i32);
+            let outputs_list = &(*(*window).server).om.outputs as *const ffi::wl_list
+                as *mut WlList;
+            let mut curr_out = (*outputs_list).next;
+            while curr_out != outputs_list {
+                let output = crate::container_of!(curr_out, crate::output::Output, link);
+                if (*output).sent.state == crate::output::OutputStateValue::Enabled {
+                    let b = (*output).sent.box_layout();
+                    ox = b.x;
+                    oy = b.y;
+                    break;
+                }
+                curr_out = (*curr_out).next;
+            }
+            if patch.scale > 0.0 {
+                let sx = ox + ((patch.x - wm.desk_pan_x) * zoom) as i32;
+                let sy = oy + ((patch.y - wm.desk_pan_y) * zoom) as i32;
+                (*window).rendering_requested.x = sx;
+                (*window).rendering_requested.y = sy;
+                (*window).scale = zoom / patch.scale;
+                (*window).box_geom.x = sx;
+                (*window).box_geom.y = sy;
+                (*window).box_geom.width = (patch.w * patch.scale).round() as i32;
+                (*window).box_geom.height = (patch.h * patch.scale).round() as i32;
+            }
+        }
         // The grid sits in the optimized-blur capture set (backdrop layers):
         // new patch content invalidates the shared blurred-backdrop cache,
         // which nothing else re-bakes when the latch lands after the