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

commitf72129340cab67387ae6684b4c849650b42667a5
parent879ef7ccf0
authorLucas Galante <lsgalante12@gmail.com>
date2026-10-06 21:14
fix(frost): the blur kernel reads a mip chain, so thin detail stops banding

The 7x7 frost kernel spaces its taps a whole stride apart (5.5 physical px by
default) and read level 0, so a tap saw only the texel or two it landed
between. Detail behind a plate thinner than the stride, such as a hairline, a
well's edge or a glyph, was not blurred: the taps that hit it picked it up
whole and the ones between missed it, leaving seven faint copies a stride
apart. Over a UI's rows that reads as horizontal bands, most visibly in an
open dropdown over the designer's parameter rows.

The blur snapshot now carries a mip chain (up to 7 levels), rebuilt by
linear blits after every frame-so-far copy. The sampler filters between
levels, and resolve_blur reads level log2(stride), so each tap is already
the average of its stride-sized cell. The sharp sawtooth down a column under
the dropdown becomes a smooth ramp, and the blur's sigma grows by about 3%.
Clean samples (clear plates, the rim) stay at level 0. A surface format that
cannot be blitted with a linear filter keeps one level and the old look.

Checked in a shadow under the Khronos validation layer: no messages.

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

 src/draw/shader2d.wgsl |  29 +++++--
 src/vk/renderer.rs     | 228 +++++++++++++++++++++++++++++++++++++++++++------
 2 files changed, 221 insertions(+), 36 deletions(-)

