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

commit9ac996fa75d80bde840f5bc7920c1270d8c59ea0
parent8e8b63a9fa
authorLucas Galante <lsgalante12@gmail.com>
date2026-10-06 08:35
feat(runner): derive each frame's damage by diffing it against the last

Only cce-grid reported damage (take_damage); every other app's frames
were full repaints, and full damage to the compositor. derive_damage,
called by the Wayland shell after build_frame, diffs the tessellated
batches (vertex bytes, scissor, clip, plate push, blur flag), the
display-list text and the image quads against the last built frame and
damages what changed, old place and new, over the common prefix/suffix.
In-place pixel updates (update_pixels / update_pixel_regions) are
tracked and damaged where drawn. Full frame on a new size/scale/clear,
changed plate carves, a skipped present owed, app-staged text, or
CCE_UI_FULL_DAMAGE=1; CCE_PRESENT_DEBUG logs each derived rect.

Pixel A/B in a scale-2 shadow (full forced vs derived, window shot after
each step): the demo over a hover grid and through focus + typing,
cce-gallery, and the settings app's Notifications page match exactly.
Derived rects there were 1x1 (nothing changed) to ~31% of the window
where a frosted region grew; the settings app's 137 no-change redraws
on that page each cost a 1x1 repaint instead of the window.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

 CLAUDE.md                    |  26 +++++
 src/backend/frame.rs         | 236 +++++++++++++++++++++++++++++++++++++++++++
 src/backend/window_runner.rs |   8 +-
 src/draw/images.rs           |  12 +++
 4 files changed, 281 insertions(+), 1 deletion(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index 4ce46cb..dd8d6fe 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -483,6 +483,32 @@ its modules exited, the launcher's backoff grew while nobody was logged in, and
 StatusNotifierWatcher came back seconds after the next login — Dropbox, starting into the
 gap, reported no tray.
 
+### The runner works out each frame's damage (`backend::frame::derive_damage`, 2026-10-06)
+
+An app that does not report its own damage (`take_damage`, which only cce-grid does) no
+longer repaints its whole window per frame: the Wayland shell diffs the tessellated
+batches (vertex bytes, scissor, clip, plate push, blur flag), the display-list text (line
+text, position, colour, size, clips) and the image quads against the last built frame,
+and damages what changed — where it was and where it is, over the common prefix and
+suffix. Ids whose pixels `update_pixels`/`update_pixel_regions` replaced are damaged where
+drawn. It gives up (full frame) on a new size, scale or clear colour, changed plate
+carves, a frame owed after a skipped present, an app that stages its own text
+(`display_list_text` false), and a 3D backdrop. `CCE_UI_FULL_DAMAGE=1` turns it off;
+`CCE_PRESENT_DEBUG` logs each derived rect.
+
+A frosted (blur-behind) batch used to force every frame full. Now the renderer grows the
+partial region instead: a frosted plate the region touches is repainted whole plus its
+blur's reach (`BLUR_REACH_PX`), until nothing more is touched — its blur reads a snapshot
+that is only right inside the region. The first-drawn frosted batch over a transparent
+clear with no scene (the root plate, frosted in every themed app) samples the zeroed
+backdrop instead of a snapshot (`first_frost_exempt`): the same pixels, no copy, and no
+dependence on what lies outside the region. `write_window_info` marks every image stale.
+
+Checked by pixel A/B in a scale-2 shadow, full repaint forced vs derived, the window shot
+after each step: the demo (hover grid, then focus + typing), cce-gallery and the settings
+app's Notifications page matched exactly; differences on its System and Power pages were
+live data (uptime, temperatures, a different saved plan per shadow home).
+
 ### A layer app can have no surface while it is empty (`Application::wants_surface`, 2026-10-05)
 
 A layer-shell app that is usually empty — the notifier, between notifications — returns
diff --git a/src/backend/frame.rs b/src/backend/frame.rs
index 5cedaf9..0998fda 100644
--- a/src/backend/frame.rs
+++ b/src/backend/frame.rs
@@ -264,6 +264,209 @@ pub fn build_frame<A: Application>(
     }
 }
 
