From 99b9a2fcd70eb7d0d633e18ea93b89c357f54535 Mon Sep 17 00:00:00 2001 From: ickshonpe Date: Tue, 8 Oct 2024 17:26:17 +0100 Subject: [PATCH] box shadow (#15204) # Objective UI box shadow support Adds a new component `BoxShadow`: ```rust pub struct BoxShadow { /// The shadow's color pub color: Color, /// Horizontal offset pub x_offset: Val, /// Vertical offset pub y_offset: Val, /// Horizontal difference in size from the occluding uninode pub spread_radius: Val, /// Blurriness of the shadow pub blur_radius: Val, } ``` To use `BoxShadow`, add the component to any Bevy UI node and a shadow will be drawn beneath that node. Also adds a resource `BoxShadowSamples` that can be used to adjust the shadow quality. #### Notes * I'm not super happy with the field names. Maybe we need a `struct Size { width: Val, height: Val }` type or something. * The shader isn't very optimised but I don't see that it's too important for now as the number of shadows being rendered is not going to be massive most of the time. I think it's more important to get the API and geometry correct with this PR. * I didn't implement an inset property, it's not essential and can easily be added in a follow up. * Shadows are only rendered for uinodes, not for images or text. * Batching isn't supported, it would need out-of-the-scope-of-this-pr changes to the way the UI handles z-ordering for it to be effective. # Showcase ```cargo run --example box_shadow -- --samples 4``` br ```cargo run --example box_shadow -- --samples 10``` s10 --- Cargo.toml | 11 + crates/bevy_ui/src/lib.rs | 1 + crates/bevy_ui/src/render/box_shadow.rs | 569 ++++++++++++++++++++++ crates/bevy_ui/src/render/box_shadow.wgsl | 99 ++++ crates/bevy_ui/src/render/mod.rs | 22 +- crates/bevy_ui/src/ui_node.rs | 60 +++ examples/README.md | 1 + examples/ui/box_shadow.rs | 224 +++++++++ 8 files changed, 984 insertions(+), 3 deletions(-) create mode 100644 crates/bevy_ui/src/render/box_shadow.rs create mode 100644 crates/bevy_ui/src/render/box_shadow.wgsl create mode 100644 examples/ui/box_shadow.rs diff --git a/Cargo.toml b/Cargo.toml index cbe7d9c79d6b1..01d1b07ba23bd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2896,6 +2896,17 @@ description = "Demonstrates how to create a node with a border" category = "UI (User Interface)" wasm = true +[[example]] +name = "box_shadow" +path = "examples/ui/box_shadow.rs" +doc-scrape-examples = true + +[package.metadata.example.box_shadow] +name = "Box Shadow" +description = "Demonstrates how to create a node with a shadow" +category = "UI (User Interface)" +wasm = true + [[example]] name = "button" path = "examples/ui/button.rs" diff --git a/crates/bevy_ui/src/lib.rs b/crates/bevy_ui/src/lib.rs index b3eb91c70b22f..f0e0b58686265 100644 --- a/crates/bevy_ui/src/lib.rs +++ b/crates/bevy_ui/src/lib.rs @@ -149,6 +149,7 @@ impl Plugin for UiPlugin { .register_type::() .register_type::() .register_type::() + .register_type::() .configure_sets( PostUpdate, ( diff --git a/crates/bevy_ui/src/render/box_shadow.rs b/crates/bevy_ui/src/render/box_shadow.rs new file mode 100644 index 0000000000000..a53f07b99544b --- /dev/null +++ b/crates/bevy_ui/src/render/box_shadow.rs @@ -0,0 +1,569 @@ +use core::{hash::Hash, ops::Range}; + +use bevy_app::prelude::*; +use bevy_asset::*; +use bevy_color::{Alpha, ColorToComponents, LinearRgba}; +use bevy_ecs::prelude::*; +use bevy_ecs::{ + prelude::Component, + storage::SparseSet, + system::{ + lifetimeless::{Read, SRes}, + *, + }, +}; +use bevy_math::{vec2, FloatOrd, Mat4, Rect, Vec2, Vec3Swizzles, Vec4Swizzles}; +use bevy_render::RenderApp; +use bevy_render::{ + camera::Camera, + render_phase::*, + render_resource::{binding_types::uniform_buffer, *}, + renderer::{RenderDevice, RenderQueue}, + texture::BevyDefault, + view::*, + world_sync::{RenderEntity, TemporaryRenderEntity}, + Extract, ExtractSchedule, Render, RenderSet, +}; +use bevy_transform::prelude::GlobalTransform; +use bytemuck::{Pod, Zeroable}; + +use crate::{ + BoxShadow, CalculatedClip, DefaultUiCamera, Node, RenderUiSystem, ResolvedBorderRadius, + TargetCamera, TransparentUi, UiBoxShadowSamples, UiScale, Val, +}; + +use super::{QUAD_INDICES, QUAD_VERTEX_POSITIONS}; + +pub const BOX_SHADOW_SHADER_HANDLE: Handle = Handle::weak_from_u128(17717747047134343426); + +/// A plugin that enables the rendering of box shadows. +pub struct BoxShadowPlugin; + +impl Plugin for BoxShadowPlugin { + fn build(&self, app: &mut App) { + load_internal_asset!( + app, + BOX_SHADOW_SHADER_HANDLE, + "box_shadow.wgsl", + Shader::from_wgsl + ); + + if let Some(render_app) = app.get_sub_app_mut(RenderApp) { + render_app + .add_render_command::() + .init_resource::() + .init_resource::() + .init_resource::>() + .add_systems( + ExtractSchedule, + extract_shadows.in_set(RenderUiSystem::ExtractBoxShadows), + ) + .add_systems( + Render, + ( + queue_shadows.in_set(RenderSet::Queue), + prepare_shadows.in_set(RenderSet::PrepareBindGroups), + ), + ); + } + } + + fn finish(&self, app: &mut App) { + if let Some(render_app) = app.get_sub_app_mut(RenderApp) { + render_app.init_resource::(); + } + } +} + +#[repr(C)] +#[derive(Copy, Clone, Pod, Zeroable)] +struct BoxShadowVertex { + position: [f32; 3], + uvs: [f32; 2], + vertex_color: [f32; 4], + size: [f32; 2], + radius: [f32; 4], + blur: f32, + bounds: [f32; 2], +} + +#[derive(Component)] +pub struct UiShadowsBatch { + pub range: Range, + pub camera: Entity, +} + +/// Contains the vertices and bind groups to be sent to the GPU +#[derive(Resource)] +pub struct BoxShadowMeta { + vertices: RawBufferVec, + indices: RawBufferVec, + view_bind_group: Option, +} + +impl Default for BoxShadowMeta { + fn default() -> Self { + Self { + vertices: RawBufferVec::new(BufferUsages::VERTEX), + indices: RawBufferVec::new(BufferUsages::INDEX), + view_bind_group: None, + } + } +} + +#[derive(Resource)] +pub struct BoxShadowPipeline { + pub view_layout: BindGroupLayout, +} + +impl FromWorld for BoxShadowPipeline { + fn from_world(world: &mut World) -> Self { + let render_device = world.resource::(); + + let view_layout = render_device.create_bind_group_layout( + "box_shadow_view_layout", + &BindGroupLayoutEntries::single( + ShaderStages::VERTEX_FRAGMENT, + uniform_buffer::(true), + ), + ); + + BoxShadowPipeline { view_layout } + } +} + +#[derive(Clone, Copy, Hash, PartialEq, Eq)] +pub struct UiTextureSlicePipelineKey { + pub hdr: bool, + /// Number of samples, a higher value results in better quality shadows. + pub samples: u32, +} + +impl SpecializedRenderPipeline for BoxShadowPipeline { + type Key = UiTextureSlicePipelineKey; + + fn specialize(&self, key: Self::Key) -> RenderPipelineDescriptor { + let vertex_layout = VertexBufferLayout::from_vertex_formats( + VertexStepMode::Vertex, + vec![ + // position + VertexFormat::Float32x3, + // uv + VertexFormat::Float32x2, + // color + VertexFormat::Float32x4, + // target rect size + VertexFormat::Float32x2, + // corner radius values (top left, top right, bottom right, bottom left) + VertexFormat::Float32x4, + // blur radius + VertexFormat::Float32, + // outer size + VertexFormat::Float32x2, + ], + ); + let shader_defs = vec![ShaderDefVal::UInt( + "SHADOW_SAMPLES".to_string(), + key.samples, + )]; + + RenderPipelineDescriptor { + vertex: VertexState { + shader: BOX_SHADOW_SHADER_HANDLE, + entry_point: "vertex".into(), + shader_defs: shader_defs.clone(), + buffers: vec![vertex_layout], + }, + fragment: Some(FragmentState { + shader: BOX_SHADOW_SHADER_HANDLE, + shader_defs, + entry_point: "fragment".into(), + targets: vec![Some(ColorTargetState { + format: if key.hdr { + ViewTarget::TEXTURE_FORMAT_HDR + } else { + TextureFormat::bevy_default() + }, + blend: Some(BlendState::ALPHA_BLENDING), + write_mask: ColorWrites::ALL, + })], + }), + layout: vec![self.view_layout.clone()], + push_constant_ranges: Vec::new(), + primitive: PrimitiveState { + front_face: FrontFace::Ccw, + cull_mode: None, + unclipped_depth: false, + polygon_mode: PolygonMode::Fill, + conservative: false, + topology: PrimitiveTopology::TriangleList, + strip_index_format: None, + }, + depth_stencil: None, + multisample: MultisampleState { + count: 1, + mask: !0, + alpha_to_coverage_enabled: false, + }, + label: Some("box_shadow_pipeline".into()), + } + } +} + +/// Description of a shadow to be sorted and queued for rendering +pub struct ExtractedBoxShadow { + pub stack_index: u32, + pub transform: Mat4, + pub rect: Rect, + pub clip: Option, + pub camera_entity: Entity, + pub color: LinearRgba, + pub radius: ResolvedBorderRadius, + pub blur_radius: f32, + pub size: Vec2, +} + +/// List of extracted shadows to be sorted and queued for rendering +#[derive(Resource, Default)] +pub struct ExtractedBoxShadows { + pub box_shadows: SparseSet, +} + +pub fn extract_shadows( + mut commands: Commands, + mut extracted_box_shadows: ResMut, + default_ui_camera: Extract, + ui_scale: Extract>, + camera_query: Extract>, + box_shadow_query: Extract< + Query<( + &Node, + &GlobalTransform, + &ViewVisibility, + &BoxShadow, + Option<&CalculatedClip>, + Option<&TargetCamera>, + )>, + >, + mapping: Extract>, +) { + for (uinode, transform, view_visibility, box_shadow, clip, camera) in &box_shadow_query { + let Some(camera_entity) = camera.map(TargetCamera::entity).or(default_ui_camera.get()) + else { + continue; + }; + + let Ok(&camera_entity) = mapping.get(camera_entity) else { + continue; + }; + + // Skip invisible images + if !view_visibility.get() || box_shadow.color.is_fully_transparent() || uinode.is_empty() { + continue; + } + + let ui_logical_viewport_size = camera_query + .get(camera_entity.id()) + .ok() + .and_then(|(_, c)| c.logical_viewport_size()) + .unwrap_or(Vec2::ZERO) + // The logical window resolution returned by `Window` only takes into account the window scale factor and not `UiScale`, + // so we have to divide by `UiScale` to get the size of the UI viewport. + / ui_scale.0; + + let resolve_val = |val, base| match val { + Val::Auto => 0., + Val::Px(px) => px, + Val::Percent(percent) => percent / 100. * base, + Val::Vw(percent) => percent / 100. * ui_logical_viewport_size.x, + Val::Vh(percent) => percent / 100. * ui_logical_viewport_size.y, + Val::VMin(percent) => percent / 100. * ui_logical_viewport_size.min_element(), + Val::VMax(percent) => percent / 100. * ui_logical_viewport_size.max_element(), + }; + + let spread_x = resolve_val(box_shadow.spread_radius, uinode.size().x); + let spread_ratio_x = (spread_x + uinode.size().x) / uinode.size().x; + + let spread = vec2( + spread_x, + (spread_ratio_x * uinode.size().y) - uinode.size().y, + ); + + let blur_radius = resolve_val(box_shadow.blur_radius, uinode.size().x); + let offset = vec2( + resolve_val(box_shadow.x_offset, uinode.size().x), + resolve_val(box_shadow.y_offset, uinode.size().y), + ); + + let shadow_size = uinode.size() + spread; + if shadow_size.cmple(Vec2::ZERO).any() { + continue; + } + + let radius = ResolvedBorderRadius { + top_left: uinode.border_radius.top_left * spread_ratio_x, + top_right: uinode.border_radius.top_right * spread_ratio_x, + bottom_left: uinode.border_radius.bottom_left * spread_ratio_x, + bottom_right: uinode.border_radius.bottom_right * spread_ratio_x, + }; + + extracted_box_shadows.box_shadows.insert( + commands.spawn(TemporaryRenderEntity).id(), + ExtractedBoxShadow { + stack_index: uinode.stack_index, + transform: transform.compute_matrix() * Mat4::from_translation(offset.extend(0.)), + color: box_shadow.color.into(), + rect: Rect { + min: Vec2::ZERO, + max: shadow_size + 6. * blur_radius, + }, + clip: clip.map(|clip| clip.clip), + camera_entity: camera_entity.id(), + radius, + blur_radius, + size: shadow_size, + }, + ); + } +} + +pub fn queue_shadows( + extracted_ui_slicers: ResMut, + ui_slicer_pipeline: Res, + mut pipelines: ResMut>, + mut transparent_render_phases: ResMut>, + mut views: Query<(Entity, &ExtractedView, Option<&UiBoxShadowSamples>)>, + pipeline_cache: Res, + draw_functions: Res>, +) { + let draw_function = draw_functions.read().id::(); + for (entity, extracted_shadow) in extracted_ui_slicers.box_shadows.iter() { + let Ok((view_entity, view, shadow_samples)) = views.get_mut(extracted_shadow.camera_entity) + else { + continue; + }; + + let Some(transparent_phase) = transparent_render_phases.get_mut(&view_entity) else { + continue; + }; + + let pipeline = pipelines.specialize( + &pipeline_cache, + &ui_slicer_pipeline, + UiTextureSlicePipelineKey { + hdr: view.hdr, + samples: shadow_samples.map(|samples| samples.0).unwrap_or_default(), + }, + ); + + transparent_phase.add(TransparentUi { + draw_function, + pipeline, + entity: *entity, + sort_key: ( + FloatOrd(extracted_shadow.stack_index as f32 - 0.1), + entity.index(), + ), + batch_range: 0..0, + extra_index: PhaseItemExtraIndex::NONE, + }); + } +} + +#[allow(clippy::too_many_arguments)] +pub fn prepare_shadows( + mut commands: Commands, + render_device: Res, + render_queue: Res, + mut ui_meta: ResMut, + mut extracted_shadows: ResMut, + view_uniforms: Res, + texture_slicer_pipeline: Res, + mut phases: ResMut>, + mut previous_len: Local, +) { + if let Some(view_binding) = view_uniforms.uniforms.binding() { + let mut batches: Vec<(Entity, UiShadowsBatch)> = Vec::with_capacity(*previous_len); + + ui_meta.vertices.clear(); + ui_meta.indices.clear(); + ui_meta.view_bind_group = Some(render_device.create_bind_group( + "ui_texture_slice_view_bind_group", + &texture_slicer_pipeline.view_layout, + &BindGroupEntries::single(view_binding), + )); + + // Buffer indexes + let mut vertices_index = 0; + let mut indices_index = 0; + + for ui_phase in phases.values_mut() { + let mut item_index = 0; + + while item_index < ui_phase.items.len() { + let item = &mut ui_phase.items[item_index]; + if let Some(box_shadow) = extracted_shadows.box_shadows.get(item.entity) { + let uinode_rect = box_shadow.rect; + + let rect_size = uinode_rect.size().extend(1.0); + + // Specify the corners of the node + let positions = QUAD_VERTEX_POSITIONS + .map(|pos| (box_shadow.transform * (pos * rect_size).extend(1.)).xyz()); + + // Calculate the effect of clipping + // Note: this won't work with rotation/scaling, but that's much more complex (may need more that 2 quads) + let positions_diff = if let Some(clip) = box_shadow.clip { + [ + Vec2::new( + f32::max(clip.min.x - positions[0].x, 0.), + f32::max(clip.min.y - positions[0].y, 0.), + ), + Vec2::new( + f32::min(clip.max.x - positions[1].x, 0.), + f32::max(clip.min.y - positions[1].y, 0.), + ), + Vec2::new( + f32::min(clip.max.x - positions[2].x, 0.), + f32::min(clip.max.y - positions[2].y, 0.), + ), + Vec2::new( + f32::max(clip.min.x - positions[3].x, 0.), + f32::min(clip.max.y - positions[3].y, 0.), + ), + ] + } else { + [Vec2::ZERO; 4] + }; + + let positions_clipped = [ + positions[0] + positions_diff[0].extend(0.), + positions[1] + positions_diff[1].extend(0.), + positions[2] + positions_diff[2].extend(0.), + positions[3] + positions_diff[3].extend(0.), + ]; + + let transformed_rect_size = box_shadow.transform.transform_vector3(rect_size); + + // Don't try to cull nodes that have a rotation + // In a rotation around the Z-axis, this value is 0.0 for an angle of 0.0 or π + // In those two cases, the culling check can proceed normally as corners will be on + // horizontal / vertical lines + // For all other angles, bypass the culling check + // This does not properly handles all rotations on all axis + if box_shadow.transform.x_axis[1] == 0.0 { + // Cull nodes that are completely clipped + if positions_diff[0].x - positions_diff[1].x >= transformed_rect_size.x + || positions_diff[1].y - positions_diff[2].y >= transformed_rect_size.y + { + continue; + } + } + + let radius = [ + box_shadow.radius.top_left, + box_shadow.radius.top_right, + box_shadow.radius.bottom_right, + box_shadow.radius.bottom_left, + ]; + + let uvs = [Vec2::ZERO, Vec2::X, Vec2::ONE, Vec2::Y]; + for i in 0..4 { + ui_meta.vertices.push(BoxShadowVertex { + position: positions_clipped[i].into(), + uvs: uvs[i].into(), + vertex_color: box_shadow.color.to_f32_array(), + size: box_shadow.size.into(), + radius, + blur: box_shadow.blur_radius, + bounds: rect_size.xy().into(), + }); + } + + for &i in &QUAD_INDICES { + ui_meta.indices.push(indices_index + i as u32); + } + + batches.push(( + item.entity, + UiShadowsBatch { + range: vertices_index..vertices_index + 6, + camera: box_shadow.camera_entity, + }, + )); + + vertices_index += 6; + indices_index += 4; + + // shadows are sent to the gpu non-batched + *ui_phase.items[item_index].batch_range_mut() = + item_index as u32..item_index as u32 + 1; + } + item_index += 1; + } + } + ui_meta.vertices.write_buffer(&render_device, &render_queue); + ui_meta.indices.write_buffer(&render_device, &render_queue); + *previous_len = batches.len(); + commands.insert_or_spawn_batch(batches); + } + extracted_shadows.box_shadows.clear(); +} + +pub type DrawBoxShadows = (SetItemPipeline, SetBoxShadowViewBindGroup<0>, DrawBoxShadow); + +pub struct SetBoxShadowViewBindGroup; +impl RenderCommand

for SetBoxShadowViewBindGroup { + type Param = SRes; + type ViewQuery = Read; + type ItemQuery = (); + + fn render<'w>( + _item: &P, + view_uniform: &'w ViewUniformOffset, + _entity: Option<()>, + ui_meta: SystemParamItem<'w, '_, Self::Param>, + pass: &mut TrackedRenderPass<'w>, + ) -> RenderCommandResult { + let Some(view_bind_group) = ui_meta.into_inner().view_bind_group.as_ref() else { + return RenderCommandResult::Failure("view_bind_group not available"); + }; + pass.set_bind_group(I, view_bind_group, &[view_uniform.offset]); + RenderCommandResult::Success + } +} + +pub struct DrawBoxShadow; +impl RenderCommand

for DrawBoxShadow { + type Param = SRes; + type ViewQuery = (); + type ItemQuery = Read; + + #[inline] + fn render<'w>( + _item: &P, + _view: (), + batch: Option<&'w UiShadowsBatch>, + ui_meta: SystemParamItem<'w, '_, Self::Param>, + pass: &mut TrackedRenderPass<'w>, + ) -> RenderCommandResult { + let Some(batch) = batch else { + return RenderCommandResult::Skip; + }; + let ui_meta = ui_meta.into_inner(); + let Some(vertices) = ui_meta.vertices.buffer() else { + return RenderCommandResult::Failure("missing vertices to draw ui"); + }; + let Some(indices) = ui_meta.indices.buffer() else { + return RenderCommandResult::Failure("missing indices to draw ui"); + }; + + // Store the vertices + pass.set_vertex_buffer(0, vertices.slice(..)); + // Define how to "connect" the vertices + pass.set_index_buffer(indices.slice(..), 0, IndexFormat::Uint32); + // Draw the vertices + pass.draw_indexed(batch.range.clone(), 0, 0..1); + RenderCommandResult::Success + } +} diff --git a/crates/bevy_ui/src/render/box_shadow.wgsl b/crates/bevy_ui/src/render/box_shadow.wgsl new file mode 100644 index 0000000000000..fc78f9490c374 --- /dev/null +++ b/crates/bevy_ui/src/render/box_shadow.wgsl @@ -0,0 +1,99 @@ +#import bevy_render::view::View; +#import bevy_render::globals::Globals; + +const PI: f32 = 3.14159265358979323846; +const SAMPLES: i32 = #SHADOW_SAMPLES; + +@group(0) @binding(0) var view: View; +@group(0) @binding(1) var globals: Globals; + +struct BoxShadowVertexOutput { + @builtin(position) position: vec4, + @location(0) point: vec2, + @location(1) color: vec4, + @location(2) @interpolate(flat) size: vec2, + @location(3) @interpolate(flat) radius: vec4, + @location(4) @interpolate(flat) blur: f32, +} + +fn gaussian(x: f32, sigma: f32) -> f32 { + return exp(-(x * x) / (2. * sigma * sigma)) / (sqrt(2. * PI) * sigma); +} + +// Approximates the Gauss error function: https://en.wikipedia.org/wiki/Error_function +fn erf(p: vec2) -> vec2 { + let s = sign(p); + let a = abs(p); + // fourth degree polynomial approximation for erf + var result = 1.0 + (0.278393 + (0.230389 + 0.078108 * (a * a)) * a) * a; + result = result * result; + return s - s / (result * result); +} + +// returns the closest corner radius based on the signs of the components of p +fn selectCorner(p: vec2, c: vec4) -> f32 { + return mix(mix(c.x, c.y, step(0., p.x)), mix(c.w, c.z, step(0., p.x)), step(0., p.y)); +} + +fn horizontalRoundedBoxShadow(x: f32, y: f32, blur: f32, corner: f32, half_size: vec2) -> f32 { + let d = min(half_size.y - corner - abs(y), 0.); + let c = half_size.x - corner + sqrt(max(0., corner * corner - d * d)); + let integral = 0.5 + 0.5 * erf((x + vec2(-c, c)) * (sqrt(0.5) / blur)); + return integral.y - integral.x; +} + +fn roundedBoxShadow( + lower: vec2, + upper: vec2, + point: vec2, + blur: f32, + corners: vec4, +) -> f32 { + let center = (lower + upper) * 0.5; + let half_size = (upper - lower) * 0.5; + let p = point - center; + let low = p.y - half_size.y; + let high = p.y + half_size.y; + let start = clamp(-3. * blur, low, high); + let end = clamp(3. * blur, low, high); + let step = (end - start) / f32(SAMPLES); + var y = start + step * 0.5; + var value: f32 = 0.0; + for (var i = 0; i < SAMPLES; i++) { + let corner = selectCorner(p, corners); + value += horizontalRoundedBoxShadow(p.x, p.y - y, blur, corner, half_size) * gaussian(y, blur) * step; + y += step; + } + return value; +} + +@vertex +fn vertex( + @location(0) vertex_position: vec3, + @location(1) uv: vec2, + @location(2) vertex_color: vec4, + @location(3) size: vec2, + @location(4) radius: vec4, + @location(5) blur: f32, + @location(6) bounds: vec2, +) -> BoxShadowVertexOutput { + var out: BoxShadowVertexOutput; + out.position = view.clip_from_world * vec4(vertex_position, 1.0); + out.point = (uv.xy - 0.5) * bounds; + out.color = vertex_color; + out.size = size; + out.radius = radius; + out.blur = blur; + return out; +} + +@fragment +fn fragment( + in: BoxShadowVertexOutput, +) -> @location(0) vec4 { + let g = in.color.a * roundedBoxShadow(-0.5 * in.size, 0.5 * in.size, in.point, max(in.blur, 0.01), in.radius); + return vec4(in.color.rgb, g); +} + + + diff --git a/crates/bevy_ui/src/render/mod.rs b/crates/bevy_ui/src/render/mod.rs index c136afdaa8af5..18b8022860fd3 100644 --- a/crates/bevy_ui/src/render/mod.rs +++ b/crates/bevy_ui/src/render/mod.rs @@ -1,3 +1,4 @@ +pub mod box_shadow; mod pipeline; mod render_pass; mod ui_material_pipeline; @@ -5,7 +6,7 @@ pub mod ui_texture_slice_pipeline; use crate::{ BackgroundColor, BorderColor, CalculatedClip, DefaultUiCamera, Node, Outline, - ResolvedBorderRadius, TargetCamera, UiAntiAlias, UiImage, UiScale, + ResolvedBorderRadius, TargetCamera, UiAntiAlias, UiBoxShadowSamples, UiImage, UiScale, }; use bevy_app::prelude::*; use bevy_asset::{load_internal_asset, AssetEvent, AssetId, Assets, Handle}; @@ -46,6 +47,7 @@ use bevy_text::Text; use bevy_text::TextLayoutInfo; use bevy_transform::components::GlobalTransform; use bevy_utils::HashMap; +use box_shadow::BoxShadowPlugin; use bytemuck::{Pod, Zeroable}; use core::ops::Range; use graph::{NodeUi, SubGraphUi}; @@ -70,6 +72,7 @@ pub const UI_SHADER_HANDLE: Handle = Handle::weak_from_u128(130128470471 #[derive(Debug, Hash, PartialEq, Eq, Clone, SystemSet)] pub enum RenderUiSystem { + ExtractBoxShadows, ExtractBackgrounds, ExtractImages, ExtractTextureSlice, @@ -96,6 +99,7 @@ pub fn build_ui_render(app: &mut App) { .configure_sets( ExtractSchedule, ( + RenderUiSystem::ExtractBoxShadows, RenderUiSystem::ExtractBackgrounds, RenderUiSystem::ExtractImages, RenderUiSystem::ExtractTextureSlice, @@ -146,6 +150,7 @@ pub fn build_ui_render(app: &mut App) { } app.add_plugins(UiTextureSlicerPlugin); + app.add_plugins(BoxShadowPlugin); } fn get_ui_graph(render_app: &mut SubApp) -> RenderGraph { @@ -453,14 +458,22 @@ pub fn extract_default_ui_camera_view( mut transparent_render_phases: ResMut>, ui_scale: Extract>, query: Extract< - Query<(&RenderEntity, &Camera, Option<&UiAntiAlias>), Or<(With, With)>>, + Query< + ( + &RenderEntity, + &Camera, + Option<&UiAntiAlias>, + Option<&UiBoxShadowSamples>, + ), + Or<(With, With)>, + >, >, mut live_entities: Local, ) { live_entities.clear(); let scale = ui_scale.0.recip(); - for (entity, camera, ui_anti_alias) in &query { + for (entity, camera, ui_anti_alias, shadow_samples) in &query { // ignore inactive cameras if !camera.is_active { continue; @@ -517,6 +530,9 @@ pub fn extract_default_ui_camera_view( if let Some(ui_anti_alias) = ui_anti_alias { entity_commands.insert(*ui_anti_alias); } + if let Some(shadow_samples) = shadow_samples { + entity_commands.insert(*shadow_samples); + } transparent_render_phases.insert_or_clear(entity); live_entities.insert(entity); diff --git a/crates/bevy_ui/src/ui_node.rs b/crates/bevy_ui/src/ui_node.rs index 5812f6a40debc..63cc8038caabc 100644 --- a/crates/bevy_ui/src/ui_node.rs +++ b/crates/bevy_ui/src/ui_node.rs @@ -2373,6 +2373,41 @@ impl ResolvedBorderRadius { }; } +#[derive(Component, Copy, Clone, Debug, PartialEq, Reflect)] +#[reflect(Component, PartialEq, Default)] +#[cfg_attr( + feature = "serialize", + derive(serde::Serialize, serde::Deserialize), + reflect(Serialize, Deserialize) +)] +pub struct BoxShadow { + /// The shadow's color + pub color: Color, + /// Horizontal offset + pub x_offset: Val, + /// Vertical offset + pub y_offset: Val, + /// How much the shadow should spread outward. + /// + /// Negative values will make the shadow shrink inwards. + /// Percentage values are based on the width of the UI node. + pub spread_radius: Val, + /// Blurriness of the shadow + pub blur_radius: Val, +} + +impl Default for BoxShadow { + fn default() -> Self { + Self { + color: Color::BLACK, + x_offset: Val::Percent(20.), + y_offset: Val::Percent(20.), + spread_radius: Val::ZERO, + blur_radius: Val::Percent(10.), + } + } +} + #[cfg(test)] mod tests { use crate::GridPlacement; @@ -2515,3 +2550,28 @@ pub enum UiAntiAlias { /// UI will render without anti-aliasing Off, } + +/// Number of shadow samples. +/// A larger value will result in higher quality shadows. +/// Default is 4, values higher than ~10 offer diminishing returns. +/// +/// ``` +/// use bevy_core_pipeline::prelude::*; +/// use bevy_ecs::prelude::*; +/// use bevy_ui::prelude::*; +/// +/// fn spawn_camera(mut commands: Commands) { +/// commands.spawn(( +/// Camera2d, +/// UiBoxShadowSamples(6), +/// )); +/// } +/// ``` +#[derive(Component, Clone, Copy, Debug, Reflect, Eq, PartialEq)] +pub struct UiBoxShadowSamples(pub u32); + +impl Default for UiBoxShadowSamples { + fn default() -> Self { + Self(4) + } +} diff --git a/examples/README.md b/examples/README.md index 700bc6f01720d..3f87031708954 100644 --- a/examples/README.md +++ b/examples/README.md @@ -496,6 +496,7 @@ Example | Description Example | Description --- | --- [Borders](../examples/ui/borders.rs) | Demonstrates how to create a node with a border +[Box Shadow](../examples/ui/box_shadow.rs) | Demonstrates how to create a node with a shadow [Button](../examples/ui/button.rs) | Illustrates creating and updating a button [CSS Grid](../examples/ui/grid.rs) | An example for CSS Grid layout [Display and Visibility](../examples/ui/display_and_visibility.rs) | Demonstrates how Display and Visibility work in the UI. diff --git a/examples/ui/box_shadow.rs b/examples/ui/box_shadow.rs new file mode 100644 index 0000000000000..0ab38518f1b63 --- /dev/null +++ b/examples/ui/box_shadow.rs @@ -0,0 +1,224 @@ +//! This example shows how to create a node with a shadow + +use argh::FromArgs; +use bevy::color::palettes::css::DEEP_SKY_BLUE; +use bevy::color::palettes::css::LIGHT_SKY_BLUE; +use bevy::prelude::*; +use bevy::winit::WinitSettings; + +#[derive(FromArgs, Resource)] +/// `box_shadow` example +struct Args { + /// number of samples + #[argh(option, default = "4")] + samples: u32, +} + +fn main() { + App::new() + .add_plugins(DefaultPlugins) + // Only run the app when there is user input. This will significantly reduce CPU/GPU use. + .insert_resource(WinitSettings::desktop_app()) + .add_systems(Startup, setup) + .run(); +} + +fn setup(mut commands: Commands) { + // `from_env` panics on the web + #[cfg(not(target_arch = "wasm32"))] + let args: Args = argh::from_env(); + #[cfg(target_arch = "wasm32")] + let args = Args::from_args(&[], &[]).unwrap(); + + // ui camera + commands.spawn((Camera2d, UiBoxShadowSamples(args.samples))); + + commands + .spawn(NodeBundle { + style: Style { + width: Val::Percent(100.0), + height: Val::Percent(100.0), + padding: UiRect::all(Val::Px(30.)), + column_gap: Val::Px(30.), + flex_wrap: FlexWrap::Wrap, + ..default() + }, + background_color: BackgroundColor(DEEP_SKY_BLUE.into()), + ..Default::default() + }) + .with_children(|commands| { + let example_nodes = [ + ( + Vec2::splat(50.), + Vec2::ZERO, + 10., + 0., + BorderRadius::bottom_right(Val::Px(10.)), + ), + (Vec2::new(50., 25.), Vec2::ZERO, 10., 0., BorderRadius::ZERO), + (Vec2::splat(50.), Vec2::ZERO, 10., 0., BorderRadius::MAX), + (Vec2::new(100., 25.), Vec2::ZERO, 10., 0., BorderRadius::MAX), + ( + Vec2::splat(50.), + Vec2::ZERO, + 10., + 0., + BorderRadius::bottom_right(Val::Px(10.)), + ), + (Vec2::new(50., 25.), Vec2::ZERO, 0., 10., BorderRadius::ZERO), + ( + Vec2::splat(50.), + Vec2::ZERO, + 0., + 10., + BorderRadius::bottom_right(Val::Px(10.)), + ), + (Vec2::new(100., 25.), Vec2::ZERO, 0., 10., BorderRadius::MAX), + ( + Vec2::splat(50.), + Vec2::splat(25.), + 0., + 0., + BorderRadius::ZERO, + ), + ( + Vec2::new(50., 25.), + Vec2::splat(25.), + 0., + 0., + BorderRadius::ZERO, + ), + ( + Vec2::splat(50.), + Vec2::splat(10.), + 0., + 0., + BorderRadius::bottom_right(Val::Px(10.)), + ), + ( + Vec2::splat(50.), + Vec2::splat(25.), + 0., + 10., + BorderRadius::ZERO, + ), + ( + Vec2::new(50., 25.), + Vec2::splat(25.), + 0., + 10., + BorderRadius::ZERO, + ), + ( + Vec2::splat(50.), + Vec2::splat(10.), + 0., + 10., + BorderRadius::bottom_right(Val::Px(10.)), + ), + ( + Vec2::splat(50.), + Vec2::splat(10.), + 0., + 3., + BorderRadius::ZERO, + ), + ( + Vec2::new(50., 25.), + Vec2::splat(10.), + 0., + 3., + BorderRadius::ZERO, + ), + ( + Vec2::splat(50.), + Vec2::splat(10.), + 0., + 3., + BorderRadius::bottom_right(Val::Px(10.)), + ), + ( + Vec2::splat(50.), + Vec2::splat(10.), + 0., + 3., + BorderRadius::all(Val::Px(20.)), + ), + ( + Vec2::new(50., 25.), + Vec2::splat(10.), + 0., + 3., + BorderRadius::all(Val::Px(20.)), + ), + ( + Vec2::new(25., 50.), + Vec2::splat(10.), + 0., + 3., + BorderRadius::MAX, + ), + ( + Vec2::splat(50.), + Vec2::splat(10.), + 0., + 10., + BorderRadius::all(Val::Px(20.)), + ), + ( + Vec2::new(50., 25.), + Vec2::splat(10.), + 0., + 10., + BorderRadius::all(Val::Px(20.)), + ), + ( + Vec2::new(25., 50.), + Vec2::splat(10.), + 0., + 10., + BorderRadius::MAX, + ), + ]; + + for (size, offset, spread, blur, border_radius) in example_nodes { + commands.spawn(box_shadow_node_bundle( + size, + offset, + spread, + blur, + border_radius, + )); + } + }); +} + +fn box_shadow_node_bundle( + size: Vec2, + offset: Vec2, + spread: f32, + blur: f32, + border_radius: BorderRadius, +) -> impl Bundle { + ( + NodeBundle { + style: Style { + width: Val::Px(size.x), + height: Val::Px(size.y), + border: UiRect::all(Val::Px(4.)), + ..default() + }, + border_color: BorderColor(LIGHT_SKY_BLUE.into()), + border_radius, + background_color: BackgroundColor(DEEP_SKY_BLUE.into()), + ..Default::default() + }, + BoxShadow { + color: Color::BLACK.with_alpha(0.8), + x_offset: Val::Percent(offset.x), + y_offset: Val::Percent(offset.y), + spread_radius: Val::Percent(spread), + blur_radius: Val::Px(blur), + }, + ) +}