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
+466 -5
View File
@@ -7,10 +7,22 @@ use cocoa::{
quartzcore::AutoresizingMask,
};
use gpui::{
AtlasTextureId, Background, Bounds, ContentMask, DevicePixels, MonochromeSprite, PaintSurface,
Path, Point, PolychromeSprite, PrimitiveBatch, Quad, ScaledPixels, Scene, Shadow, Size,
Surface, Underline, point, size,
AtlasTextureId, Background, Bounds, ContentMask, Corners, DevicePixels, FilterBoundary,
MonochromeSprite, PaintSurface, Path, Point, PolychromeSprite, PrimitiveBatch, Quad,
ScaledFilter, ScaledPixels, Scene, Shadow, Size, Surface, Underline, point, size,
};
/// 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(any(test, feature = "test-support"))]
use image::RgbaImage;
@@ -40,6 +52,12 @@ const SHADERS_SOURCE_FILE: &str = include_str!(concat!(env!("OUT_DIR"), "/stitch
// https://developer.apple.com/documentation/metal/mtldevice/1433355-supportstexturesamplecount
const PATH_SAMPLE_COUNT: u32 = 4;
/// 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. Must match
/// the wgpu backend's `MAX_FILTER_DEPTH` so nested blur renders consistently across platforms.
const MAX_FILTER_DEPTH: usize = 2;
pub(crate) type Context = Arc<Mutex<InstanceBufferPool>>;
pub(crate) type Renderer = MetalRenderer;
@@ -125,6 +143,11 @@ pub(crate) struct MetalRenderer {
monochrome_sprites_pipeline_state: metal::RenderPipelineState,
polychrome_sprites_pipeline_state: metal::RenderPipelineState,
surfaces_pipeline_state: metal::RenderPipelineState,
// Blur pipelines: downsample (no blend, also used for the final blit), separable gaussian
// (no blend), and composite (alpha blend into a rounded rect). See `shaders.metal`.
blur_downsample_pipeline_state: metal::RenderPipelineState,
blur_pipeline_state: metal::RenderPipelineState,
blur_composite_pipeline_state: metal::RenderPipelineState,
unit_vertices: metal::Buffer,
#[allow(clippy::arc_with_non_send_sync)]
instance_buffer_pool: Arc<Mutex<InstanceBufferPool>>,
@@ -132,9 +155,68 @@ pub(crate) struct MetalRenderer {
core_video_texture_cache: core_video::metal_texture_cache::CVMetalTextureCache,
path_intermediate_texture: Option<metal::Texture>,
path_intermediate_msaa_texture: Option<metal::Texture>,
// Offscreen scene target (the scene is rendered here, then blitted to the drawable, so blur
// passes can sample already-painted content), the half-res ping/pong blur targets, and a
// full-res target for content-filter groups.
scene_color_texture: Option<metal::Texture>,
blur_ping_texture: Option<metal::Texture>,
blur_pong_texture: Option<metal::Texture>,
/// Full-resolution offscreen targets a content-filter (`filter`) group renders into before
/// being blurred and composited back. One per nesting level (indexed by isolation depth) so
/// nested content blurs isolate correctly, up to [`MAX_FILTER_DEPTH`]; deeper nests render
/// inline.
group_textures: Vec<metal::Texture>,
path_sample_count: u32,
}
/// Mirrors the `BlurParams` struct in `shaders.metal`. Passed to the blur pipelines via
/// `setVertexBytes`/`setFragmentBytes`.
#[repr(C)]
#[derive(Clone, Copy)]
struct BlurUniform {
bounds: Bounds<ScaledPixels>,
content_mask: Bounds<ScaledPixels>,
corner_radii: Corners<ScaledPixels>,
direction: [f32; 2],
sigma: f32,
opacity: f32,
tap_count: f32,
/// 1.0 clips the composite to the rounded rect (backdrop); 0.0 lets content blur bleed past
/// its bounds like CSS `filter: blur`.
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,
/// Spacing between taps in pixels (gaussian passes only); >1 lets `tap_count` taps span very
/// large radii without truncating the gaussian, matching the wgpu backend.
tap_step: f32,
}
impl Default for BlurUniform {
fn default() -> Self {
BlurUniform {
bounds: Bounds::default(),
content_mask: Bounds::default(),
corner_radii: Corners::default(),
direction: [0.0, 0.0],
sigma: 0.0,
opacity: 1.0,
tap_count: 0.0,
clip_rounded: 0.0,
downsample: 0.0,
tap_step: 0.0,
}
}
}
#[repr(C)]
enum BlurInputIndex {
Vertices = 0,
Params = 1,
ViewportSize = 2,
}
#[repr(C)]
pub struct PathRasterizationVertex {
pub xy_position: Point<ScaledPixels>,
@@ -318,6 +400,32 @@ impl MetalRenderer {
"surface_fragment",
MTLPixelFormat::BGRA8Unorm,
);
let blur_downsample_pipeline_state = build_blur_pipeline_state(
&device,
&library,
"blur_downsample",
"blur_fullscreen_vertex",
"blur_downsample_fragment",
MTLPixelFormat::BGRA8Unorm,
);
let blur_pipeline_state = build_blur_pipeline_state(
&device,
&library,
"blur",
"blur_fullscreen_vertex",
"blur_fragment",
MTLPixelFormat::BGRA8Unorm,
);
// Premultiplied blend (One / OneMinusSourceAlpha) — the composite outputs a premultiplied
// blurred sample; straight-alpha blending would darken the faded edges.
let blur_composite_pipeline_state = build_path_sprite_pipeline_state(
&device,
&library,
"blur_composite",
"blur_composite_vertex",
"blur_composite_fragment",
MTLPixelFormat::BGRA8Unorm,
);
let command_queue = device.new_command_queue();
let sprite_atlas = Arc::new(MetalAtlas::new(device.clone(), is_apple_gpu));
@@ -340,12 +448,19 @@ impl MetalRenderer {
monochrome_sprites_pipeline_state,
polychrome_sprites_pipeline_state,
surfaces_pipeline_state,
blur_downsample_pipeline_state,
blur_pipeline_state,
blur_composite_pipeline_state,
unit_vertices,
instance_buffer_pool,
sprite_atlas,
core_video_texture_cache,
path_intermediate_texture: None,
path_intermediate_msaa_texture: None,
scene_color_texture: None,
blur_ping_texture: None,
blur_pong_texture: None,
group_textures: Vec::new(),
path_sample_count: PATH_SAMPLE_COUNT,
}
}
@@ -395,6 +510,10 @@ impl MetalRenderer {
if size.width.0 <= 0 || size.height.0 <= 0 {
self.path_intermediate_texture = None;
self.path_intermediate_msaa_texture = None;
self.scene_color_texture = None;
self.blur_ping_texture = None;
self.blur_pong_texture = None;
self.group_textures.clear();
return;
}
@@ -407,6 +526,27 @@ impl MetalRenderer {
.set_usage(metal::MTLTextureUsage::RenderTarget | metal::MTLTextureUsage::ShaderRead);
self.path_intermediate_texture = Some(self.device.new_texture(&texture_descriptor));
// Full-res scene + group targets, and half-res ping/pong blur targets.
let make_color_texture = |width: u64, height: u64| {
let descriptor = metal::TextureDescriptor::new();
descriptor.set_width(width.max(1));
descriptor.set_height(height.max(1));
descriptor.set_pixel_format(metal::MTLPixelFormat::BGRA8Unorm);
descriptor.set_storage_mode(metal::MTLStorageMode::Private);
descriptor.set_usage(
metal::MTLTextureUsage::RenderTarget | metal::MTLTextureUsage::ShaderRead,
);
self.device.new_texture(&descriptor)
};
let full_w = size.width.0 as u64;
let full_h = size.height.0 as u64;
self.scene_color_texture = Some(make_color_texture(full_w, full_h));
self.group_textures = (0..MAX_FILTER_DEPTH)
.map(|_| make_color_texture(full_w, full_h))
.collect();
self.blur_ping_texture = Some(make_color_texture(full_w / 2, full_h / 2));
self.blur_pong_texture = Some(make_color_texture(full_w / 2, full_h / 2));
if self.path_sample_count > 1 {
// https://developer.apple.com/documentation/metal/choosing-a-resource-storage-mode-for-apple-gpus
// Rendering MSAA textures are done in a single pass, so we can use memory-less storage on Apple Silicon
@@ -751,9 +891,31 @@ impl MetalRenderer {
let alpha = if self.opaque { 1. } else { 0. };
let mut instance_offset = 0;
// Render the scene into an offscreen color texture (so filters can sample it), then
// blit it to `texture`. Owned clones keep the textures borrowable without borrowing
// `self` across the batch loop (which calls `&mut self` methods like `draw_surfaces`).
// Only route through the offscreen scene texture when the scene actually contains blur
// filters; otherwise render straight to `texture` exactly as before (no regression, no
// extra blit for the common case).
let use_offscreen =
!scene.backdrop_filters.is_empty() || !scene.filter_boundaries.is_empty();
let scene_color_owned = self.scene_color_texture.clone();
let blur_ping_owned = self.blur_ping_texture.clone();
let blur_pong_owned = self.blur_pong_texture.clone();
let group_owned = self.group_textures.clone();
let scene_color: &metal::TextureRef = if use_offscreen {
scene_color_owned.as_deref().unwrap_or(texture)
} else {
texture
};
// The active render target; switches to the group texture inside a content-filter group.
let mut current_target: &metal::TextureRef = scene_color;
// (boundary, parent target to composite back into, whether this level is isolated).
let mut filter_stack: Vec<(FilterBoundary, &metal::TextureRef, bool)> = Vec::new();
let mut command_encoder = new_command_encoder_for_texture(
command_buffer,
texture,
current_target,
viewport_size,
|color_attachment| {
color_attachment.set_load_action(metal::MTLLoadAction::Clear);
@@ -791,7 +953,7 @@ impl MetalRenderer {
command_encoder = new_command_encoder_for_texture(
command_buffer,
texture,
current_target,
viewport_size,
|color_attachment| {
color_attachment.set_load_action(metal::MTLLoadAction::Load);
@@ -842,6 +1004,98 @@ impl MetalRenderer {
viewport_size,
command_encoder,
),
PrimitiveBatch::BackdropFilters(range) => {
command_encoder.end_encoding();
if let (Some(ping), Some(pong)) =
(blur_ping_owned.as_deref(), blur_pong_owned.as_deref())
{
for filter in &scene.backdrop_filters[range] {
self.metal_blur_and_composite(
command_buffer,
current_target,
current_target,
ping,
pong,
viewport_size,
filter.bounds,
filter.content_mask.bounds,
filter.corner_radii,
max_blur_radius(&filter.filters),
filter.opacity,
true,
);
}
}
command_encoder = new_command_encoder_for_texture(
command_buffer,
current_target,
viewport_size,
|color_attachment| {
color_attachment.set_load_action(metal::MTLLoadAction::Load);
},
);
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_owned.len() {
command_encoder.end_encoding();
let parent = current_target;
current_target = group_owned[depth].as_ref();
filter_stack.push((boundary, parent, true));
command_encoder = new_command_encoder_for_texture(
command_buffer,
current_target,
viewport_size,
|color_attachment| {
color_attachment.set_load_action(metal::MTLLoadAction::Clear);
color_attachment
.set_clear_color(metal::MTLClearColor::new(0., 0., 0., 0.));
},
);
} else {
filter_stack.push((boundary, current_target, false));
}
} else if let Some((boundary, parent, isolated)) = filter_stack.pop() {
if isolated {
command_encoder.end_encoding();
if let (Some(ping), Some(pong)) =
(blur_ping_owned.as_deref(), blur_pong_owned.as_deref())
{
self.metal_blur_and_composite(
command_buffer,
current_target,
parent,
ping,
pong,
viewport_size,
boundary.bounds,
boundary.content_mask.bounds,
boundary.corner_radii,
max_blur_radius(&boundary.filters),
boundary.opacity,
false,
);
}
current_target = parent;
command_encoder = new_command_encoder_for_texture(
command_buffer,
current_target,
viewport_size,
|color_attachment| {
color_attachment.set_load_action(metal::MTLLoadAction::Load);
},
);
}
}
true
}
PrimitiveBatch::SubpixelSprites { .. } => unreachable!(),
};
if !ok {
@@ -861,6 +1115,19 @@ impl MetalRenderer {
command_encoder.end_encoding();
// Present the offscreen scene by copying it into the drawable/target texture.
if use_offscreen && scene_color_owned.is_some() {
self.run_metal_blur_pass(
command_buffer,
&self.blur_downsample_pipeline_state,
texture,
scene_color,
viewport_size,
BlurUniform::default(),
false,
);
}
if !self.is_unified_memory {
// Sync the instance buffer to the GPU
instance_buffer.metal_buffer.did_modify_range(NSRange {
@@ -872,6 +1139,170 @@ impl MetalRenderer {
Ok(command_buffer.to_owned())
}
/// Run a single blur pass: draw a full-screen (or composite) quad sampling `source` into
/// `target`. `params` is supplied to both shader stages; `load` keeps existing target
/// contents (used by the composite), otherwise the target is cleared.
#[allow(clippy::too_many_arguments)]
fn run_metal_blur_pass(
&self,
command_buffer: &metal::CommandBufferRef,
pipeline: &metal::RenderPipelineState,
target: &metal::TextureRef,
source: &metal::TextureRef,
target_viewport: Size<DevicePixels>,
params: BlurUniform,
load: bool,
) {
let encoder = new_command_encoder_for_texture(
command_buffer,
target,
target_viewport,
|color_attachment| {
if load {
color_attachment.set_load_action(metal::MTLLoadAction::Load);
} else {
color_attachment.set_load_action(metal::MTLLoadAction::Clear);
color_attachment.set_clear_color(metal::MTLClearColor::new(0., 0., 0., 0.));
}
},
);
encoder.set_render_pipeline_state(pipeline);
encoder.set_vertex_buffer(
BlurInputIndex::Vertices as u64,
Some(&self.unit_vertices),
0,
);
encoder.set_vertex_bytes(
BlurInputIndex::Params as u64,
mem::size_of::<BlurUniform>() as u64,
&params as *const BlurUniform as *const _,
);
encoder.set_vertex_bytes(
BlurInputIndex::ViewportSize as u64,
mem::size_of_val(&target_viewport) as u64,
&target_viewport as *const Size<DevicePixels> as *const _,
);
encoder.set_fragment_bytes(
BlurInputIndex::Params as u64,
mem::size_of::<BlurUniform>() as u64,
&params as *const BlurUniform as *const _,
);
encoder.set_fragment_bytes(
BlurInputIndex::ViewportSize as u64,
mem::size_of_val(&target_viewport) as u64,
&target_viewport as *const Size<DevicePixels> as *const _,
);
encoder.set_fragment_texture(0, Some(source));
encoder.draw_primitives(metal::MTLPrimitiveType::Triangle, 0, 6);
encoder.end_encoding();
}
/// Blur `source` (full-resolution) using the half-res ping/pong textures 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.
#[allow(clippy::too_many_arguments)]
fn metal_blur_and_composite(
&self,
command_buffer: &metal::CommandBufferRef,
source: &metal::TextureRef,
target: &metal::TextureRef,
ping: &metal::TextureRef,
pong: &metal::TextureRef,
viewport_size: Size<DevicePixels>,
bounds: Bounds<ScaledPixels>,
content_mask: Bounds<ScaledPixels>,
corner_radii: Corners<ScaledPixels>,
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. Matches wgpu.
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);
// Content blur bleeds ~3·radius past the box, so its composite quad covers a dilated rect.
let composite_bounds = if clip_rounded {
bounds
} else {
bounds.dilate(ScaledPixels(3.0 * blur_radius))
};
let half = Size {
width: DevicePixels((i32::from(viewport_size.width) / 2).max(1)),
height: DevicePixels((i32::from(viewport_size.height) / 2).max(1)),
};
let half_w = i32::from(half.width) as f32;
let half_h = i32::from(half.height) as f32;
// Downsample source -> ping, then separable gaussian ping -> pong -> ping.
self.run_metal_blur_pass(
command_buffer,
&self.blur_downsample_pipeline_state,
ping,
source,
half,
BlurUniform {
downsample: 1.0,
..Default::default()
},
false,
);
self.run_metal_blur_pass(
command_buffer,
&self.blur_pipeline_state,
pong,
ping,
half,
BlurUniform {
direction: [1.0 / half_w, 0.0],
sigma,
tap_count,
tap_step,
..Default::default()
},
false,
);
self.run_metal_blur_pass(
command_buffer,
&self.blur_pipeline_state,
ping,
pong,
half,
BlurUniform {
direction: [0.0, 1.0 / half_h],
sigma,
tap_count,
tap_step,
..Default::default()
},
false,
);
// Composite the blurred result into the target (preserving its contents).
self.run_metal_blur_pass(
command_buffer,
&self.blur_composite_pipeline_state,
target,
ping,
viewport_size,
BlurUniform {
bounds: composite_bounds,
content_mask,
corner_radii,
opacity,
clip_rounded: if clip_rounded { 1.0 } else { 0.0 },
..Default::default()
},
true,
);
}
fn draw_paths_to_intermediate(
&self,
paths: &[Path<ScaledPixels>],
@@ -1615,6 +2046,36 @@ fn build_path_rasterization_pipeline_state(
.expect("could not create render pipeline state")
}
// Blur downsample/gaussian passes overwrite their target (no blending). The composite pass
// uses the normal alpha-blending pipeline (`build_pipeline_state`) instead.
fn build_blur_pipeline_state(
device: &metal::DeviceRef,
library: &metal::LibraryRef,
label: &str,
vertex_fn_name: &str,
fragment_fn_name: &str,
pixel_format: metal::MTLPixelFormat,
) -> metal::RenderPipelineState {
let vertex_fn = library
.get_function(vertex_fn_name, None)
.expect("error locating vertex function");
let fragment_fn = library
.get_function(fragment_fn_name, None)
.expect("error locating fragment function");
let descriptor = metal::RenderPipelineDescriptor::new();
descriptor.set_label(label);
descriptor.set_vertex_function(Some(vertex_fn.as_ref()));
descriptor.set_fragment_function(Some(fragment_fn.as_ref()));
let color_attachment = descriptor.color_attachments().object_at(0).unwrap();
color_attachment.set_pixel_format(pixel_format);
color_attachment.set_blending_enabled(false);
device
.new_render_pipeline_state(&descriptor)
.expect("could not create render pipeline state")
}
// Align to multiples of 256 make Metal happy.
fn align_offset(offset: &mut usize) {
*offset = (*offset).div_ceil(256) * 256;
+130
View File
@@ -1277,3 +1277,133 @@ float4 fill_color(Background background,
return color;
}
// --- blur --- //
//
// Shared by backdrop (`backdrop-filter`) and content (`filter`) blur. Three passes:
// downsample (full -> half res), separable gaussian (run twice), and a composite that
// samples the blurred texture into a rounded rectangle. `BlurParams` is supplied via
// `setFragmentBytes`/`setVertexBytes` and mirrors the Rust `BlurUniform` struct exactly.
//
// Buffer/texture indices (raw, matching gpui_macos::metal_renderer::BlurInputIndex):
// buffer(0) = unit vertices, buffer(1) = BlurParams, buffer(2) = viewport size
// texture(0) = source
struct BlurParams {
Bounds_ScaledPixels bounds;
Bounds_ScaledPixels content_mask;
Corners_ScaledPixels corner_radii;
float2 direction;
float sigma;
float opacity;
float tap_count;
float clip_rounded;
// 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).
float downsample;
// Spacing between taps in pixels (gaussian passes only); >1 lets `tap_count` taps span very
// large radii without truncating the gaussian.
float tap_step;
};
struct BlurVertexOutput {
float4 position [[position]];
float2 uv;
};
vertex BlurVertexOutput blur_fullscreen_vertex(
uint unit_vertex_id [[vertex_id]],
constant float2 *unit_vertices [[buffer(0)]]) {
float2 uv = unit_vertices[unit_vertex_id];
BlurVertexOutput out;
out.position = float4(uv.x * 2.0 - 1.0, 1.0 - uv.y * 2.0, 0.0, 1.0);
out.uv = uv;
return out;
}
fragment float4 blur_downsample_fragment(
BlurVertexOutput input [[stage_in]],
texture2d<float> source [[texture(0)]],
constant BlurParams &params [[buffer(1)]]) {
constexpr sampler s(mag_filter::linear, min_filter::linear);
if (params.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. 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. Uses the source's own dimensions
// because this pass binds the half-res target size as the viewport.
float2 dst = floor(input.position.xy);
float2 src_size = float2(float(source.get_width()), float(source.get_height()));
float2 src_uv = (dst * 2.0 + 1.0) / src_size;
return source.sample(s, src_uv);
}
// 1:1 copy at matching resolution (used to blit the offscreen scene into the drawable).
return source.sample(s, input.uv);
}
fragment float4 blur_fragment(
BlurVertexOutput input [[stage_in]],
texture2d<float> source [[texture(0)]],
constant BlurParams &params [[buffer(1)]]) {
constexpr sampler s(mag_filter::linear, min_filter::linear);
int taps = int(params.tap_count);
float4 color = float4(0.0);
float weight_sum = 0.0;
for (int i = -taps; i <= taps; i++) {
float offset = float(i) * params.tap_step;
float w = gaussian(offset, params.sigma);
color += source.sample(s, input.uv + params.direction * offset) * w;
weight_sum += w;
}
return color / max(weight_sum, 1e-5);
}
struct BlurCompositeVertexOutput {
float4 position [[position]];
float clip_distance [[clip_distance]][4];
};
struct BlurCompositeFragmentInput {
float4 position [[position]];
};
vertex BlurCompositeVertexOutput blur_composite_vertex(
uint unit_vertex_id [[vertex_id]],
constant float2 *unit_vertices [[buffer(0)]],
constant BlurParams &params [[buffer(1)]],
constant Size_DevicePixels *viewport_size [[buffer(2)]]) {
float2 unit_vertex = unit_vertices[unit_vertex_id];
BlurCompositeVertexOutput out;
out.position = to_device_position(unit_vertex, params.bounds, viewport_size);
float4 clip = distance_from_clip_rect(unit_vertex, params.bounds, params.content_mask);
out.clip_distance[0] = clip.x;
out.clip_distance[1] = clip.y;
out.clip_distance[2] = clip.z;
out.clip_distance[3] = clip.w;
return out;
}
fragment float4 blur_composite_fragment(
BlurCompositeFragmentInput input [[stage_in]],
texture2d<float> source [[texture(0)]],
constant BlurParams &params [[buffer(1)]],
constant Size_DevicePixels *viewport_size [[buffer(2)]]) {
constexpr sampler s(mag_filter::linear, min_filter::linear);
// Sample the half-res blur by screen position, on the SAME fixed 2:1 grid the snapped downsample
// wrote (anchored at the origin, independent of viewport parity): 2 * the half-res texture size
// maps screen pixel p to half-res texel p/2 at every window size, so it doesn't wobble on resize.
float2 half_size = float2(float(source.get_width()), float(source.get_height()));
float2 uv = input.position.xy / (2.0 * half_size);
float4 blurred = source.sample(s, uv);
// Backdrop clips to the rounded rect (the panel has a defined shape); content blur bleeds past
// its bounds like CSS `filter: blur`, so its shape comes from the blurred group's own alpha.
float dist = quad_sdf(input.position.xy, params.bounds, params.corner_radii);
float coverage = params.clip_rounded > 0.5 ? saturate(0.5 - dist) : 1.0;
// The blurred sample is premultiplied (blurring against the transparent surround scales rgb with
// the fading alpha), so output premultiplied and use a premultiplied-blend pipeline. A backdrop's
// scene is opaque (so this replaces); a content-filter group is transparent outside its subtree
// (so the target shows through there instead of darkening).
float a = coverage * params.opacity;
return float4(blurred.rgb * a, blurred.a * a);
}