Filter API + Blur (#40)
Co-authored-by: Newspicel <newspicel+claude@pm.me>
This commit is contained in:
co-authored by
Newspicel
parent
cfec5ff014
commit
69d467e4d1
@@ -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,
|
||||
¶ms 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,
|
||||
¶ms 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;
|
||||
|
||||
@@ -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 ¶ms [[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 ¶ms [[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 ¶ms [[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 ¶ms [[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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user