GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git
feat: an app can repaint only the part of its surface that changed
Application::take_damage returns the rect that differs from the last
painted frame. The renderer then loads the swapchain image instead of
clearing it, scissors every draw to what that image is missing (the
frame's damage plus what frames sent to the other images changed), and
presents with VK_KHR_incremental_present so the compositor is told the
rect rather than "everything".
The default is None, a full frame, so no existing app changes. It is for
a small change on a very large surface: cce-grid dragging an image
across a 7552x8160 patch repainted sixty million pixels per pointer
event. Frames with a backdrop or a blur-behind plate stay full, and a
frame that took the damage but was not presented makes the next one
full.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
src/backend/menu_popup.rs | 1 +
src/backend/window_runner.rs | 42 ++++++++++
src/vk/core.rs | 10 +++
src/vk/renderer.rs | 180 ++++++++++++++++++++++++++++++++++++++-----
4 files changed, 215 insertions(+), 18 deletions(-)
diff --git a/src/backend/menu_popup.rs b/src/backend/menu_popup.rs
index 2330aba..8936ef3 100644
--- a/src/backend/menu_popup.rs
+++ b/src/backend/menu_popup.rs
@@ -350,6 +350,7 @@ impl<A: Application> EngineState<A> {
images: &[],
plate_features: &plate_features,
clear_color: [0.0; 4],
+ damage: None,
});
}
diff --git a/src/backend/window_runner.rs b/src/backend/window_runner.rs
index 33d6d2c..9f97d5e 100644
--- a/src/backend/window_runner.rs
+++ b/src/backend/window_runner.rs
@@ -3568,6 +3568,19 @@ pub trait Application: Sized + 'static {
/// On-top overlay quads drawn after the display list and its text (e.g. the status bar's
/// tray-hover highlights). Deliberately separate from the single paint path.
fn overlay_quads(&mut self, _quads: &mut Vec<(f32, f32, f32, f32, [f32; 4])>, _size: LogicalSize, _scale: f64) {}
+ /// The part of the surface that changed since the last frame this app
+ /// painted, as (x, y, w, h) in logical px, taken (and reset) once per
+ /// rendered frame right after `display_list`. `None` — the default —
+ /// means all of it. Returning a rect makes the frame a partial one: only
+ /// that rect is repainted and only it is reported to the compositor as
+ /// damage, which is what keeps a small change on a very large surface
+ /// (an image dragged across the desktop grid) from costing a full
+ /// repaint on both sides. The app vouches for the rect: anything that
+ /// changed outside it keeps its old pixels. A frame that was skipped is
+ /// the runner's to make up — the next one is painted in full.
+ fn take_damage(&mut self, _size: LogicalSize, _scale: f64) -> Option<(f32, f32, f32, f32)> {
+ None
+ }
fn input_regions(&self) -> Option<Vec<(i32, i32, i32, i32)>> {
None
}
@@ -3928,6 +3941,9 @@ pub struct EngineState<A: Application> {
pub exit: bool,
pub redraw: bool,
+ /// 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,
pub frame_callback_pending: bool,
/// When the pending frame callback was armed — the starvation fallback's
/// clock (see the render gate in `run`).
@@ -4305,6 +4321,28 @@ impl<A: Application> EngineState<A> {
let dl = self.inner.as_mut().unwrap()
.display_list(LogicalSize::new(logical_w, logical_h), scale_factor)
.unwrap_or_else(|| crate::scene::paint::PaintCtx::new().finish());
+ // Taken with the display list it describes. A frame that took the
+ // app's damage and then was not presented owes those pixels, so the
+ // next one that is presented repaints everything.
+ let app_damage = self
+ .inner
+ .as_mut()
+ .unwrap()
+ .take_damage(LogicalSize::new(logical_w, logical_h), scale_factor);
+ let damage = match (app_damage, self.damage_owed) {
+ (Some((x, y, w, h)), false) => {
+ // Outward to whole physical pixels, plus one for an
+ // antialiased edge.
+ let s = scale_factor as f32;
+ let x0 = ((x * s).floor() - 1.0).max(0.0);
+ let y0 = ((y * s).floor() - 1.0).max(0.0);
+ let x1 = ((x + w) * s).ceil() + 1.0;
+ let y1 = ((y + h) * s).ceil() + 1.0;
+ Some((x0 as u32, y0 as u32, (x1 - x0).max(0.0) as u32, (y1 - y0).max(0.0) as u32))
+ }
+ _ => None,
+ };
+ self.damage_owed = true;
// 1a. Phase 6 display-list text: shape the list's Text prims through the shared buffer
// cache and hold them for the glyph pass (the TextSpans built below borrow these).
@@ -4492,6 +4530,7 @@ impl<A: Application> EngineState<A> {
images: &image_quads,
plate_features: &plate_features,
clear_color,
+ damage,
}) {
// No present happened (swapchain out-of-date, or the created
// swapchain didn't match the requested extent). The frame
@@ -4499,6 +4538,8 @@ impl<A: Application> EngineState<A> {
// clear it or the demand-driven loop stalls waiting forever.
self.frame_callback_pending = false;
self.redraw = true;
+ } else {
+ self.damage_owed = false;
}
}
}
@@ -5982,6 +6023,7 @@ fn run_session<'l, A: Application>(
display_ptr: 0,
exit: false,
redraw: false,
+ damage_owed: true,
frame_callback_pending: false,
frame_callback_armed_at: None,
warm_until: None,
diff --git a/src/vk/core.rs b/src/vk/core.rs
index ee21638..8fdd6ca 100644
--- a/src/vk/core.rs
+++ b/src/vk/core.rs
@@ -67,6 +67,10 @@ pub struct VkCore {
/// maxSamplerAnisotropy); 1.0 when the samplerAnisotropy feature is
/// absent, which is a sampler that asks for none.
pub(crate) max_anisotropy: f32,
+ /// VK_KHR_incremental_present is enabled: a present may name the
+ /// rectangles that changed, which the Wayland WSI forwards as the
+ /// surface's buffer damage in place of "everything".
+ pub(crate) incremental_present: bool,
}
/// The process-wide Vulkan entry + instance every [`VkCore`] hangs off.
@@ -490,6 +494,11 @@ impl VkCore {
let mut device_info = vk::DeviceCreateInfo::default()
.queue_create_infos(&queue_infos)
.enabled_features(&enabled_features);
+ let incremental_present =
+ wayland.is_some() && has_ext(ash::khr::incremental_present::NAME);
+ if incremental_present {
+ device_extensions.push(ash::khr::incremental_present::NAME.as_ptr());
+ }
if ray_query {
device_extensions.push(ash::khr::acceleration_structure::NAME.as_ptr());
device_extensions.push(ash::khr::ray_query::NAME.as_ptr());
@@ -555,6 +564,7 @@ impl VkCore {
as_scratch_align,
max_line_width,
max_anisotropy,
+ incremental_present,
},
surface,
))
diff --git a/src/vk/renderer.rs b/src/vk/renderer.rs
index 51f0f81..6b0dbbe 100644
--- a/src/vk/renderer.rs
+++ b/src/vk/renderer.rs
@@ -125,6 +125,59 @@ pub struct Frame2D<'a> {
/// Plate batches reference them by offset+count in `PlatePush::host`.
pub plate_features: &'a [[f32; 12]],
pub clear_color: [f32; 4],
+ /// The only part of the surface that differs from the previous frame,
+ /// (x, y, w, h) in physical pixels; None = all of it. With a rect the
+ /// renderer keeps the pixels outside it (see [`ImageAge`]) and tells the
+ /// compositor that only the rect changed. The caller vouches for it: a
+ /// pixel that changed outside the rect stays as it was.
+ pub damage: Option<(u32, u32, u32, u32)>,
+}
+
+/// How far a swapchain image's pixels are behind the latest frame. A frame
+/// with [`Frame2D::damage`] repaints only what the image it acquired is
+/// missing — the frame's own damage plus whatever frames that went to the
+/// OTHER images changed in the meantime — instead of every pixel.
+#[derive(Debug, Clone, Copy, PartialEq)]
+enum ImageAge {
+ /// Never rendered, or a full-surface frame went by: repaint everything.
+ Unknown,
+ /// Holds the latest frame.
+ Current,
+ /// Holds the latest frame except inside this rect.
+ Behind(vk::Rect2D),
+}
+
+fn rect_union(a: vk::Rect2D, b: vk::Rect2D) -> vk::Rect2D {
+ if a.extent.width == 0 || a.extent.height == 0 {
+ return b;
+ }
+ if b.extent.width == 0 || b.extent.height == 0 {
+ return a;
+ }
+ let x0 = a.offset.x.min(b.offset.x);
+ let y0 = a.offset.y.min(b.offset.y);
+ let x1 = (a.offset.x + a.extent.width as i32).max(b.offset.x + b.extent.width as i32);
+ let y1 = (a.offset.y + a.extent.height as i32).max(b.offset.y + b.extent.height as i32);
+ vk::Rect2D {
+ offset: vk::Offset2D { x: x0, y: y0 },
+ extent: vk::Extent2D { width: (x1 - x0) as u32, height: (y1 - y0) as u32 },
+ }
+}
+
+/// `a` cut down to `b`; zero-sized (a legal scissor that draws nothing) when
+/// they do not meet.
+fn rect_intersect(a: vk::Rect2D, b: vk::Rect2D) -> vk::Rect2D {
+ let x0 = a.offset.x.max(b.offset.x);
+ let y0 = a.offset.y.max(b.offset.y);
+ let x1 = (a.offset.x + a.extent.width as i32).min(b.offset.x + b.extent.width as i32);
+ let y1 = (a.offset.y + a.extent.height as i32).min(b.offset.y + b.extent.height as i32);
+ vk::Rect2D {
+ offset: vk::Offset2D { x: x0, y: y0 },
+ extent: vk::Extent2D {
+ width: (x1 - x0).max(0) as u32,
+ height: (y1 - y0).max(0) as u32,
+ },
+ }
}
const FRAMES_IN_FLIGHT: usize = 2;
@@ -237,6 +290,11 @@ pub struct VkRenderer {
/// UI pass over a backdrop copy: loadOp LOAD, initial layout TRANSFER_DST.
/// Framebuffers are shared with `render_pass` (compatible attachments).
render_pass_load: vk::RenderPass,
+ /// LOAD from PRESENT_SRC: a partial frame repaints inside a swapchain
+ /// image that still holds an earlier frame.
+ render_pass_partial: vk::RenderPass,
+ /// Per swapchain image, what it is missing; reset with the swapchain.
+ image_ages: Vec<ImageAge>,
descriptor_set_layout: vk::DescriptorSetLayout,
pipeline_layout: vk::PipelineLayout,
pipeline: vk::Pipeline,
@@ -565,6 +623,28 @@ impl VkRenderer {
)
.expect("Failed to create load render pass");
+ // Variant for a partial frame: keep what the image already shows and
+ // repaint inside the damage only. A presented image comes back from
+ // acquire in PRESENT_SRC with its contents intact.
+ let attachments_partial = [vk::AttachmentDescription::default()
+ .format(surface_format.format)
+ .samples(vk::SampleCountFlags::TYPE_1)
+ .load_op(vk::AttachmentLoadOp::LOAD)
+ .store_op(vk::AttachmentStoreOp::STORE)
+ .stencil_load_op(vk::AttachmentLoadOp::DONT_CARE)
+ .stencil_store_op(vk::AttachmentStoreOp::DONT_CARE)
+ .initial_layout(vk::ImageLayout::PRESENT_SRC_KHR)
+ .final_layout(vk::ImageLayout::PRESENT_SRC_KHR)];
+ let render_pass_partial = device
+ .create_render_pass(
+ &vk::RenderPassCreateInfo::default()
+ .attachments(&attachments_partial)
+ .subpasses(&subpasses)
+ .dependencies(&dependencies),
+ None,
+ )
+ .expect("Failed to create partial render pass");
+
// Descriptor set layout mirroring shader.wgsl @group(0): naga maps WGSL
// texture/sampler/uniform bindings 1:1 onto set 0 descriptor bindings.
let bindings = [
@@ -910,6 +990,8 @@ impl VkRenderer {
render_finished: Vec::new(),
render_pass,
render_pass_load,
+ render_pass_partial,
+ image_ages: Vec::new(),
descriptor_set_layout,
pipeline_layout,
pipeline,
@@ -1143,6 +1225,7 @@ impl VkRenderer {
.get_swapchain_images(self.swapchain)
.map_err(|result| SurfaceLost { call: "vkGetSwapchainImagesKHR", result })?;
self.swapchain_images = images.clone();
+ self.image_ages = vec![ImageAge::Unknown; images.len()];
let subresource_range = vk::ImageSubresourceRange::default()
.aspect_mask(vk::ImageAspectFlags::COLOR)
.base_mip_level(0)
@@ -1681,6 +1764,7 @@ impl VkRenderer {
images: &[],
plate_features: &[],
clear_color: [0.0; 4],
+ damage: None,
})
}
@@ -1950,8 +2034,37 @@ impl VkRenderer {
let clear_values = [vk::ClearValue {
color: vk::ClearColorValue { float32: frame2d.clear_color },
}];
+ let full_scissor = vk::Rect2D {
+ offset: vk::Offset2D { x: 0, y: 0 },
+ extent: self.extent,
+ };
+ // This frame's damage, and whether the acquired image can be
+ // brought up to date by repainting part of it. Blur-behind and
+ // backdrop frames copy whole images around and stay full.
+ let damage = frame2d.damage.map(|(x, y, w, h)| {
+ rect_intersect(
+ vk::Rect2D {
+ offset: vk::Offset2D { x: x as i32, y: y as i32 },
+ extent: vk::Extent2D { width: w, height: h },
+ },
+ full_scissor,
+ )
+ });
+ let partial: Option<vk::Rect2D> = match (damage, self.image_ages[image_index as usize]) {
+ _ if use_backdrop || frame2d.batches.iter().any(|b| b.blur_behind) => None,
+ (Some(d), ImageAge::Current) => Some(d),
+ (Some(d), ImageAge::Behind(missing)) => Some(rect_union(d, missing)),
+ _ => None,
+ };
+ // Every scissor of the frame passes through this.
+ let clip = |r: vk::Rect2D| match partial {
+ Some(region) => rect_intersect(r, region),
+ None => r,
+ };
let (ui_pass, ui_clear_values): (vk::RenderPass, &[vk::ClearValue]) = if use_backdrop {
(self.render_pass_load, &[])
+ } else if partial.is_some() {
+ (self.render_pass_partial, &[])
} else {
(self.render_pass, &clear_values)
};
@@ -1969,18 +2082,21 @@ impl VkRenderer {
);
self.core.device
.cmd_set_viewport(cmd, 0, &[flipped_viewport(self.extent)]);
- self.core.device.cmd_set_scissor(
- cmd,
- 0,
- &[vk::Rect2D {
- offset: vk::Offset2D { x: 0, y: 0 },
- extent: self.extent,
- }],
- );
- let full_scissor = vk::Rect2D {
- offset: vk::Offset2D { x: 0, y: 0 },
- extent: self.extent,
- };
+ self.core.device.cmd_set_scissor(cmd, 0, &[clip(full_scissor)]);
+ if let Some(region) = partial {
+ // The partial pass loads instead of clearing; clear what it
+ // is about to repaint.
+ if region.extent.width > 0 && region.extent.height > 0 {
+ self.core.device.cmd_clear_attachments(
+ cmd,
+ &[vk::ClearAttachment::default()
+ .aspect_mask(vk::ImageAspectFlags::COLOR)
+ .color_attachment(0)
+ .clear_value(clear_values[0])],
+ &[vk::ClearRect::default().rect(region).layer_count(1)],
+ );
+ }
+ }
// Display-list geometry interleaved with user images: each image
// quad draws before the vertex its `z_before` names, so it sits
// above earlier geometry and below later geometry.
@@ -2040,7 +2156,7 @@ impl VkRenderer {
},
None => full_scissor,
};
- self.core.device.cmd_set_scissor(cmd, 0, &[img_scissor]);
+ self.core.device.cmd_set_scissor(cmd, 0, &[clip(img_scissor)]);
self.image.record_quad(&self.core.device, cmd, frame_index, k, q.image);
snapshot_fresh = false;
}
@@ -2093,7 +2209,7 @@ impl VkRenderer {
},
None => full_scissor,
};
- self.core.device.cmd_set_scissor(cmd, 0, &[img_scissor]);
+ self.core.device.cmd_set_scissor(cmd, 0, &[clip(img_scissor)]);
self.image.record_quad(&self.core.device, cmd, frame_index, k, q.image);
continue;
}
@@ -2111,7 +2227,7 @@ impl VkRenderer {
);
self.core.device
.cmd_bind_vertex_buffers(cmd, 0, &[frame.vertex.buffer], &[0]);
- self.core.device.cmd_set_scissor(cmd, 0, &[scissor]);
+ self.core.device.cmd_set_scissor(cmd, 0, &[clip(scissor)]);
// Per-batch rounded-rect clip (fragments outside
// discard) + the SDF-lit plate block when this
// batch is a plate cover quad.
@@ -2163,11 +2279,11 @@ impl VkRenderer {
},
None => full_scissor,
};
- self.core.device.cmd_set_scissor(cmd, 0, &[img_scissor]);
+ self.core.device.cmd_set_scissor(cmd, 0, &[clip(img_scissor)]);
self.image.record_quad(&self.core.device, cmd, frame_index, k, q.image);
}
// Restore for the text/overlay draws.
- self.core.device.cmd_set_scissor(cmd, 0, &[full_scissor]);
+ self.core.device.cmd_set_scissor(cmd, 0, &[clip(full_scissor)]);
}
self.text.record_draw(&self.core.device, cmd, frame_index);
if frame.overlay_count > 0 {
@@ -2216,12 +2332,39 @@ impl VkRenderer {
.queue_submit(self.core.queue, &[submit], in_flight)
.expect("Queue submit failed");
+ // The image now holds this frame; every other image fell behind
+ // by this frame's damage.
+ for (k, age) in self.image_ages.iter_mut().enumerate() {
+ *age = if k == image_index as usize {
+ ImageAge::Current
+ } else {
+ match (damage, *age) {
+ (Some(d), ImageAge::Current) => ImageAge::Behind(d),
+ (Some(d), ImageAge::Behind(m)) => ImageAge::Behind(rect_union(d, m)),
+ _ => ImageAge::Unknown,
+ }
+ };
+ }
+
let swapchains = [self.swapchain];
let image_indices = [image_index];
- let present = vk::PresentInfoKHR::default()
+ // What changed since the previous present — the frame's damage,
+ // however much of the image had to be repainted to get there.
+ // Only for a frame that was itself partial: the first frames of
+ // a swapchain replace a buffer of another size or none at all.
+ let present_rects = [vk::RectLayerKHR::default()
+ .offset(damage.unwrap_or(full_scissor).offset)
+ .extent(damage.unwrap_or(full_scissor).extent)
+ .layer(0)];
+ let present_region = [vk::PresentRegionKHR::default().rectangles(&present_rects)];
+ let mut present_regions = vk::PresentRegionsKHR::default().regions(&present_region);
+ let mut present = vk::PresentInfoKHR::default()
.wait_semaphores(&signal_semaphores)
.swapchains(&swapchains)
.image_indices(&image_indices);
+ if self.core.incremental_present && partial.is_some() {
+ present = present.push_next(&mut present_regions);
+ }
if present_debug() {
eprintln!("[vk] frame {} present img {}...", self.present_debug_count, image_index);
}
@@ -2317,6 +2460,7 @@ impl Drop for VkRenderer {
self.core.device.destroy_shader_module(self.shader_module, None);
self.core.device.destroy_render_pass(self.render_pass, None);
self.core.device.destroy_render_pass(self.render_pass_load, None);
+ self.core.device.destroy_render_pass(self.render_pass_partial, None);
self.core.surface_loader.destroy_surface(self.surface, None);
// The rest (allocator, command pool, device, instance) is the
// core's Drop, which runs after this body.