diff --git a/src/draw/shader2d.wgsl b/src/draw/shader2d.wgsl
index c98c6cb..d9b96e0 100644
--- a/src/draw/shader2d.wgsl
+++ b/src/draw/shader2d.wgsl
@@ -1227,10 +1227,10 @@ fn fs_main(in: VertexOutput) -> @location(0) vec4f {
 // push block (MODE_PLATE), or are the no-recipe defaults (droplet, raw
 // vertices). A stride of 0 is a CLEAR plate: one clean sample, tinted.
 //
-// The backdrop is sampled at level 0 explicitly: it has one level, so that is
-// the texel an implicit LOD would pick, and this runs under non-uniform control
-// flow (the blur branch), where WebGPU forbids the derivatives an implicit LOD
-// takes.
+// Every sample names its level explicitly: this runs under non-uniform
+// control flow (the blur branch), where WebGPU forbids the derivatives an
+// implicit LOD takes. The kernel's taps read the level that matches their
+// stride; the clean samples (a clear plate, the rim) read level 0.
 fn resolve_blur(pos: vec2f, color: vec4f, refract: vec2f, clarity: f32, k_in: f32, stride: f32) -> vec4f {
     let tex_size = vec2f(textureDimensions(t_backdrop));
 
@@ -1241,16 +1241,27 @@ fn resolve_blur(pos: vec2f, color: vec4f, refract: vec2f, clarity: f32, k_in: f3
         var blurred = vec4f(0.0);
         var total_weight = 0.0;
         // 7x7 Gaussian kernel at `stride` px (sigma two taps, reach ±3
-        // taps); the linear sampler between taps papers over the stride.
-        // The panel default is 5.5 px; a 2.5 px stride was technically a
-        // blur but read as plain translucency — fine detail beneath a
-        // frosted menu stayed legible, which is not what frosted glass does.
+        // taps). The panel default is 5.5 px; a 2.5 px stride was
+        // technically a blur but read as plain translucency — fine detail
+        // beneath a frosted menu stayed legible, which is not what frosted
+        // glass does.
+        //
+        // Each tap reads the mip level whose texel is one stride wide, so it
+        // is the AVERAGE of the stride-sized cell it stands in. At level 0 a
+        // tap read the texel or two it landed between, and detail thinner
+        // than the stride — a hairline, a well's edge, a glyph — was picked
+        // up whole by the taps that hit it and missed by those between:
+        // seven faint copies a stride apart, horizontal bands under a menu
+        // over rows. The blur snapshot carries the chain
+        // (`snapshot_mip_chain`); the scene backdrop has one level, which
+        // every level clamps to.
+        let lod = max(log2(stride), 0.0);
         for (var x = -3.0; x <= 3.0; x += 1.0) {
             for (var y = -3.0; y <= 3.0; y += 1.0) {
                 let offset = vec2f(x, y) * stride;
                 let sample_uv = (pos + offset) / tex_size;
                 let weight = exp(-(x*x + y*y) / (2.0 * 2.0 * 2.0));
-                blurred += textureSampleLevel(t_backdrop, s_backdrop, sample_uv, 0.0) * weight;
+                blurred += textureSampleLevel(t_backdrop, s_backdrop, sample_uv, lod) * weight;
                 total_weight += weight;
             }
         }
diff --git a/src/vk/renderer.rs b/src/vk/renderer.rs
index 8017423..5d7dc3c 100644
--- a/src/vk/renderer.rs
+++ b/src/vk/renderer.rs
@@ -286,6 +286,13 @@ pub struct VkRenderer {
     /// Whether a frame has needed the blur snapshot; until one has, it is
     /// not allocated (`sync_snapshot_target`).
     snapshot_wanted: bool,
+    /// The snapshot's mip levels: 1 where the surface format cannot be
+    /// blitted with a linear filter (`blur_mips`), else
+    /// [`snapshot_levels`] of the surface. See `snapshot_mip_chain`.
+    snapshot_levels: u32,
+    /// Whether the surface format can be a blit's source and destination
+    /// and filter linearly — what building the snapshot's mip chain takes.
+    blur_mips: bool,
     /// Ask for the surface's minimum image count rather than one more
     /// (`set_minimal_swapchain`).
     minimal_swapchain: bool,
@@ -542,13 +549,32 @@ pub(crate) fn compile_wgsl_ray_query(source: &str) -> Vec<u32> {
     naga::back::spv::write_vec(&module, &info, &options, None).expect("SPIR-V write failed")
 }
 
-const COLOR_RANGE: vk::ImageSubresourceRange = vk::ImageSubresourceRange {
-    aspect_mask: vk::ImageAspectFlags::COLOR,
-    base_mip_level: 0,
-    level_count: 1,
-    base_array_layer: 0,
-    layer_count: 1,
-};
+const COLOR_RANGE: vk::ImageSubresourceRange = color_levels(0, 1);
+
+/// `count` mip levels of a colour image from `base`.
+const fn color_levels(base: u32, count: u32) -> vk::ImageSubresourceRange {
+    vk::ImageSubresourceRange {
+        aspect_mask: vk::ImageAspectFlags::COLOR,
+        base_mip_level: base,
+        level_count: count,
+        base_array_layer: 0,
+        layer_count: 1,
+    }
+}
+
+/// How many mip levels the blur snapshot carries: enough that the coarsest
+/// texel is as wide as the widest kernel stride a plate asks for (a radius of
+/// 16 px at scale 4 is a 64 px stride, level 6), and no more — the levels
+/// past that would be built every snapshot and never read. See
+/// `snapshot_mip_chain`.
+pub(crate) const SNAPSHOT_LEVELS_MAX: u32 = 7;
+
+/// The levels a snapshot of `extent` can have, at most [`SNAPSHOT_LEVELS_MAX`]:
+/// a level stops halving at one texel.
+pub(crate) fn snapshot_levels(extent: vk::Extent2D) -> u32 {
+    let side = extent.width.max(extent.height).max(1);
+    (32 - side.leading_zeros()).min(SNAPSHOT_LEVELS_MAX)
+}
 
 /// One-time submit: clear a color image and leave it in SHADER_READ_ONLY, so a
 /// freshly created backdrop is always legal to sample.
@@ -558,6 +584,18 @@ pub(crate) fn clear_image_to_shader_read(
     command_pool: vk::CommandPool,
     image: vk::Image,
 ) {
+    clear_image_levels_to_shader_read(device, queue, command_pool, image, 1);
+}
+
+/// [`clear_image_to_shader_read`] over the first `levels` mip levels.
+pub(crate) fn clear_image_levels_to_shader_read(
+    device: &ash::Device,
+    queue: vk::Queue,
+    command_pool: vk::CommandPool,
+    image: vk::Image,
+    levels: u32,
+) {
+    let range = color_levels(0, levels);
     unsafe {
         let cmd = device
             .allocate_command_buffers(
@@ -589,14 +627,14 @@ pub(crate) fn clear_image_to_shader_read(
                 .src_queue_family_index(vk::QUEUE_FAMILY_IGNORED)
                 .dst_queue_family_index(vk::QUEUE_FAMILY_IGNORED)
                 .image(image)
-                .subresource_range(COLOR_RANGE)],
+                .subresource_range(range)],
         );
         device.cmd_clear_color_image(
             cmd,
             image,
             vk::ImageLayout::TRANSFER_DST_OPTIMAL,
             &vk::ClearColorValue { float32: [0.0, 0.0, 0.0, 0.0] },
-            &[COLOR_RANGE],
+            &[range],
         );
         device.cmd_pipeline_barrier(
             cmd,
@@ -613,7 +651,7 @@ pub(crate) fn clear_image_to_shader_read(
                 .src_queue_family_index(vk::QUEUE_FAMILY_IGNORED)
                 .dst_queue_family_index(vk::QUEUE_FAMILY_IGNORED)
                 .image(image)
-                .subresource_range(COLOR_RANGE)],
+                .subresource_range(range)],
         );
         device.end_command_buffer(cmd).unwrap();
         let cmds = [cmd];
@@ -812,13 +850,17 @@ impl VkRenderer {
         );
         clear_image_to_shader_read(&device, queue, command_pool, scene.backdrop_image);
 
-        // Matches the wgpu backdrop sampler: linear, clamp-to-edge.
+        // Linear, clamp-to-edge, and linear BETWEEN mip levels: the blur
+        // snapshot carries a mip chain and the frost kernel reads it at a
+        // fractional level (shader2d's `resolve_blur`). The scene backdrop has
+        // one level, which every level clamps to.
         let backdrop_sampler = device
             .create_sampler(
                 &vk::SamplerCreateInfo::default()
                     .mag_filter(vk::Filter::LINEAR)
                     .min_filter(vk::Filter::LINEAR)
-                    .mipmap_mode(vk::SamplerMipmapMode::NEAREST)
+                    .mipmap_mode(vk::SamplerMipmapMode::LINEAR)
+                    .max_lod(vk::LOD_CLAMP_NONE)
                     .address_mode_u(vk::SamplerAddressMode::CLAMP_TO_EDGE)
                     .address_mode_v(vk::SamplerAddressMode::CLAMP_TO_EDGE)
                     .address_mode_w(vk::SamplerAddressMode::CLAMP_TO_EDGE),
@@ -982,6 +1024,17 @@ impl VkRenderer {
                 .optimal_tiling_features
                 .contains(needed)
             });
+        let blur_mips = {
+            let needed = vk::FormatFeatureFlags::BLIT_SRC
+                | vk::FormatFeatureFlags::BLIT_DST
+                | vk::FormatFeatureFlags::SAMPLED_IMAGE_FILTER_LINEAR;
+            unsafe {
+                core.instance
+                    .get_physical_device_format_properties(core.physical_device, surface_format.format)
+            }
+            .optimal_tiling_features
+            .contains(needed)
+        };
         let image = ImageStage::new(
             &device,
             allocator,
@@ -1017,6 +1070,8 @@ impl VkRenderer {
             snapshot_view: vk::ImageView::null(),
             snapshot_allocation: None,
             snapshot_wanted: false,
+            snapshot_levels: 1,
+            blur_mips,
             minimal_swapchain: false,
             backdrop_sampler,
             window_info,
@@ -1340,7 +1395,8 @@ impl VkRenderer {
                         .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.
+                    // Covers the previous frame's blur reads of the snapshot,
+                    // every level of it: the whole chain is rewritten.
                     vk::ImageMemoryBarrier::default()
                         .src_access_mask(vk::AccessFlags::SHADER_READ)
                         .dst_access_mask(vk::AccessFlags::TRANSFER_WRITE)
@@ -1349,7 +1405,7 @@ impl VkRenderer {
                         .src_queue_family_index(vk::QUEUE_FAMILY_IGNORED)
                         .dst_queue_family_index(vk::QUEUE_FAMILY_IGNORED)
                         .image(self.snapshot_image)
-                        .subresource_range(COLOR_RANGE),
+                        .subresource_range(color_levels(0, self.snapshot_levels)),
                 ],
             );
             let subresource = vk::ImageSubresourceLayers::default()
@@ -1370,6 +1426,7 @@ impl VkRenderer {
                         depth: 1,
                     })],
             );
+            self.snapshot_mip_chain(cmd);
             device.cmd_pipeline_barrier(
                 cmd,
                 vk::PipelineStageFlags::TRANSFER,
@@ -1378,15 +1435,6 @@ impl VkRenderer {
                 &[],
                 &[],
                 &[
-                    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)
@@ -1413,6 +1461,112 @@ impl VkRenderer {
         }
     }
 
+    /// Build the snapshot's mip chain from the level 0 just copied in, and
+    /// leave every level SHADER_READ_ONLY. Expects every level in
+    /// TRANSFER_DST, as `snapshot_frame_so_far` leaves them.
+    ///
+    /// The chain is what the frost kernel samples (shader2d's `resolve_blur`):
+    /// its 7x7 taps stand a whole STRIDE apart — 5.5 physical px for the
+    /// panel's default kernel — and a tap at level 0 reads only the texel or
+    /// two it lands between. So anything behind a plate thinner than the
+    /// stride (a hairline, a well's edge, a glyph) was not blurred but picked
+    /// up whole by the taps that hit it and missed by the rest: seven faint
+    /// copies a stride apart, which over a UI's rows read as horizontal
+    /// bands. Read at the level whose texel is as wide as the stride, each tap
+    /// is already the average of the cell around it, and the copies merge
+    /// into one smooth smear. Each level is a linear-filtered blit of the
+    /// one above it, a 2x2 box.
+    fn snapshot_mip_chain(&self, cmd: vk::CommandBuffer) {
+        let device = &self.core.device;
+        let barrier = |level: u32, src: vk::AccessFlags, dst: vk::AccessFlags, old: vk::ImageLayout, new: vk::ImageLayout| {
+            vk::ImageMemoryBarrier::default()
+                .src_access_mask(src)
+                .dst_access_mask(dst)
+                .old_layout(old)
+                .new_layout(new)
+                .src_queue_family_index(vk::QUEUE_FAMILY_IGNORED)
+                .dst_queue_family_index(vk::QUEUE_FAMILY_IGNORED)
+                .image(self.snapshot_image)
+                .subresource_range(color_levels(level, 1))
+        };
+        let size = |level: u32| {
+            [
+                (self.extent.width >> level).max(1) as i32,
+                (self.extent.height >> level).max(1) as i32,
+            ]
+        };
+        unsafe {
+            for level in 1..self.snapshot_levels {
+                // The level above is written; read it for the blit.
+                device.cmd_pipeline_barrier(
+                    cmd,
+                    vk::PipelineStageFlags::TRANSFER,
+                    vk::PipelineStageFlags::TRANSFER,
+                    vk::DependencyFlags::empty(),
+                    &[],
+                    &[],
+                    &[barrier(
+                        level - 1,
+                        vk::AccessFlags::TRANSFER_WRITE,
+                        vk::AccessFlags::TRANSFER_READ,
+                        vk::ImageLayout::TRANSFER_DST_OPTIMAL,
+                        vk::ImageLayout::TRANSFER_SRC_OPTIMAL,
+                    )],
+                );
+                let ([sw, sh], [dw, dh]) = (size(level - 1), size(level));
+                let layers = |mip: u32| {
+                    vk::ImageSubresourceLayers::default()
+                        .aspect_mask(vk::ImageAspectFlags::COLOR)
+                        .mip_level(mip)
+                        .layer_count(1)
+                };
+                device.cmd_blit_image(
+                    cmd,
+                    self.snapshot_image,
+                    vk::ImageLayout::TRANSFER_SRC_OPTIMAL,
+                    self.snapshot_image,
+                    vk::ImageLayout::TRANSFER_DST_OPTIMAL,
+                    &[vk::ImageBlit::default()
+                        .src_subresource(layers(level - 1))
+                        .src_offsets([vk::Offset3D::default(), vk::Offset3D { x: sw, y: sh, z: 1 }])
+                        .dst_subresource(layers(level))
+                        .dst_offsets([vk::Offset3D::default(), vk::Offset3D { x: dw, y: dh, z: 1 }])],
+                    vk::Filter::LINEAR,
+                );
+            }
+            // Every level sampleable: the ones blitted FROM are in
+            // TRANSFER_SRC, the last (or the only) one still in TRANSFER_DST.
+            let last = self.snapshot_levels - 1;
+            let mut to_read: Vec<vk::ImageMemoryBarrier> = (0..last)
+                .map(|l| {
+                    barrier(
+                        l,
+                        vk::AccessFlags::TRANSFER_READ,
+                        vk::AccessFlags::SHADER_READ,
+                        vk::ImageLayout::TRANSFER_SRC_OPTIMAL,
+                        vk::ImageLayout::SHADER_READ_ONLY_OPTIMAL,
+                    )
+                })
+                .collect();
+            to_read.push(barrier(
+                last,
+                vk::AccessFlags::TRANSFER_WRITE,
+                vk::AccessFlags::SHADER_READ,
+                vk::ImageLayout::TRANSFER_DST_OPTIMAL,
+                vk::ImageLayout::SHADER_READ_ONLY_OPTIMAL,
+            ));
+            device.cmd_pipeline_barrier(
+                cmd,
+                vk::PipelineStageFlags::TRANSFER,
+                vk::PipelineStageFlags::FRAGMENT_SHADER,
+                vk::DependencyFlags::empty(),
+                &[],
+                &[],
+                &to_read,
+            );
+        }
+    }
+
     /// 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.
@@ -1467,6 +1621,8 @@ impl VkRenderer {
     /// patch. The device must not be using the snapshot set (idle, or the set
     /// never bound because the snapshot never existed).
     fn sync_snapshot_target(&mut self) {
+        let levels = if self.blur_mips { snapshot_levels(self.extent) } else { 1 };
+        self.snapshot_levels = levels;
         // Same format as the swapchain, so cmd_copy_image from it is legal.
         unsafe {
             let device = &self.core.device;
@@ -1490,12 +1646,14 @@ impl VkRenderer {
                             height: self.extent.height,
                             depth: 1,
                         })
-                        .mip_levels(1)
+                        .mip_levels(levels)
                         .array_layers(1)
                         .samples(vk::SampleCountFlags::TYPE_1)
                         .tiling(vk::ImageTiling::OPTIMAL)
                         .usage(
-                            vk::ImageUsageFlags::SAMPLED | vk::ImageUsageFlags::TRANSFER_DST,
+                            vk::ImageUsageFlags::SAMPLED
+                                | vk::ImageUsageFlags::TRANSFER_DST
+                                | vk::ImageUsageFlags::TRANSFER_SRC,
                         )
                         .initial_layout(vk::ImageLayout::UNDEFINED),
                     None,
@@ -1527,7 +1685,7 @@ impl VkRenderer {
                         .image(snapshot_image)
                         .view_type(vk::ImageViewType::TYPE_2D)
                         .format(self.surface_format.format)
-                        .subresource_range(COLOR_RANGE),
+                        .subresource_range(color_levels(0, levels)),
                     None,
                 )
                 .expect("Failed to create snapshot view");
@@ -1536,11 +1694,12 @@ impl VkRenderer {
             self.snapshot_allocation = Some(allocation);
         }
         // A fresh snapshot must be legal to sample before its first copy.
-        clear_image_to_shader_read(
+        clear_image_levels_to_shader_read(
             &self.core.device,
             self.core.queue,
             self.core.command_pool,
             self.snapshot_image,
+            levels,
         );
         let snapshot_infos = [vk::DescriptorImageInfo::default()
             .image_view(self.snapshot_view)
@@ -2672,6 +2831,21 @@ mod tests {
         assert!(!super::shader2d_spirv().is_empty());
     }
 
+    /// The blur snapshot halves down to a single texel or to the cap, so a
+    /// tiny surface is not asked for levels it cannot have, and a large one
+    /// does not build levels no stride reads.
+    #[test]
+    fn the_blur_snapshot_has_as_many_levels_as_the_widest_stride_reads() {
+        use super::{snapshot_levels, SNAPSHOT_LEVELS_MAX};
+        let e = |width, height| ash::vk::Extent2D { width, height };
+        assert_eq!(snapshot_levels(e(1, 1)), 1);
+        assert_eq!(snapshot_levels(e(2, 1)), 2);
+        assert_eq!(snapshot_levels(e(40, 7)), 6);
+        assert_eq!(snapshot_levels(e(2560, 1600)), SNAPSHOT_LEVELS_MAX);
+        // The coarsest level's texel covers a 64 px stride.
+        assert_eq!(1 << (SNAPSHOT_LEVELS_MAX - 1), 64);
+    }
+
     /// The WebGPU variants validate with no capabilities at all — WebGPU has
     /// no push constants — and the 2D one carries its block as the uniform
     /// the web renderer binds. naga does not see everything a browser's