diff --git a/Cargo.toml b/Cargo.toml index e6712f2781b61..234f507f2d1c8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1611,6 +1611,18 @@ description = "Renders two 3d passes to the same window from different perspecti category = "3D Rendering" wasm = true +[[example]] +name = "blur" +path = "examples/3d/blur.rs" +doc-scrape-examples = true +required-features = ["bevy_feathers"] + +[package.metadata.example.blur] +name = "Blur" +description = "Demonstrates blurring selected UI panels over a live 3D scene" +category = "3D Rendering" +wasm = true + [[example]] name = "vertex_colors" path = "examples/3d/vertex_colors.rs" diff --git a/crates/bevy_ui_render/src/blur.rs b/crates/bevy_ui_render/src/blur.rs new file mode 100644 index 0000000000000..5ec35eb1e0a8b --- /dev/null +++ b/crates/bevy_ui_render/src/blur.rs @@ -0,0 +1,976 @@ +//! Renders blurred regions of the screen behind UI nodes. +//! +//! Add a [`BlurRegionCamera`] to a camera and tag UI nodes with [`BlurRegion`]. +//! Every frame the plugin mirrors the layout rectangles of tagged nodes into the +//! camera, and the selected [`BlurSetting`] algorithm is applied to those regions +//! as a post process. + +use bevy_app::{App, Plugin, PostUpdate}; +use bevy_asset::{embedded_asset, load_embedded_asset, AssetServer, Handle}; +use bevy_core_pipeline::{ + tonemapping::tonemapping, Core2d, Core2dSystems, Core3d, Core3dSystems, FullscreenShader, +}; +use bevy_ecs::{ + component::Component, + prelude::{Commands, Entity, Query, Res, ResMut, With}, + query::QueryItem, + resource::Resource, + schedule::IntoScheduleConfigs, +}; +use bevy_math::{Rect, Vec4}; +use bevy_render::{ + extract_component::{ + ComponentUniforms, DynamicUniformIndex, ExtractComponent, ExtractComponentPlugin, + UniformComponentPlugin, + }, + render_resource::{ + binding_types::{sampler, texture_2d, uniform_buffer}, + AddressMode, BindGroup, BindGroupEntries, BindGroupLayout, BindGroupLayoutDescriptor, + BindGroupLayoutEntries, CachedRenderPipelineId, ColorTargetState, ColorWrites, Extent3d, + FilterMode, FragmentState, MultisampleState, Operations, PipelineCache, PrimitiveState, + RenderPassColorAttachment, RenderPassDescriptor, RenderPipeline, RenderPipelineDescriptor, + Sampler, SamplerBindingType, SamplerDescriptor, ShaderStages, ShaderType, + SpecializedRenderPipeline, SpecializedRenderPipelines, TextureDescriptor, TextureDimension, + TextureFormat, TextureSampleType, TextureUsages, TextureView, + }, + renderer::{RenderContext, RenderDevice, ViewQuery}, + sync_component::SyncComponent, + texture::{CachedTexture, TextureCache}, + view::ViewTarget, + Render, RenderApp, RenderStartup, RenderSystems, +}; +use bevy_shader::{Shader, ShaderDefVal}; +use bevy_ui::{ComputedNode, ComputedUiTargetCamera, UiGlobalTransform, UiSystems}; +use bevy_utils::default; +use tracing::warn; + +/// The texture format used for intermediate blur render targets. +const INTERMEDIATE_TEXTURE_FORMAT: TextureFormat = TextureFormat::Rgba16Float; + +/// The default maximum number of blur regions per camera. This is a compile-time constant to allow the shader array sizes to be known at compile time; +/// if you need more regions, create a custom [`BlurRegionCamera`] with a larger `N` and register a matching `BlurShaderPlugin::`. +pub const DEFAULT_MAX_BLUR_REGIONS_COUNT: usize = 32; + +/// Parameters of the single-component complex kernel used by [`BlurSetting::Bokeh`]. +/// The constants are drawn from via +/// (1-component row). +/// +/// These must stay in sync with the constants of the same names in `blur.wgsl`. +const BOKEH_KERNEL_A: f32 = 0.862325; +const BOKEH_KERNEL_B: f32 = 1.624835; +const BOKEH_WEIGHT_REAL: f32 = 0.767583; +const BOKEH_WEIGHT_IMAG: f32 = 1.862321; +const BOKEH_KERNEL_SCALE: f32 = 1.4; + +/// Selects the blur algorithm and its parameters for a [`BlurRegionCamera`]. +/// +/// All algorithms can have their parameters changed freely at runtime. Different algorithms can provide different tradeoffs +/// between cost and quality. The best choice depends on the blur size, the content being blurred, and personal taste. +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum BlurSetting { + /// A separable box blur: every pixel in the kernel contributes equally. + /// + /// A somewhat expensive and visually the crudest, producing visible + /// streaks for large radii. Runs as a horizontal and a vertical pass. + BoxBlur { + /// Number of pixels sampled on each side of the center, per axis. + /// The kernel covers `2 * kernel_radius + 1` pixels in each direction. + kernel_radius: u32, + /// Multiplier for the spacing between samples, in pixels. + /// `1.0` samples adjacent pixels; larger values widen the blur for free + /// at the cost of slight grain (bilinear filtering hides most of it). + scale: f32, + }, + /// A separable Gaussian blur. + /// + /// A expensive but way more beautiful blur algorithm. Uses bilinear filtering to halve the number of texture samples. + /// Runs as a horizontal and a vertical pass. + Gaussian { + /// The diameter, in physical pixels, of the circle of confusion: the area + /// around each pixel that contributes to the blur. Larger is blurrier. + /// + circle_of_confusion: f32, + /// Standard deviation (σ) of the kernel as a fraction of the circle of + /// confusion. The default of `0.25` means σ = `CoC` × 0.25, which keeps + /// virtually all of the kernel's weight inside the circle of confusion. + sigma_multiplier: f32, + }, + /// A dual Kawase blur: downsamples the scene through a chain of progressively + /// half-resolution textures and upsamples back, blurring at each step. + /// + /// Produces very smooth, wide blurs at a fraction of the cost of an equally + /// wide Gaussian, since most passes run at reduced resolution. The strength + /// grows roughly exponentially with `mip_count`. + DualKawase { + /// Number of downsample levels, `1..=6`. Level `n` runs at `1/2ⁿ` resolution. + mip_count: u32, + /// Sample offset multiplier used by every pass. Typical range `0.5..=4.0`; + /// larger values blur more aggressively but can shimmer beyond ~3.0. + offset: f32, + }, + /// A bokeh blur that mimics a camera aperture: out-of-focus highlights bloom + /// into bright, hard-edged discs instead of smearing out. + /// + /// Implemented as a separable convolution with a single-component complex + /// kernel whose magnitude approximates a disc (see `BOKEH_KERNEL_A`). + /// Runs as a horizontal pass into two intermediate textures holding the + /// complex response, then a vertical pass that resolves them to a color. + /// The most expensive algorithm: cost scales linearly with `radius`. + Bokeh { + /// Radius of the aperture disc in physical pixels, `1..=64`. + radius: u32, + }, +} + +impl BlurSetting { + /// Reasonable defaults for each algorithm, handy as starting points. + pub const BOX_BLUR: BlurSetting = BlurSetting::BoxBlur { + kernel_radius: 8, + scale: 2.0, + }; + /// See [`BlurSetting::BOX_BLUR`]. + pub const GAUSSIAN: BlurSetting = BlurSetting::Gaussian { + circle_of_confusion: 100.0, + sigma_multiplier: 0.25, + }; + /// See [`BlurSetting::BOX_BLUR`]. + pub const DUAL_KAWASE: BlurSetting = BlurSetting::DualKawase { + mip_count: 3, + offset: 1.5, + }; + /// See [`BlurSetting::BOX_BLUR`]. + pub const BOKEH: BlurSetting = BlurSetting::Bokeh { radius: 24 }; + + /// Packs the algorithm parameters into the generic `params` uniform vector. + /// The meaning of each component is algorithm specific; see `blur.wgsl`. + fn shader_params(&self) -> Vec4 { + match *self { + BlurSetting::BoxBlur { + kernel_radius, + scale, + } => Vec4::new(kernel_radius as f32, scale.max(0.0), 0.0, 0.0), + BlurSetting::Gaussian { + circle_of_confusion, + sigma_multiplier, + } => Vec4::new( + circle_of_confusion.max(0.0), + sigma_multiplier.max(0.001), + 0.0, + 0.0, + ), + BlurSetting::DualKawase { offset, .. } => Vec4::new(offset.max(0.0), 0.0, 0.0, 0.0), + BlurSetting::Bokeh { radius } => { + let radius = radius.clamp(1, 64); + Vec4::new(radius as f32, 1.0 / bokeh_normalization(radius), 0.0, 0.0) + } + } + } +} + +impl Default for BlurSetting { + fn default() -> Self { + BlurSetting::GAUSSIAN + } +} + +/// Computes the normalization factor of the 2D bokeh kernel for a given radius. +/// +/// Following `normalize_kernels` in the reference implementation, the 2D kernel is +/// the outer product of the 1D complex kernel with itself, and the normalization is +/// the weighted sum `Σᵢⱼ A·Re(kᵢ·kⱼ) + B·Im(kᵢ·kⱼ)`. Because `Σᵢⱼ kᵢ·kⱼ = (Σᵢ kᵢ)²`, +/// this reduces to evaluating the square of the 1D kernel sum. +fn bokeh_normalization(radius: u32) -> f32 { + let r = radius.max(1) as i32; + let mut sum_re = 0.0f32; + let mut sum_im = 0.0f32; + for x in -r..=r { + let t = x as f32 * BOKEH_KERNEL_SCALE / r as f32; + let t2 = t * t; + let e = bevy_math::ops::exp(-BOKEH_KERNEL_A * t2); + sum_re += e * bevy_math::ops::cos(BOKEH_KERNEL_B * t2); + sum_im += e * bevy_math::ops::sin(BOKEH_KERNEL_B * t2); + } + let total = BOKEH_WEIGHT_REAL * (sum_re * sum_re - sum_im * sum_im) + + BOKEH_WEIGHT_IMAG * (2.0 * sum_re * sum_im); + // The kernel sum is strictly positive for the parameter set above, but guard + // against division by zero so a bad parameter tweak degrades instead of NaNs. + total.max(f32::EPSILON) +} + +/// A plugin that adds support for rendering blurred regions behind UI nodes. +pub struct BlurShaderPlugin; + +impl Plugin for BlurShaderPlugin { + fn build(&self, app: &mut App) { + embedded_asset!(app, "blur.wgsl"); + + app.add_plugins(( + ExtractComponentPlugin::>::default(), + UniformComponentPlugin::>::default(), + )) + .add_systems(PostUpdate, sync_blur_regions::.after(UiSystems::Layout)); + + let Some(render_app) = app.get_sub_app_mut(RenderApp) else { + return; + }; + + render_app + .init_resource::>>() + .add_systems(RenderStartup, init_blur_pipeline::) + .add_systems( + Render, + prepare_blur_regions_passes::.in_set(RenderSystems::Prepare), + ) + .add_systems( + Core3d, + blur_regions_pass:: + .after(tonemapping) + .in_set(Core3dSystems::PostProcess), + ) + .add_systems( + Core2d, + blur_regions_pass:: + .after(tonemapping) + .in_set(Core2dSystems::PostProcess), + ); + } +} + +// Components and systems + +/// Add this marker component to a UI node to create a blur region behind it. +/// +/// The node's layout rectangle and border radius are mirrored into the +/// [`BlurRegionCamera`] of the camera that renders the node, every frame. +#[derive(Component, Default, Clone, Copy)] +pub struct BlurRegion; + +/// The final computed values of a blur region, in physical pixels. +#[derive(Default, Debug, Clone, ShaderType)] +struct ComputedBlurRegion { + min_x: f32, + max_x: f32, + min_y: f32, + max_y: f32, + border_radii: Vec4, +} + +impl ComputedBlurRegion { + const OFFSCREEN: ComputedBlurRegion = ComputedBlurRegion { + min_x: -1.0, + max_x: -1.0, + min_y: -1.0, + max_y: -1.0, + border_radii: Vec4::ZERO, + }; +} + +/// Indicates that this camera should render blur regions, and selects the blur +/// algorithm via [`BlurSetting`]. +/// +/// Regions are normally populated automatically from UI nodes tagged with +/// [`BlurRegion`]. Regions can also be pushed manually with [`Self::blur`] and +/// friends, from a system scheduled after `sync_blur_regions` in [`PostUpdate`] +/// (the sync system rebuilds the region list each frame). +#[derive(Component, Debug, Clone)] +pub struct BlurRegionCamera { + /// The blur algorithm and its parameters. Can be changed freely at runtime. + pub settings: BlurSetting, + current_regions_count: u32, + regions: [ComputedBlurRegion; N], +} + +impl Default for BlurRegionCamera { + fn default() -> Self { + Self::new(BlurSetting::default()) + } +} + +impl BlurRegionCamera { + /// Creates a camera with the default maximum number of blur regions. + pub fn new(settings: BlurSetting) -> Self { + Self::with_settings(settings) + } +} + +impl BlurRegionCamera { + /// Creates a camera with a custom maximum number of blur regions. + /// Requires registering a matching `BlurShaderPlugin::`. + pub fn with_settings(settings: BlurSetting) -> Self { + BlurRegionCamera { + settings, + current_regions_count: 0, + regions: core::array::from_fn(|_| ComputedBlurRegion::OFFSCREEN), + } + } + + /// Adds a rectangular blur region, in physical pixels. + pub fn blur(&mut self, rect: Rect) { + self.rounded_blur(rect, Vec4::ZERO); + } + + /// Adds a rounded rectangular blur region, in physical pixels. + pub fn rounded_blur(&mut self, rect: Rect, border_radii: Vec4) { + if self.current_regions_count == N as u32 { + warn!("Blur region ignored as the max blur region count has already been reached"); + return; + } + + self.regions[self.current_regions_count as usize] = ComputedBlurRegion { + min_x: rect.min.x, + max_x: rect.max.x, + min_y: rect.min.y, + max_y: rect.max.y, + border_radii, + }; + self.current_regions_count += 1; + } + + pub fn blur_all(&mut self, rect: &[Rect]) { + for rect in rect { + self.blur(*rect); + } + } + + pub fn rounded_blur_all(&mut self, rect: &[(Rect, Vec4)]) { + for rect in rect { + self.rounded_blur(rect.0, rect.1); + } + } + + /// Removes all blur regions. Called automatically every frame by `sync_blur_regions`. + pub fn clear(&mut self) { + self.current_regions_count = 0; + } +} + +/// Mirrors the layout rectangles of UI nodes tagged with [`BlurRegion`] into the +/// [`BlurRegionCamera`] of the camera that renders them. +pub fn sync_blur_regions( + mut cameras: Query<&mut BlurRegionCamera>, + nodes: Query<(&ComputedNode, &UiGlobalTransform, &ComputedUiTargetCamera), With>, +) { + for mut camera in &mut cameras { + camera.clear(); + } + + for (node, transform, target) in &nodes { + if node.is_empty() { + continue; + } + let Some(camera_entity) = target.get() else { + continue; + }; + let Ok(mut camera) = cameras.get_mut(camera_entity) else { + continue; + }; + + let rect = Rect::from_center_size(transform.translation, node.size()); + let border_radii = Vec4::from_array(node.border_radius.into()); + camera.rounded_blur(rect, border_radii); + } +} + +// Extraction + +/// The render world copy of [`BlurRegionCamera::settings`], used to decide which +/// passes and intermediate textures each view needs. +#[derive(Component, Debug, Clone, Copy)] +pub struct ExtractedBlurSettings(pub BlurSetting); + +/// The GPU uniform shared by every blur pass. `params` is interpreted per +/// algorithm; see the parameter documentation in `blur.wgsl`. +#[derive(Component, Clone, ShaderType)] +pub struct BlurRegionUniform { + params: Vec4, + current_regions_count: u32, + regions: [ComputedBlurRegion; N], +} + +impl SyncComponent for BlurRegionCamera { + type Target = (BlurRegionUniform, ExtractedBlurSettings); +} + +impl ExtractComponent for BlurRegionCamera { + type QueryData = &'static Self; + type QueryFilter = (); + type Out = (BlurRegionUniform, ExtractedBlurSettings); + + fn extract_component(camera: QueryItem<'_, '_, Self::QueryData>) -> Option { + Some(( + BlurRegionUniform { + params: camera.settings.shader_params(), + current_regions_count: camera.current_regions_count, + regions: camera.regions.clone(), + }, + ExtractedBlurSettings(camera.settings), + )) + } +} + +// Pipelines + +#[derive(Resource)] +pub struct BlurRegionPipeline { + /// Layout for passes reading a single texture: gaussian, box, kawase + /// down/upsample and the bokeh horizontal pass. + single_input_layout: BindGroupLayout, + single_input_layout_descriptor: BindGroupLayoutDescriptor, + /// Layout for the kawase composite pass: the original scene plus the blurred chain. + composite_layout: BindGroupLayout, + composite_layout_descriptor: BindGroupLayoutDescriptor, + /// Layout for the bokeh vertical pass: the original scene plus the two + /// complex-response textures produced by the horizontal pass. + bokeh_layout: BindGroupLayout, + bokeh_layout_descriptor: BindGroupLayoutDescriptor, + sampler: Sampler, + fullscreen_shader: FullscreenShader, + shader: Handle, +} + +fn init_blur_pipeline( + mut commands: Commands, + render_device: Res, + fullscreen_shader: Res, + asset_server: Res, +) { + commands.insert_resource(BlurRegionPipeline::::new( + &render_device, + fullscreen_shader.clone(), + load_embedded_asset!(asset_server.as_ref(), "blur.wgsl"), + )); +} + +impl BlurRegionPipeline { + fn new( + render_device: &RenderDevice, + fullscreen_shader: FullscreenShader, + shader: Handle, + ) -> Self { + let single_input_entries = BindGroupLayoutEntries::sequential( + ShaderStages::FRAGMENT, + ( + texture_2d(TextureSampleType::Float { filterable: true }), + sampler(SamplerBindingType::Filtering), + uniform_buffer::>(true), + ), + ); + let composite_entries = BindGroupLayoutEntries::sequential( + ShaderStages::FRAGMENT, + ( + texture_2d(TextureSampleType::Float { filterable: true }), + sampler(SamplerBindingType::Filtering), + uniform_buffer::>(true), + texture_2d(TextureSampleType::Float { filterable: true }), + ), + ); + let bokeh_entries = BindGroupLayoutEntries::sequential( + ShaderStages::FRAGMENT, + ( + texture_2d(TextureSampleType::Float { filterable: true }), + sampler(SamplerBindingType::Filtering), + uniform_buffer::>(true), + texture_2d(TextureSampleType::Float { filterable: true }), + texture_2d(TextureSampleType::Float { filterable: true }), + ), + ); + + let single_input_layout = render_device + .create_bind_group_layout("blur_single_input_layout", &single_input_entries); + let single_input_layout_descriptor = BindGroupLayoutDescriptor::new( + "blur_single_input_layout", + single_input_entries.to_vec().leak(), + ); + let composite_layout = + render_device.create_bind_group_layout("blur_composite_layout", &composite_entries); + let composite_layout_descriptor = BindGroupLayoutDescriptor::new( + "blur_composite_layout", + composite_entries.to_vec().leak(), + ); + let bokeh_layout = + render_device.create_bind_group_layout("blur_bokeh_layout", &bokeh_entries); + let bokeh_layout_descriptor = + BindGroupLayoutDescriptor::new("blur_bokeh_layout", bokeh_entries.to_vec().leak()); + + // Linear filtering is load-bearing: the gaussian pass samples between texel + // centers to fetch two texels at once, and the kawase and bokeh passes rely + // on bilinear interpolation when sampling across resolutions. + let sampler = render_device.create_sampler(&SamplerDescriptor { + address_mode_u: AddressMode::MirrorRepeat, + address_mode_v: AddressMode::MirrorRepeat, + mag_filter: FilterMode::Linear, + min_filter: FilterMode::Linear, + ..default() + }); + + Self { + single_input_layout, + single_input_layout_descriptor, + composite_layout, + composite_layout_descriptor, + bokeh_layout, + bokeh_layout_descriptor, + sampler, + fullscreen_shader, + shader, + } + } +} + +/// One fullscreen pass of a blur algorithm, keyed to a shader entry point. +#[derive(PartialEq, Eq, Hash, Clone, Copy, Debug)] +enum BlurPass { + GaussianHorizontal, + GaussianVertical, + BoxHorizontal, + BoxVertical, + KawaseDownsample, + KawaseUpsample, + KawaseComposite, + BokehHorizontal, + BokehVertical, +} + +impl BlurPass { + fn entry_point(&self) -> &'static str { + match self { + BlurPass::GaussianHorizontal => "gaussian_horizontal", + BlurPass::GaussianVertical => "gaussian_vertical", + BlurPass::BoxHorizontal => "box_horizontal", + BlurPass::BoxVertical => "box_vertical", + BlurPass::KawaseDownsample => "kawase_downsample", + BlurPass::KawaseUpsample => "kawase_upsample", + BlurPass::KawaseComposite => "kawase_composite", + BlurPass::BokehHorizontal => "bokeh_horizontal", + BlurPass::BokehVertical => "bokeh_vertical", + } + } +} + +#[derive(PartialEq, Eq, Hash, Clone, Copy)] +pub struct BlurRegionPipelineKey { + pass: BlurPass, + /// The format of the view target; intermediate passes ignore this and render + /// to [`INTERMEDIATE_TEXTURE_FORMAT`]. + target_format: TextureFormat, +} + +impl SpecializedRenderPipeline for BlurRegionPipeline { + type Key = BlurRegionPipelineKey; + + fn specialize(&self, key: Self::Key) -> RenderPipelineDescriptor { + let layout = match key.pass { + BlurPass::KawaseComposite => self.composite_layout_descriptor.clone(), + BlurPass::BokehVertical => self.bokeh_layout_descriptor.clone(), + _ => self.single_input_layout_descriptor.clone(), + }; + + let intermediate_target = Some(ColorTargetState { + format: INTERMEDIATE_TEXTURE_FORMAT, + blend: None, + write_mask: ColorWrites::ALL, + }); + let targets = match key.pass { + // The horizontal bokeh pass writes the complex response of all three + // color channels across two textures. + BlurPass::BokehHorizontal => vec![intermediate_target.clone(), intermediate_target], + BlurPass::KawaseDownsample | BlurPass::KawaseUpsample => vec![intermediate_target], + _ => vec![Some(ColorTargetState { + format: key.target_format, + blend: None, + write_mask: ColorWrites::ALL, + })], + }; + + RenderPipelineDescriptor { + label: Some(format!("blur_pipeline_{}", key.pass.entry_point()).into()), + layout: vec![layout], + vertex: self.fullscreen_shader.to_vertex_state(), + primitive: PrimitiveState::default(), + depth_stencil: None, + multisample: MultisampleState::default(), + fragment: Some(FragmentState { + shader: self.shader.clone(), + shader_defs: vec![ShaderDefVal::UInt( + "MAX_BLUR_REGIONS_COUNT".into(), + N as u32, + )], + entry_point: Some(key.pass.entry_point().into()), + targets, + constants: Vec::new(), + }), + ..default() + } + } +} + +// Pass preparation + +/// The prepared pipelines and intermediate textures for one view, shaped by the +/// view's [`BlurSetting`]. Only the resources the selected algorithm actually +/// needs are created. +#[derive(Component)] +pub enum BlurRegionPasses { + /// Two ping-pong passes over the view target: gaussian and box blur. + Separable { + horizontal: CachedRenderPipelineId, + vertical: CachedRenderPipelineId, + }, + /// Dual kawase: downsample through `chain`, upsample back, then composite + /// into the view target with region masking. + DualKawase { + downsample: CachedRenderPipelineId, + upsample: CachedRenderPipelineId, + composite: CachedRenderPipelineId, + /// Progressively half-resolution textures; `chain[0]` is half the view size. + chain: Vec, + }, + /// Bokeh: a horizontal pass producing the complex kernel response, then a + /// vertical pass resolving it back to color. + Bokeh { + horizontal: CachedRenderPipelineId, + vertical: CachedRenderPipelineId, + textures: Box, + }, +} + +/// The intermediate textures holding the complex kernel response of the bokeh +/// horizontal pass. +pub struct BokehTextures { + /// Complex response of the red and green channels: `(R.re, R.im, G.re, G.im)`. + red_green: CachedTexture, + /// Complex response of the blue channel: `(B.re, B.im, 0, 0)`. + blue: CachedTexture, +} + +fn prepare_blur_regions_passes( + mut commands: Commands, + pipeline_cache: Res, + mut pipelines: ResMut>>, + pipeline: Res>, + render_device: Res, + mut texture_cache: ResMut, + views: Query<( + Entity, + &ViewTarget, + &ExtractedBlurSettings, + &BlurRegionUniform, + )>, +) { + for (entity, view_target, settings, uniform) in &views { + if uniform.current_regions_count == 0 { + commands.entity(entity).remove::(); + continue; + } + + let target_format = view_target.main_texture_format(); + let mut specialize = |pass| { + pipelines.specialize( + &pipeline_cache, + &pipeline, + BlurRegionPipelineKey { + pass, + target_format, + }, + ) + }; + + let passes = match settings.0 { + BlurSetting::Gaussian { .. } => BlurRegionPasses::Separable { + horizontal: specialize(BlurPass::GaussianHorizontal), + vertical: specialize(BlurPass::GaussianVertical), + }, + BlurSetting::BoxBlur { .. } => BlurRegionPasses::Separable { + horizontal: specialize(BlurPass::BoxHorizontal), + vertical: specialize(BlurPass::BoxVertical), + }, + BlurSetting::DualKawase { mip_count, .. } => { + let downsample = specialize(BlurPass::KawaseDownsample); + let upsample = specialize(BlurPass::KawaseUpsample); + let composite = specialize(BlurPass::KawaseComposite); + + let size = view_target.main_texture().size(); + let chain = (1..=mip_count.clamp(1, 6)) + .map(|mip| { + texture_cache.get( + &render_device, + TextureDescriptor { + label: Some("blur_regions_dual_kawase_target"), + size: Extent3d { + width: (size.width >> mip).max(1), + height: (size.height >> mip).max(1), + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: TextureDimension::D2, + format: INTERMEDIATE_TEXTURE_FORMAT, + usage: TextureUsages::RENDER_ATTACHMENT + | TextureUsages::TEXTURE_BINDING, + view_formats: &[], + }, + ) + }) + .collect(); + + BlurRegionPasses::DualKawase { + downsample, + upsample, + composite, + chain, + } + } + BlurSetting::Bokeh { .. } => { + let horizontal = specialize(BlurPass::BokehHorizontal); + let vertical = specialize(BlurPass::BokehVertical); + + let descriptor = |label: &'static str| TextureDescriptor { + label: Some(label), + size: view_target.main_texture().size(), + mip_level_count: 1, + sample_count: 1, + dimension: TextureDimension::D2, + format: INTERMEDIATE_TEXTURE_FORMAT, + usage: TextureUsages::RENDER_ATTACHMENT | TextureUsages::TEXTURE_BINDING, + view_formats: &[], + }; + + BlurRegionPasses::Bokeh { + horizontal, + vertical, + textures: Box::new(BokehTextures { + red_green: texture_cache + .get(&render_device, descriptor("blur_regions_bokeh_red_green")), + blue: texture_cache + .get(&render_device, descriptor("blur_regions_bokeh_blue")), + }), + } + } + }; + + commands.entity(entity).insert(passes); + } +} + +// Rendering +fn blur_regions_pass( + view: ViewQuery<( + &ViewTarget, + &BlurRegionPasses, + &DynamicUniformIndex>, + )>, + blur_regions_pipeline: Res>, + pipeline_cache: Res, + blur_regions_uniforms: Res>>, + mut render_context: RenderContext, +) { + let (view_target, passes, uniform_index) = view.into_inner(); + + let Some(uniform_binding) = blur_regions_uniforms.uniforms().binding() else { + return; + }; + let uniform_offset = uniform_index.index(); + + match passes { + BlurRegionPasses::Separable { + horizontal, + vertical, + } => { + let (Some(horizontal), Some(vertical)) = ( + pipeline_cache.get_render_pipeline(*horizontal), + pipeline_cache.get_render_pipeline(*vertical), + ) else { + return; + }; + + for (label, pipeline) in [ + ("blur_regions_horizontal_pass", horizontal), + ("blur_regions_vertical_pass", vertical), + ] { + let post_process = view_target.post_process_write(); + let bind_group = render_context.render_device().create_bind_group( + "blur_regions_bind_group", + &blur_regions_pipeline.single_input_layout, + &BindGroupEntries::sequential(( + post_process.source, + &blur_regions_pipeline.sampler, + uniform_binding.clone(), + )), + ); + + run_blur_pass( + &mut render_context, + label, + pipeline, + &bind_group, + uniform_offset, + &[color_attachment(post_process.destination)], + ); + } + } + BlurRegionPasses::DualKawase { + downsample, + upsample, + composite, + chain, + } => { + let (Some(downsample), Some(upsample), Some(composite)) = ( + pipeline_cache.get_render_pipeline(*downsample), + pipeline_cache.get_render_pipeline(*upsample), + pipeline_cache.get_render_pipeline(*composite), + ) else { + return; + }; + + let post_process = view_target.post_process_write(); + + let single_bind_group = |render_context: &RenderContext, source: &TextureView| { + render_context.render_device().create_bind_group( + "blur_regions_bind_group", + &blur_regions_pipeline.single_input_layout, + &BindGroupEntries::sequential(( + source, + &blur_regions_pipeline.sampler, + uniform_binding.clone(), + )), + ) + }; + + // Downsample the scene through the chain of half-resolution textures. + let mut source = post_process.source; + for texture in chain { + let bind_group = single_bind_group(&render_context, source); + run_blur_pass( + &mut render_context, + "blur_regions_kawase_downsample_pass", + downsample, + &bind_group, + uniform_offset, + &[color_attachment(&texture.default_view)], + ); + source = &texture.default_view; + } + + // Upsample back up the chain, stopping at the half-resolution level. + for i in (1..chain.len()).rev() { + let bind_group = single_bind_group(&render_context, &chain[i].default_view); + run_blur_pass( + &mut render_context, + "blur_regions_kawase_upsample_pass", + upsample, + &bind_group, + uniform_offset, + &[color_attachment(&chain[i - 1].default_view)], + ); + } + + // The final upsample to full resolution also selects, per pixel, between + // the blurred result and the untouched scene based on the blur regions. + let bind_group = render_context.render_device().create_bind_group( + "blur_regions_composite_bind_group", + &blur_regions_pipeline.composite_layout, + &BindGroupEntries::sequential(( + post_process.source, + &blur_regions_pipeline.sampler, + uniform_binding.clone(), + &chain[0].default_view, + )), + ); + run_blur_pass( + &mut render_context, + "blur_regions_kawase_composite_pass", + composite, + &bind_group, + uniform_offset, + &[color_attachment(post_process.destination)], + ); + } + BlurRegionPasses::Bokeh { + horizontal, + vertical, + textures, + } => { + let BokehTextures { red_green, blue } = textures.as_ref(); + let (Some(horizontal), Some(vertical)) = ( + pipeline_cache.get_render_pipeline(*horizontal), + pipeline_cache.get_render_pipeline(*vertical), + ) else { + return; + }; + + let post_process = view_target.post_process_write(); + + let bind_group = render_context.render_device().create_bind_group( + "blur_regions_bind_group", + &blur_regions_pipeline.single_input_layout, + &BindGroupEntries::sequential(( + post_process.source, + &blur_regions_pipeline.sampler, + uniform_binding.clone(), + )), + ); + run_blur_pass( + &mut render_context, + "blur_regions_bokeh_horizontal_pass", + horizontal, + &bind_group, + uniform_offset, + &[ + color_attachment(&red_green.default_view), + color_attachment(&blue.default_view), + ], + ); + + let bind_group = render_context.render_device().create_bind_group( + "blur_regions_bokeh_bind_group", + &blur_regions_pipeline.bokeh_layout, + &BindGroupEntries::sequential(( + post_process.source, + &blur_regions_pipeline.sampler, + uniform_binding.clone(), + &red_green.default_view, + &blue.default_view, + )), + ); + run_blur_pass( + &mut render_context, + "blur_regions_bokeh_vertical_pass", + vertical, + &bind_group, + uniform_offset, + &[color_attachment(post_process.destination)], + ); + } + } +} + +fn color_attachment(view: &TextureView) -> Option> { + Some(RenderPassColorAttachment { + view, + resolve_target: None, + depth_slice: None, + ops: Operations::default(), + }) +} + +fn run_blur_pass( + render_context: &mut RenderContext, + label: &'static str, + pipeline: &RenderPipeline, + bind_group: &BindGroup, + uniform_offset: u32, + color_attachments: &[Option], +) { + let mut render_pass = render_context.begin_tracked_render_pass(RenderPassDescriptor { + label: Some(label), + color_attachments, + depth_stencil_attachment: None, + timestamp_writes: None, + occlusion_query_set: None, + multiview_mask: None, + }); + + render_pass.set_render_pipeline(pipeline); + render_pass.set_bind_group(0, bind_group, &[uniform_offset]); + render_pass.draw(0..3, 0..1); +} diff --git a/crates/bevy_ui_render/src/blur.wgsl b/crates/bevy_ui_render/src/blur.wgsl new file mode 100644 index 0000000000000..25ba78c184fdc --- /dev/null +++ b/crates/bevy_ui_render/src/blur.wgsl @@ -0,0 +1,355 @@ +// Blur algorithms for blur regions behind UI nodes. +// +// Every pass shares the same uniform. `params` is interpreted per algorithm: +// +// Box blur: x = kernel radius in pixels, y = sample spacing multiplier +// Gaussian: x = circle of confusion diameter in pixels, y = sigma multiplier +// Dual kawase: x = sample offset multiplier +// Bokeh: x = kernel radius in pixels, y = 1 / kernel normalization factor + +#import bevy_core_pipeline::fullscreen_vertex_shader::FullscreenVertexOutput +#import bevy_ui::ui_node::sd_rounded_box + +@group(0) @binding(0) var input_texture: texture_2d; +@group(0) @binding(1) var texture_sampler: sampler; +@group(0) @binding(2) var blur_regions: BlurRegionUniform; +// Auxiliary inputs, only bound for the passes that use them. +// Kawase composite: the half-resolution blurred scene. +// Bokeh vertical: the complex response of the red and green channels. +@group(0) @binding(3) var aux_texture_a: texture_2d; +// Bokeh vertical: the complex response of the blue channel. +@group(0) @binding(4) var aux_texture_b: texture_2d; + +struct BlurRegionUniform { + params: vec4, + current_regions_count: u32, + regions: array, +} + +struct ComputedBlurRegion { + min_x: f32, + max_x: f32, + min_y: f32, + max_y: f32, + border_radii: vec4, +} + +// Whether a pixel falls inside any blur region, so that passes can skip the +// expensive blur work for pixels that don't need it. +fn is_blurred(position: vec2) -> bool { + return min_region_distance(position) <= 0.0; +} + +// Returns the signed distance, in pixels, from `position` to the closest blur +// region. Negative inside a region, positive outside. +fn min_region_distance(position: vec2) -> f32 { + var min_distance = 1e30; + for (var i = 0u; i < blur_regions.current_regions_count; i++) { + let region = blur_regions.regions[i]; + let center = vec2((region.max_x + region.min_x) * 0.5, (region.max_y + region.min_y) * 0.5); + let dims = vec2(region.max_x - region.min_x, region.max_y - region.min_y); + let half_smallest_dimension = min(dims.x, dims.y) * 0.5; + + let distance = sd_rounded_box( + position - center, + dims, + min(region.border_radii, vec4(half_smallest_dimension)), + ); + min_distance = min(min_distance, distance); + } + return min_distance; +} + +// --------------------------------------------------------------------------- +// Gaussian blur +// --------------------------------------------------------------------------- +// Performs a single direction of the separable Gaussian blur kernel. +// +// * `frag_coord` is the screen-space pixel coordinate of the fragment. +// +// * `frag_offset` is the vector, in screen-space units, from one sample to the +// next: `vec2(1.0, 0.0)` for the horizontal pass and `vec2(0.0, 1.0)` for the +// vertical pass. +fn gaussian_blur(frag_coord: vec4, frag_offset: vec2) -> vec4 { + // The standard deviation as a fraction of the circle of confusion. Usually σ + // is half the radius, and the radius is half the CoC, giving the default 0.25. + let coc = blur_regions.params.x; + let sigma = coc * blur_regions.params.y; + + // 1.5σ is a good, somewhat aggressive default for support—the number of + // texels on each side of the center that we process. + let support = i32(ceil(sigma * 1.5)); + let uv = frag_coord.xy / vec2(textureDimensions(input_texture)); + let offset = frag_offset / vec2(textureDimensions(input_texture)); + + // The probability density function of the Gaussian blur is (up to constant factors) `exp(-1 / 2σ² * + // x²). We precalculate the constant factor here to avoid having to + // calculate it in the inner loop. + let exp_factor = -1.0 / (2.0 * sigma * sigma); + + // Accumulate samples on both sides of the current texel. Go two at a time, + // taking advantage of bilinear filtering. + var sum = textureSampleLevel(input_texture, texture_sampler, uv, 0.0).rgb; + var weight_sum = 1.0; + for (var i = 1; i <= support; i += 2) { + // This is a well-known trick to reduce the number of needed texture + // samples by a factor of two. We seek to accumulate two adjacent + // samples c₀ and c₁ with weights w₀ and w₁ respectively, with a single + // texture sample at a carefully chosen location. Observe that: + // + // k ⋅ lerp(c₀, c₁, t) = w₀⋅c₀ + w₁⋅c₁ + // + // w₁ + // if k = w₀ + w₁ and t = ─────── + // w₀ + w₁ + // + // Therefore, if we sample at a distance of t = w₁ / (w₀ + w₁) texels in + // between the two texel centers and scale by k = w₀ + w₁ afterward, we + // effectively evaluate w₀⋅c₀ + w₁⋅c₁ with a single texture lookup. + let w0 = exp(exp_factor * f32(i) * f32(i)); + let w1 = exp(exp_factor * f32(i + 1) * f32(i + 1)); + let uv_offset = offset * (f32(i) + w1 / (w0 + w1)); + let weight = w0 + w1; + + sum += ( + textureSampleLevel(input_texture, texture_sampler, uv + uv_offset, 0.0).rgb + + textureSampleLevel(input_texture, texture_sampler, uv - uv_offset, 0.0).rgb + ) * weight; + weight_sum += weight * 2.0; + } + + return vec4(sum / weight_sum, 1.0); +} + +@fragment +fn gaussian_horizontal(in: FullscreenVertexOutput) -> @location(0) vec4 { + if !is_blurred(in.position.xy) { + return textureSampleLevel(input_texture, texture_sampler, in.uv, 0.0); + } + return gaussian_blur(in.position, vec2(1.0, 0.0)); +} + +@fragment +fn gaussian_vertical(in: FullscreenVertexOutput) -> @location(0) vec4 { + if !is_blurred(in.position.xy) { + return textureSampleLevel(input_texture, texture_sampler, in.uv, 0.0); + } + return gaussian_blur(in.position, vec2(0.0, 1.0)); +} + +// --------------------------------------------------------------------------- +// Box blur +// --------------------------------------------------------------------------- +// Performs a single direction of the separable box blur kernel: a plain average +// of `2 * radius + 1` samples spaced `scale` pixels apart along `frag_offset`. +fn box_blur(frag_coord: vec4, frag_offset: vec2) -> vec4 { + let radius = i32(blur_regions.params.x); + let scale = blur_regions.params.y; + + let uv = frag_coord.xy / vec2(textureDimensions(input_texture)); + let offset = frag_offset * scale / vec2(textureDimensions(input_texture)); + + var sum = textureSampleLevel(input_texture, texture_sampler, uv, 0.0).rgb; + for (var i = 1; i <= radius; i++) { + sum += textureSampleLevel(input_texture, texture_sampler, uv + offset * f32(i), 0.0).rgb; + sum += textureSampleLevel(input_texture, texture_sampler, uv - offset * f32(i), 0.0).rgb; + } + + return vec4(sum / f32(2 * radius + 1), 1.0); +} + +@fragment +fn box_horizontal(in: FullscreenVertexOutput) -> @location(0) vec4 { + if !is_blurred(in.position.xy) { + return textureSampleLevel(input_texture, texture_sampler, in.uv, 0.0); + } + return box_blur(in.position, vec2(1.0, 0.0)); +} + +@fragment +fn box_vertical(in: FullscreenVertexOutput) -> @location(0) vec4 { + if !is_blurred(in.position.xy) { + return textureSampleLevel(input_texture, texture_sampler, in.uv, 0.0); + } + return box_blur(in.position, vec2(0.0, 1.0)); +} + +// --------------------------------------------------------------------------- +// Dual kawase blur +// --------------------------------------------------------------------------- +// The scene is downsampled through a chain of progressively half-resolution +// textures and then upsampled back, blurring a little at every step. Because +// most passes run at reduced resolution this produces very wide, smooth blurs +// far cheaper than a single-pass kernel of equivalent width. Blur regions are +// only applied in the final composite pass, which picks per pixel between the +// blurred chain and the untouched scene. + +// Downsamples to half resolution: a center sample weighted 4 plus the four +// diagonal neighbors. `half_pixel` of the destination equals one source texel. +@fragment +fn kawase_downsample(in: FullscreenVertexOutput) -> @location(0) vec4 { + let offset = blur_regions.params.x; + let half_pixel = offset / vec2(textureDimensions(input_texture)); + + var sum = textureSampleLevel(input_texture, texture_sampler, in.uv, 0.0).rgb * 4.0; + sum += textureSampleLevel(input_texture, texture_sampler, in.uv - half_pixel, 0.0).rgb; + sum += textureSampleLevel(input_texture, texture_sampler, in.uv + half_pixel, 0.0).rgb; + sum += textureSampleLevel(input_texture, texture_sampler, in.uv + vec2(half_pixel.x, -half_pixel.y), 0.0).rgb; + sum += textureSampleLevel(input_texture, texture_sampler, in.uv - vec2(half_pixel.x, -half_pixel.y), 0.0).rgb; + + return vec4(sum / 8.0, 1.0); +} + +// Upsamples `source` to double resolution with the 8-tap dual kawase pattern. +// `half_pixel` of the destination equals a quarter of a source texel. +fn kawase_upsample_from(source: texture_2d, uv: vec2) -> vec4 { + let offset = blur_regions.params.x; + let half_pixel = 0.25 * offset / vec2(textureDimensions(source)); + + var sum = textureSampleLevel(source, texture_sampler, uv + vec2(-half_pixel.x * 2.0, 0.0), 0.0).rgb; + sum += textureSampleLevel(source, texture_sampler, uv + vec2(-half_pixel.x, half_pixel.y), 0.0).rgb * 2.0; + sum += textureSampleLevel(source, texture_sampler, uv + vec2(0.0, half_pixel.y * 2.0), 0.0).rgb; + sum += textureSampleLevel(source, texture_sampler, uv + vec2(half_pixel.x, half_pixel.y), 0.0).rgb * 2.0; + sum += textureSampleLevel(source, texture_sampler, uv + vec2(half_pixel.x * 2.0, 0.0), 0.0).rgb; + sum += textureSampleLevel(source, texture_sampler, uv + vec2(half_pixel.x, -half_pixel.y), 0.0).rgb * 2.0; + sum += textureSampleLevel(source, texture_sampler, uv + vec2(0.0, -half_pixel.y * 2.0), 0.0).rgb; + sum += textureSampleLevel(source, texture_sampler, uv + vec2(-half_pixel.x, -half_pixel.y), 0.0).rgb * 2.0; + + return vec4(sum / 12.0, 1.0); +} + +@fragment +fn kawase_upsample(in: FullscreenVertexOutput) -> @location(0) vec4 { + return kawase_upsample_from(input_texture, in.uv); +} + +// The final upsample back to full resolution, masked to the blur regions. +// `input_texture` is the untouched scene and `aux_texture_a` is the +// half-resolution blurred chain. +@fragment +fn kawase_composite(in: FullscreenVertexOutput) -> @location(0) vec4 { + if !is_blurred(in.position.xy) { + return textureSampleLevel(input_texture, texture_sampler, in.uv, 0.0); + } + return kawase_upsample_from(aux_texture_a, in.uv); +} + +// --------------------------------------------------------------------------- +// Bokeh blur +// --------------------------------------------------------------------------- +// A separable convolution with a single-component complex Gaussian kernel +// +// c(x) = exp(-a·x²) · (cos(b·x²) + i·sin(b·x²)) +// +// whose 2D self-product has a magnitude close to a flat disc, mimicking the +// aperture of a camera. Parameters are drawn from +// via . +// +// The horizontal pass convolves each color channel with the complex kernel and +// stores the complex responses across two textures. The vertical pass finishes +// the separable convolution with complex multiplies and resolves the result to +// a real color as `A·real + B·imag`, normalized by a factor computed on the CPU +// (`bokeh_normalization` in `blur.rs`). +// +// These constants must stay in sync with their counterparts in `blur.rs`. +const BOKEH_KERNEL_A: f32 = 0.862325; +const BOKEH_KERNEL_B: f32 = 1.624835; +const BOKEH_WEIGHT_REAL: f32 = 0.767583; +const BOKEH_WEIGHT_IMAG: f32 = 1.862321; +const BOKEH_KERNEL_SCALE: f32 = 1.4; + +// Evaluates the complex kernel at `x` texels from the center as (real, imaginary). +fn bokeh_kernel(x: f32, radius: f32) -> vec2 { + let t = x * BOKEH_KERNEL_SCALE / radius; + let t2 = t * t; + let magnitude = exp(-BOKEH_KERNEL_A * t2); + return vec2(magnitude * cos(BOKEH_KERNEL_B * t2), magnitude * sin(BOKEH_KERNEL_B * t2)); +} + +fn complex_multiply(a: vec2, b: vec2) -> vec2 { + return vec2(a.x * b.x - a.y * b.y, a.x * b.y + a.y * b.x); +} + +struct BokehHorizontalOutput { + // The complex response of the red and green channels: (R.re, R.im, G.re, G.im). + @location(0) red_green: vec4, + // The complex response of the blue channel: (B.re, B.im, 0, 0). + @location(1) blue: vec4, +} + +@fragment +fn bokeh_horizontal(in: FullscreenVertexOutput) -> BokehHorizontalOutput { + var out: BokehHorizontalOutput; + out.red_green = vec4(0.0); + out.blue = vec4(0.0); + + // The vertical pass only reads this texture within `radius` pixels of a blur + // region, so everything further away can be skipped. + let radius = blur_regions.params.x; + if min_region_distance(in.position.xy) > radius { + return out; + } + + let texel = 1.0 / vec2(textureDimensions(input_texture)); + let support = i32(radius); + for (var x = -support; x <= support; x++) { + let kernel = bokeh_kernel(f32(x), radius); + let color = textureSampleLevel( + input_texture, texture_sampler, in.uv + vec2(f32(x), 0.0) * texel, 0.0).rgb; + // The input color is real-valued, so this is a scalar-complex product. + out.red_green += vec4(color.r * kernel, color.g * kernel); + out.blue += vec4(color.b * kernel, 0.0, 0.0); + } + + return out; +} + +@fragment +fn bokeh_vertical(in: FullscreenVertexOutput) -> @location(0) vec4 { + if !is_blurred(in.position.xy) { + return textureSampleLevel(input_texture, texture_sampler, in.uv, 0.0); + } + + let radius = blur_regions.params.x; + let inverse_normalization = blur_regions.params.y; + let texel = 1.0 / vec2(textureDimensions(aux_texture_a)); + + var red = vec2(0.0); + var green = vec2(0.0); + var blue = vec2(0.0); + + let support = i32(radius); + for (var y = -support; y <= support; y++) { + let kernel = bokeh_kernel(f32(y), radius); + let uv = in.uv + vec2(0.0, f32(y)) * texel; + let red_green = textureSampleLevel(aux_texture_a, texture_sampler, uv, 0.0); + let blue_sample = textureSampleLevel(aux_texture_b, texture_sampler, uv, 0.0); + red += complex_multiply(red_green.xy, kernel); + green += complex_multiply(red_green.zw, kernel); + blue += complex_multiply(blue_sample.xy, kernel); + } + + // Resolve the complex accumulators to a real color. + let weights = vec2(BOKEH_WEIGHT_REAL, BOKEH_WEIGHT_IMAG); + let color = vec3(dot(red, weights), dot(green, weights), dot(blue, weights)) + * inverse_normalization; + + // The kernel has small negative lobes, so the reconstruction rings into + // negative, out-of-gamut values around hard HDR edges. Desaturate toward the + // (non-negative) luminance just enough to lift the most-negative channel to + // zero. This preserves luminance and keeps the hue stable. + + // The constants for calculating luminance are from the BT.709 standard. + let bt709 : vec3 = vec3(0.2126, 0.7152, 0.0722); + + let luma = max(dot(color, bt709), 0.0); + let min_channel = min(color.r, min(color.g, color.b)); + var resolved = color; + if min_channel < 0.0 { + // Solve luma + t * (min_channel - luma) = 0 for the blend toward gray. + let t = luma / (luma - min_channel); + resolved = mix(vec3(luma), color, t); + } + // Guard against any residual negatives from floating-point error. + return vec4(max(resolved, vec3(0.0)), 1.0); +} diff --git a/crates/bevy_ui_render/src/lib.rs b/crates/bevy_ui_render/src/lib.rs index a1efacc959275..bd66eaa3856ca 100644 --- a/crates/bevy_ui_render/src/lib.rs +++ b/crates/bevy_ui_render/src/lib.rs @@ -7,6 +7,7 @@ //! Provides rendering functionality for `bevy_ui`. +mod blur; pub mod box_shadow; mod gradient; mod pipeline; @@ -74,6 +75,9 @@ use box_shadow::BoxShadowPlugin; use bytemuck::{Pod, Zeroable}; use core::ops::Range; +pub use blur::{ + BlurRegion, BlurRegionCamera, BlurSetting, BlurShaderPlugin, DEFAULT_MAX_BLUR_REGIONS_COUNT, +}; pub use pipeline::*; pub use render_pass::*; pub use ui_material_pipeline::*; @@ -274,6 +278,7 @@ impl Plugin for UiRenderPlugin { app.add_plugins(UiTextureSlicerPlugin); app.add_plugins(GradientPlugin); app.add_plugins(BoxShadowPlugin); + app.add_plugins(BlurShaderPlugin::); } } diff --git a/examples/3d/blur.rs b/examples/3d/blur.rs new file mode 100644 index 0000000000000..9c754d07deb07 --- /dev/null +++ b/examples/3d/blur.rs @@ -0,0 +1,565 @@ +//! Demonstrates blurring selected UI regions over a live 3D scene. +//! +//! The control panel itself is a blur region: use it to switch between the blur +//! algorithms (gaussian, box, dual kawase, bokeh) and tweak their parameters live. + +use bevy::{ + camera::Hdr, + color::palettes::css::{ + AQUAMARINE, DEEP_PINK, GOLD, LIGHT_SKY_BLUE, ORANGE_RED, ROYAL_BLUE, SEA_GREEN, + }, + core_pipeline::tonemapping::Tonemapping, + feathers::{ + controls::{FeathersRadio, FeathersSlider}, + dark_theme::create_dark_theme, + theme::{ThemedText, UiTheme}, + FeathersPlugins, + }, + prelude::*, + ui::{Checked, InteractionDisabled}, + ui_render::{BlurRegion, BlurRegionCamera, BlurSetting, DEFAULT_MAX_BLUR_REGIONS_COUNT}, + ui_widgets::{RadioGroup, SliderPrecision, SliderRange, SliderStep, SliderValue, ValueChange}, +}; +use std::f32::consts::PI; + +fn main() { + App::new() + .add_plugins((DefaultPlugins, FeathersPlugins)) + .insert_resource(UiTheme(create_dark_theme())) + .insert_resource(BlurDemoState::default()) + .add_systems(Startup, setup) + .add_systems(Update, (spin_meshes, orbit_lights, apply_blur_settings)) + .run(); +} + +// Blur algorithm selection and parameters + +#[derive(Clone, Copy, PartialEq, Debug, Default)] +enum Algorithm { + #[default] + Gaussian, + BoxBlur, + DualKawase, + Bokeh, +} + +/// Range and label of one parameter slider for one algorithm. +struct ParamSpec { + label: &'static str, + min: f32, + max: f32, + step: f32, +} + +impl Algorithm { + fn label(self) -> &'static str { + match self { + Algorithm::Gaussian => "Gaussian", + Algorithm::BoxBlur => "Box blur", + Algorithm::DualKawase => "Dual Kawase", + Algorithm::Bokeh => "Bokeh", + } + } + + fn param_specs(self) -> [Option; 2] { + match self { + Algorithm::Gaussian => [ + Some(ParamSpec { + label: "Circle of confusion", + min: 0.0, + max: 400.0, + step: 1.0, + }), + Some(ParamSpec { + label: "Sigma multiplier", + min: 0.05, + max: 0.5, + step: 0.01, + }), + ], + Algorithm::BoxBlur => [ + Some(ParamSpec { + label: "Kernel radius", + min: 0.0, + max: 64.0, + step: 1.0, + }), + Some(ParamSpec { + label: "Sample spacing", + min: 0.5, + max: 6.0, + step: 0.1, + }), + ], + Algorithm::DualKawase => [ + Some(ParamSpec { + label: "Mip levels", + min: 1.0, + max: 6.0, + step: 1.0, + }), + Some(ParamSpec { + label: "Sample offset", + min: 0.0, + max: 4.0, + step: 0.1, + }), + ], + Algorithm::Bokeh => [ + Some(ParamSpec { + label: "Aperture radius", + min: 1.0, + max: 64.0, + step: 1.0, + }), + None, + ], + } + } +} + +/// The active algorithm and the two slider values of every algorithm, preserved +/// across switches. +#[derive(Resource)] +struct BlurDemoState { + algorithm: Algorithm, + params: [[f32; 2]; 4], +} + +impl Default for BlurDemoState { + fn default() -> Self { + BlurDemoState { + algorithm: Algorithm::Gaussian, + params: [ + // Gaussian: circle of confusion, sigma multiplier + [100.0, 0.25], + // Box blur: kernel radius, sample spacing + [8.0, 2.0], + // Dual kawase: mip levels, sample offset + [3.0, 1.5], + // Bokeh: aperture radius + [24.0, 0.0], + ], + } + } +} + +impl BlurDemoState { + fn settings(&self) -> BlurSetting { + let [primary, secondary] = self.params[self.algorithm as usize]; + match self.algorithm { + Algorithm::Gaussian => BlurSetting::Gaussian { + circle_of_confusion: primary, + sigma_multiplier: secondary, + }, + Algorithm::BoxBlur => BlurSetting::BoxBlur { + kernel_radius: primary as u32, + scale: secondary, + }, + Algorithm::DualKawase => BlurSetting::DualKawase { + mip_count: primary as u32, + offset: secondary, + }, + Algorithm::Bokeh => BlurSetting::Bokeh { + radius: primary as u32, + }, + } + } +} + +/// Marks a radio button as selecting an algorithm. +#[derive(Component, Clone, Copy, Default)] +struct AlgorithmRadio(Algorithm); + +/// Marks one of the two parameter sliders. The payload is the parameter slot. +#[derive(Component, Clone, Copy, Default)] +struct ParamSlider(usize); + +/// Marks the text label above a parameter slider. +#[derive(Component, Clone, Copy, Default)] +struct ParamLabel(usize); + +#[derive(Component)] +struct Spin { + speed: f32, +} + +/// Orbits an entity around `center` at a fixed height and radius. +#[derive(Component)] +struct Orbit { + center: Vec3, + radius: f32, + speed: f32, + phase: f32, +} + +fn setup( + mut commands: Commands, + mut meshes: ResMut>, + mut materials: ResMut>, + state: Res, +) { + commands.spawn(( + Camera3d::default(), + Hdr, + Tonemapping::TonyMcMapface, + Transform::from_xyz(0.0, 5.5, 14.0).looking_at(Vec3::new(0.0, 1.5, 0.0), Vec3::Y), + BlurRegionCamera::new(state.settings()), + )); + + commands.insert_resource(GlobalAmbientLight { + color: Color::WHITE, + brightness: 80.0, + ..default() + }); + + commands.spawn(( + DirectionalLight { + illuminance: 12000.0, + shadow_maps_enabled: true, + ..default() + }, + Transform::from_rotation(Quat::from_euler(EulerRot::ZYX, 0.0, -PI / 4.0, -PI / 3.5)), + )); + + // Colored point lights orbiting the scene at different radii and speeds. + let point_lights = [ + (Color::from(ORANGE_RED), 6.0, 0.4, 0.0), + (Color::from(LIGHT_SKY_BLUE), 9.0, -0.25, 2.1), + (Color::from(SEA_GREEN), 4.0, 0.6, 4.2), + ]; + for (color, radius, speed, phase) in point_lights { + commands.spawn(( + PointLight { + color, + intensity: 400_000.0, + range: 25.0, + ..default() + }, + Mesh3d(meshes.add(Sphere::new(0.12).mesh().uv(16, 9))), + MeshMaterial3d(materials.add(StandardMaterial { + base_color: color, + emissive: color.to_linear() * 60.0, + unlit: true, + ..default() + })), + Transform::from_xyz(radius, 3.0, 0.0), + Orbit { + center: Vec3::new(0.0, 3.0, -2.0), + radius, + speed, + phase, + }, + )); + } + + commands.spawn(( + Mesh3d(meshes.add(Plane3d::default().mesh().size(60.0, 60.0))), + MeshMaterial3d(materials.add(Color::srgb(0.08, 0.09, 0.11))), + )); + + // Spinning shapes at a spread of distances from the camera. + let shapes = [ + ( + meshes.add(Capsule3d::default()), + materials.add(Color::from(SEA_GREEN)), + Transform::from_xyz(-2.2, 1.4, 5.5), + 0.5, + ), + ( + meshes.add(Cuboid::new(2.0, 2.0, 2.0)), + materials.add(Color::from(ORANGE_RED)), + Transform::from_xyz(-4.5, 1.5, 0.0), + 0.7, + ), + ( + meshes.add(Sphere::new(1.35).mesh().uv(32, 18)), + materials.add(Color::from(AQUAMARINE)), + Transform::from_xyz(0.0, 1.8, -1.5), + -0.9, + ), + ( + meshes.add(Torus::new(0.8, 1.6)), + materials.add(Color::from(ROYAL_BLUE)), + Transform::from_xyz(4.5, 2.0, 0.75), + 1.1, + ), + ( + meshes.add(Cuboid::new(3.0, 3.0, 3.0)), + materials.add(Color::from(DEEP_PINK)), + Transform::from_xyz(-7.0, 2.2, -9.0), + 0.3, + ), + ( + meshes.add(Sphere::new(2.0).mesh().uv(32, 18)), + materials.add(Color::from(GOLD)), + Transform::from_xyz(7.5, 2.6, -13.0), + -0.4, + ), + ]; + + for (mesh, material, transform, speed) in shapes { + commands.spawn(( + Mesh3d(mesh), + MeshMaterial3d(material), + transform, + Spin { speed }, + )); + } + + // Small emissive spheres scattered through the depth of the scene. With the + // bokeh algorithm these bloom into bright aperture-shaped discs. + let fairy_lights = [ + (Vec3::new(-1.0, 0.4, 7.0), GOLD), + (Vec3::new(2.8, 0.6, 4.0), DEEP_PINK), + (Vec3::new(-5.8, 0.5, 2.5), LIGHT_SKY_BLUE), + (Vec3::new(1.5, 0.3, 1.0), GOLD), + (Vec3::new(6.2, 0.7, -2.0), AQUAMARINE), + (Vec3::new(-3.0, 0.4, -4.5), GOLD), + (Vec3::new(0.5, 0.6, -7.0), DEEP_PINK), + (Vec3::new(-8.5, 0.5, -11.0), LIGHT_SKY_BLUE), + (Vec3::new(4.0, 0.8, -16.0), GOLD), + (Vec3::new(-2.0, 1.0, -20.0), AQUAMARINE), + ]; + let fairy_mesh = meshes.add(Sphere::new(0.09).mesh().uv(16, 9)); + for (position, color) in fairy_lights { + commands.spawn(( + Mesh3d(fairy_mesh.clone()), + MeshMaterial3d(materials.add(StandardMaterial { + base_color: Color::from(color), + emissive: Color::from(color).to_linear() * 40.0, + unlit: true, + ..default() + })), + Transform::from_translation(position), + )); + } + + commands.spawn_scene(ui_root(&state)); +} + +/// The full-screen UI: the control panel plus a large, nearly untinted blur +/// region in the middle of the scene, so the character of each algorithm is easy +/// to compare. +fn ui_root(state: &BlurDemoState) -> impl Scene + use<> { + bsn! { + Node { + width: percent(100), + height: percent(100), + justify_content: JustifyContent::Center, + align_items: AlignItems::Center, + } + Children [ + control_panel(state), + ( + BlurRegion + Node { + width: percent(100), + height: percent(100), + justify_content: JustifyContent::Center, + align_items: AlignItems::FlexEnd, + padding: UiRect::all(px(14)), + border_radius: BorderRadius::all(px(36)), + } + BorderColor::all(Color::srgba(0.85, 0.92, 1.0, 0.45)) + Children [( + TextFont { + font_size: FontSize::Px(15.0), + } + TextColor(Color::srgba(1.0, 1.0, 1.0, 0.85)) + )] + ), + ] + } +} + +/// The blurred control panel: algorithm radio buttons and two parameter sliders. +fn control_panel(state: &BlurDemoState) -> impl Scene + use<> { + let specs = state.algorithm.param_specs(); + let values = state.params[state.algorithm as usize]; + + bsn! { + Node { + position_type: PositionType::Absolute, + left: px(10), + bottom: px(10), + width: px(340), + padding: UiRect::axes(px(20), px(18)), + flex_direction: FlexDirection::Column, + row_gap: px(10), + border_radius: BorderRadius::all(px(28)), + } + ZIndex(1) + BackgroundColor(Color::srgba(0.05, 0.08, 0.11, 0.25)) + BorderColor::all(Color::srgba(0.85, 0.92, 1.0, 0.65)) + Children [ + ( + Text("Blur playground") + TextFont { + font_size: FontSize::Px(26.0), + } + TextColor(Color::WHITE) + ), + ( + Node { + flex_direction: FlexDirection::Column, + row_gap: px(4), + } + RadioGroup + on(algorithm_selected) + Children [ + // The demo starts on the gaussian algorithm, so it spawns checked. + (algorithm_radio(Algorithm::Gaussian) Checked), + algorithm_radio(Algorithm::BoxBlur), + algorithm_radio(Algorithm::DualKawase), + algorithm_radio(Algorithm::Bokeh), + ] + ), + param_label(0, &specs[0]), + param_slider(0, values[0], &specs[0]), + param_label(1, &specs[1]), + param_slider(1, values[1], &specs[1]), + ] + } +} + +fn algorithm_radio(algorithm: Algorithm) -> impl Scene { + let label = algorithm.label(); + bsn! { + @FeathersRadio { + @caption: bsn! { Text(label) ThemedText } + } + AlgorithmRadio(algorithm) + } +} + +fn param_label(slot: usize, spec: &Option) -> impl Scene + use<> { + let label = spec.as_ref().map_or("(unused)", |spec| spec.label); + bsn! { + ParamLabel(slot) + Text(label) + TextFont { + font_size: FontSize::Px(14.0), + } + TextColor(Color::srgba(0.93, 0.97, 1.0, 0.92)) + } +} + +fn param_slider(slot: usize, value: f32, spec: &Option) -> impl Scene + use<> { + let (min, max, step) = spec + .as_ref() + .map_or((0.0, 1.0, 0.1), |spec| (spec.min, spec.max, spec.step)); + + bsn! { + @FeathersSlider { + @value: value, + @min: min, + @max: max, + } + SliderStep(step) + SliderPrecision({ step_precision(step).0 }) + ParamSlider(slot) + on(move |change: On>, + mut state: ResMut, + mut commands: Commands| { + commands + .entity(change.source) + .insert(SliderValue(change.value)); + let algorithm = state.algorithm; + state.params[algorithm as usize][slot] = change.value; + }) + } +} + +/// The number of decimals shown on a slider: whole numbers for integer-stepped +/// parameters, two decimals otherwise. +fn step_precision(step: f32) -> SliderPrecision { + SliderPrecision(if step >= 1.0 { 0 } else { 2 }) +} + +/// Handles a radio button selection: updates the checked states and the demo state. +fn algorithm_selected( + change: On>, + radios: Query<(Entity, &AlgorithmRadio)>, + mut state: ResMut, + mut commands: Commands, +) { + for (entity, algorithm_radio) in &radios { + if entity == change.value { + commands.entity(entity).insert(Checked); + state.algorithm = algorithm_radio.0; + } else { + commands.entity(entity).remove::(); + } + } +} + +/// Pushes the demo state into the camera, and refreshes the slider labels and +/// ranges when the algorithm changes. +fn apply_blur_settings( + state: Res, + mut previous_algorithm: Local>, + mut blur_cameras: Query<&mut BlurRegionCamera<{ DEFAULT_MAX_BLUR_REGIONS_COUNT }>>, + sliders: Query<(Entity, &ParamSlider)>, + mut labels: Query<(&ParamLabel, &mut Text)>, + mut commands: Commands, +) { + if !state.is_changed() { + return; + } + + for mut camera in &mut blur_cameras { + camera.settings = state.settings(); + } + + if *previous_algorithm == Some(state.algorithm) { + return; + } + *previous_algorithm = Some(state.algorithm); + + // Re-target the sliders and labels to the new algorithm's parameters. + let specs = state.algorithm.param_specs(); + let values = state.params[state.algorithm as usize]; + + for (entity, param_slider) in &sliders { + let slot = param_slider.0; + match &specs[slot] { + Some(spec) => { + commands.entity(entity).insert(( + SliderRange::new(spec.min, spec.max), + SliderValue(values[slot].clamp(spec.min, spec.max)), + SliderStep(spec.step), + step_precision(spec.step), + )); + commands.entity(entity).remove::(); + } + None => { + commands + .entity(entity) + .insert((InteractionDisabled, SliderValue(0.0))); + } + } + } + + for (param_label, mut text) in &mut labels { + text.0 = specs[param_label.0] + .as_ref() + .map_or("(unused)", |spec| spec.label) + .to_string(); + } +} + +fn spin_meshes(mut query: Query<(&Spin, &mut Transform)>, time: Res