git.lucas.co / cce-ui
GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git

commit37e140ec2c62be31d0bbe005fc8c872fe5fdacd5
parenta73777c998
authorLucas Galante <[email protected]>
date2026-08-12 20:29
fix: 200ms vsync-paced render warm-down after activity — hover tracks the cursor

Sparse, isolated commits get their frame callbacks serviced multiple
compositor frames late on cce-fx (wire-measured commit->done growing
22->128ms per sparse commit; arm->commit stays <1ms, compositor renders
every vsync, GPU clocks pegged — the hold is compositor-side and still
unexplained), while a continuously committing surface is serviced in
~16ms flat. Keeping the engine rendering the cached frame for 200ms
after the last genuine dirty frame — still gated on frame callbacks, so
no forced presents and no judder (unlike the reverted 33ms starvation
pacer) — keeps interactive sequences in the healthy continuous regime.
Measured: hover-sweep callback waits drop from 22-128ms to 15-16ms;
rebuilds land within 1-2 frames of each row crossing. Idle decays in
one 200ms window.

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

 src/backend/window_runner.rs | 24 ++++++++++++++++++++++++
 1 file changed, 24 insertions(+)

diff --git a/src/backend/window_runner.rs b/src/backend/window_runner.rs
index 1e64174..07a99ee 100644
--- a/src/backend/window_runner.rs
+++ b/src/backend/window_runner.rs
@@ -2761,6 +2761,13 @@ pub struct EngineState<A: Application> {
     /// When the pending frame callback was armed — the starvation fallback's
     /// clock (see the render gate in `run`).
     pub frame_callback_armed_at: Option<std::time::Instant>,
+    /// Keep rendering (vsync-paced) briefly after the last genuine dirty frame.
+    /// Sparse, isolated commits get their frame callbacks serviced multiple
+    /// compositor frames late (measured 22-128ms on cce-fx, growing per sparse
+    /// commit), while a continuously committing surface is serviced in one
+    /// frame (~16ms). A short warm-down keeps interactive sequences (hover,
+    /// typing, scrolling) in the healthy continuous regime; idle still idles.
+    pub warm_until: Option<std::time::Instant>,
     /// Consecutive renders skipped by the extent gate (pending swapchain size
     /// != the size the current logical size and scale call for). Normally 0 or
     /// 1; a persistent count means no frame is presenting and deserves a warn.
@@ -4193,6 +4200,7 @@ pub fn run<A: Application>() {
         redraw: false,
         frame_callback_pending: false,
         frame_callback_armed_at: None,
+        warm_until: None,
         extent_gate_skips: 0,
         first_configure_received: false,
         ctrl_pressed: false,
@@ -4462,11 +4470,27 @@ pub fn run<A: Application>() {
             }
         }
 
+        if engine_state.redraw {
+            // Genuine dirt (input, app state, animation) extends the warm window;
+            // warm-down renders below do NOT, so idle decays in one window.
+            engine_state.warm_until =
+                Some(std::time::Instant::now() + std::time::Duration::from_millis(200));
+        }
         if engine_state.redraw && !engine_state.frame_callback_pending {
             engine_state.redraw = false;
             if engine_state.first_configure_received {
                 engine_state.render();
             }
+        } else if !engine_state.redraw
+            && !engine_state.frame_callback_pending
+            && engine_state
+                .warm_until
+                .is_some_and(|t| std::time::Instant::now() < t)
+        {
+            // Warm-down re-render of the cached frame, paced by frame callbacks.
+            if engine_state.first_configure_received {
+                engine_state.render();
+            }
         }
     }
     engine_state.inner.as_mut().unwrap().on_exit();