Filter API + Blur (#40)

Co-authored-by: Newspicel <newspicel+claude@pm.me>
This commit is contained in:
Leonard Seibold
2026-06-14 10:18:25 -04:00
committed by GitHub
co-authored by Newspicel
parent cfec5ff014
commit 69d467e4d1
14 changed files with 3109 additions and 70 deletions
+133
View File
@@ -1348,3 +1348,136 @@ fn fs_surface(input: SurfaceVarying) -> @location(0) vec4<f32> {
return textureSampleLevel(t_surface, s_surface, input.texture_position, 0.0);
}
// --- blur --- //
//
// Backdrop and content filters share these passes:
// 1. `fs_blur_downsample` copies a source texture into the half-resolution blur texture
// (also reused to blit the offscreen scene into the swapchain).
// 2. `fs_blur` runs one axis of a separable gaussian; the host invokes it twice.
// 3. `fs_blur_composite` samples the blurred texture and composites it into a rounded
// rectangle, clipped and modulated by opacity.
//
// Notes (review #5, #7): the scene/blur textures use the swapchain's (typically non-sRGB)
// format, so the gaussian runs on gamma-encoded values rather than linear light — consistent
// with the rest of gpui's compositing and close to what browsers do; bright detail darkens
// slightly. A content-filter group's texture is transparent outside the painted subtree, so
// the blur bleeds toward transparent at the group's edges (a soft edge ring) before the
// rounded-rect clip — this matches CSS `filter: blur` edge behaviour.
struct BlurParams {
bounds: Bounds,
content_mask: Bounds,
corner_radii: vec4<f32>,
direction: vec2<f32>,
sigma: f32,
opacity: f32,
tap_count: f32,
// Spacing between taps, in pixels. >1 when the radius is so large the kernel would need more
// than `tap_count` taps to span ±3σ — the taps spread out instead of truncating the gaussian.
tap_step: f32,
// 1.0 = clip the composite to the rounded rect (backdrop); 0.0 = let the blurred result fade
// out on its own (content `filter` bleeds past the element box like CSS).
clip_rounded: f32,
// 1.0 = snapped 2:1 box downsample (anchor the half-res grid to a fixed 2px grid at the origin
// so a stationary element blurs identically at every window size); 0.0 = 1:1 copy (scene blit).
downsample: f32,
}
@group(1) @binding(0) var<uniform> blur_locals: BlurParams;
@group(1) @binding(1) var t_blur: texture_2d<f32>;
@group(1) @binding(2) var s_blur: sampler;
struct BlurVarying {
@builtin(position) position: vec4<f32>,
@location(0) uv: vec2<f32>,
@location(3) clip_distances: vec4<f32>,
}
@vertex
fn vs_blur_fullscreen(@builtin(vertex_index) vertex_id: u32) -> BlurVarying {
// A single triangle large enough to cover the whole framebuffer.
let uv = vec2<f32>(f32((vertex_id << 1u) & 2u), f32(vertex_id & 2u));
var out = BlurVarying();
out.uv = uv;
out.position = vec4<f32>(uv.x * 2.0 - 1.0, 1.0 - uv.y * 2.0, 0.0, 1.0);
out.clip_distances = vec4<f32>(1.0);
return out;
}
@fragment
fn fs_blur_downsample(input: BlurVarying) -> @location(0) vec4<f32> {
if (blur_locals.downsample > 0.5) {
// Snapped 2:1 box downsample. Half-res texel `px` samples source at full-res coordinate
// 2*px + 1 (the boundary between source texels 2*px and 2*px+1), so one bilinear tap
// averages exactly that pair. The grid is anchored to the origin and independent of the
// viewport size, so an element at fixed pixels blurs identically at every window size —
// otherwise the implicit `floor(W/2)` grid stretches and the halo wobbles by ~1px on resize.
let dst = floor(input.position.xy);
let src_uv = (dst * 2.0 + 1.0) / globals.viewport_size;
return textureSampleLevel(t_blur, s_blur, src_uv, 0.0);
}
// 1:1 copy at matching resolution (used to blit the offscreen scene into the swapchain).
return textureSampleLevel(t_blur, s_blur, input.uv, 0.0);
}
@fragment
fn fs_blur(input: BlurVarying) -> @location(0) vec4<f32> {
let sigma = blur_locals.sigma;
let taps = i32(blur_locals.tap_count);
let step = blur_locals.tap_step;
var color = vec4<f32>(0.0);
var weight_sum = 0.0;
for (var i = -taps; i <= taps; i = i + 1) {
let offset = f32(i) * step;
let weight = gaussian(offset, sigma);
let uv = input.uv + blur_locals.direction * offset;
color += textureSampleLevel(t_blur, s_blur, uv, 0.0) * weight;
weight_sum += weight;
}
return color / max(weight_sum, 1e-5);
}
@vertex
fn vs_blur_composite(@builtin(vertex_index) vertex_id: u32) -> BlurVarying {
let unit_vertex = vec2<f32>(f32(vertex_id & 1u), 0.5 * f32(vertex_id & 2u));
var out = BlurVarying();
out.position = to_device_position(unit_vertex, blur_locals.bounds);
out.uv = unit_vertex;
out.clip_distances = distance_from_clip_rect(unit_vertex, blur_locals.bounds, blur_locals.content_mask);
return out;
}
@fragment
fn fs_blur_composite(input: BlurVarying) -> @location(0) vec4<f32> {
if (any(input.clip_distances < vec4<f32>(0.0))) {
return vec4<f32>(0.0);
}
// Sample the half-res blur by screen position, using the SAME fixed 2:1 grid the snapped
// downsample wrote (anchored at the origin, independent of viewport parity). `2*floor(W/2)` is
// the source span the half-res texture covers; dividing by it maps screen pixel p to half-res
// texel p/2 at every window size, so the composite stays put rather than wobbling on resize.
let blur_span = 2.0 * floor(globals.viewport_size * 0.5);
let uv = input.position.xy / blur_span;
let blurred = textureSampleLevel(t_blur, s_blur, uv, 0.0);
let corner_radii = Corners(
blur_locals.corner_radii.x,
blur_locals.corner_radii.y,
blur_locals.corner_radii.z,
blur_locals.corner_radii.w,
);
// Backdrop blur clips to the rounded rect (the frosted panel has a defined shape). Content
// blur does not — it bleeds past the element box like CSS `filter: blur`, so the soft fade
// isn't sharply truncated at the edge; its shape comes from the blurred group's own alpha.
let distance = quad_sdf(input.position.xy, blur_locals.bounds, corner_radii);
let coverage = select(1.0, saturate(0.5 - distance), blur_locals.clip_rounded > 0.5);
// The blurred sample is premultiplied (blurring against the transparent, rgb=0 surround scales
// rgb with the fading alpha), so output premultiplied and let the pipeline blend premultiplied.
// A backdrop's scene is opaque (alpha ~= 1) so this replaces; a content-filter group is
// transparent outside its subtree, so the target shows through there instead of darkening.
let c = coverage * blur_locals.opacity;
return vec4<f32>(blurred.rgb * c, blurred.a * c);
}
+686 -7
View File
@@ -1,11 +1,24 @@
use crate::{CompositorGpuHint, WgpuAtlas, WgpuContext, WgpuDeviceRequirements};
use bytemuck::{Pod, Zeroable};
use gpui::{
AtlasTextureId, Background, Bounds, DevicePixels, GpuSpecs, MonochromeSprite, PaintSurface,
Path, Point, PolychromeSprite, PrimitiveBatch, Quad, ScaledPixels, Scene, Shadow, Size,
SubpixelSprite, Underline, get_gamma_correction_ratios,
AtlasTextureId, BackdropFilter, Background, Bounds, DevicePixels, FilterBoundary, GpuSpecs,
MonochromeSprite, PaintSurface, Path, Point, PolychromeSprite, PrimitiveBatch, Quad,
ScaledFilter, ScaledPixels, Scene, Shadow, Size, SubpixelSprite, Underline,
get_gamma_correction_ratios,
};
use log::warn;
/// The largest blur radius in a scene-space filter chain, in device pixels — used to size the
/// blur kernel and the dilated region the blur passes are scissored to.
///
/// The `match` is exhaustive on purpose: adding a [`ScaledFilter`] variant breaks it here,
/// forcing this backend to handle (or deliberately ignore) the new filter rather than silently
/// dropping it.
fn max_blur_radius(filters: &[ScaledFilter]) -> f32 {
filters.iter().fold(0.0, |acc, filter| match filter {
ScaledFilter::Blur(radius) => acc.max(radius.0),
})
}
#[cfg(not(target_family = "wasm"))]
use raw_window_handle::{HasDisplayHandle, HasWindowHandle};
use std::cell::RefCell;
@@ -22,7 +35,7 @@ struct GlobalParams {
}
#[repr(C)]
#[derive(Clone, Copy, Pod, Zeroable)]
#[derive(Clone, Copy, Default, Pod, Zeroable)]
struct PodBounds {
origin: [f32; 2],
size: [f32; 2],
@@ -44,6 +57,38 @@ struct SurfaceParams {
content_mask: PodBounds,
}
/// Uniform passed to the blur pipelines. The same struct drives the downsample, separable
/// gaussian, and composite passes; fields not relevant to a given pass are left zero.
#[repr(C)]
#[derive(Clone, Copy, Default, Pod, Zeroable)]
struct BlurParams {
/// Composite target rectangle, in device pixels (composite pass only).
bounds: PodBounds,
/// Clip rectangle, in device pixels (composite pass only).
content_mask: PodBounds,
/// Rounded-corner radii (tl, tr, br, bl), in device pixels (composite pass only).
corner_radii: [f32; 4],
/// Per-tap sampling step in UV space (gaussian passes only): (1/width, 0) or (0, 1/height).
direction: [f32; 2],
/// Gaussian sigma, in the (half-resolution) blur texture's pixels.
sigma: f32,
/// Element opacity, multiplied into the composited result.
opacity: f32,
/// Number of taps to each side of center (gaussian passes only).
tap_count: f32,
/// Spacing between taps in pixels; >1 lets `tap_count` taps span very large radii without
/// truncating the gaussian (see #6 in review).
tap_step: f32,
/// 1.0 to clip the composite to the rounded rect (backdrop — the panel has a defined shape),
/// 0.0 to let the blurred result fade out on its own (content `filter` — it bleeds past the
/// element bounds like CSS, so the fade isn't sharply truncated at the box edge).
clip_rounded: f32,
/// 1.0 = snapped 2:1 box downsample (anchor the half-res grid to a fixed 2px grid at the
/// origin, so a stationary element blurs identically at every window size); 0.0 = 1:1 copy
/// (the scene blit, which must not downsample). Downsample pass only.
downsample: f32,
}
#[repr(C)]
#[derive(Clone, Copy, Pod, Zeroable)]
struct GammaParams {
@@ -91,6 +136,14 @@ struct WgpuPipelines {
subpixel_sprites: Option<wgpu::RenderPipeline>,
poly_sprites: wgpu::RenderPipeline,
surfaces: wgpu::RenderPipeline,
/// Copies a source texture into the (smaller) target with one bilinear tap. Used both to
/// downsample the scene into the half-resolution blur texture and to blit the offscreen
/// scene into the swapchain at the end of the frame.
blur_downsample: wgpu::RenderPipeline,
/// One axis of a separable gaussian blur; direction is supplied per draw via [`BlurParams`].
blur: wgpu::RenderPipeline,
/// Composites a blurred texture into a rounded rectangle (with clip + opacity).
blur_composite: wgpu::RenderPipeline,
}
struct WgpuBindGroupLayouts {
@@ -98,6 +151,7 @@ struct WgpuBindGroupLayouts {
instances: wgpu::BindGroupLayout,
instances_with_texture: wgpu::BindGroupLayout,
surfaces: wgpu::BindGroupLayout,
blur: wgpu::BindGroupLayout,
}
/// Shared GPU context reference, used to coordinate device recovery across multiple windows.
@@ -113,6 +167,10 @@ struct WgpuResources {
atlas_sampler: wgpu::Sampler,
surface_sampler: wgpu::Sampler,
surface_uniform_buffer: wgpu::Buffer,
/// One reused uniform buffer holding [`BlurParams`] for every blur pass in a frame, each at a
/// distinct (alignment-strided) offset. Avoids allocating a buffer per pass; distinct offsets
/// mean `write_buffer`'s last-write-at-submit semantics don't clobber earlier passes.
blur_params_buffer: wgpu::Buffer,
globals_buffer: wgpu::Buffer,
globals_bind_group: wgpu::BindGroup,
path_globals_bind_group: wgpu::BindGroup,
@@ -121,6 +179,23 @@ struct WgpuResources {
path_intermediate_view: Option<wgpu::TextureView>,
path_msaa_texture: Option<wgpu::Texture>,
path_msaa_view: Option<wgpu::TextureView>,
/// Blur offscreen targets. Allocated lazily (only when a frame actually uses a blur filter)
/// so apps that never blur pay no extra VRAM. `None`/empty until first use.
///
/// Full-resolution offscreen color target the scene is rendered into so that blur passes
/// can sample already-painted content; blitted to the swapchain at the end of the frame.
scene_color_texture: Option<wgpu::Texture>,
scene_color_view: Option<wgpu::TextureView>,
/// Half-resolution ping/pong targets for the downsample + separable gaussian passes.
blur_ping_texture: Option<wgpu::Texture>,
blur_ping_view: Option<wgpu::TextureView>,
blur_pong_texture: Option<wgpu::Texture>,
blur_pong_view: Option<wgpu::TextureView>,
/// Full-resolution offscreen targets a content-filter (`filter`) group renders into before
/// being blurred and composited back. One per nesting level (indexed by depth) so nested
/// content blurs isolate correctly, up to [`MAX_FILTER_DEPTH`]; deeper nests render inline.
group_textures: Vec<wgpu::Texture>,
group_views: Vec<wgpu::TextureView>,
}
impl WgpuResources {
@@ -129,9 +204,26 @@ impl WgpuResources {
self.path_intermediate_view = None;
self.path_msaa_texture = None;
self.path_msaa_view = None;
self.scene_color_texture = None;
self.scene_color_view = None;
self.blur_ping_texture = None;
self.blur_ping_view = None;
self.blur_pong_texture = None;
self.blur_pong_view = None;
self.group_textures.clear();
self.group_views.clear();
}
}
/// Number of content-filter (`filter`) nesting levels that get their own isolated group texture.
/// Two covers the realistic "a blurred element inside another blurred element" case; deeper nests
/// render inline (unblurred at the inner level) rather than allocating unbounded VRAM.
const MAX_FILTER_DEPTH: usize = 2;
/// Number of [`BlurParams`] slots in the shared blur-params buffer (one per blur pass per frame).
/// Each frame uses 4 passes per backdrop/group plus one blit; 256 covers dozens of filters.
const BLUR_PARAMS_SLOTS: u64 = 256;
pub struct WgpuRenderer {
/// Shared GPU context for device recovery coordination (unused on WASM).
#[allow(dead_code)]
@@ -150,6 +242,10 @@ pub struct WgpuRenderer {
instance_buffer_capacity: u64,
max_buffer_size: u64,
storage_buffer_alignment: u64,
/// Stride between [`BlurParams`] slots in `blur_params_buffer`, and a per-frame bump cursor
/// (in slots) handed out to blur passes. Cell so the `&self` blur helpers can advance it.
blur_params_stride: u64,
blur_params_slot: std::cell::Cell<u64>,
rendering_params: RenderingParameters,
is_bgr: bool,
dual_source_blending: bool,
@@ -395,6 +491,16 @@ impl WgpuRenderer {
});
let uniform_alignment = device.limits().min_uniform_buffer_offset_alignment as u64;
// Shared blur-params buffer: BLUR_PARAMS_SLOTS slots, each one alignment stride apart.
let blur_params_stride =
(std::mem::size_of::<BlurParams>() as u64).next_multiple_of(uniform_alignment);
let blur_params_buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("blur_params_buffer"),
size: blur_params_stride * BLUR_PARAMS_SLOTS,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let globals_size = std::mem::size_of::<GlobalParams>() as u64;
let gamma_size = std::mem::size_of::<GammaParams>() as u64;
let path_globals_offset = globals_size.next_multiple_of(uniform_alignment);
@@ -481,6 +587,7 @@ impl WgpuRenderer {
atlas_sampler,
surface_sampler,
surface_uniform_buffer,
blur_params_buffer,
globals_buffer,
globals_bind_group,
path_globals_bind_group,
@@ -491,6 +598,14 @@ impl WgpuRenderer {
path_intermediate_view: None,
path_msaa_texture: None,
path_msaa_view: None,
scene_color_texture: None,
scene_color_view: None,
blur_ping_texture: None,
blur_ping_view: None,
blur_pong_texture: None,
blur_pong_view: None,
group_textures: Vec::new(),
group_views: Vec::new(),
};
Ok(Self {
@@ -505,6 +620,8 @@ impl WgpuRenderer {
instance_buffer_capacity: initial_instance_buffer_capacity,
max_buffer_size,
storage_buffer_alignment,
blur_params_stride,
blur_params_slot: std::cell::Cell::new(0),
rendering_params,
is_bgr: false,
dual_source_blending,
@@ -626,11 +743,44 @@ impl WgpuRenderer {
],
});
let blur = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("blur_layout"),
entries: &[
wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: NonZeroU64::new(std::mem::size_of::<BlurParams>() as u64),
},
count: None,
},
wgpu::BindGroupLayoutEntry {
binding: 1,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Texture {
sample_type: wgpu::TextureSampleType::Float { filterable: true },
view_dimension: wgpu::TextureViewDimension::D2,
multisampled: false,
},
count: None,
},
wgpu::BindGroupLayoutEntry {
binding: 2,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
count: None,
},
],
});
WgpuBindGroupLayouts {
globals,
instances,
instances_with_texture,
surfaces,
blur,
}
}
@@ -891,7 +1041,60 @@ impl WgpuRenderer {
&layouts.globals,
&layouts.surfaces,
wgpu::PrimitiveTopology::TriangleStrip,
&[Some(color_target)],
&[Some(color_target.clone())],
1,
&shader_module,
);
// Blur pipelines all sample one texture into another; the downsample and gaussian passes
// overwrite their (intermediate) target, while the composite blends over the scene.
let no_blend_target = wgpu::ColorTargetState {
format: surface_format,
blend: None,
write_mask: wgpu::ColorWrites::ALL,
};
let blur_downsample = create_pipeline(
"blur_downsample",
"vs_blur_fullscreen",
"fs_blur_downsample",
&layouts.globals,
&layouts.blur,
wgpu::PrimitiveTopology::TriangleList,
&[Some(no_blend_target.clone())],
1,
&shader_module,
);
let blur = create_pipeline(
"blur",
"vs_blur_fullscreen",
"fs_blur",
&layouts.globals,
&layouts.blur,
wgpu::PrimitiveTopology::TriangleList,
&[Some(no_blend_target)],
1,
&shader_module,
);
// The blurred sample is premultiplied (blurring against the transparent, rgb=0 region
// around the source scales rgb with the fading alpha), so the composite outputs
// premultiplied and blends premultiplied — straight alpha blending would multiply rgb by
// alpha a second time and darken the faded edges. Independent of the window's alpha mode.
let premultiplied_target = wgpu::ColorTargetState {
format: surface_format,
blend: Some(wgpu::BlendState::PREMULTIPLIED_ALPHA_BLENDING),
write_mask: wgpu::ColorWrites::ALL,
};
let blur_composite = create_pipeline(
"blur_composite",
"vs_blur_composite",
"fs_blur_composite",
&layouts.globals,
&layouts.blur,
wgpu::PrimitiveTopology::TriangleStrip,
&[Some(premultiplied_target)],
1,
&shader_module,
);
@@ -906,6 +1109,9 @@ impl WgpuRenderer {
subpixel_sprites,
poly_sprites,
surfaces,
blur_downsample,
blur,
blur_composite,
}
}
@@ -998,6 +1204,19 @@ impl WgpuRenderer {
if let Some(ref texture) = resources.path_msaa_texture {
texture.destroy();
}
for texture in [
&resources.scene_color_texture,
&resources.blur_ping_texture,
&resources.blur_pong_texture,
]
.into_iter()
.flatten()
{
texture.destroy();
}
for texture in &resources.group_textures {
texture.destroy();
}
resources
.surface
@@ -1038,6 +1257,40 @@ impl WgpuRenderer {
resources.path_msaa_view = path_msaa_view;
}
/// Lazily allocate the blur offscreen targets — the full-res scene texture, half-res
/// ping/pong, and one full-res group texture per nesting level. Called only on frames that
/// actually use a blur filter, so non-blurring apps never pay this VRAM. A no-op once
/// allocated (invalidated alongside the path intermediates on resize / device loss).
fn ensure_blur_textures(&mut self) {
if self.resources().scene_color_texture.is_some() {
return;
}
let format = self.surface_config.format;
let width = self.surface_config.width;
let height = self.surface_config.height;
let blur_width = (width / 2).max(1);
let blur_height = (height / 2).max(1);
let resources = self.resources_mut();
let (t, v) = Self::create_path_intermediate(&resources.device, format, width, height);
resources.scene_color_texture = Some(t);
resources.scene_color_view = Some(v);
let (t, v) =
Self::create_path_intermediate(&resources.device, format, blur_width, blur_height);
resources.blur_ping_texture = Some(t);
resources.blur_ping_view = Some(v);
let (t, v) =
Self::create_path_intermediate(&resources.device, format, blur_width, blur_height);
resources.blur_pong_texture = Some(t);
resources.blur_pong_view = Some(v);
for _ in 0..MAX_FILTER_DEPTH {
let (t, v) = Self::create_path_intermediate(&resources.device, format, width, height);
resources.group_textures.push(t);
resources.group_views.push(v);
}
}
pub fn set_subpixel_layout(&mut self, is_bgr: bool) {
self.is_bgr = is_bgr;
}
@@ -1171,6 +1424,14 @@ impl WgpuRenderer {
// Now that we know the surface is healthy, ensure intermediate textures exist
self.ensure_intermediate_textures();
// Blur is the only thing that needs the offscreen scene texture; allocate it (and the
// ping/pong/group targets) lazily so non-blurring apps pay no extra VRAM or blit.
let use_offscreen =
!scene.backdrop_filters.is_empty() || !scene.filter_boundaries.is_empty();
if use_offscreen {
self.ensure_blur_textures();
}
let frame_view = frame
.texture
.create_view(&wgpu::TextureViewDescriptor::default());
@@ -1224,6 +1485,8 @@ impl WgpuRenderer {
loop {
let mut instance_offset: u64 = 0;
// Reset the blur-params bump cursor each (re)render of the scene.
self.blur_params_slot.set(0);
let mut overflow = false;
let mut encoder =
@@ -1233,11 +1496,41 @@ impl WgpuRenderer {
label: Some("main_encoder"),
});
// When the scene contains blur filters, render into the offscreen scene texture (so
// filters can sample already-painted content mid-frame) and blit to the swapchain at
// the end; otherwise render straight to the swapchain. `use_offscreen` and the blur
// textures were computed/allocated above.
let scene_color_view = if use_offscreen {
Some(
self.resources()
.scene_color_view
.as_ref()
.expect("scene_color_view allocated by ensure_blur_textures")
.clone(),
)
} else {
None
};
// The active render target. While inside a content-filter (`filter`) group it points
// at a group texture so the group renders in isolation.
let mut current_target = match &scene_color_view {
Some(view) => view.clone(),
None => frame_view.clone(),
};
// One group texture per nesting depth; empty when not blurring.
let group_views = if use_offscreen {
self.resources().group_views.clone()
} else {
Vec::new()
};
// (boundary, parent target to composite back into, whether this level is isolated).
let mut filter_stack: Vec<(FilterBoundary, wgpu::TextureView, bool)> = Vec::new();
{
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("main_pass"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &frame_view,
view: &current_target,
resolve_target: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT),
@@ -1276,7 +1569,7 @@ impl WgpuRenderer {
pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("main_pass_continued"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &frame_view,
view: &current_target,
resolve_target: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Load,
@@ -1327,6 +1620,103 @@ impl WgpuRenderer {
PrimitiveBatch::Surfaces(range) => {
self.draw_surfaces(&scene.surfaces[range], &mut pass)
}
PrimitiveBatch::BackdropFilters(range) => {
// Interrupt the current pass, blur the content painted so far behind
// each backdrop's rounded rect, then resume drawing on top.
drop(pass);
for filter in &scene.backdrop_filters[range] {
self.draw_backdrop_filter(&mut encoder, filter, &current_target);
}
pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("main_pass_continued"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &current_target,
resolve_target: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Load,
store: wgpu::StoreOp::Store,
},
depth_slice: None,
})],
depth_stencil_attachment: None,
..Default::default()
});
true
}
PrimitiveBatch::FilterBoundary(ix) => {
let boundary = scene.filter_boundaries[ix].clone();
if boundary.is_start {
// Each isolated nesting level uses its own group texture from the
// pool (indexed by current isolation depth). Beyond the pool size
// (MAX_FILTER_DEPTH) deeper filters render inline without isolation
// rather than corrupting an outer group.
let depth = filter_stack.iter().filter(|entry| entry.2).count();
if depth < group_views.len() {
drop(pass);
let parent = current_target.clone();
current_target = group_views[depth].clone();
filter_stack.push((boundary, parent, true));
pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("filter_group"),
color_attachments: &[Some(
wgpu::RenderPassColorAttachment {
view: &current_target,
resolve_target: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(
wgpu::Color::TRANSPARENT,
),
store: wgpu::StoreOp::Store,
},
depth_slice: None,
},
)],
depth_stencil_attachment: None,
..Default::default()
});
} else {
filter_stack.push((boundary, current_target.clone(), false));
}
} else if let Some((boundary, parent, isolated)) = filter_stack.pop() {
if isolated {
drop(pass);
self.blur_and_composite(
&mut encoder,
&current_target,
&parent,
boundary.bounds,
boundary.content_mask.bounds,
[
boundary.corner_radii.top_left.0,
boundary.corner_radii.top_right.0,
boundary.corner_radii.bottom_right.0,
boundary.corner_radii.bottom_left.0,
],
max_blur_radius(&boundary.filters),
boundary.opacity,
false,
);
current_target = parent;
pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("main_pass_continued"),
color_attachments: &[Some(
wgpu::RenderPassColorAttachment {
view: &current_target,
resolve_target: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Load,
store: wgpu::StoreOp::Store,
},
depth_slice: None,
},
)],
depth_stencil_attachment: None,
..Default::default()
});
}
}
true
}
};
if !ok {
overflow = true;
@@ -1349,6 +1739,12 @@ impl WgpuRenderer {
continue;
}
// Present the offscreen scene by copying it into the swapchain texture. Skipped when
// rendering went straight to the swapchain (no filters this frame).
if let Some(scene_color_view) = &scene_color_view {
self.blit_to_frame(&mut encoder, scene_color_view, &frame_view);
}
self.resources()
.queue
.submit(std::iter::once(encoder.finish()));
@@ -1498,6 +1894,289 @@ impl WgpuRenderer {
true
}
/// Build a bind group for a blur pass. Writes `params` into the next slot of the shared
/// `blur_params_buffer` (no per-pass allocation) and references that slot, the source texture,
/// and the filtering sampler. Distinct per-pass offsets keep `write_buffer`'s
/// last-write-at-submit semantics from clobbering earlier passes within a frame.
fn make_blur_bind_group(
&self,
params: BlurParams,
source: &wgpu::TextureView,
) -> wgpu::BindGroup {
let resources = self.resources();
let slot = self.blur_params_slot.get() % BLUR_PARAMS_SLOTS;
self.blur_params_slot.set(slot + 1);
let offset = slot * self.blur_params_stride;
resources.queue.write_buffer(
&resources.blur_params_buffer,
offset,
bytemuck::bytes_of(&params),
);
resources
.device
.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("blur_bind_group"),
layout: &resources.bind_group_layouts.blur,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding {
buffer: &resources.blur_params_buffer,
offset,
size: NonZeroU64::new(std::mem::size_of::<BlurParams>() as u64),
}),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::TextureView(source),
},
wgpu::BindGroupEntry {
binding: 2,
resource: wgpu::BindingResource::Sampler(&resources.surface_sampler),
},
],
})
}
/// Run a full-screen (3-vertex) blur pass that overwrites `target` by sampling `source`.
/// `scissor` (x, y, w, h, in `target` pixels) limits fragment work to the region that
/// actually feeds the composite — the element bounds dilated by the kernel radius.
fn run_blur_pass(
&self,
encoder: &mut wgpu::CommandEncoder,
label: &str,
pipeline: &wgpu::RenderPipeline,
target: &wgpu::TextureView,
source: &wgpu::TextureView,
params: BlurParams,
scissor: [u32; 4],
) {
let bind_group = self.make_blur_bind_group(params, source);
let resources = self.resources();
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some(label),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: target,
resolve_target: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT),
store: wgpu::StoreOp::Store,
},
depth_slice: None,
})],
depth_stencil_attachment: None,
..Default::default()
});
pass.set_pipeline(pipeline);
pass.set_bind_group(0, &resources.globals_bind_group, &[]);
pass.set_bind_group(1, &bind_group, &[]);
pass.set_scissor_rect(scissor[0], scissor[1], scissor[2], scissor[3]);
pass.draw(0..3, 0..1);
}
/// Blur `source` (full-resolution) and composite the result into `target`, clipped to
/// `bounds`/`corner_radii`/`content_mask` and modulated by `opacity`. Shared by the backdrop
/// and content-filter paths. Uses the half-resolution ping/pong textures as scratch.
#[allow(clippy::too_many_arguments)]
fn blur_and_composite(
&self,
encoder: &mut wgpu::CommandEncoder,
source: &wgpu::TextureView,
target: &wgpu::TextureView,
bounds: Bounds<ScaledPixels>,
content_mask: Bounds<ScaledPixels>,
corner_radii: [f32; 4],
blur_radius: f32,
opacity: f32,
// Backdrop clips to the rounded rect; content (`filter`) bleeds past its bounds.
clip_rounded: bool,
) {
// Sigma is halved because the blur runs at half resolution.
let sigma = (blur_radius * 0.5).max(0.0);
if sigma <= 0.0 {
return;
}
// Span ±3σ. If that needs more than 32 taps, spread the taps apart (tap_step > 1) rather
// than truncating the kernel — keeps very large radii from clipping (review #6).
let ideal_taps = (3.0 * sigma).ceil();
let tap_count = ideal_taps.clamp(1.0, 32.0);
let tap_step = (ideal_taps / tap_count).max(1.0);
let full_w = self.surface_config.width;
let full_h = self.surface_config.height;
let blur_width = (full_w / 2).max(1) as f32;
let blur_height = (full_h / 2).max(1) as f32;
// Limit the half-res passes to the element bounds dilated by the kernel radius (3·sigma,
// full-res) — outside that the composite never samples, so there's no reason to blur it.
let dilation = 3.0 * blur_radius;
let hw = (full_w / 2).max(1);
let hh = (full_h / 2).max(1);
let x0 = (((bounds.origin.x.0 - dilation) * 0.5).floor().max(0.0) as u32).min(hw);
let y0 = (((bounds.origin.y.0 - dilation) * 0.5).floor().max(0.0) as u32).min(hh);
let x1 = ((((bounds.origin.x.0 + bounds.size.width.0 + dilation) * 0.5)
.ceil()
.max(0.0) as u32)
.min(hw))
.max(x0);
let y1 = ((((bounds.origin.y.0 + bounds.size.height.0 + dilation) * 0.5)
.ceil()
.max(0.0) as u32)
.min(hh))
.max(y0);
let scissor = [x0, y0, x1 - x0, y1 - y0];
if scissor[2] == 0 || scissor[3] == 0 {
return;
}
// Owned handles so the passes below don't borrow `self`.
let (ping, pong) = {
let resources = self.resources();
match (
resources.blur_ping_view.as_ref(),
resources.blur_pong_view.as_ref(),
) {
(Some(ping), Some(pong)) => (ping.clone(), pong.clone()),
_ => return,
}
};
// Downsample source -> ping, then separable gaussian ping -> pong -> ping.
self.run_blur_pass(
encoder,
"blur_downsample",
&self.resources().pipelines.blur_downsample,
&ping,
source,
BlurParams {
downsample: 1.0,
..Default::default()
},
scissor,
);
self.run_blur_pass(
encoder,
"blur_horizontal",
&self.resources().pipelines.blur,
&pong,
&ping,
BlurParams {
direction: [1.0 / blur_width, 0.0],
sigma,
tap_count,
tap_step,
..Default::default()
},
scissor,
);
self.run_blur_pass(
encoder,
"blur_vertical",
&self.resources().pipelines.blur,
&ping,
&pong,
BlurParams {
direction: [0.0, 1.0 / blur_height],
sigma,
tap_count,
tap_step,
..Default::default()
},
scissor,
);
// Composite the blurred result into the target (loads existing content). For content blur
// the quad covers the dilated region so the blur can fade out past the element box (no
// sharp clip); for backdrop the quad is the element bounds and the shader clips to the
// rounded rect.
let composite_bounds = if clip_rounded {
bounds
} else {
bounds.dilate(ScaledPixels(dilation))
};
let params = BlurParams {
bounds: composite_bounds.into(),
content_mask: content_mask.into(),
corner_radii,
opacity,
clip_rounded: if clip_rounded { 1.0 } else { 0.0 },
..Default::default()
};
let bind_group = self.make_blur_bind_group(params, &ping);
let resources = self.resources();
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("blur_composite"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: target,
resolve_target: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Load,
store: wgpu::StoreOp::Store,
},
depth_slice: None,
})],
depth_stencil_attachment: None,
..Default::default()
});
pass.set_pipeline(&resources.pipelines.blur_composite);
pass.set_bind_group(0, &resources.globals_bind_group, &[]);
pass.set_bind_group(1, &bind_group, &[]);
pass.draw(0..4, 0..1);
}
/// Blur the scene painted so far behind `filter.bounds` and composite it back as frosted glass.
fn draw_backdrop_filter(
&self,
encoder: &mut wgpu::CommandEncoder,
filter: &BackdropFilter,
scene_color_view: &wgpu::TextureView,
) {
self.blur_and_composite(
encoder,
scene_color_view,
scene_color_view,
filter.bounds,
filter.content_mask.bounds,
[
filter.corner_radii.top_left.0,
filter.corner_radii.top_right.0,
filter.corner_radii.bottom_right.0,
filter.corner_radii.bottom_left.0,
],
max_blur_radius(&filter.filters),
filter.opacity,
true,
);
}
/// Copy the offscreen scene texture into the swapchain texture.
fn blit_to_frame(
&self,
encoder: &mut wgpu::CommandEncoder,
source: &wgpu::TextureView,
frame_view: &wgpu::TextureView,
) {
let bind_group = self.make_blur_bind_group(BlurParams::default(), source);
let resources = self.resources();
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("scene_blit"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: frame_view,
resolve_target: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT),
store: wgpu::StoreOp::Store,
},
depth_slice: None,
})],
depth_stencil_attachment: None,
..Default::default()
});
pass.set_pipeline(&resources.pipelines.blur_downsample);
pass.set_bind_group(0, &resources.globals_bind_group, &[]);
pass.set_bind_group(1, &bind_group, &[]);
pass.draw(0..3, 0..1);
}
fn draw_polychrome_sprites(
&self,
sprites: &[PolychromeSprite],