GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git
feat: blur-behind plates sample the real frame-so-far
The blur branch used to sample the scene backdrop — empty everywhere
the 3D pass's viewport-pane scissor didn't reach, so a blur plate over
UI content blurred nothing (the renderer's declared placeholder TODO).
A Plate/Bevel with the negative-alpha blur marker now flags its batch
(Batch2D::blur_behind); the renderer suspends the UI pass there, copies
the swapchain's frame-so-far into a dedicated snapshot image, resumes
via the LOAD pass, and binds a twin descriptor set whose binding 0 is
the snapshot. Everything painted beneath the plate — background,
widgets, wires — feeds the blur; later blur plates re-snapshot so they
include earlier ones. The shader is untouched: resolve_blur just
samples binding 0.
Co-Authored-By: Claude Fable 5 <[email protected]>
src/backend/window_runner.rs | 27 +++-
src/vk/renderer.rs | 289 ++++++++++++++++++++++++++++++++++++++++---
2 files changed, 293 insertions(+), 23 deletions(-)
diff --git a/src/backend/window_runner.rs b/src/backend/window_runner.rs
index 20547cf..0b85da0 100644
--- a/src/backend/window_runner.rs
+++ b/src/backend/window_runner.rs
@@ -1422,6 +1422,11 @@ pub struct DlBatch {
/// When set, this batch is one SDF-lit plate cover quad (see
/// [`crate::vk::PlatePush`]; already in physical px). Never merged.
pub plate: Option<crate::vk::PlatePush>,
+ /// A blur-behind plate (negative-alpha color): before drawing this batch
+ /// the renderer snapshots the swapchain-so-far into its snapshot image, so
+ /// the blur samples everything painted beneath the plate — not just the 3D
+ /// scene backdrop. Never merged.
+ pub blur_behind: bool,
}
/// An image draw from the display list: `at` is the vertex index it sorts
@@ -1476,14 +1481,22 @@ pub fn tessellate_display_list(
let start = verts.len() as u32;
let mut plate: Option<crate::vk::PlatePush> = None;
let mut made_plate: Option<crate::scene::layout::Rect> = None;
+ // Blur-behind marker: a Plate/Bevel whose fill alpha is negative asks
+ // the renderer to snapshot the frame-so-far before it draws.
+ let blur_behind = matches!(
+ &item.prim,
+ crate::scene::paint::Prim::Bevel { color, .. }
+ | crate::scene::paint::Prim::Plate { color, .. } if color[3] < 0.0
+ );
// Logical [cx, cy, r] → the physical-pixel triple the vertex attribute carries.
let no = item
.clip_circle
.map(|c| [c[0] * scale, c[1] * scale, c[2] * scale])
.unwrap_or([0.0f32, 0.0, 0.0]);
// Fixed 16-segment fans read as polygons once a circle/arc is pane-sized; scale
- // the fan with the radius (capped — beyond 64 the chord error is subpixel).
- let segs = |radius: f32| -> usize { (radius as usize).clamp(16, 64) };
+ // the fan with the PHYSICAL radius (capped — beyond 128 the chord error is
+ // subpixel even on HiDPI).
+ let segs = |radius: f32| -> usize { ((radius * scale) as usize).clamp(16, 128) };
match &item.prim {
Prim::Text { .. } => continue, // text goes through the glyph/text-span path
Prim::Image { image, rect, alpha } => {
@@ -1792,8 +1805,9 @@ pub fn tessellate_display_list(
}
}
// Merge into the previous batch if it shares this clip pair and is contiguous.
- // Plate batches carry per-draw push constants, so they never merge.
- if plate.is_none() {
+ // Plate batches carry per-draw push constants, and blur-behind batches
+ // trigger the renderer's snapshot copy, so neither ever merges.
+ if plate.is_none() && !blur_behind {
// Ordinary geometry painted after a plate ends its carve-grouping
// window: a recess emitted later must overlay this geometry (the
// fallback path), not shade beneath it inside the plate's draw.
@@ -1809,7 +1823,7 @@ pub fn tessellate_display_list(
}
}
}
- batches.push(DlBatch { scissor: item.clip, clip_rrect: item.clip_rrect, start, end, plate });
+ batches.push(DlBatch { scissor: item.clip, clip_rrect: item.clip_rrect, start, end, plate, blur_behind });
if let Some(prect) = made_plate {
last_plate = Some((batches.len() - 1, prect));
}
@@ -2679,7 +2693,7 @@ impl<A: Application> EngineState<A> {
let pre_custom = verts.len() as u32;
self.inner.as_mut().unwrap().custom_vertices(&mut verts, LogicalSize::new(logical_w, logical_h), scale_factor);
if (verts.len() as u32) > pre_custom {
- dl_batches.push(DlBatch { scissor: None, clip_rrect: None, start: pre_custom, end: verts.len() as u32, plate: None });
+ dl_batches.push(DlBatch { scissor: None, clip_rrect: None, start: pre_custom, end: verts.len() as u32, plate: None, blur_behind: false });
}
// 1b. Overlay quads (drawn after the text pass).
@@ -2818,6 +2832,7 @@ impl<A: Application> EngineState<A> {
start: batch.start,
end: batch.end,
plate: batch.plate,
+ blur_behind: batch.blur_behind,
})
.collect();
diff --git a/src/vk/renderer.rs b/src/vk/renderer.rs
index 511959c..df9d81e 100644
--- a/src/vk/renderer.rs
+++ b/src/vk/renderer.rs
@@ -1,9 +1,11 @@
//! The ash renderer. One graphics queue, a classic render pass, two frames in
//! flight, FIFO (vsync) presentation. Memory goes through gpu-allocator; the
//! descriptor set mirrors `shader.wgsl`'s @group(0): sampled backdrop texture
-//! (binding 0), sampler (binding 1), WindowInfo uniform (binding 2). The backdrop
-//! is a 1x1 placeholder until the blur-behind path is wired to a real framebuffer
-//! copy at cutover.
+//! (binding 0), sampler (binding 1), WindowInfo uniform (binding 2). Binding 0
+//! is the scene backdrop until the first blur-behind plate: there the UI pass
+//! suspends, the frame-so-far is copied into the snapshot image, and the
+//! snapshot descriptor set takes over — so blur plates blur everything painted
+//! beneath them, not just the 3D scene.
use std::ffi::c_void;
@@ -33,6 +35,11 @@ pub struct Batch2D {
/// When set, this batch is a single SDF-lit plate cover quad: the params go
/// out as push constants and shader2d's plate branch lights it per pixel.
pub plate: Option<PlatePush>,
+ /// A blur-behind plate (negative-alpha color): the renderer suspends the UI
+ /// pass, copies the swapchain-so-far into its snapshot image, and resumes —
+ /// so the plate's blur samples everything painted beneath it (background,
+ /// widgets, wires), not just the 3D scene backdrop.
+ pub blur_behind: bool,
}
/// Push-constant block for one SDF-lit plate batch (physical px throughout).
@@ -193,6 +200,15 @@ pub struct VkRenderer {
descriptor_pool: vk::DescriptorPool,
descriptor_set: vk::DescriptorSet,
+ /// Twin of `descriptor_set` with binding 0 pointing at `snapshot_image`
+ /// instead of the scene backdrop; bound for every draw after the first
+ /// mid-pass snapshot so blur plates sample the frame-so-far.
+ descriptor_set_snapshot: vk::DescriptorSet,
+ /// Mid-frame copy target for blur-behind plates: the swapchain content so
+ /// far, sampled by the resumed pass's blur draws. Sized with the surface.
+ snapshot_image: vk::Image,
+ snapshot_view: vk::ImageView,
+ snapshot_allocation: Option<Allocation>,
backdrop_sampler: vk::Sampler,
window_info: AllocatedBuffer,
/// The bevel-profile generation `window_info` was last written with —
@@ -658,32 +674,36 @@ impl VkRenderer {
"plate-features",
);
+ // Two sets: the scene-backdrop set and its snapshot twin (binding 0
+ // differs; 1-3 alias the same sampler/uniforms).
let pool_sizes = [
vk::DescriptorPoolSize::default()
.ty(vk::DescriptorType::SAMPLED_IMAGE)
- .descriptor_count(1),
+ .descriptor_count(2),
vk::DescriptorPoolSize::default()
.ty(vk::DescriptorType::SAMPLER)
- .descriptor_count(1),
+ .descriptor_count(2),
vk::DescriptorPoolSize::default()
.ty(vk::DescriptorType::UNIFORM_BUFFER)
- .descriptor_count(2),
+ .descriptor_count(4),
];
let descriptor_pool = device
.create_descriptor_pool(
&vk::DescriptorPoolCreateInfo::default()
- .max_sets(1)
+ .max_sets(2)
.pool_sizes(&pool_sizes),
None,
)
.expect("Failed to create descriptor pool");
- let descriptor_set = device
+ let both_layouts = [descriptor_set_layout, descriptor_set_layout];
+ let sets = device
.allocate_descriptor_sets(
&vk::DescriptorSetAllocateInfo::default()
.descriptor_pool(descriptor_pool)
- .set_layouts(&set_layouts),
+ .set_layouts(&both_layouts),
)
- .expect("Failed to allocate descriptor set")[0];
+ .expect("Failed to allocate descriptor sets");
+ let (descriptor_set, descriptor_set_snapshot) = (sets[0], sets[1]);
let image_infos = [vk::DescriptorImageInfo::default()
.image_view(scene.backdrop_view)
@@ -719,6 +739,24 @@ impl VkRenderer {
.dst_binding(3)
.descriptor_type(vk::DescriptorType::UNIFORM_BUFFER)
.buffer_info(&feature_infos),
+ // Snapshot twin: bindings 1-3 alias the same objects; binding 0
+ // is written by `sync_backdrop_targets` once the snapshot image
+ // exists.
+ vk::WriteDescriptorSet::default()
+ .dst_set(descriptor_set_snapshot)
+ .dst_binding(1)
+ .descriptor_type(vk::DescriptorType::SAMPLER)
+ .image_info(&sampler_infos),
+ vk::WriteDescriptorSet::default()
+ .dst_set(descriptor_set_snapshot)
+ .dst_binding(2)
+ .descriptor_type(vk::DescriptorType::UNIFORM_BUFFER)
+ .buffer_info(&buffer_infos),
+ vk::WriteDescriptorSet::default()
+ .dst_set(descriptor_set_snapshot)
+ .dst_binding(3)
+ .descriptor_type(vk::DescriptorType::UNIFORM_BUFFER)
+ .buffer_info(&feature_infos),
],
&[],
);
@@ -780,6 +818,10 @@ impl VkRenderer {
shader_module,
descriptor_pool,
descriptor_set,
+ descriptor_set_snapshot,
+ snapshot_image: vk::Image::null(),
+ snapshot_view: vk::ImageView::null(),
+ snapshot_allocation: None,
backdrop_sampler,
window_info,
profile_gen: 0,
@@ -905,7 +947,10 @@ impl VkRenderer {
.image_array_layers(1)
.image_usage(
vk::ImageUsageFlags::COLOR_ATTACHMENT
- | vk::ImageUsageFlags::TRANSFER_DST,
+ | vk::ImageUsageFlags::TRANSFER_DST
+ // Blur-behind plates copy the frame-so-far out
+ // of the swapchain into the snapshot image.
+ | vk::ImageUsageFlags::TRANSFER_SRC,
)
.image_sharing_mode(vk::SharingMode::EXCLUSIVE)
.pre_transform(caps.current_transform)
@@ -982,6 +1027,110 @@ impl VkRenderer {
self.sync_backdrop_targets();
}
+ /// Suspend the UI pass, copy the swapchain's frame-so-far into the blur
+ /// snapshot image, and resume drawing — the mechanism behind blur-behind
+ /// plates (`Batch2D::blur_behind`). Ending the pass leaves the swapchain in
+ /// its PRESENT final layout; the copy walks it through TRANSFER_SRC and
+ /// hands it back in TRANSFER_DST, which is exactly `render_pass_load`'s
+ /// expected initial layout, so the resume reuses that pass (and the shared
+ /// framebuffers). Dynamic viewport state dies with the pass and is restored;
+ /// scissor/pipeline/descriptors are re-bound per draw by the batch loop.
+ fn snapshot_frame_so_far(&self, cmd: vk::CommandBuffer, image_index: usize) {
+ let device = &self.core.device;
+ let swapchain_image = self.swapchain_images[image_index];
+ unsafe {
+ device.cmd_end_render_pass(cmd);
+ device.cmd_pipeline_barrier(
+ cmd,
+ vk::PipelineStageFlags::COLOR_ATTACHMENT_OUTPUT
+ | vk::PipelineStageFlags::FRAGMENT_SHADER,
+ vk::PipelineStageFlags::TRANSFER,
+ vk::DependencyFlags::empty(),
+ &[],
+ &[],
+ &[
+ vk::ImageMemoryBarrier::default()
+ .src_access_mask(vk::AccessFlags::COLOR_ATTACHMENT_WRITE)
+ .dst_access_mask(vk::AccessFlags::TRANSFER_READ)
+ .old_layout(vk::ImageLayout::PRESENT_SRC_KHR)
+ .new_layout(vk::ImageLayout::TRANSFER_SRC_OPTIMAL)
+ .src_queue_family_index(vk::QUEUE_FAMILY_IGNORED)
+ .dst_queue_family_index(vk::QUEUE_FAMILY_IGNORED)
+ .image(swapchain_image)
+ .subresource_range(COLOR_RANGE),
+ // Covers the previous frame's blur reads of the snapshot.
+ vk::ImageMemoryBarrier::default()
+ .src_access_mask(vk::AccessFlags::SHADER_READ)
+ .dst_access_mask(vk::AccessFlags::TRANSFER_WRITE)
+ .old_layout(vk::ImageLayout::SHADER_READ_ONLY_OPTIMAL)
+ .new_layout(vk::ImageLayout::TRANSFER_DST_OPTIMAL)
+ .src_queue_family_index(vk::QUEUE_FAMILY_IGNORED)
+ .dst_queue_family_index(vk::QUEUE_FAMILY_IGNORED)
+ .image(self.snapshot_image)
+ .subresource_range(COLOR_RANGE),
+ ],
+ );
+ let subresource = vk::ImageSubresourceLayers::default()
+ .aspect_mask(vk::ImageAspectFlags::COLOR)
+ .layer_count(1);
+ device.cmd_copy_image(
+ cmd,
+ swapchain_image,
+ vk::ImageLayout::TRANSFER_SRC_OPTIMAL,
+ self.snapshot_image,
+ vk::ImageLayout::TRANSFER_DST_OPTIMAL,
+ &[vk::ImageCopy::default()
+ .src_subresource(subresource)
+ .dst_subresource(subresource)
+ .extent(vk::Extent3D {
+ width: self.extent.width,
+ height: self.extent.height,
+ depth: 1,
+ })],
+ );
+ device.cmd_pipeline_barrier(
+ cmd,
+ vk::PipelineStageFlags::TRANSFER,
+ vk::PipelineStageFlags::FRAGMENT_SHADER | vk::PipelineStageFlags::TRANSFER,
+ vk::DependencyFlags::empty(),
+ &[],
+ &[],
+ &[
+ vk::ImageMemoryBarrier::default()
+ .src_access_mask(vk::AccessFlags::TRANSFER_WRITE)
+ .dst_access_mask(vk::AccessFlags::SHADER_READ)
+ .old_layout(vk::ImageLayout::TRANSFER_DST_OPTIMAL)
+ .new_layout(vk::ImageLayout::SHADER_READ_ONLY_OPTIMAL)
+ .src_queue_family_index(vk::QUEUE_FAMILY_IGNORED)
+ .dst_queue_family_index(vk::QUEUE_FAMILY_IGNORED)
+ .image(self.snapshot_image)
+ .subresource_range(COLOR_RANGE),
+ vk::ImageMemoryBarrier::default()
+ .src_access_mask(vk::AccessFlags::TRANSFER_READ)
+ .dst_access_mask(vk::AccessFlags::TRANSFER_WRITE)
+ .old_layout(vk::ImageLayout::TRANSFER_SRC_OPTIMAL)
+ .new_layout(vk::ImageLayout::TRANSFER_DST_OPTIMAL)
+ .src_queue_family_index(vk::QUEUE_FAMILY_IGNORED)
+ .dst_queue_family_index(vk::QUEUE_FAMILY_IGNORED)
+ .image(swapchain_image)
+ .subresource_range(COLOR_RANGE),
+ ],
+ );
+ device.cmd_begin_render_pass(
+ cmd,
+ &vk::RenderPassBeginInfo::default()
+ .render_pass(self.render_pass_load)
+ .framebuffer(self.framebuffers[image_index])
+ .render_area(vk::Rect2D {
+ offset: vk::Offset2D { x: 0, y: 0 },
+ extent: self.extent,
+ }),
+ vk::SubpassContents::INLINE,
+ );
+ device.cmd_set_viewport(cmd, 0, &[flipped_viewport(self.extent)]);
+ }
+ }
+
/// Recreate backdrop + depth at the surface size (device must be idle),
/// re-point the UI descriptor at the new view, and make the fresh image
/// legal to sample.
@@ -997,16 +1146,102 @@ impl VkRenderer {
self.core.command_pool,
self.scene.backdrop_image,
);
+ // The blur snapshot target tracks the surface size alongside the
+ // backdrop (same format so cmd_copy_image from the swapchain is legal).
+ unsafe {
+ let device = &self.core.device;
+ if self.snapshot_view != vk::ImageView::null() {
+ device.destroy_image_view(self.snapshot_view, None);
+ device.destroy_image(self.snapshot_image, None);
+ self.snapshot_view = vk::ImageView::null();
+ self.snapshot_image = vk::Image::null();
+ }
+ if let Some(alloc) = self.snapshot_allocation.take() {
+ let _ = self.core.allocator.as_mut().unwrap().free(alloc);
+ }
+ let device = &self.core.device;
+ let snapshot_image = device
+ .create_image(
+ &vk::ImageCreateInfo::default()
+ .image_type(vk::ImageType::TYPE_2D)
+ .format(self.surface_format.format)
+ .extent(vk::Extent3D {
+ width: self.extent.width,
+ height: self.extent.height,
+ depth: 1,
+ })
+ .mip_levels(1)
+ .array_layers(1)
+ .samples(vk::SampleCountFlags::TYPE_1)
+ .tiling(vk::ImageTiling::OPTIMAL)
+ .usage(
+ vk::ImageUsageFlags::SAMPLED | vk::ImageUsageFlags::TRANSFER_DST,
+ )
+ .initial_layout(vk::ImageLayout::UNDEFINED),
+ None,
+ )
+ .expect("Failed to create snapshot image");
+ let requirements = device.get_image_memory_requirements(snapshot_image);
+ let allocation = self
+ .core
+ .allocator
+ .as_mut()
+ .unwrap()
+ .allocate(&AllocationCreateDesc {
+ name: "blur-snapshot",
+ requirements,
+ location: MemoryLocation::GpuOnly,
+ linear: false,
+ allocation_scheme: AllocationScheme::GpuAllocatorManaged,
+ })
+ .expect("Failed to allocate snapshot memory");
+ self.core
+ .device
+ .bind_image_memory(snapshot_image, allocation.memory(), allocation.offset())
+ .expect("Failed to bind snapshot memory");
+ let snapshot_view = self
+ .core
+ .device
+ .create_image_view(
+ &vk::ImageViewCreateInfo::default()
+ .image(snapshot_image)
+ .view_type(vk::ImageViewType::TYPE_2D)
+ .format(self.surface_format.format)
+ .subresource_range(COLOR_RANGE),
+ None,
+ )
+ .expect("Failed to create snapshot view");
+ self.snapshot_image = snapshot_image;
+ self.snapshot_view = snapshot_view;
+ self.snapshot_allocation = Some(allocation);
+ }
+ // A fresh snapshot must be legal to sample before its first copy.
+ clear_image_to_shader_read(
+ &self.core.device,
+ self.core.queue,
+ self.core.command_pool,
+ self.snapshot_image,
+ );
let image_infos = [vk::DescriptorImageInfo::default()
.image_view(self.scene.backdrop_view)
.image_layout(vk::ImageLayout::SHADER_READ_ONLY_OPTIMAL)];
+ let snapshot_infos = [vk::DescriptorImageInfo::default()
+ .image_view(self.snapshot_view)
+ .image_layout(vk::ImageLayout::SHADER_READ_ONLY_OPTIMAL)];
unsafe {
self.core.device.update_descriptor_sets(
- &[vk::WriteDescriptorSet::default()
- .dst_set(self.descriptor_set)
- .dst_binding(0)
- .descriptor_type(vk::DescriptorType::SAMPLED_IMAGE)
- .image_info(&image_infos)],
+ &[
+ vk::WriteDescriptorSet::default()
+ .dst_set(self.descriptor_set)
+ .dst_binding(0)
+ .descriptor_type(vk::DescriptorType::SAMPLED_IMAGE)
+ .image_info(&image_infos),
+ vk::WriteDescriptorSet::default()
+ .dst_set(self.descriptor_set_snapshot)
+ .dst_binding(0)
+ .descriptor_type(vk::DescriptorType::SAMPLED_IMAGE)
+ .image_info(&snapshot_infos),
+ ],
&[],
);
}
@@ -1440,11 +1675,22 @@ impl VkRenderer {
start: 0,
end: frame.vertex_count,
plate: None,
+ blur_behind: false,
}];
let batches: &[Batch2D] =
if frame2d.batches.is_empty() { &default_batch } else { frame2d.batches };
+ // Which @group(0) the vertex draws bind: the scene-backdrop set
+ // until the first blur-behind snapshot, the snapshot set after —
+ // so blur plates sample the frame-so-far, and later blur plates
+ // sample refreshed copies that include earlier ones.
+ let mut active_set = self.descriptor_set;
+
for batch in batches {
+ if batch.blur_behind {
+ self.snapshot_frame_so_far(cmd, image_index as usize);
+ active_set = self.descriptor_set_snapshot;
+ }
// Resolve the batch scissor; a degenerate one skips the
// vertex draws (images still process on their own clips).
let batch_scissor: Option<vk::Rect2D> = match batch.scissor {
@@ -1498,7 +1744,7 @@ impl VkRenderer {
vk::PipelineBindPoint::GRAPHICS,
self.pipeline_layout,
0,
- &[self.descriptor_set],
+ &[active_set],
&[],
);
self.core.device
@@ -1656,6 +1902,15 @@ impl Drop for VkRenderer {
}
self.core.device.destroy_sampler(self.backdrop_sampler, None);
+ if self.snapshot_view != vk::ImageView::null() {
+ self.core.device.destroy_image_view(self.snapshot_view, None);
+ self.core.device.destroy_image(self.snapshot_image, None);
+ }
+ if let Some(alloc) = self.snapshot_allocation.take() {
+ if let Some(allocator) = self.core.allocator.as_mut() {
+ let _ = allocator.free(alloc);
+ }
+ }
if let Some(allocator) = self.core.allocator.as_mut() {
self.scene.destroy(&self.core.device, allocator);
self.image.destroy(&self.core.device, allocator);