GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git
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