+/// What a presented frame drew, kept so the next frame can be diffed against
+/// it ([`derive_damage`]). One per window; physical px throughout.
+#[derive(Default)]
+pub struct FrameRecord {
+    prev: Option<FrameSig>,
+}
+
+/// A box as (x0, y0, x1, y1), physical px.
+type PxBox = (i32, i32, i32, i32);
+
+struct FrameSig {
+    physical: (u32, u32),
+    clear: [u32; 4],
+    features: u64,
+    batches: Vec<(u64, Option<PxBox>)>,
+    texts: Vec<(u64, Option<PxBox>)>,
+    images: Vec<(u64, Option<PxBox>, u32)>,
+    overlay: (u64, Option<PxBox>),
+}
+
+fn hash_of(f: impl FnOnce(&mut std::collections::hash_map::DefaultHasher)) -> u64 {
+    use std::hash::Hasher;
+    let mut h = std::collections::hash_map::DefaultHasher::new();
+    f(&mut h);
+    h.finish()
+}
+
+fn union_box(a: Option<PxBox>, b: Option<PxBox>) -> Option<PxBox> {
+    match (a, b) {
+        (Some(a), Some(b)) => Some((a.0.min(b.0), a.1.min(b.1), a.2.max(b.2), a.3.max(b.3))),
+        (a, b) => a.or(b),
+    }
+}
+
+fn clip_box(b: Option<PxBox>, clip: Option<(u32, u32, u32, u32)>) -> Option<PxBox> {
+    let b = b?;
+    let Some((cx, cy, cw, ch)) = clip else { return Some(b) };
+    let c = (cx as i32, cy as i32, (cx + cw) as i32, (cy + ch) as i32);
+    let r = (b.0.max(c.0), b.1.max(c.1), b.2.min(c.2), b.3.min(c.3));
+    (r.0 < r.2 && r.1 < r.3).then_some(r)
+}
+
+fn verts_box(verts: &[Vertex], (pw, ph): (u32, u32)) -> Option<PxBox> {
+    let (mut x0, mut y0, mut x1, mut y1) = (f32::MAX, f32::MAX, f32::MIN, f32::MIN);
+    for v in verts {
+        let px = (v.position[0] + 1.0) * 0.5 * pw as f32;
+        let py = (1.0 - v.position[1]) * 0.5 * ph as f32;
+        x0 = x0.min(px);
+        y0 = y0.min(py);
+        x1 = x1.max(px);
+        y1 = y1.max(py);
+    }
+    (x0 <= x1).then(|| ((x0.floor() as i32) - 2, (y0.floor() as i32) - 2, (x1.ceil() as i32) + 2, (y1.ceil() as i32) + 2))
+}
+
+impl FrameSig {
+    fn of(frame: &BuiltFrame, texts: &[DlText]) -> Self {
+        use std::hash::Hash;
+        let physical = frame.physical;
+        let batches = frame
+            .batches
+            .iter()
+            .map(|b| {
+                let verts = frame.verts.get(b.start as usize..b.end as usize).unwrap_or(&[]);
+                let key = hash_of(|h| {
+                    bytemuck::cast_slice::<Vertex, u8>(verts).hash(h);
+                    b.scissor.hash(h);
+                    b.clip_rrect.map(|c| c.map(f32::to_bits)).hash(h);
+                    b.blur_behind.hash(h);
+                    if let Some(p) = &b.plate {
+                        format!("{p:?}").hash(h);
+                    }
+                });
+                (key, clip_box(verts_box(verts, physical), b.scissor))
+            })
+            .collect();
+        let s = frame.scale;
+        let texts = texts
+            .iter()
+            .map(|t| {
+                let m = t.buffer.metrics();
+                let (mut w, mut bottom) = (0.0f32, 0.0f32);
+                let key = hash_of(|h| {
+                    for line in &t.buffer.lines {
+                        line.text().hash(h);
+                    }
+                    for run in t.buffer.layout_runs() {
+                        w = w.max(run.line_w);
+                        bottom = bottom.max(run.line_top + run.line_height);
+                    }
+                    (t.x.to_bits(), t.y.to_bits(), t.color.0, m.font_size.to_bits()).hash(h);
+                    t.bounds.map(|b| b.map(f32::to_bits)).hash(h);
+                    t.clip_circle.map(|c| c.map(f32::to_bits)).hash(h);
+                    t.clip_rrect.map(|c| c.map(f32::to_bits)).hash(h);
+                });
+                // Generous: a glyph can overhang its advance box, and the
+                // run's top is not the ink's.
+                let pad = m.font_size.max(m.line_height);
+                let (x, y) = (t.x * s, t.y * s);
+                let b = (
+                    (x - pad).floor() as i32,
+                    (y - pad - m.line_height).floor() as i32,
+                    (x + w + pad).ceil() as i32,
+                    (y + bottom + pad).ceil() as i32,
+                );
+                (key, Some(b))
+            })
+            .collect();
+        let images = frame
+            .images
+            .iter()
+            .map(|q| {
+                let key = hash_of(|h| {
+                    (q.image, q.rect.0.to_bits(), q.rect.1.to_bits(), q.rect.2.to_bits(), q.rect.3.to_bits()).hash(h);
+                    (q.alpha.to_bits(), q.z_before, q.clip).hash(h);
+                });
+                let r = q.rect;
+                let b = Some((r.0.floor() as i32 - 1, r.1.floor() as i32 - 1, (r.0 + r.2).ceil() as i32 + 1, (r.1 + r.3).ceil() as i32 + 1));
+                (key, clip_box(b, q.clip), q.image)
+            })
+            .collect();
+        let overlay = (
+            hash_of(|h| bytemuck::cast_slice::<Vertex, u8>(&frame.overlay_verts).hash(h)),
+            verts_box(&frame.overlay_verts, physical),
+        );
+        FrameSig {
+            physical,
+            clear: frame.clear_color.map(f32::to_bits),
+            features: hash_of(|h| frame.plate_features.iter().for_each(|f| f.map(f32::to_bits).hash(h))),
+            batches,
+            texts,
+            images,
+            overlay,
+        }
+    }
+}
+
+/// The boxes that differ between two runs of (key, box), old and new: the
+/// common prefix and suffix are unchanged, and everything between them —
+/// inserted, removed or altered — is damage, where it was AND where it is.
+fn diff_runs<T>(old: &[T], new: &[T], key: impl Fn(&T) -> u64, bx: impl Fn(&T) -> Option<PxBox>) -> Option<PxBox> {
+    let pre = old.iter().zip(new).take_while(|(a, b)| key(a) == key(b)).count();
+    let suf = old[pre..].iter().rev().zip(new[pre..].iter().rev()).take_while(|(a, b)| key(a) == key(b)).count();
+    let mut d = None;
+    for x in &old[pre..old.len() - suf] {
+        d = union_box(d, bx(x).or(Some((i32::MIN / 2, i32::MIN / 2, i32::MAX / 2, i32::MAX / 2))));
+    }
+    for x in &new[pre..new.len() - suf] {
+        d = union_box(d, bx(x).or(Some((i32::MIN / 2, i32::MIN / 2, i32::MAX / 2, i32::MAX / 2))));
+    }
+    d
+}
+
+/// Damage the runner works out for itself, for an app that does not report
+/// its own (`Application::take_damage`): what this frame drew that the last
+/// presented one did not, by diffing the tessellated batches, the text and
+/// the images. The renderer repaints only that (growing it around frosted
+/// plates) and reports only it to the compositor. Until 2026-10-06 every such
+/// frame was a full repaint — a one-button hover change repainted and
+/// recomposited the whole window.
+///
+/// Conservative: anything it cannot see a frame's difference in makes the
+/// frame full — a new size, scale or clear colour, changed plate carves, the
+/// first frame, an app that stages its own text, an image whose pixels were
+/// replaced in place is damaged where it is drawn. `CCE_UI_FULL_DAMAGE=1`
+/// turns it off.
+pub fn derive_damage(frame: &mut BuiltFrame, record: &mut FrameRecord, texts: &[DlText], damage_owed: bool) {
+    static OFF: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
+    let off = *OFF.get_or_init(|| std::env::var_os("CCE_UI_FULL_DAMAGE").is_some());
+    let updated = crate::draw::images::take_updated_ids();
+    let sig = FrameSig::of(frame, texts);
+    let prev = record.prev.replace(sig);
+    if off || damage_owed || frame.damage.is_some() || !frame.dl_text {
+        return;
+    }
+    let (Some(prev), Some(cur)) = (prev, record.prev.as_ref()) else { return };
+    if prev.physical != cur.physical || prev.clear != cur.clear || prev.features != cur.features {
+        return;
+    }
+    let mut d = diff_runs(&prev.batches, &cur.batches, |b| b.0, |b| b.1);
+    d = union_box(d, diff_runs(&prev.texts, &cur.texts, |t| t.0, |t| t.1));
+    d = union_box(d, diff_runs(&prev.images, &cur.images, |i| i.0, |i| i.1));
+    if prev.overlay.0 != cur.overlay.0 {
+        d = union_box(d, union_box(prev.overlay.1, cur.overlay.1));
+    }
+    for (_, b, id) in &cur.images {
+        if updated.contains(id) {
+            d = union_box(d, *b);
+        }
+    }
+    let (pw, ph) = cur.physical;
+    let (x0, y0, x1, y1) = d.unwrap_or((0, 0, 1, 1));
+    let (x0, y0) = (x0.clamp(0, pw as i32), y0.clamp(0, ph as i32));
+    let (x1, y1) = (x1.clamp(x0, pw as i32), y1.clamp(y0, ph as i32));
+    if x1 - x0 >= pw as i32 && y1 - y0 >= ph as i32 {
+        return;
+    }
+    frame.damage = Some((x0 as u32, y0 as u32, (x1 - x0).max(1) as u32, (y1 - y0).max(1) as u32));
+    if crate::vk::present_debug() {
+        eprintln!("[vk] derived damage {}x{}+{}+{} of {pw}x{ph}", x1 - x0, y1 - y0, x0, y0);
+    }
+}
+
 #[cfg(test)]
 mod tests {
     //! The builder with no window and no GPU: a mock app, an empty font
@@ -364,3 +567,36 @@ mod tests {
         assert_eq!((f2.clear_color, f2.damage), (f.clear_color, f.damage));
     }
 }
+
+#[cfg(test)]
+mod damage_tests {
+    use super::*;
+
+    fn k(v: &[(u64, i32)]) -> Vec<(u64, Option<PxBox>)> {
+        v.iter().map(|&(key, x)| (key, Some((x, 0, x + 10, 10)))).collect()
+    }
+
+    /// Unchanged runs are no damage; a changed, inserted or removed item is
+    /// damaged where it was and where it is, and nothing beyond the common
+    /// prefix and suffix is.
+    #[test]
+    fn diff_runs_takes_the_middle() {
+        let (a, b) = (k(&[(1, 0), (2, 20), (3, 40)]), k(&[(1, 0), (2, 20), (3, 40)]));
+        assert_eq!(diff_runs(&a, &b, |x| x.0, |x| x.1), None);
+        let changed = k(&[(1, 0), (9, 25), (3, 40)]);
+        assert_eq!(diff_runs(&a, &changed, |x| x.0, |x| x.1), Some((20, 0, 35, 10)));
+        let inserted = k(&[(1, 0), (7, 60), (2, 20), (3, 40)]);
+        assert_eq!(diff_runs(&a, &inserted, |x| x.0, |x| x.1), Some((60, 0, 70, 10)));
+        let removed = k(&[(1, 0), (3, 40)]);
+        assert_eq!(diff_runs(&a, &removed, |x| x.0, |x| x.1), Some((20, 0, 30, 10)));
+    }
+
+    /// An item with no box (nothing to bound it by) damages everything.
+    #[test]
+    fn an_unbounded_change_is_everything() {
+        let a = vec![(1u64, None)];
+        let b = vec![(2u64, None)];
+        let d = diff_runs(&a, &b, |x: &(u64, Option<PxBox>)| x.0, |x| x.1).unwrap();
+        assert!(d.0 < -1_000_000 && d.2 > 1_000_000);
+    }
+}
diff --git a/src/backend/window_runner.rs b/src/backend/window_runner.rs
index 8260d95..b92c1cd 100644
--- a/src/backend/window_runner.rs
+++ b/src/backend/window_runner.rs
@@ -144,6 +144,9 @@ pub struct EngineState<A: Application> {
     /// A frame took the app's damage (`Application::take_damage`) and was
     /// not presented: the next presented frame is a full one.
     pub damage_owed: bool,
+    /// The last built frame, for the damage the runner derives
+    /// (`backend::frame::derive_damage`).
+    pub frame_record: crate::backend::frame::FrameRecord,
     pub frame_callback_pending: bool,
     /// When the pending frame callback was armed — the starvation fallback's
     /// clock (see the render gate in `run`).
@@ -456,7 +459,8 @@ impl<A: Application> EngineState<A> {
         }
         
         // The frame itself, built with no window system in it (`backend::frame`).
-        let frame = build_frame(
+        let owed = self.damage_owed;
+        let mut frame = build_frame(
             self.inner.as_mut().unwrap(),
             self.font_system.as_mut().unwrap(),
             LogicalSize::new(logical_w, logical_h),
@@ -464,6 +468,7 @@ impl<A: Application> EngineState<A> {
             &mut self.damage_owed,
             &mut self.dl_text_items,
         );
+        crate::backend::frame::derive_damage(&mut frame, &mut self.frame_record, &self.dl_text_items, owed);
 
         // Upload the frame's glyphs. An app without display-list text owns
         // the renderer's text state itself (it stages via stage_renderer
@@ -2053,6 +2058,7 @@ fn run_session<'l, A: Application>(
         exit: false,
         redraw: false,
         damage_owed: true,
+        frame_record: Default::default(),
         frame_callback_pending: false,
         frame_callback_armed_at: None,
         keepalive_pending: false,
diff --git a/src/draw/images.rs b/src/draw/images.rs
index ec1db89..3ac6683 100644
--- a/src/draw/images.rs
+++ b/src/draw/images.rs
@@ -60,6 +60,16 @@ pub enum Pending {
 pub type Region = (u32, u32, u32, u32);
 
 static PENDING: Mutex<Vec<Pending>> = Mutex::new(Vec::new());
+/// Ids whose pixels were replaced in place ([`update_pixels`],
+/// [`update_pixel_regions`]) since the last frame was built: a frame that
+/// draws one of them has changed there even though its display list did not.
+static UPDATED: Mutex<Vec<u32>> = Mutex::new(Vec::new());
+
+/// The ids [`update_pixels`] and [`update_pixel_regions`] touched since the
+/// last call — the frame builder's, for its damage.
+pub(crate) fn take_updated_ids() -> Vec<u32> {
+    std::mem::take(&mut *UPDATED.lock().unwrap())
+}
 static NEXT_ID: AtomicU32 = AtomicU32::new(1);
 
 /// A fresh image id from the process-wide counter, for a renderer that
@@ -122,6 +132,7 @@ fn queue_upload(pixels: Vec<u8>, width: u32, height: u32, format: PixelFormat, m
 pub fn update_pixels(id: u32, pixels: Vec<u8>, width: u32, height: u32, format: PixelFormat) {
     assert_eq!(pixels.len(), (width * height * 4) as usize, "8888 size mismatch");
     PENDING.lock().unwrap().push(Pending::Update { id, pixels, width, height, format });
+    UPDATED.lock().unwrap().push(id);
 }
 
 /// Replace only the given rectangles of `id`, keeping the rest of what it
@@ -161,6 +172,7 @@ pub fn update_pixel_regions(
         return;
     }
     PENDING.lock().unwrap().push(Pending::UpdateRegions { id, pixels, width, height, format, regions });
+    UPDATED.lock().unwrap().push(id);
 }
 
 /// A pixel buffer to fill, reusing one the renderer has finished with when